);
}
// Renders nothing unless the PR is fully green (CI success + mergeable +
// approval or no-review-needed). When `useRepoMergeMethods` hasn't yet
// returned the repo's allowed methods (loading, failure, or expired cache)
// the button still renders — clicks fall through to the backend resolver.
// Shared by the PR detail panel header and the topbar hover popover so a
// "ready" PR can be merged from either surface. `compact` switches to the
// smaller pill variant used inside the dense popover.
export function PRMergeButton({
taskPR,
onMerged,
compact = false,
}: {
taskPR: TaskPR;
onMerged?: () => void;
compact?: boolean;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const [merging, setMerging] = useState(false);
// After GitHub accepts a direct or queued merge, stay hidden until refreshed
// provider state catches up so a repeat click cannot submit a second request.
const [acceptedFor, setAcceptedFor] = useState(null);
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const methods = useRepoMergeMethods(workspaceId, taskPR.owner, taskPR.repo);
const { status } = useGitHubStatus(workspaceId);
const mutationActor = getGitHubMutationActor(status);
// Reset after switching PRs or after GitHub reports an observable lifecycle
// or mergeability transition, including a PR ejected from a merge queue.
const signature = `${taskPR.id}:${taskPR.state}:${taskPR.mergeable_state}`;
const accepted = acceptedFor === signature;
if (accepted || !canAttemptPRMerge(taskPR)) return null;
// `methods` may be null on first render, on lookup failure, or after the
// 5-minute cache window. We still render the button — clicking with no
// method routes through the backend's GetRepoMergeMethods resolver, so
// the merge succeeds either way. The trade-off is a brief label flicker
// from "Merge PR" → "Squash and merge" on first load; locking the user
// out of merging on a transient fetch failure is the worse alternative.
const allowed = allowedMethods(methods);
const primary = pickPrimaryMethod(allowed);
const runMerge = async (method?: MergeMethod) => {
if (!workspaceId) return;
const submittedSignature = signature;
setMerging(true);
try {
const result = await mergePR(
workspaceId,
taskPR.owner,
taskPR.repo,
taskPR.pr_number,
method,
);
setAcceptedFor(submittedSignature);
toast({
description: t(
result.status === "queued" ? "github:prAddedToMergeQueue" : "github:prMerged",
),
variant: "success",
});
onMerged?.();
} catch (err) {
toast({
title: t("github:failedToMerge"),
description: err instanceof Error ? err.message : t("github:anErrorOccurred"),
variant: "error",
});
} finally {
setMerging(false);
}
};
const handlePrimary = (e: React.MouseEvent) => {
e.stopPropagation();
void runMerge(primary);
};
return (
m !== primary)
}
onPickMethod={(m) => void runMerge(m)}
/>
);
}
type MergeButtonShellProps = {
compact: boolean;
label: string;
disabled: boolean;
actor: string | null;
onPrimaryClick: (e: React.MouseEvent) => void;
extraMethods: MergeMethod[];
onPickMethod: (m: MergeMethod) => void;
};
function MergeButtonShell({
compact,
label,
disabled,
actor,
onPrimaryClick,
extraMethods,
onPickMethod,
}: MergeButtonShellProps) {
const { t } = useTranslation();
// Compact (hover popover): a single full-width primary button plus quiet
// "or " links for the alternates. Deliberately avoids a dropdown —
// its menu renders in a detached portal that closes the hover popover the
// moment the pointer leaves it (the bug this replaces). Plain buttons keep
// every action inside the popover's own DOM.
if (compact) {
return (
);
}
const showDropdown = extraMethods.length > 0;
const primaryBtn = (
);
if (!showDropdown) {
return (
{primaryBtn}
);
}
return (
{primaryBtn}
{extraMethods.map((m) => (
onPickMethod(m)}>
{t(mergeLabelKey(m))}
))}
);
}
/** `method` is GitHub's persisted merge method; only the label is copy. */
function mergeLabelKey(method?: MergeMethod): string {
switch (method) {
case "squash":
return "github:mergeMethodSquash";
case "rebase":
return "github:mergeMethodRebase";
case "merge":
return "github:mergeMethodMergeCommit";
default:
return "github:mergeMethodDefault";
}
}
// Short form used by the compact "or " alternates so the row fits the
// narrow popover without wrapping.
function mergeShortLabelKey(method: MergeMethod): string {
switch (method) {
case "squash":
return "github:mergeMethodSquashShort";
case "rebase":
return "github:mergeMethodRebaseShort";
case "merge":
return "github:mergeMethodMergeCommitShort";
}
}
// Returns the methods the repo allows, in the order we prefer to surface
// them (squash → merge → rebase, matching GitHub's own button defaults).
// When the lookup hasn't resolved (or failed), returns an empty array so
// the button still renders without a dropdown and the backend's resolver
// picks the method at merge time.
function allowedMethods(
methods: { merge: boolean; squash: boolean; rebase: boolean } | null,
): MergeMethod[] {
if (!methods) return [];
const order: MergeMethod[] = ["squash", "merge", "rebase"];
return order.filter((m) => methods[m]);
}
function pickPrimaryMethod(allowed: MergeMethod[]): MergeMethod | undefined {
return allowed[0];
}