1import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
2import type {
3 EnvironmentId,
4 PullRequestRef,
5 PullRequestStack,
6 PullRequestMergeMethod,
7} from "@t3tools/contracts";
8import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
9import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
10import { useState } from "react";
11import { useAtomCommand } from "~/state/use-atom-command";
12import { pullRequestEnvironment } from "~/state/pullRequests";
13import { Button } from "../ui/button";
14import { Menu, MenuPopup, MenuTrigger, MenuItem, MenuGroup, MenuSeparator } from "../ui/menu";
15import {
16 Dialog,
17 DialogPopup,
18 DialogTitle,
19 DialogDescription,
20 DialogHeader,
21 DialogPanel,
22 DialogFooter,
23} from "../ui/dialog";
24import { toastManager } from "../ui/toast";
25import { PullRequestStackLayers } from "./PullRequestStackLayers";
26import { PullRequestStackHeader } from "./PullRequestStackHeader";
27import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent";
28import { PullRequestGlyph } from "./pullRequestIcons";
29
30export function PullRequestStackMenu({
31 hostLabel,
32 stack,
33 reference,
34 environmentId,
35 canMerge,
36 canRebase,
37 mergeMethod,
38 onSelect,
39 onActed,
40 notice,
41 onRetry,
42}: {
43 /** The host's name, which reports what it did with the stack. */
44 hostLabel: string;
45 notice?: string | null;
46 onRetry?: (() => void) | undefined;
47 stack: PullRequestStack;
48 reference: PullRequestRef;
49 environmentId: EnvironmentId;
50 canMerge: boolean;
51 canRebase: boolean;
52 mergeMethod: PullRequestMergeMethod;
53 onSelect?: ((reference: PullRequestRef) => void) | undefined;
54 onActed: () => void;
55}) {
56 const [open, setOpen] = useState(false);
57 const [confirmation, setConfirmation] = useState<"merge" | "update-branch" | null>(null);
58 const [pending, setPending] = useState(false);
59 const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });
60 const top = stack.layers.at(-1);
61 const unmerged = stack.layers.filter((layer) => layer.state !== "merged");
62 const hasClosed = unmerged.some((layer) => layer.state !== "open");
63 const position = stack.layers.findIndex((layer) => layer.number === reference.number) + 1;
64 const mergeLayers = stack.layers.slice(0, position).filter((layer) => layer.state !== "merged");
65 const selectedLayer = stack.layers[position - 1];
66 const mergeHasClosed = mergeLayers.some((layer) => layer.state !== "open");
67 const expectedStackHeads = unmerged.flatMap((layer) =>
68 layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [],
69 );
70 const hasUnknownHead = expectedStackHeads.length !== unmerged.length;
71 const mergeDisabled =
72 pending ||
73 selectedLayer?.state !== "open" ||
74 mergeLayers.some((layer) => !layer.headSha) ||
75 mergeHasClosed ||
76 mergeLayers.length === 0 ||
77 mergeLayers.some((layer) => layer.isDraft);
78 const rebaseDisabled = pending || hasUnknownHead || hasClosed || unmerged.length === 0;
79 const run = async () => {
80 if (
81 pending ||
82 !confirmation ||
83 (confirmation === "merge" ? !canMerge || mergeDisabled : !canRebase || rebaseDisabled)
84 )
85 return;
86 const action = confirmation;
87 const target = action === "merge" ? selectedLayer : top;
88 if (!target?.headSha) return;
89 const actionHeads = (action === "merge" ? mergeLayers : unmerged).flatMap((layer) =>
90 layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [],
91 );
92 setPending(true);
93 setConfirmation(null);
94 const toastId = toastManager.add({
95 type: "loading",
96 title: action === "merge" ? "Merging stack..." : "Rebasing stack...",
97 });
98 const result = await runAction({
99 environmentId,
100 input: {
101 ...reference,
102 number: target.number,
103 stackNumber: stack.number,
104 expectedStackHeads: actionHeads,
105 action,
106 ...(action === "merge" ? { mergeMethod } : { updateMethod: "rebase" }),
107 },
108 });
109 setPending(false);
110 onActed();
111 if (result._tag === "Failure") {
112 toastManager.update(toastId, {
113 type: "error",
114 title: action === "merge" ? "Could not merge the stack" : "Could not rebase the stack",
115 description: String(squashAtomCommandFailure(result)),
116 });
117 } else {
118 toastManager.update(toastId, {
119 type: "success",
120 title: action === "merge" ? "Stack merge request completed" : "Stack rebased",
121 description:
122 action === "merge"
123 ? `${hostLabel} merged the stack or added it to its merge queue.`
124 : undefined,
125 });
126 }
127 };
128 const confirmationLayers = confirmation === "merge" ? mergeLayers : unmerged;
129 return (
130 <>
131 <Menu open={open} onOpenChange={setOpen}>
132 <Tooltip>
133 <TooltipTrigger
134 render={
135 <MenuTrigger
136 render={
137 <Button
138 variant="ghost"
139 size="xs"
140 aria-label={`Stack ${stack.number}, layer ${position} of ${stack.layers.length}`}
141 />
142 }
143 >
144 <PullRequestGlyph.stack aria-hidden className="size-3.5" /> {position}/
145 {stack.layers.length}
146 {onRetry ? <TriangleAlertIcon aria-hidden className="size-3 text-warning" /> : null}
147 </MenuTrigger>
148 }
149 />
150 <TooltipPopup>
151 View stack #{stack.number}, layer {position} of {stack.layers.length}
152 {notice ? ` · ${notice}` : null}
153 </TooltipPopup>
154 </Tooltip>
155 <MenuPopup align="start">
156 <MenuGroup>
157 <PullRequestStackHeader number={stack.number} notice={notice} stale={!!onRetry} />
158 {onRetry ? <MenuItem onClick={onRetry}>Retry stack refresh</MenuItem> : null}
159 <PullRequestStackLayers
160 stack={stack}
161 reference={reference}
162 pending={pending}
163 onSelect={
164 onSelect
165 ? (target) => {
166 setOpen(false);
167 onSelect(target);
168 }
169 : undefined
170 }
171 />
172 </MenuGroup>
173 {canMerge || canRebase ? (
174 <>
175 <MenuSeparator />
176 {canMerge ? (
177 <MenuItem disabled={mergeDisabled} onClick={() => setConfirmation("merge")}>
178 <PullRequestGlyph.merged aria-hidden />
179 Merge stack ({mergeLayers.length})
180 </MenuItem>
181 ) : null}
182 {canRebase ? (
183 <MenuItem
184 disabled={rebaseDisabled}
185 onClick={() => setConfirmation("update-branch")}
186 >
187 <RefreshCwIcon aria-hidden />
188 Rebase stack
189 </MenuItem>
190 ) : null}
191 {mergeHasClosed || mergeLayers.some((layer) => layer.isDraft) ? (
192 <p className="px-2 py-1 text-xs text-muted-foreground">
193 Every layer being merged must be open and ready for review.
194 </p>
195 ) : null}
196 </>
197 ) : null}
198 </MenuPopup>
199 </Menu>
200 {canMerge && selectedLayer?.state === "open" ? (
201 <Tooltip>
202 <TooltipTrigger
203 render={
204 <span className="inline-flex">
205 <Button
206 variant="default"
207 size="xs"
208 disabled={mergeDisabled}
209 onClick={() => setConfirmation("merge")}
210 >
211 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
212 Merge stack
213 </Button>
214 </span>
215 }
216 />
217 <TooltipPopup>
218 Merge stack through #{reference.number} into {stack.base} ({mergeLayers.length}{" "}
219 {mergeLayers.length === 1 ? "pull request" : "pull requests"})
220 </TooltipPopup>
221 </Tooltip>
222 ) : null}
223 <Dialog
224 open={confirmation !== null}
225 onOpenChange={(value) => {
226 if (!value) setConfirmation(null);
227 }}
228 >
229 <DialogPopup className="max-w-md">
230 <DialogHeader>
231 <DialogTitle>
232 {confirmation === "merge"
233 ? `Merge ${mergeLayers.length} pull requests?`
234 : `Rebase ${unmerged.length} pull requests?`}
235 </DialogTitle>
236 <DialogDescription>
237 {confirmation === "merge"
238 ? `Merge #${reference.number} and its unmerged layers below into ${stack.base} using ${mergeMethod}. ${hostLabel} checks their rules before merging or queueing them and rebases the remaining stack after merging.`
239 : `Rebase the remote branches from bottom to top onto ${stack.base}. This rewrites branch history and may restart checks. If a layer fails, earlier updates remain.`}
240 </DialogDescription>
241 </DialogHeader>
242 <DialogPanel>
243 <ul className="max-h-48 space-y-1 overflow-y-auto text-sm">
244 {confirmationLayers.map((layer) => (
245 <li
246 key={layer.number}
247 className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2"
248 >
249 <PullRequestStackLayerContent layer={layer} compact />
250 </li>
251 ))}
252 </ul>
253 </DialogPanel>
254 <DialogFooter>
255 <Button variant="outline" onClick={() => setConfirmation(null)}>
256 Cancel
257 </Button>
258 <Button onClick={() => void run()}>
259 {confirmation === "merge" ? "Merge stack" : "Rebase stack"}
260 </Button>
261 </DialogFooter>
262 </DialogPopup>
263 </Dialog>
264 </>
265 );
266}