1import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
2import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts";
4import { AsyncResult } from "effect/reactivity";
5import { useRef, useState } from "react";
6import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
7import { AppText as Text } from "../../components/AppText";
8import { SymbolView } from "../../components/AppSymbol";
9import { ControlPillMenu } from "../../components/ControlPillMenu";
10import { MaterialButton } from "../../components/MaterialButton";
11import { MaterialIconButton } from "../../components/MaterialIconButton";
12import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
13import { composerPullRequests } from "../../state/pull-requests";
14import { useAtomCommand } from "../../state/use-atom-command";
15import {
16 allowedMergeMethods,
17 canPerformPullRequestAction,
18 mergeUnavailableReason,
19 MERGE_CONFLICT_REASON,
20 MERGE_METHODS,
21} from "./pullRequestActions";
22import { pullRequestStackMerge, singlePullRequestMergeAllowed } from "./pullRequestStack";
23import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
24import { usePullRequestPermissions } from "./usePullRequestPermissions";
25import type { PullRequestStackState } from "./usePullRequestStack";
26
27export function PullRequestMergeControls(props: {
28 readonly pr: PullRequestDetail;
29 readonly params: PullRequestParams;
30 readonly stack: PullRequestStackState;
31}) {
32 const { pr, stack } = props;
33 const target = pullRequestTarget(props.params);
34 const permissions = usePullRequestPermissions(props.params, pr);
35 const preferences = useAtomValue(mobilePreferencesAtom);
36 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
37 const runAction = useAtomCommand(composerPullRequests.runAction, { reportFailure: false });
38 const refreshDetail = useAtomRefresh(composerPullRequests.detail(target));
39 const refreshActivity = useAtomRefresh(composerPullRequests.activity(target));
40 const [selectedMethod, setSelectedMethod] = useState<PullRequestMergeMethod | null>(null);
41 const [busy, setBusy] = useState(false);
42 const pending = useRef(false);
43 const methods = allowedMergeMethods(pr);
44 const preferred =
45 selectedMethod ??
46 (AsyncResult.isSuccess(preferences) ? preferences.value.pullRequestMergeMethod : undefined) ??
47 "squash";
48 const method = methods.includes(preferred)
49 ? preferred
50 : methods.includes("squash")
51 ? "squash"
52 : methods[0];
53 const scope = stack.stack ? pullRequestStackMerge(stack.stack, pr.number) : null;
54 const canMergeSingle = singlePullRequestMergeAllowed({
55 supportsStackActions: stack.supportsStackActions,
56 hasStack: stack.stack !== null,
57 isFresh: stack.isFresh,
58 });
59 const checkingStack =
60 permissions.connected && stack.supportsStackActions && !stack.isFresh && !stack.error;
61 const reason =
62 (!permissions.connected
63 ? "Reconnect to merge."
64 : mergeUnavailableReason(pr, permissions.canWrite)) ??
65 (stack.stack
66 ? !stack.supportsStackActions
67 ? "This environment can display stacks but cannot merge them."
68 : !stack.isFresh
69 ? "Refresh the stack before merging."
70 : scope?.reason
71 : !canMergeSingle
72 ? stack.error
73 ? "Couldn't check stack membership. Refresh before merging."
74 : "Checking stack membership…"
75 : null);
76 const canMerge = !busy && method !== undefined && !reason;
77 const label = method
78 ? scope && scope.layers.length > 1
79 ? `${MERGE_METHODS[method].title} stack (${scope.layers.length})`
80 : MERGE_METHODS[method].title
81 : "Merge unavailable";
82 const autoAction = pr.autoMergeEnabled ? "disable-auto-merge" : "enable-auto-merge";
83 const canAutoMerge =
84 !busy &&
85 (autoAction === "disable-auto-merge" || (!stack.stack && canMergeSingle)) &&
86 permissions.canWrite &&
87 canPerformPullRequestAction(pr, autoAction);
88
89 const perform = async (action: "merge" | "enable-auto-merge" | "disable-auto-merge") => {
90 if (
91 pending.current ||
92 (action !== "disable-auto-merge" && !method) ||
93 (action === "merge" ? !canMerge : !canAutoMerge)
94 )
95 return;
96 pending.current = true;
97 setBusy(true);
98 try {
99 const response = await runAction({
100 ...target,
101 input: {
102 ...target.input,
103 action,
104 ...(action === "merge" && method && stack.stack && scope
105 ? {
106 stackNumber: stack.stack.number,
107 expectedStackHeads: scope.heads,
108 mergeMethod: method,
109 }
110 : action === "merge" && method
111 ? {
112 resolveMergeMethod: (fresh) => {
113 if (
114 !canPerformPullRequestAction(fresh, "merge") ||
115 !allowedMergeMethods(fresh).includes(method)
116 )
117 throw new Error("The pull request changed. Refresh before merging.");
118 return method;
119 },
120 }
121 : action === "enable-auto-merge" && method
122 ? { mergeMethod: method }
123 : {}),
124 },
125 });
126 if (response._tag === "Failure") throw squashAtomCommandFailure(response);
127 } catch (cause) {
128 Alert.alert(
129 "Couldn't complete the merge action",
130 cause instanceof Error ? cause.message : "Try again.",
131 );
132 } finally {
133 // A stack can partly merge before the provider reports a failure.
134 refreshDetail();
135 refreshActivity();
136 stack.refresh();
137 pending.current = false;
138 setBusy(false);
139 }
140 };
141 const confirm = (action: "merge" | "enable-auto-merge" | "disable-auto-merge") => {
142 const title =
143 action === "merge"
144 ? scope && scope.layers.length > 1
145 ? `Merge ${scope.layers.length} stack layers?`
146 : "Merge pull request?"
147 : action === "enable-auto-merge"
148 ? "Enable auto-merge?"
149 : "Disable auto-merge?";
150 const message =
151 action === "merge" && scope
152 ? `Merge ${scope.layers.map((layer) => `#${layer.number}`).join(" → ")} into ${stack.stack?.base} using ${method}. Layers above #${pr.number} stay open.`
153 : action === "enable-auto-merge"
154 ? `Merge #${pr.number} using ${method} when its requirements are met. This may happen immediately.`
155 : action === "disable-auto-merge"
156 ? "Stop the provider from automatically merging this pull request."
157 : `Merge #${pr.number} into ${pr.baseBranch} using ${method}.`;
158 Alert.alert(title, message, [
159 { text: "Cancel", style: "cancel" },
160 {
161 text: action === "merge" ? label : title.replace("?", ""),
162 onPress: () => void perform(action),
163 },
164 ]);
165 };
166 return (
167 <View className="gap-2 border-t border-border-subtle p-3">
168 <View
169 className={
170 Platform.OS === "android"
171 ? "flex-row items-center gap-2"
172 : `flex-row overflow-hidden rounded-xl border-continuous ${canMerge ? "bg-prominent-action" : "bg-subtle-strong"}`
173 }
174 >
175 {Platform.OS === "android" ? (
176 <View className="min-w-0 flex-1">
177 <MaterialButton
178 label={busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label}
179 tone="primary"
180 fullWidth
181 disabled={!canMerge}
182 loading={busy || checkingStack}
183 onPress={() => confirm("merge")}
184 />
185 </View>
186 ) : (
187 <Pressable
188 accessibilityRole="button"
189 accessibilityLabel={
190 scope ? `${label}, ${scope.layers.length} stack layers through #${pr.number}` : label
191 }
192 accessibilityState={{ disabled: !canMerge, busy: busy || checkingStack }}
193 disabled={!canMerge}
194 onPress={() => confirm("merge")}
195 className="min-h-11 min-w-0 flex-1 flex-row items-center justify-center gap-2 px-3 py-2 active:opacity-70"
196 >
197 {busy || checkingStack ? (
198 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
199 ) : (
200 <SymbolView
201 name="arrow.triangle.merge"
202 size={16}
203 tintColorClassName={
204 canMerge ? "accent-prominent-action-foreground" : "accent-icon-muted"
205 }
206 />
207 )}
208 <Text
209 className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-prominent-action-foreground" : "text-foreground-muted"}`}
210 >
211 {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label}
212 </Text>
213 </Pressable>
214 )}
215 {methods.length > 0 || canAutoMerge ? (
216 <ControlPillMenu
217 title="Merge options"
218 actions={[
219 ...methods.map((id) => ({
220 id,
221 title: MERGE_METHODS[id].title,
222 state: id === method ? ("on" as const) : ("off" as const),
223 attributes: { disabled: busy },
224 })),
225 ...(canAutoMerge
226 ? [
227 {
228 id: autoAction,
229 title: pr.autoMergeEnabled ? "Disable auto-merge" : "Enable auto-merge",
230 image: "clock",
231 },
232 ]
233 : []),
234 ]}
235 onPressAction={({ nativeEvent }) => {
236 const selected = methods.find((id) => id === nativeEvent.event);
237 if (selected) {
238 setSelectedMethod(selected);
239 savePreferences({ pullRequestMergeMethod: selected });
240 } else if (nativeEvent.event === autoAction && canAutoMerge) confirm(autoAction);
241 }}
242 >
243 {Platform.OS === "android" ? (
244 <View pointerEvents="none">
245 <MaterialIconButton
246 accessibilityLabel="Merge options"
247 icon="chevron.down"
248 variant="tonal"
249 />
250 </View>
251 ) : (
252 <Pressable
253 accessibilityRole="button"
254 accessibilityLabel="Merge options"
255 className="min-h-11 w-11 items-center justify-center border-l border-border-subtle"
256 >
257 <SymbolView
258 name="chevron.down"
259 size={13}
260 tintColorClassName={
261 canMerge ? "accent-prominent-action-foreground" : "accent-icon-muted"
262 }
263 />
264 </Pressable>
265 )}
266 </ControlPillMenu>
267 ) : null}
268 </View>
269 {permissions.connected &&
270 reason &&
271 reason !== MERGE_CONFLICT_REASON &&
272 reason !== "Checking stack membership…" &&
273 !(checkingStack && reason === "Refresh the stack before merging.") ? (
274 <Text className="px-1 text-xs text-foreground-muted">{reason}</Text>
275 ) : null}
276 {pr.autoMergeEnabled ? (
277 <Text className="px-1 text-xs text-foreground-muted">
278 Auto-merge is enabled{pr.autoMergeMethod ? ` · ${pr.autoMergeMethod}` : ""}
279 </Text>
280 ) : null}
281 {stack.error && stack.supportsStackActions ? (
282 <Pressable
283 accessibilityRole="button"
284 onPress={stack.refresh}
285 className="min-h-11 justify-center px-1"
286 >
287 <Text className="text-sm text-primary-text">Retry stack lookup</Text>
288 </Pressable>
289 ) : null}
290 </View>
291 );
292}