8e55c3c477
* 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.
182 lines
5.2 KiB
TypeScript
182 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import type { MergeableState } from "@/lib/types/github";
|
|
|
|
// --- Pure descriptor (unit-tested) ---
|
|
|
|
export type MergeabilityNotice =
|
|
| { kind: "none" }
|
|
| { kind: "banner" } // dirty / merge conflicts
|
|
| { kind: "chip"; label: string } // blocked / behind base
|
|
| { kind: "text" }; // generic non-mergeable fallback
|
|
|
|
/** Maps a PR's merge state to how prominently the panel surfaces it. */
|
|
export function describeMergeability({
|
|
state,
|
|
mergeable,
|
|
isDraft,
|
|
prState,
|
|
}: {
|
|
state: MergeableState | undefined;
|
|
mergeable: boolean;
|
|
isDraft: boolean;
|
|
prState: string;
|
|
}): MergeabilityNotice {
|
|
if (prState !== "open" || isDraft) return { kind: "none" };
|
|
switch (state) {
|
|
case "dirty":
|
|
return { kind: "banner" };
|
|
case "blocked":
|
|
return { kind: "chip", label: "Blocked" };
|
|
case "behind":
|
|
return { kind: "chip", label: "Behind base" };
|
|
case "clean":
|
|
case "unstable":
|
|
case "has_hooks":
|
|
case "draft":
|
|
// "draft" is gated above via isDraft, but handle the enum value too.
|
|
return { kind: "none" };
|
|
default:
|
|
// unknown / "" / future states: defer to the legacy boolean signal.
|
|
return mergeable === false ? { kind: "text" } : { kind: "none" };
|
|
}
|
|
}
|
|
|
|
/** Chat-context message sent to the agent when the user clicks "Resolve conflicts". */
|
|
export function buildConflictResolutionMessage({
|
|
prNumber,
|
|
headBranch,
|
|
baseBranch,
|
|
}: {
|
|
prNumber: number;
|
|
headBranch: string;
|
|
baseBranch: string;
|
|
}): string {
|
|
return (
|
|
`PR #${prNumber} (\`${headBranch}\` → \`${baseBranch}\`) has merge conflicts and ` +
|
|
`can't be merged automatically. Please merge \`${baseBranch}\` into \`${headBranch}\` ` +
|
|
`(or rebase onto it) and resolve the conflicts.`
|
|
);
|
|
}
|
|
|
|
// --- Presentation ---
|
|
|
|
function ConflictBanner({
|
|
baseBranch,
|
|
onResolveConflicts,
|
|
resolveDisabled,
|
|
popover,
|
|
}: {
|
|
baseBranch: string;
|
|
onResolveConflicts?: () => void;
|
|
resolveDisabled?: boolean;
|
|
popover?: boolean;
|
|
}) {
|
|
return (
|
|
<div
|
|
data-testid="pr-conflict-banner"
|
|
className={cn(
|
|
"flex items-start gap-2.5 rounded-md border border-red-500/35 bg-red-500/10",
|
|
popover ? "px-2.5 py-2" : "px-3 py-2.5",
|
|
)}
|
|
>
|
|
<IconAlertTriangle className="h-4 w-4 shrink-0 text-red-600 dark:text-red-400 mt-0.5" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-xs font-semibold text-red-600 dark:text-red-400">
|
|
Merge conflicts with <code className="font-mono">{baseBranch}</code>
|
|
</div>
|
|
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">
|
|
This branch can't be merged automatically. Resolve the conflicts before merging.
|
|
</p>
|
|
{onResolveConflicts && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
data-testid="pr-resolve-conflicts-button"
|
|
className="mt-2 h-6 cursor-pointer px-2 text-[11px]"
|
|
onClick={onResolveConflicts}
|
|
disabled={resolveDisabled}
|
|
>
|
|
{resolveDisabled ? "Added to chat context" : "Resolve conflicts"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MergeabilityChip({ label, popover }: { label: string; popover?: boolean }) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"flex items-center text-amber-600 dark:text-amber-400",
|
|
popover ? "gap-1.5 text-xs" : "gap-1 text-[10px]",
|
|
)}
|
|
>
|
|
<IconAlertTriangle className={popover ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function NotMergeableText({ popover }: { popover?: boolean }) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"flex items-center text-yellow-600 dark:text-yellow-400",
|
|
popover ? "gap-1.5 text-xs" : "gap-1 text-[10px]",
|
|
)}
|
|
>
|
|
<IconAlertTriangle className={popover ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
|
Not mergeable
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function PRMergeabilityNotice({
|
|
state,
|
|
mergeable,
|
|
isDraft,
|
|
prState,
|
|
baseBranch,
|
|
onResolveConflicts,
|
|
resolveDisabled,
|
|
popover,
|
|
}: {
|
|
state: MergeableState | undefined;
|
|
mergeable: boolean;
|
|
isDraft: boolean;
|
|
prState: string;
|
|
baseBranch: string;
|
|
onResolveConflicts?: () => void;
|
|
resolveDisabled?: boolean;
|
|
/** Render the CI-hover-popover variant: the banner uses tighter card padding,
|
|
* and the chip/text match the popover's `text-xs` rows (so it is slightly
|
|
* larger than the detail-panel default, which sits in a denser chip row). */
|
|
popover?: boolean;
|
|
}) {
|
|
const notice = describeMergeability({ state, mergeable, isDraft, prState });
|
|
if (notice.kind === "none") return null;
|
|
if (notice.kind === "banner")
|
|
return (
|
|
<ConflictBanner
|
|
baseBranch={baseBranch}
|
|
onResolveConflicts={onResolveConflicts}
|
|
resolveDisabled={resolveDisabled}
|
|
popover={popover}
|
|
/>
|
|
);
|
|
return (
|
|
<div className={cn("flex items-center gap-1.5 flex-wrap", popover && "px-1 py-1")}>
|
|
{notice.kind === "chip" ? (
|
|
<MergeabilityChip label={notice.label} popover={popover} />
|
|
) : (
|
|
<NotMergeableText popover={popover} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|