import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";import type { EnvironmentId, PullRequestRef, PullRequestStack, PullRequestMergeMethod,} from "@t3tools/contracts";import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";import { GitMergeIcon, LayersIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react";import { useState } from "react";import { useAtomCommand } from "~/state/use-atom-command";import { pullRequestEnvironment } from "~/state/pullRequests";import { Button } from "../ui/button";import { Menu, MenuPopup, MenuTrigger, MenuItem, MenuGroup, MenuSeparator } from "../ui/menu";import { Dialog, DialogPopup, DialogTitle, DialogDescription, DialogHeader, DialogPanel, DialogFooter,} from "../ui/dialog";import { toastManager } from "../ui/toast";import { PullRequestStackLayers } from "./PullRequestStackLayers";import { PullRequestStackHeader } from "./PullRequestStackHeader";import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent"; export function PullRequestStackMenu({ stack, reference, environmentId, canMerge, canRebase, mergeMethod, onSelect, onActed, notice, onRetry,}: { notice?: string | null; onRetry?: (() => void) | undefined; stack: PullRequestStack; reference: PullRequestRef; environmentId: EnvironmentId; canMerge: boolean; canRebase: boolean; mergeMethod: PullRequestMergeMethod; onSelect?: ((reference: PullRequestRef) => void) | undefined; onActed: () => void;}) { const [open, setOpen] = useState(false); const [confirmation, setConfirmation] = useState<"merge" | "update-branch" | null>(null); const [pending, setPending] = useState(false); const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); const top = stack.layers.at(-1); const unmerged = stack.layers.filter((layer) => layer.state !== "merged"); const hasClosed = unmerged.some((layer) => layer.state !== "open"); const position = stack.layers.findIndex((layer) => layer.number === reference.number) + 1; const mergeLayers = stack.layers.slice(0, position).filter((layer) => layer.state !== "merged"); const selectedLayer = stack.layers[position - 1]; const mergeHasClosed = mergeLayers.some((layer) => layer.state !== "open"); const expectedStackHeads = unmerged.flatMap((layer) => layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [], ); const hasUnknownHead = expectedStackHeads.length !== unmerged.length; const mergeDisabled = pending || selectedLayer?.state !== "open" || mergeLayers.some((layer) => !layer.headSha) || mergeHasClosed || mergeLayers.length === 0 || mergeLayers.some((layer) => layer.isDraft); const rebaseDisabled = pending || hasUnknownHead || hasClosed || unmerged.length === 0; const run = async () => { if ( pending || !confirmation || (confirmation === "merge" ? !canMerge || mergeDisabled : !canRebase || rebaseDisabled) ) return; const action = confirmation; const target = action === "merge" ? selectedLayer : top; if (!target?.headSha) return; const actionHeads = (action === "merge" ? mergeLayers : unmerged).flatMap((layer) => layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [], ); setPending(true); const result = await runAction({ environmentId, input: { ...reference, number: target.number, stackNumber: stack.number, expectedStackHeads: actionHeads, action, ...(action === "merge" ? { mergeMethod } : { updateMethod: "rebase" }), }, }); setPending(false); setConfirmation(null); onActed(); if (result._tag === "Failure") { toastManager.add({ type: "error", title: "Stack operation did not complete", description: String(squashAtomCommandFailure(result)), }); } else { toastManager.add({ type: "success", title: action === "merge" ? "Stack merge request completed" : "Stack rebased", description: action === "merge" ? "GitHub merged the stack or added it to its merge queue." : undefined, }); } }; const confirmationLayers = confirmation === "merge" ? mergeLayers : unmerged; return ( <> <Menu open={open} onOpenChange={setOpen}> <Tooltip> <TooltipTrigger render={ <MenuTrigger render={ <Button variant="ghost" size="xs" aria-label={`Stack ${stack.number}, layer ${position} of ${stack.layers.length}`} /> } > <LayersIcon aria-hidden className="size-3.5" /> {position}/{stack.layers.length} {onRetry ? <TriangleAlertIcon aria-hidden className="size-3 text-warning" /> : null} </MenuTrigger> } /> <TooltipPopup> View stack #{stack.number}, layer {position} of {stack.layers.length} {notice ? ` · ${notice}` : null} </TooltipPopup> </Tooltip> <MenuPopup align="start" className="w-96 max-w-[calc(100vw-2rem)]"> <MenuGroup> <PullRequestStackHeader number={stack.number} notice={notice} stale={!!onRetry} /> {onRetry ? <MenuItem onClick={onRetry}>Retry stack refresh</MenuItem> : null} <PullRequestStackLayers stack={stack} reference={reference} pending={pending} onSelect={ onSelect ? (target) => { setOpen(false); onSelect(target); } : undefined } /> </MenuGroup> {canMerge || canRebase ? ( <> <MenuSeparator /> {canMerge ? ( <MenuItem disabled={mergeDisabled} onClick={() => setConfirmation("merge")}> <GitMergeIcon aria-hidden /> Merge stack ({mergeLayers.length}) </MenuItem> ) : null} {canRebase ? ( <MenuItem disabled={rebaseDisabled} onClick={() => setConfirmation("update-branch")} > <RefreshCwIcon aria-hidden /> Rebase stack </MenuItem> ) : null} {mergeHasClosed || mergeLayers.some((layer) => layer.isDraft) ? ( <p className="px-2 py-1 text-xs text-muted-foreground"> Every layer being merged must be open and ready for review. </p> ) : null} </> ) : null} </MenuPopup> </Menu> {canMerge && selectedLayer?.state === "open" ? ( <Tooltip> <TooltipTrigger render={ <span className="inline-flex"> <Button variant="default" size="xs" disabled={mergeDisabled} onClick={() => setConfirmation("merge")} > <GitMergeIcon aria-hidden className="size-3.5" /> Merge stack </Button> </span> } /> <TooltipPopup> Merge stack through #{reference.number} into {stack.base} ({mergeLayers.length}{" "} {mergeLayers.length === 1 ? "pull request" : "pull requests"}) </TooltipPopup> </Tooltip> ) : null} <Dialog open={confirmation !== null} onOpenChange={(value) => { if (!value && !pending) setConfirmation(null); }} > <DialogPopup className="max-w-md" showCloseButton={!pending}> <DialogHeader> <DialogTitle> {confirmation === "merge" ? `Merge ${mergeLayers.length} pull requests?` : `Rebase ${unmerged.length} pull requests?`} </DialogTitle> <DialogDescription> {confirmation === "merge" ? `Merge #${reference.number} and its unmerged layers below into ${stack.base} using ${mergeMethod}. GitHub checks their rules before merging or queueing them and rebases the remaining stack after merging.` : `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.`} </DialogDescription> </DialogHeader> <DialogPanel> <ul className="max-h-48 space-y-1 overflow-y-auto text-sm"> {confirmationLayers.map((layer) => ( <li key={layer.number} className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2" > <PullRequestStackLayerContent layer={layer} compact /> </li> ))} </ul> </DialogPanel> <DialogFooter> <Button variant="outline" disabled={pending} onClick={() => setConfirmation(null)}> Cancel </Button> <Button disabled={pending} onClick={() => void run()}> {pending ? "Working…" : confirmation === "merge" ? "Merge stack" : "Rebase stack"} </Button> </DialogFooter> </DialogPopup> </Dialog> </> );}
import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
import type {
EnvironmentId,
PullRequestRef,
PullRequestStack,
PullRequestMergeMethod,
} from "@t3tools/contracts";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { GitMergeIcon, LayersIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
import { useState } from "react";
import { useAtomCommand } from "~/state/use-atom-command";
import { pullRequestEnvironment } from "~/state/pullRequests";
import { Button } from "../ui/button";
import { Menu, MenuPopup, MenuTrigger, MenuItem, MenuGroup, MenuSeparator } from "../ui/menu";
import {
Dialog,
DialogPopup,
DialogTitle,
DialogDescription,
DialogHeader,
DialogPanel,
DialogFooter,
} from "../ui/dialog";
import { toastManager } from "../ui/toast";
import { PullRequestStackLayers } from "./PullRequestStackLayers";
import { PullRequestStackHeader } from "./PullRequestStackHeader";
import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent";
export function PullRequestStackMenu({
stack,
reference,
environmentId,
canMerge,
canRebase,
mergeMethod,
onSelect,
onActed,
notice,
onRetry,
}: {
notice?: string | null;
onRetry?: (() => void) | undefined;
stack: PullRequestStack;
reference: PullRequestRef;
environmentId: EnvironmentId;
canMerge: boolean;
canRebase: boolean;
mergeMethod: PullRequestMergeMethod;
onSelect?: ((reference: PullRequestRef) => void) | undefined;
onActed: () => void;
}) {
const [open, setOpen] = useState(false);
const [confirmation, setConfirmation] = useState<"merge" | "update-branch" | null>(null);
const [pending, setPending] = useState(false);
const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });
const top = stack.layers.at(-1);
const unmerged = stack.layers.filter((layer) => layer.state !== "merged");
const hasClosed = unmerged.some((layer) => layer.state !== "open");
const position = stack.layers.findIndex((layer) => layer.number === reference.number) + 1;
const mergeLayers = stack.layers.slice(0, position).filter((layer) => layer.state !== "merged");
const selectedLayer = stack.layers[position - 1];
const mergeHasClosed = mergeLayers.some((layer) => layer.state !== "open");
const expectedStackHeads = unmerged.flatMap((layer) =>
layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [],
);
const hasUnknownHead = expectedStackHeads.length !== unmerged.length;
const mergeDisabled =
pending ||
selectedLayer?.state !== "open" ||
mergeLayers.some((layer) => !layer.headSha) ||
mergeHasClosed ||
mergeLayers.length === 0 ||
mergeLayers.some((layer) => layer.isDraft);
const rebaseDisabled = pending || hasUnknownHead || hasClosed || unmerged.length === 0;
const run = async () => {
if (
pending ||
!confirmation ||
(confirmation === "merge" ? !canMerge || mergeDisabled : !canRebase || rebaseDisabled)
)
return;
const action = confirmation;
const target = action === "merge" ? selectedLayer : top;
if (!target?.headSha) return;
const actionHeads = (action === "merge" ? mergeLayers : unmerged).flatMap((layer) =>
layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [],
);
setPending(true);
const result = await runAction({
environmentId,
input: {
...reference,
number: target.number,
stackNumber: stack.number,
expectedStackHeads: actionHeads,
action,
...(action === "merge" ? { mergeMethod } : { updateMethod: "rebase" }),
},
});
setPending(false);
setConfirmation(null);
onActed();
if (result._tag === "Failure") {
toastManager.add({
type: "error",
title: "Stack operation did not complete",
description: String(squashAtomCommandFailure(result)),
});
} else {
toastManager.add({
type: "success",
title: action === "merge" ? "Stack merge request completed" : "Stack rebased",
description:
action === "merge"
? "GitHub merged the stack or added it to its merge queue."
: undefined,
});
}
};
const confirmationLayers = confirmation === "merge" ? mergeLayers : unmerged;
return (
<>
<Menu open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger
render={
<MenuTrigger
render={
<Button
variant="ghost"
size="xs"
aria-label={`Stack ${stack.number}, layer ${position} of ${stack.layers.length}`}
/>
}
>
<LayersIcon aria-hidden className="size-3.5" /> {position}/{stack.layers.length}
{onRetry ? <TriangleAlertIcon aria-hidden className="size-3 text-warning" /> : null}
</MenuTrigger>
}
/>
<TooltipPopup>
View stack #{stack.number}, layer {position} of {stack.layers.length}
{notice ? ` · ${notice}` : null}
</TooltipPopup>
</Tooltip>
<MenuPopup align="start" className="w-96 max-w-[calc(100vw-2rem)]">
<MenuGroup>
<PullRequestStackHeader number={stack.number} notice={notice} stale={!!onRetry} />
{onRetry ? <MenuItem onClick={onRetry}>Retry stack refresh</MenuItem> : null}
<PullRequestStackLayers
stack={stack}
reference={reference}
pending={pending}
onSelect={
onSelect
? (target) => {
setOpen(false);
onSelect(target);
}
: undefined
}
/>
</MenuGroup>
{canMerge || canRebase ? (
<>
<MenuSeparator />
{canMerge ? (
<MenuItem disabled={mergeDisabled} onClick={() => setConfirmation("merge")}>
<GitMergeIcon aria-hidden />
Merge stack ({mergeLayers.length})
</MenuItem>
) : null}
{canRebase ? (
<MenuItem
disabled={rebaseDisabled}
onClick={() => setConfirmation("update-branch")}
>
<RefreshCwIcon aria-hidden />
Rebase stack
</MenuItem>
) : null}
{mergeHasClosed || mergeLayers.some((layer) => layer.isDraft) ? (
<p className="px-2 py-1 text-xs text-muted-foreground">
Every layer being merged must be open and ready for review.
</p>
) : null}
</>
) : null}
</MenuPopup>
</Menu>
{canMerge && selectedLayer?.state === "open" ? (
<Tooltip>
<TooltipTrigger
render={
<span className="inline-flex">
<Button
variant="default"
size="xs"
disabled={mergeDisabled}
onClick={() => setConfirmation("merge")}
>
<GitMergeIcon aria-hidden className="size-3.5" />
Merge stack
</Button>
</span>
}
/>
<TooltipPopup>
Merge stack through #{reference.number} into {stack.base} ({mergeLayers.length}{" "}
{mergeLayers.length === 1 ? "pull request" : "pull requests"})
</TooltipPopup>
</Tooltip>
) : null}
<Dialog
open={confirmation !== null}
onOpenChange={(value) => {
if (!value && !pending) setConfirmation(null);
}}
>
<DialogPopup className="max-w-md" showCloseButton={!pending}>
<DialogHeader>
<DialogTitle>
{confirmation === "merge"
? `Merge ${mergeLayers.length} pull requests?`
: `Rebase ${unmerged.length} pull requests?`}
</DialogTitle>
<DialogDescription>
{confirmation === "merge"
? `Merge #${reference.number} and its unmerged layers below into ${stack.base} using ${mergeMethod}. GitHub checks their rules before merging or queueing them and rebases the remaining stack after merging.`
: `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.`}
</DialogDescription>
</DialogHeader>
<DialogPanel>
<ul className="max-h-48 space-y-1 overflow-y-auto text-sm">
{confirmationLayers.map((layer) => (
<li
key={layer.number}
className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2"
>
<PullRequestStackLayerContent layer={layer} compact />
</li>
))}
</ul>
</DialogPanel>
<DialogFooter>
<Button variant="outline" disabled={pending} onClick={() => setConfirmation(null)}>
Cancel
</Button>
<Button disabled={pending} onClick={() => void run()}>
{pending ? "Working…" : confirmation === "merge" ? "Merge stack" : "Rebase stack"}
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
</>
);
}