| 1 | import { useAtomRefresh } from "@effect/atom-react"; |
| 2 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 5 | import type { PullRequestReviewVerdict } from "@t3tools/contracts"; |
| 6 | import { useCallback, useRef, useState } from "react"; |
| 7 | import { useFocusEffect } from "@react-navigation/native"; |
| 8 | import { Platform, Pressable, View } from "react-native"; |
| 9 | import { clearSubmittedReview } from "./pullRequestReviewDrafts"; |
| 10 | import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; |
| 11 | import { reviewPositionLabel } from "./pullRequestDiffComments"; |
| 12 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 13 | import { MaterialButton } from "../../components/MaterialButton"; |
| 14 | import { SegmentedControl } from "../../components/SegmentedControl"; |
| 15 | import { composerPullRequests } from "../../state/pull-requests"; |
| 16 | import { useEnvironmentQuery } from "../../state/query"; |
| 17 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 18 | import { PullRequestSheetContent as SheetContent } from "./PullRequestSheetContent"; |
| 19 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; |
| 20 | import { usePullRequestPermissions } from "./usePullRequestPermissions"; |
| 21 | |
| 22 | function useActionSubmission(params: PullRequestParams) { |
| 23 | const navigation = useNavigation(); |
| 24 | const refreshDetail = useAtomRefresh(composerPullRequests.detail(pullRequestTarget(params))); |
| 25 | const refreshActivity = useAtomRefresh(composerPullRequests.activity(pullRequestTarget(params))); |
| 26 | const pending = useRef(false); |
| 27 | const [busy, setBusy] = useState(false); |
| 28 | const [error, setError] = useState<string | null>(null); |
| 29 | const submit = async (operation: () => Promise<void>) => { |
| 30 | if (pending.current) return; |
| 31 | pending.current = true; |
| 32 | setBusy(true); |
| 33 | setError(null); |
| 34 | try { |
| 35 | await operation(); |
| 36 | refreshDetail(); |
| 37 | refreshActivity(); |
| 38 | navigation.goBack(); |
| 39 | } catch (cause) { |
| 40 | setError( |
| 41 | cause instanceof Error |
| 42 | ? cause.message |
| 43 | : "The provider could not complete this action. Try again.", |
| 44 | ); |
| 45 | } finally { |
| 46 | pending.current = false; |
| 47 | setBusy(false); |
| 48 | } |
| 49 | }; |
| 50 | return { busy, error, submit }; |
| 51 | } |
| 52 | |
| 53 | const VERDICTS = [ |
| 54 | { value: "comment", title: "Comment" }, |
| 55 | { value: "approve", title: "Approve" }, |
| 56 | { |
| 57 | value: "request-changes", |
| 58 | title: "Request changes", |
| 59 | }, |
| 60 | ] as const; |
| 61 | |
| 62 | export function PullRequestReviewSheet({ |
| 63 | route, |
| 64 | }: StaticScreenProps<PullRequestParams & { readonly mode: "review" | "comment" }>) { |
| 65 | const params = route.params; |
| 66 | const target = pullRequestTarget(params); |
| 67 | const result = useEnvironmentQuery(composerPullRequests.detail(target)); |
| 68 | const { refresh: refreshDetail } = result; |
| 69 | useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail])); |
| 70 | const permissions = usePullRequestPermissions(params, result.data); |
| 71 | const submission = useActionSubmission(params); |
| 72 | const review = useAtomCommand(composerPullRequests.submitReview, { reportFailure: false }); |
| 73 | const comment = useAtomCommand(composerPullRequests.comment, { reportFailure: false }); |
| 74 | const navigation = useNavigation(); |
| 75 | const store = usePullRequestReviewDraft(params); |
| 76 | const body = params.mode === "review" ? store.draft.summary : store.draft.comment; |
| 77 | const setBody = (body: string) => |
| 78 | store.update((draft) => ({ |
| 79 | ...draft, |
| 80 | ...(params.mode === "review" ? { summary: body } : { comment: body }), |
| 81 | })); |
| 82 | const stale = store.draft.comments.some((comment) => comment.headSha !== result.data?.headSha); |
| 83 | const [requestedVerdict, setRequestedVerdict] = useState<PullRequestReviewVerdict>("comment"); |
| 84 | const offered = VERDICTS.filter((option) => permissions.verdicts.includes(option.value)); |
| 85 | const verdict = offered.find((option) => option.value === requestedVerdict) ?? offered[0]; |
| 86 | const reviewing = params.mode === "review"; |
| 87 | const canSubmitComments = !store.draft.comments.length || permissions.canInlineComment; |
| 88 | // Forgejo requires a summary when requesting changes, even with inline comments. |
| 89 | const summaryRequired = |
| 90 | verdict !== undefined && result.data |
| 91 | ? sourceControlClients.get(result.data.provider).reviewSummaryRequired(verdict.value) |
| 92 | : false; |
| 93 | const canSubmit = |
| 94 | store.ready && |
| 95 | (!reviewing || (!stale && canSubmitComments)) && |
| 96 | (reviewing |
| 97 | ? verdict !== undefined && |
| 98 | (summaryRequired |
| 99 | ? body.trim().length > 0 |
| 100 | : verdict.value === "approve" || |
| 101 | body.trim().length > 0 || |
| 102 | store.draft.comments.length > 0) |
| 103 | : permissions.canComment && body.trim().length > 0); |
| 104 | const perform = () => |
| 105 | submission.submit(async () => { |
| 106 | if (!canSubmit) |
| 107 | throw new Error("Add your feedback and check your permissions before submitting."); |
| 108 | const submitted = store.draft; |
| 109 | const response = |
| 110 | reviewing && verdict |
| 111 | ? await review({ |
| 112 | ...target, |
| 113 | input: { |
| 114 | ...target.input, |
| 115 | verdict: verdict.value, |
| 116 | body, |
| 117 | comments: submitted.comments.map( |
| 118 | ({ id: _id, headSha: _sha, code: _code, ...comment }) => comment, |
| 119 | ), |
| 120 | }, |
| 121 | }) |
| 122 | : await comment({ ...target, input: { ...target.input, body } }); |
| 123 | if (response._tag === "Failure") throw squashAtomCommandFailure(response); |
| 124 | store.update((current) => |
| 125 | reviewing |
| 126 | ? clearSubmittedReview(current, submitted) |
| 127 | : { ...current, comment: current.comment === submitted.comment ? "" : current.comment }, |
| 128 | ); |
| 129 | await store.flush(); |
| 130 | }); |
| 131 | return ( |
| 132 | <SheetContent |
| 133 | title={reviewing ? "Submit review" : "Add comment"} |
| 134 | subtitle={`#${params.number}${reviewing && result.data?.headSha ? ` · reviewing ${result.data.headSha.slice(0, 7)}` : ` · ${params.repository}`}`} |
| 135 | busy={submission.busy} |
| 136 | footer={ |
| 137 | <MaterialButton |
| 138 | label={ |
| 139 | submission.busy |
| 140 | ? "Submitting…" |
| 141 | : reviewing |
| 142 | ? verdict?.value === "approve" |
| 143 | ? "Approve" |
| 144 | : verdict?.value === "request-changes" |
| 145 | ? "Request changes" |
| 146 | : "Submit review" |
| 147 | : "Post comment" |
| 148 | } |
| 149 | fullWidth |
| 150 | tone={Platform.OS === "android" ? "primary" : "contrast"} |
| 151 | loading={submission.busy} |
| 152 | disabled={!canSubmit || result.isPending || result.error !== null} |
| 153 | onPress={() => void perform()} |
| 154 | /> |
| 155 | } |
| 156 | > |
| 157 | {reviewing && offered.length > 1 ? ( |
| 158 | <SegmentedControl |
| 159 | options={offered.map((option) => ({ value: option.value, label: option.title }))} |
| 160 | selected={verdict?.value ?? "comment"} |
| 161 | onSelect={(value) => { |
| 162 | if (!submission.busy) setRequestedVerdict(value); |
| 163 | }} |
| 164 | size="compact" |
| 165 | /> |
| 166 | ) : null} |
| 167 | <Text className="px-4 text-xs text-foreground-muted"> |
| 168 | {reviewing ? "Summary" : "Comment"} |
| 169 | </Text> |
| 170 | <TextInput |
| 171 | multiline |
| 172 | accessibilityLabel={reviewing ? "Review summary" : "Comment"} |
| 173 | placeholder={ |
| 174 | reviewing |
| 175 | ? summaryRequired |
| 176 | ? "Summary required to request changes…" |
| 177 | : "Summarize your review…" |
| 178 | : "Write a comment…" |
| 179 | } |
| 180 | placeholderTextColorClassName="accent-foreground-muted" |
| 181 | editable={store.ready && !submission.busy} |
| 182 | value={body} |
| 183 | maxLength={65536} |
| 184 | onChangeText={setBody} |
| 185 | className="min-h-36 rounded-[20px] bg-grouped-card p-4 text-base text-foreground" |
| 186 | style={{ textAlignVertical: "top" }} |
| 187 | /> |
| 188 | {reviewing && store.draft.comments.length ? ( |
| 189 | <View className="gap-2"> |
| 190 | <Text className="text-sm font-t3-medium"> |
| 191 | {store.draft.comments.length} pending line{" "} |
| 192 | {store.draft.comments.length === 1 ? "comment" : "comments"} |
| 193 | </Text> |
| 194 | {store.draft.comments.map((draft) => ( |
| 195 | <Pressable |
| 196 | key={draft.id} |
| 197 | accessibilityRole="button" |
| 198 | disabled={submission.busy} |
| 199 | onPress={() => { |
| 200 | const outdated = draft.headSha !== result.data?.headSha; |
| 201 | if (outdated) |
| 202 | navigation.dispatch( |
| 203 | StackActions.replace("PullRequestFile", { |
| 204 | ...params, |
| 205 | path: draft.path, |
| 206 | replacingDraftId: draft.id, |
| 207 | }), |
| 208 | ); |
| 209 | else |
| 210 | navigation.dispatch( |
| 211 | StackActions.replace("PullRequestInlineComment", { |
| 212 | ...params, |
| 213 | ...draft, |
| 214 | draftId: draft.id, |
| 215 | }), |
| 216 | ); |
| 217 | }} |
| 218 | className="gap-1 rounded-[20px] bg-grouped-card p-4" |
| 219 | > |
| 220 | <Text className="font-mono text-xs text-foreground-muted"> |
| 221 | {draft.path} · {reviewPositionLabel(draft.position)} |
| 222 | </Text> |
| 223 | <Text numberOfLines={3} className="text-sm"> |
| 224 | {draft.body} |
| 225 | </Text> |
| 226 | <Text className="text-2xs text-foreground-muted"> |
| 227 | {draft.headSha !== result.data?.headSha |
| 228 | ? "Branch changed · Tap to choose a new line" |
| 229 | : "Tap to edit"} |
| 230 | </Text> |
| 231 | </Pressable> |
| 232 | ))} |
| 233 | </View> |
| 234 | ) : null} |
| 235 | {reviewing && stale ? ( |
| 236 | <Text className="text-sm text-danger-foreground"> |
| 237 | The branch changed since you wrote a comment. Choose its current line before submitting. |
| 238 | </Text> |
| 239 | ) : null} |
| 240 | {reviewing && !canSubmitComments ? ( |
| 241 | <Text className="text-sm text-danger-foreground"> |
| 242 | You can no longer submit line comments on this pull request. Your drafts are kept here. |
| 243 | </Text> |
| 244 | ) : null} |
| 245 | {submission.error || result.error || store.error ? ( |
| 246 | <Text selectable className="text-sm text-danger-foreground"> |
| 247 | {submission.error ?? result.error ?? store.error} |
| 248 | </Text> |
| 249 | ) : null} |
| 250 | </SheetContent> |
| 251 | ); |
| 252 | } |