"use client"; import { useState } from "react"; import { IconChevronDown, IconGitMerge } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { controlSizingClassName } from "@kandev/ui/control-sizing"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { useRepoMergeMethods } from "@/hooks/domains/github/use-repo-merge-methods"; import { mergePR } from "@/lib/api/domains/github-api"; import { getGitHubMutationActor } from "@/lib/github-auth"; import type { MergeMethod, TaskPR } from "@/lib/types/github"; import { canAttemptPRMerge } from "./pr-task-icon"; import { useTranslation } from "react-i18next"; function MutationActor({ actor }: { actor: string | null }) { if (!actor) return null; return as {actor}; } function CompactMergeButton({ label, disabled, actor, onPrimaryClick, extraMethods, onPickMethod, }: Omit) { const { t } = useTranslation(); return (
{extraMethods.length > 0 && (
{t("github:or")} {extraMethods.map((method) => ( ))}
)}
); } // 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]; }