Files
kandev/apps/web/components/github/pr-merge-button.tsx
T
José Almeida 8e55c3c477 feat(web): surface PR merge-conflict & mergeability state in the CI popover (#1448)
* feat(web): surface PR mergeability + conflict state in the CI popover

The CI hover popover showed checks/reviews but nothing about mergeability, so an
unmergeable PR silently dropped its merge button with no reason. Add a mergeability
row that reuses the detail panel's conflict banner + "Resolve conflicts" agent prompt
for dirty PRs, a "Behind base" chip, and a branch-protection note explaining why a
blocked PR is gated. Replace the compact merge split-button+dropdown with an inline
primary + "or <method>" buttons so the menu's detached portal no longer closes the
hover popover.

* feat(web): reflect PR mergeability in status colors and badges

The PR status pill, badge, and chat-input CI chip derived their colour from
checks/reviews only, so a conflicted or blocked PR still read as a green "all good"
check. Map dirty -> red, behind -> amber, and branch-protection blocked -> amber across
getPRStatusColor, the PRStatusIcon badge, and the chip glyph, so the indicators agree
with the popover. Blocked stays sky/in-progress while a requested review is still
outstanding (that's the awaiting-review gate, not a hard block).

* refactor(web): remove dead merge-menu keep-open plumbing

The compact merge control was redesigned to inline buttons (no dropdown portal),
which already fixes the hover popover closing. That leaves the keepOpen ref /
setKeepOpen / onMergeMenuOpenChange / onMenuOpenChange chain threaded through
usePopoverInteractions, PRCIPopover, and PRMergeButton unreachable: the popover
always renders PRMergeButton with compact, and the only DropdownMenu lives in the
non-compact detail-panel path that never passes the callback. Drop the unused
scaffolding (flagged by OpenCode + greptile).

* fix(web): keep CI popover quiet for blocked PRs only awaiting review

PRMergeabilityRow rendered "Blocked by branch protection" for any blocked PR,
including one blocked solely because a requested reviewer hasn't responded. That
contradicted the chip/pill/badge, which deliberately read that case as the calm
"awaiting review" state. Skip the note (render nothing) when isPRAwaitingReview is
true, and narrow blockedReason so an empty checks_state no longer falsely claims a
required status check failed. Flagged by Claude (blocker) and greptile.

* refactor(web): rename mergeability-notice compact prop to popover

The `compact` prop made the chip/text larger (to match the popover's text-xs rows)
while ConflictBanner's `compact` tightened padding — so "compact" rendered bigger,
which reads as inverted. Rename it to `popover` (the variant rendered inside the CI
hover popover) and document that the chip intentionally matches the popover rows.
No visual change. Flagged by cubic.

* fix(web): show default cursor on disabled compact merge alternates

The compact "or <method>" alternates kept cursor-pointer while disabled (during the
~1s merging state); add disabled:cursor-default to match the primary button. Flagged
by OpenCode.
2026-06-19 14:56:24 +01:00

241 lines
8.2 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { IconChevronDown, IconGitMerge } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { useToast } from "@/components/toast-provider";
import { useRepoMergeMethods } from "@/hooks/domains/github/use-repo-merge-methods";
import { mergePR } from "@/lib/api/domains/github-api";
import type { MergeMethod, TaskPR } from "@/lib/types/github";
import { isPRReadyToMerge } from "./pr-task-icon";
// 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 { toast } = useToast();
const [merging, setMerging] = useState(false);
// After a successful merge we stay hidden until the store catches up to
// state="merged" — otherwise the button briefly re-enables during the async
// refresh window and a double-click would hit the GitHub API again.
const [merged, setMerged] = useState(false);
const methods = useRepoMergeMethods(taskPR.owner, taskPR.repo);
// If the same component instance ever renders a different PR (e.g. the user
// switches the active task while the panel/popover stays mounted), the
// sticky `merged` flag from a previous merge would hide the button for an
// unrelated, still-mergeable PR. Reset it whenever the underlying PR id
// changes.
useEffect(() => {
setMerged(false);
}, [taskPR.id]);
if (merged || !isPRReadyToMerge(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) => {
setMerging(true);
try {
await mergePR(taskPR.owner, taskPR.repo, taskPR.pr_number, method);
setMerged(true);
toast({ description: "PR merged", variant: "success" });
onMerged?.();
} catch (err) {
toast({
title: "Failed to merge",
description: err instanceof Error ? err.message : "An error occurred",
variant: "error",
});
} finally {
setMerging(false);
}
};
const handlePrimary = (e: React.MouseEvent) => {
e.stopPropagation();
void runMerge(primary);
};
return (
<MergeButtonShell
compact={compact}
label={merging ? "Merging..." : mergeLabel(primary)}
disabled={merging}
onPrimaryClick={handlePrimary}
extraMethods={allowed.filter((m) => m !== primary)}
onPickMethod={(m) => void runMerge(m)}
/>
);
}
function MergeButtonShell({
compact,
label,
disabled,
onPrimaryClick,
extraMethods,
onPickMethod,
}: {
compact: boolean;
label: string;
disabled: boolean;
onPrimaryClick: (e: React.MouseEvent) => void;
extraMethods: MergeMethod[];
onPickMethod: (m: MergeMethod) => void;
}) {
// Compact (hover popover): a single full-width primary button plus quiet
// "or <method>" 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 (
<div className="flex flex-col gap-1.5">
<button
type="button"
data-testid="pr-merge-button"
onClick={onPrimaryClick}
disabled={disabled}
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md bg-green-600 px-2 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-green-700 disabled:cursor-default disabled:opacity-60 cursor-pointer"
>
<IconGitMerge className="h-3.5 w-3.5" />
{label}
</button>
{extraMethods.length > 0 && (
<div className="flex items-center justify-center gap-1 text-[11px] text-muted-foreground">
<span>or</span>
{extraMethods.map((m) => (
<button
key={m}
type="button"
data-testid={`pr-merge-method-${m}`}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
onPickMethod(m);
}}
className="cursor-pointer rounded px-1.5 py-0.5 font-medium hover:bg-muted hover:text-foreground disabled:cursor-default disabled:opacity-60"
>
{mergeShortLabel(m)}
</button>
))}
</div>
)}
</div>
);
}
const showDropdown = extraMethods.length > 0;
const primaryBtn = (
<Button
data-testid="pr-merge-button"
size="sm"
className={`cursor-pointer gap-1.5 border-0 bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 ${showDropdown ? "rounded-r-none" : ""}`}
onClick={onPrimaryClick}
disabled={disabled}
>
<IconGitMerge className="h-3.5 w-3.5" />
{label}
</Button>
);
if (!showDropdown) {
return <span className="self-end">{primaryBtn}</span>;
}
return (
<span className="self-end inline-flex">
{primaryBtn}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-testid="pr-merge-button-more"
aria-label="Choose merge method"
disabled={disabled}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center rounded-r-md border-l border-green-700/40 bg-green-600 px-2 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 disabled:opacity-60 cursor-pointer"
>
<IconChevronDown className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
{extraMethods.map((m) => (
<DropdownMenuItem key={m} onSelect={() => onPickMethod(m)}>
{mergeLabel(m)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</span>
);
}
function mergeLabel(method?: MergeMethod): string {
switch (method) {
case "squash":
return "Squash and merge";
case "rebase":
return "Rebase and merge";
case "merge":
return "Create a merge commit";
default:
return "Merge PR";
}
}
// Short form used by the compact "or <method>" alternates so the row fits the
// narrow popover without wrapping.
function mergeShortLabel(method: MergeMethod): string {
switch (method) {
case "squash":
return "Squash";
case "rebase":
return "Rebase";
case "merge":
return "Merge commit";
}
}
// 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];
}