| 1 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; |
| 2 | import type { |
| 3 | EnvironmentId, |
| 4 | PullRequestRef, |
| 5 | PullRequestStack, |
| 6 | PullRequestMergeMethod, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 9 | import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react"; |
| 10 | import { useState } from "react"; |
| 11 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 12 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 13 | import { Button } from "../ui/button"; |
| 14 | import { Menu, MenuPopup, MenuTrigger, MenuItem, MenuGroup, MenuSeparator } from "../ui/menu"; |
| 15 | import { |
| 16 | Dialog, |
| 17 | DialogPopup, |
| 18 | DialogTitle, |
| 19 | DialogDescription, |
| 20 | DialogHeader, |
| 21 | DialogPanel, |
| 22 | DialogFooter, |
| 23 | } from "../ui/dialog"; |
| 24 | import { toastManager } from "../ui/toast"; |
| 25 | import { PullRequestStackLayers } from "./PullRequestStackLayers"; |
| 26 | import { PullRequestStackHeader } from "./PullRequestStackHeader"; |
| 27 | import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent"; |
| 28 | import { PullRequestGlyph } from "./pullRequestIcons"; |
| 29 | |
| 30 | export 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 | } |