1import { useAtomRefresh } from "@effect/atom-react";
2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import type { PullRequestReviewVerdict } from "@t3tools/contracts";
6import { useCallback, useRef, useState } from "react";
7import { useFocusEffect } from "@react-navigation/native";
8import { Platform, Pressable, View } from "react-native";
9import { clearSubmittedReview } from "./pullRequestReviewDrafts";
10import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft";
11import { reviewPositionLabel } from "./pullRequestDiffComments";
12import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
13import { MaterialButton } from "../../components/MaterialButton";
14import { SegmentedControl } from "../../components/SegmentedControl";
15import { composerPullRequests } from "../../state/pull-requests";
16import { useEnvironmentQuery } from "../../state/query";
17import { useAtomCommand } from "../../state/use-atom-command";
18import { PullRequestSheetContent as SheetContent } from "./PullRequestSheetContent";
19import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
20import { usePullRequestPermissions } from "./usePullRequestPermissions";
21
22function 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
53const 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
62export 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}