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.
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/lib/github/check-buckets";
|
||||
import type { CheckRun, TaskPR } from "@/lib/types/github";
|
||||
import { PRMergeButton } from "./pr-merge-button";
|
||||
import { PRMergeabilityRow } from "./pr-mergeability-row";
|
||||
|
||||
type CountsView = {
|
||||
passed: number;
|
||||
@@ -543,6 +544,7 @@ export function PRCIPopover({
|
||||
<PRReviewRow pr={pr} />
|
||||
<PRCommentsRow pr={pr} />
|
||||
</div>
|
||||
<PRMergeabilityRow pr={pr} />
|
||||
<PRMergeButton taskPR={pr} onMerged={refetch} compact />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -108,19 +108,50 @@ function MergeButtonShell({
|
||||
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 = compact ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pr-merge-button"
|
||||
onClick={onPrimaryClick}
|
||||
disabled={disabled}
|
||||
className={`inline-flex items-center gap-1 bg-green-600 px-2 py-0.5 text-[11px] font-medium text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 disabled:opacity-60 cursor-pointer ${showDropdown ? "rounded-l-full" : "rounded-full"}`}
|
||||
>
|
||||
<IconGitMerge className="h-3 w-3" />
|
||||
{label}
|
||||
</button>
|
||||
) : (
|
||||
const primaryBtn = (
|
||||
<Button
|
||||
data-testid="pr-merge-button"
|
||||
size="sm"
|
||||
@@ -134,11 +165,11 @@ function MergeButtonShell({
|
||||
);
|
||||
|
||||
if (!showDropdown) {
|
||||
return compact ? primaryBtn : <span className="self-end">{primaryBtn}</span>;
|
||||
return <span className="self-end">{primaryBtn}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={compact ? "inline-flex" : "self-end inline-flex"}>
|
||||
<span className="self-end inline-flex">
|
||||
{primaryBtn}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -148,13 +179,9 @@ function MergeButtonShell({
|
||||
aria-label="Choose merge method"
|
||||
disabled={disabled}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={
|
||||
compact
|
||||
? "inline-flex items-center rounded-r-full border-l border-green-700/40 bg-green-600 px-1.5 py-0.5 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 disabled:opacity-60 cursor-pointer"
|
||||
: "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"
|
||||
}
|
||||
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={compact ? "h-3 w-3" : "h-3.5 w-3.5"} />
|
||||
<IconChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
@@ -182,6 +209,19 @@ function mergeLabel(method?: MergeMethod): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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) ---
|
||||
@@ -67,15 +68,20 @@ function ConflictBanner({
|
||||
baseBranch,
|
||||
onResolveConflicts,
|
||||
resolveDisabled,
|
||||
popover,
|
||||
}: {
|
||||
baseBranch: string;
|
||||
onResolveConflicts?: () => void;
|
||||
resolveDisabled?: boolean;
|
||||
popover?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="pr-conflict-banner"
|
||||
className="flex items-start gap-2.5 rounded-md border border-red-500/35 bg-red-500/10 px-3 py-2.5"
|
||||
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">
|
||||
@@ -102,19 +108,29 @@ function ConflictBanner({
|
||||
);
|
||||
}
|
||||
|
||||
function MergeabilityChip({ label }: { label: string }) {
|
||||
function MergeabilityChip({ label, popover }: { label: string; popover?: boolean }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-amber-600 dark:text-amber-400">
|
||||
<IconAlertTriangle className="h-3 w-3" />
|
||||
<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() {
|
||||
function NotMergeableText({ popover }: { popover?: boolean }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
<IconAlertTriangle className="h-3 w-3" />
|
||||
<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>
|
||||
);
|
||||
@@ -128,6 +144,7 @@ export function PRMergeabilityNotice({
|
||||
baseBranch,
|
||||
onResolveConflicts,
|
||||
resolveDisabled,
|
||||
popover,
|
||||
}: {
|
||||
state: MergeableState | undefined;
|
||||
mergeable: boolean;
|
||||
@@ -136,6 +153,10 @@ export function PRMergeabilityNotice({
|
||||
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;
|
||||
@@ -145,11 +166,16 @@ export function PRMergeabilityNotice({
|
||||
baseBranch={baseBranch}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
resolveDisabled={resolveDisabled}
|
||||
popover={popover}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{notice.kind === "chip" ? <MergeabilityChip label={notice.label} /> : <NotMergeableText />}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { useCommentsStore, isPRFeedbackComment } from "@/lib/state/slices/comments";
|
||||
import { PRMergeabilityRow, blockedReason } from "./pr-mergeability-row";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { MergeableState, TaskPR } from "@/lib/types/github";
|
||||
|
||||
const SESSION_ID = "sess-1";
|
||||
|
||||
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
|
||||
return {
|
||||
id: "id",
|
||||
task_id: "task-1",
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
pr_number: 7,
|
||||
pr_url: "",
|
||||
pr_title: "Test PR",
|
||||
head_branch: "feat",
|
||||
base_branch: "main",
|
||||
author_login: "alice",
|
||||
state: "open",
|
||||
review_state: "",
|
||||
checks_state: "",
|
||||
mergeable_state: "",
|
||||
review_count: 0,
|
||||
pending_review_count: 0,
|
||||
comment_count: 0,
|
||||
unresolved_review_threads: 0,
|
||||
checks_total: 0,
|
||||
checks_passing: 0,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
created_at: "",
|
||||
merged_at: null,
|
||||
closed_at: null,
|
||||
last_synced_at: null,
|
||||
updated_at: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRow(pr: TaskPR, sessionId: string | null = SESSION_ID) {
|
||||
const initialState = {
|
||||
tasks: { activeSessionId: sessionId },
|
||||
} as unknown as Partial<AppState>;
|
||||
return render(
|
||||
<StateProvider initialState={initialState}>
|
||||
<ToastProvider>
|
||||
<div data-testid="row-host">
|
||||
<PRMergeabilityRow pr={pr} />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useCommentsStore.setState({
|
||||
byId: {},
|
||||
bySession: {},
|
||||
pendingForChat: [],
|
||||
editingCommentId: null,
|
||||
});
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("PRMergeabilityRow", () => {
|
||||
it("shows the conflict banner with a Resolve conflicts CTA for a dirty PR", () => {
|
||||
const { queryByTestId } = renderRow(makePR({ mergeable_state: "dirty" }));
|
||||
expect(queryByTestId("pr-conflict-banner")).not.toBeNull();
|
||||
expect(queryByTestId("pr-resolve-conflicts-button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("explains *why* a blocked PR is gated (not just 'Blocked')", () => {
|
||||
const { container, queryByTestId } = renderRow(
|
||||
makePR({ mergeable_state: "blocked", checks_state: "failure" }),
|
||||
);
|
||||
expect(queryByTestId("pr-conflict-banner")).toBeNull();
|
||||
expect(queryByTestId("pr-blocked-note")).not.toBeNull();
|
||||
expect(container.textContent).toContain("Blocked by branch protection");
|
||||
});
|
||||
|
||||
it("stays silent for a blocked PR that is only awaiting a requested review", () => {
|
||||
// The block is just an outstanding reviewer — the review row + calm chip
|
||||
// already convey that, so the row must not show a contradictory note/chip.
|
||||
const { getByTestId } = renderRow(
|
||||
makePR({
|
||||
mergeable_state: "blocked",
|
||||
review_state: "approved",
|
||||
checks_state: "success",
|
||||
pending_review_count: 1,
|
||||
}),
|
||||
);
|
||||
expect(getByTestId("row-host").childElementCount).toBe(0);
|
||||
});
|
||||
|
||||
it("shows a Behind base chip for a behind PR", () => {
|
||||
const { container } = renderRow(makePR({ mergeable_state: "behind" }));
|
||||
expect(container.textContent).toContain("Behind base");
|
||||
});
|
||||
|
||||
it.each(["clean", "unstable", "has_hooks", "unknown", ""] as MergeableState[])(
|
||||
"renders nothing for mergeable_state=%s (stays quiet, no false alarm)",
|
||||
(mergeable_state) => {
|
||||
const { getByTestId } = renderRow(makePR({ mergeable_state }));
|
||||
expect(getByTestId("row-host").childElementCount).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("renders nothing for a non-open PR even when dirty", () => {
|
||||
const { getByTestId } = renderRow(makePR({ state: "merged", mergeable_state: "dirty" }));
|
||||
expect(getByTestId("row-host").childElementCount).toBe(0);
|
||||
});
|
||||
|
||||
it("queues a conflict-resolution prompt for the active session when the CTA is clicked", () => {
|
||||
const { getByTestId } = renderRow(makePR({ mergeable_state: "dirty", pr_number: 7 }));
|
||||
fireEvent.click(getByTestId("pr-resolve-conflicts-button"));
|
||||
|
||||
const queued = useCommentsStore
|
||||
.getState()
|
||||
.pendingForChat.map((id) => useCommentsStore.getState().byId[id])
|
||||
.filter((c) => !!c && isPRFeedbackComment(c));
|
||||
expect(queued).toHaveLength(1);
|
||||
const comment = queued[0]!;
|
||||
expect(isPRFeedbackComment(comment) && comment.feedbackType).toBe("conflict");
|
||||
expect(isPRFeedbackComment(comment) && comment.prNumber).toBe(7);
|
||||
expect(isPRFeedbackComment(comment) && comment.sessionId).toBe(SESSION_ID);
|
||||
expect(comment.content.toLowerCase()).toContain("conflict");
|
||||
});
|
||||
|
||||
it("hides the Resolve conflicts CTA when there is no active session", () => {
|
||||
const { queryByTestId } = renderRow(makePR({ mergeable_state: "dirty" }), null);
|
||||
// Banner still surfaces the conflict, but the CTA needs a session to target.
|
||||
expect(queryByTestId("pr-conflict-banner")).not.toBeNull();
|
||||
expect(queryByTestId("pr-resolve-conflicts-button")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("blockedReason", () => {
|
||||
it("names the approval shortfall when required reviews aren't met", () => {
|
||||
expect(blockedReason(makePR({ required_reviews: 2, review_count: 0 }))).toContain(
|
||||
"2 more approvals",
|
||||
);
|
||||
expect(blockedReason(makePR({ required_reviews: 2, review_count: 1 }))).toContain(
|
||||
"1 more approval",
|
||||
);
|
||||
});
|
||||
|
||||
it("points at a failing required check when CI is failure/pending", () => {
|
||||
expect(blockedReason(makePR({ checks_state: "failure" })).toLowerCase()).toContain(
|
||||
"required status check",
|
||||
);
|
||||
expect(blockedReason(makePR({ checks_state: "pending" })).toLowerCase()).toContain(
|
||||
"required status check",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not claim a check failed when no CI is configured (empty checks_state)", () => {
|
||||
// Regression: `checks_state !== "success"` also matched "" and falsely
|
||||
// reported a status-check block on a code-owners/conversation gate.
|
||||
const msg = blockedReason(makePR({ checks_state: "" })).toLowerCase();
|
||||
expect(msg).not.toContain("required status check");
|
||||
expect(msg).toContain("branch protection rule");
|
||||
});
|
||||
|
||||
it("falls back to a generic protection note for other rules", () => {
|
||||
const msg = blockedReason(
|
||||
makePR({ checks_state: "success", required_reviews: 1, review_count: 1 }),
|
||||
);
|
||||
expect(msg.toLowerCase()).toContain("branch protection rule");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import {
|
||||
useCommentsStore,
|
||||
isPRFeedbackComment,
|
||||
type PRFeedbackComment,
|
||||
} from "@/lib/state/slices/comments";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import { isPRAwaitingReview } from "./pr-task-icon";
|
||||
import { PRMergeabilityNotice, buildConflictResolutionMessage } from "./pr-mergeability-notice";
|
||||
|
||||
/**
|
||||
* Wires the "Resolve conflicts" CTA to chat context, mirroring the PR detail
|
||||
* panel (`useAddPRFeedbackAsContext` + `conflictQueued`). Returns a null handler
|
||||
* when there is no active session so the button is suppressed rather than no-op.
|
||||
*/
|
||||
function useResolveConflicts(pr: TaskPR): {
|
||||
onResolveConflicts: (() => void) | null;
|
||||
conflictQueued: boolean;
|
||||
} {
|
||||
const sessionId = useAppStore((s) => s.tasks.activeSessionId);
|
||||
const addComment = useCommentsStore((s) => s.addComment);
|
||||
const { toast } = useToast();
|
||||
const prNumber = pr.pr_number;
|
||||
const headBranch = pr.head_branch;
|
||||
const baseBranch = pr.base_branch;
|
||||
|
||||
// True once a conflict prompt for this PR is already queued — avoids piling
|
||||
// up identical instructions if the user clicks "Resolve conflicts" again.
|
||||
const conflictQueued = useCommentsStore((s) =>
|
||||
s.pendingForChat.some((id) => {
|
||||
const c = s.byId[id];
|
||||
return (
|
||||
!!c &&
|
||||
isPRFeedbackComment(c) &&
|
||||
c.feedbackType === "conflict" &&
|
||||
c.sessionId === sessionId &&
|
||||
c.prNumber === prNumber
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = useCallback(() => {
|
||||
if (!sessionId || conflictQueued) return;
|
||||
const content = buildConflictResolutionMessage({ prNumber, headBranch, baseBranch });
|
||||
const comment: PRFeedbackComment = {
|
||||
id: `pr-feedback-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
sessionId,
|
||||
text: content,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: "pending",
|
||||
source: "pr-feedback",
|
||||
prNumber,
|
||||
feedbackType: "conflict",
|
||||
content,
|
||||
};
|
||||
addComment(comment);
|
||||
toast({ description: "Added to chat context" });
|
||||
}, [sessionId, conflictQueued, prNumber, headBranch, baseBranch, addComment, toast]);
|
||||
|
||||
return { onResolveConflicts: sessionId ? handler : null, conflictQueued };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mergeability cue for the CI hover popover. Surfaces the conflict banner
|
||||
* ("dirty") and the Blocked / Behind-base chips so an unmergeable PR no longer
|
||||
* silently drops its merge button with no explanation. Renders nothing for
|
||||
* clean / unstable / has_hooks and for transient unknown/empty states (we pass
|
||||
* `mergeable: true` so the popover stays quiet instead of flapping "Not
|
||||
* mergeable" while GitHub recomputes).
|
||||
*/
|
||||
export function PRMergeabilityRow({ pr }: { pr: TaskPR }) {
|
||||
const { onResolveConflicts, conflictQueued } = useResolveConflicts(pr);
|
||||
// "blocked" gets a richer note than the bare chip: it explains *why* the
|
||||
// merge is gated. But when the block is only an outstanding requested review,
|
||||
// stay silent — the review row above already conveys that and the
|
||||
// pill/chip/badge deliberately read it as the calm "awaiting review" state,
|
||||
// so a "Blocked by branch protection" note (or even the bare chip) here would
|
||||
// contradict them.
|
||||
if (pr.state === "open" && pr.mergeable_state === "blocked") {
|
||||
if (isPRAwaitingReview(pr)) return null;
|
||||
return <BlockedNote reason={blockedReason(pr)} />;
|
||||
}
|
||||
return (
|
||||
<PRMergeabilityNotice
|
||||
popover
|
||||
state={pr.mergeable_state}
|
||||
mergeable
|
||||
isDraft={pr.mergeable_state === "draft"}
|
||||
prState={pr.state}
|
||||
baseBranch={pr.base_branch}
|
||||
onResolveConflicts={onResolveConflicts ?? undefined}
|
||||
resolveDisabled={conflictQueued}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort explanation for a `blocked` mergeable state. GitHub doesn't tell
|
||||
* us which branch-protection rule failed, so we infer the common cases from the
|
||||
* fields we do have (required reviews, CI state) and fall back to a generic
|
||||
* note for the rest (code owners, required conversations, etc.).
|
||||
*/
|
||||
export function blockedReason(pr: TaskPR): string {
|
||||
const required = pr.required_reviews ?? null;
|
||||
if (required != null && pr.review_count < required) {
|
||||
const missing = required - pr.review_count;
|
||||
return `Needs ${missing} more approval${missing === 1 ? "" : "s"} before it can merge.`;
|
||||
}
|
||||
// Only "failure"/"pending" indicate an actual check problem; "" means no CI
|
||||
// is configured (or it hasn't loaded), which isn't a status-check block.
|
||||
if (pr.checks_state === "failure" || pr.checks_state === "pending") {
|
||||
return "A required status check hasn't passed yet.";
|
||||
}
|
||||
return "A branch protection rule (required reviews, checks, or code owners) must be satisfied.";
|
||||
}
|
||||
|
||||
function BlockedNote({ reason }: { reason: string }) {
|
||||
return (
|
||||
<div data-testid="pr-blocked-note" className="flex items-start gap-1.5 px-1 py-1 text-xs">
|
||||
<IconAlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-amber-600 dark:text-amber-400">
|
||||
Blocked by branch protection
|
||||
</div>
|
||||
<p className="text-[11px] leading-snug text-muted-foreground">{reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -224,6 +224,52 @@ describe("PRStatusChip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PRStatusChip — mergeability", () => {
|
||||
it("is 'conflict' (not 'passed') for a dirty PR even with green checks + approval", () => {
|
||||
// Regression: the chip read mergeable_state-blind and showed the green
|
||||
// "passed" check on a PR that actually had merge conflicts.
|
||||
renderWithStore(
|
||||
{ taskPRs: { byTaskId: { "task-1": [makePR({ mergeable_state: "dirty" })] } } },
|
||||
<PRStatusChip taskId="task-1" />,
|
||||
);
|
||||
const chip = screen.getByTestId(CHIP_TESTID);
|
||||
expect(chip.getAttribute(ATTR_STATUS)).toBe("conflict");
|
||||
expect(chip.getAttribute("data-pr-ready-to-merge")).toBe("false");
|
||||
});
|
||||
|
||||
it("is 'behind' for a behind-base PR that is otherwise green", () => {
|
||||
renderWithStore(
|
||||
{ taskPRs: { byTaskId: { "task-1": [makePR({ mergeable_state: "behind" })] } } },
|
||||
<PRStatusChip taskId="task-1" />,
|
||||
);
|
||||
expect(screen.getByTestId(CHIP_TESTID).getAttribute(ATTR_STATUS)).toBe("behind");
|
||||
});
|
||||
|
||||
it("is 'blocked' (amber, not green) for a blocked PR with green checks + approval", () => {
|
||||
renderWithStore(
|
||||
{ taskPRs: { byTaskId: { "task-1": [makePR({ mergeable_state: "blocked" })] } } },
|
||||
<PRStatusChip taskId="task-1" />,
|
||||
);
|
||||
expect(screen.getByTestId(CHIP_TESTID).getAttribute(ATTR_STATUS)).toBe("blocked");
|
||||
});
|
||||
|
||||
it("stays 'in_progress' for a blocked PR that is still awaiting a requested review", () => {
|
||||
// Blocked because a reviewer is still pending → that's the awaiting-review
|
||||
// gate, not a generic protection block. Keep the in-progress reading.
|
||||
renderWithStore(
|
||||
{
|
||||
taskPRs: {
|
||||
byTaskId: {
|
||||
"task-1": [makePR({ mergeable_state: "blocked", pending_review_count: 1 })],
|
||||
},
|
||||
},
|
||||
},
|
||||
<PRStatusChip taskId="task-1" />,
|
||||
);
|
||||
expect(screen.getByTestId(CHIP_TESTID).getAttribute(ATTR_STATUS)).toBe("in_progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateChipStatus", () => {
|
||||
it("returns 'neutral' for an empty list", () => {
|
||||
expect(aggregateChipStatus([])).toBe("neutral");
|
||||
@@ -240,6 +286,18 @@ describe("aggregateChipStatus", () => {
|
||||
const pending = makePR({ id: "pend", review_state: "", checks_state: "pending" });
|
||||
expect(aggregateChipStatus([passing, pending])).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("lets a conflicting PR dominate a passing sibling", () => {
|
||||
const passing = makePR();
|
||||
const conflict = makePR({ id: "dirty", mergeable_state: "dirty" });
|
||||
expect(aggregateChipStatus([passing, conflict])).toBe("conflict");
|
||||
});
|
||||
|
||||
it("ranks a failing PR above a conflicting one", () => {
|
||||
const conflict = makePR({ id: "dirty", mergeable_state: "dirty" });
|
||||
const failing = makePR({ id: "fail", checks_state: "failure" });
|
||||
expect(aggregateChipStatus([conflict, failing])).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PRStatusChip — multiple PRs", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconChecklist,
|
||||
IconLoader2,
|
||||
IconPointFilled,
|
||||
IconAlertTriangleFilled,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card";
|
||||
@@ -36,10 +37,22 @@ const HOVER_CLOSE_DELAY_MS = 150;
|
||||
|
||||
// Terminal states (merged / closed) never reach here — PRStatusChip returns
|
||||
// null for them before rendering — so the chip status union omits them.
|
||||
type ChipStatus = "passed" | "failed" | "in_progress" | "neutral";
|
||||
type ChipStatus =
|
||||
| "passed"
|
||||
| "failed"
|
||||
| "conflict"
|
||||
| "blocked"
|
||||
| "behind"
|
||||
| "in_progress"
|
||||
| "neutral";
|
||||
|
||||
function chipStatus(pr: TaskPR): ChipStatus {
|
||||
if (pr.review_state === "changes_requested" || pr.checks_state === "failure") return "failed";
|
||||
// Merge conflicts / behind-base block the merge even when CI is green — the
|
||||
// chip must never read as a passed check in that case. Mirrors
|
||||
// getPRStatusColor + PRStatusIcon (dirty = red, behind = amber).
|
||||
if (pr.mergeable_state === "dirty") return "conflict";
|
||||
if (pr.mergeable_state === "behind") return "behind";
|
||||
// Pending checks / pending review must beat checks_state === "success" so a
|
||||
// PR with all checks green but reviewers still outstanding renders as
|
||||
// in-progress, not passed. Without this order, the chip flips to green the
|
||||
@@ -49,14 +62,20 @@ function chipStatus(pr: TaskPR): ChipStatus {
|
||||
// Mirror getPRStatusColor priority: ready-to-merge beats awaiting-review so
|
||||
// the chip and icon never disagree on a (theoretical) clean+approved+pending PR.
|
||||
if (isPRAwaitingReview(pr) && !isPRReadyToMerge(pr)) return "in_progress";
|
||||
// Blocked-by-branch-protection that isn't just an outstanding review (those
|
||||
// are caught above) is a real gate — amber, not the green "passed" check.
|
||||
if (pr.mergeable_state === "blocked") return "blocked";
|
||||
if (pr.checks_state === "success") return "passed";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
// Higher = more attention-worthy. Drives the aggregate glyph when a task has
|
||||
// multiple open PRs — one failing PR colours the whole chip red.
|
||||
// multiple open PRs — one failing/conflicting PR colours the whole chip.
|
||||
const CHIP_STATUS_RANK: Record<ChipStatus, number> = {
|
||||
failed: 3,
|
||||
failed: 6,
|
||||
conflict: 5,
|
||||
blocked: 4,
|
||||
behind: 3,
|
||||
in_progress: 2,
|
||||
passed: 1,
|
||||
neutral: 0,
|
||||
@@ -329,6 +348,11 @@ function ChipStatusGlyph({ status }: { status: ChipStatus }) {
|
||||
return <IconCircleCheckFilled className="h-3.5 w-3.5 text-green-500" aria-hidden="true" />;
|
||||
case "failed":
|
||||
return <IconCircleXFilled className="h-3.5 w-3.5 text-red-500" aria-hidden="true" />;
|
||||
case "conflict":
|
||||
return <IconAlertTriangleFilled className="h-3.5 w-3.5 text-red-500" aria-hidden="true" />;
|
||||
case "behind":
|
||||
case "blocked":
|
||||
return <IconAlertTriangleFilled className="h-3.5 w-3.5 text-yellow-500" aria-hidden="true" />;
|
||||
case "in_progress":
|
||||
// CI runs take minutes, so slow the spin to ~3s/rotation — the default
|
||||
// animate-spin (1s) feels frantic for a long-running task.
|
||||
|
||||
@@ -45,6 +45,8 @@ function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
|
||||
}
|
||||
|
||||
const SKY_400 = "text-sky-400";
|
||||
const RED_500 = "text-red-500";
|
||||
const YELLOW_500 = "text-yellow-500";
|
||||
|
||||
describe("isPRReadyToMerge", () => {
|
||||
it("is true when open + approved + success + clean", () => {
|
||||
@@ -207,14 +209,16 @@ describe("getPRStatusColor", () => {
|
||||
expect(getPRStatusColor(pr)).toBe("text-emerald-400");
|
||||
});
|
||||
|
||||
it("returns plain green for approved+success but mergeable_state blocked", () => {
|
||||
it("returns yellow for approved+success but mergeable_state blocked (branch protection)", () => {
|
||||
// Blocked-by-branch-protection with no outstanding review request is an
|
||||
// attention state — the plain green would imply the PR is good to go.
|
||||
const pr = makePR({
|
||||
state: "open",
|
||||
review_state: "approved",
|
||||
checks_state: "success",
|
||||
mergeable_state: "blocked",
|
||||
});
|
||||
expect(getPRStatusColor(pr)).toBe("text-green-500");
|
||||
expect(getPRStatusColor(pr)).toBe(YELLOW_500);
|
||||
});
|
||||
|
||||
it("returns sky-400 for approved PR that still has pending reviewers (1 of N required)", () => {
|
||||
@@ -296,12 +300,12 @@ describe("getPRStatusColor", () => {
|
||||
checks_state: "success",
|
||||
mergeable_state: "clean",
|
||||
});
|
||||
expect(getPRStatusColor(pr)).toBe("text-red-500");
|
||||
expect(getPRStatusColor(pr)).toBe(RED_500);
|
||||
});
|
||||
|
||||
it("returns yellow for pending CI", () => {
|
||||
const pr = makePR({ state: "open", checks_state: "pending" });
|
||||
expect(getPRStatusColor(pr)).toBe("text-yellow-500");
|
||||
expect(getPRStatusColor(pr)).toBe(YELLOW_500);
|
||||
});
|
||||
|
||||
it("returns purple for merged", () => {
|
||||
@@ -309,6 +313,37 @@ describe("getPRStatusColor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPRStatusColor — mergeability", () => {
|
||||
it("returns red for a dirty (merge-conflict) PR even when approved + checks pass", () => {
|
||||
// Regression: before the mergeability branch, an approved+success but
|
||||
// conflicted PR fell through to plain green — the icon read "good" while
|
||||
// the PR could not be merged.
|
||||
const pr = makePR({
|
||||
state: "open",
|
||||
review_state: "approved",
|
||||
checks_state: "success",
|
||||
mergeable_state: "dirty",
|
||||
});
|
||||
expect(getPRStatusColor(pr)).toBe(RED_500);
|
||||
});
|
||||
|
||||
it("returns yellow for a behind-base PR that is otherwise approved + green", () => {
|
||||
const pr = makePR({
|
||||
state: "open",
|
||||
review_state: "approved",
|
||||
checks_state: "success",
|
||||
mergeable_state: "behind",
|
||||
});
|
||||
expect(getPRStatusColor(pr)).toBe(YELLOW_500);
|
||||
});
|
||||
|
||||
it("ignores dirty/behind mergeable_state on terminal (merged) PRs", () => {
|
||||
expect(getPRStatusColor(makePR({ state: "merged", mergeable_state: "dirty" }))).toBe(
|
||||
"text-purple-500",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPRAwaitingReview", () => {
|
||||
it("is true when CI succeeded and review is pending", () => {
|
||||
expect(
|
||||
@@ -406,7 +441,7 @@ describe("aggregatePRStatusColor", () => {
|
||||
review_state: "changes_requested",
|
||||
checks_state: "success",
|
||||
});
|
||||
expect(aggregatePRStatusColor([green, red])).toBe("text-red-500");
|
||||
expect(aggregatePRStatusColor([green, red])).toBe(RED_500);
|
||||
});
|
||||
|
||||
it("returns emerald only when all PRs are ready to merge", () => {
|
||||
@@ -422,7 +457,7 @@ describe("aggregatePRStatusColor", () => {
|
||||
it("yellow CI pending beats merged purple", () => {
|
||||
const pending = makePR({ state: "open", checks_state: "pending" });
|
||||
const merged = makePR({ state: "merged" });
|
||||
expect(aggregatePRStatusColor([merged, pending])).toBe("text-yellow-500");
|
||||
expect(aggregatePRStatusColor([merged, pending])).toBe(YELLOW_500);
|
||||
});
|
||||
|
||||
it("ignores a merged PR when a fresh open PR has no status yet", () => {
|
||||
@@ -450,7 +485,7 @@ describe("aggregatePRStatusColor", () => {
|
||||
const merged = makePR({ state: "merged" });
|
||||
expect(aggregatePRStatusColor([merged, merged])).toBe("text-purple-500");
|
||||
const closed = makePR({ state: "closed" });
|
||||
expect(aggregatePRStatusColor([closed, closed])).toBe("text-red-500");
|
||||
expect(aggregatePRStatusColor([closed, closed])).toBe(RED_500);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -51,12 +51,26 @@ export function isPRAwaitingReview(pr: TaskPR): boolean {
|
||||
return pr.review_state === "pending" || pr.pending_review_count > 0;
|
||||
}
|
||||
|
||||
// Colour for the hard merge blockers that must beat ready/awaiting-review:
|
||||
// conflicts ("dirty") are a hard stop, "behind" needs a base update first.
|
||||
// Returns null for every other state so the caller falls through to its
|
||||
// review/check-driven colours. ("blocked" is handled later, after
|
||||
// awaiting-review, so an outstanding review still reads as sky.)
|
||||
function openMergeBlockerColor(pr: TaskPR): string | null {
|
||||
if (pr.state !== "open") return null;
|
||||
if (pr.mergeable_state === "dirty") return "text-red-500";
|
||||
if (pr.mergeable_state === "behind") return "text-yellow-500";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getPRStatusColor(pr: TaskPR): string {
|
||||
if (pr.state === "merged") return "text-purple-500";
|
||||
if (pr.state === "closed") return "text-red-500";
|
||||
if (pr.review_state === "changes_requested" || pr.checks_state === "failure") {
|
||||
return "text-red-500";
|
||||
}
|
||||
const blockerColor = openMergeBlockerColor(pr);
|
||||
if (blockerColor) return blockerColor;
|
||||
if (isPRReadyToMerge(pr)) {
|
||||
return "text-emerald-400";
|
||||
}
|
||||
@@ -65,6 +79,12 @@ export function getPRStatusColor(pr: TaskPR): string {
|
||||
if (isPRAwaitingReview(pr)) {
|
||||
return "text-sky-400";
|
||||
}
|
||||
// Blocked by branch protection (a rule other than outstanding reviews, which
|
||||
// the awaiting-review branch above already caught) is an attention state —
|
||||
// not the plain green of an approved+passing PR.
|
||||
if (pr.state === "open" && pr.mergeable_state === "blocked") {
|
||||
return "text-yellow-500";
|
||||
}
|
||||
if (pr.review_state === "approved" && pr.checks_state === "success") {
|
||||
return "text-green-500";
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
IconGitPullRequest,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconClock,
|
||||
IconChevronDown,
|
||||
IconAlertTriangle,
|
||||
} from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover";
|
||||
@@ -37,6 +38,21 @@ import type { TaskPR } from "@/lib/types/github";
|
||||
const POPOVER_OPEN_DELAY_MS = 150;
|
||||
const POPOVER_CLOSE_DELAY_MS = 150;
|
||||
|
||||
// Badge for the hard merge blockers that must beat ready/awaiting-review:
|
||||
// conflicts (red) and behind-base (amber). Mirrors openMergeBlockerColor so
|
||||
// the badge agrees with the pill colour. Returns null otherwise. ("blocked"
|
||||
// is handled later, after awaiting-review.)
|
||||
function mergeBlockerBadge(pr: TaskPR): ReactNode | null {
|
||||
if (pr.state !== "open") return null;
|
||||
if (pr.mergeable_state === "dirty") {
|
||||
return <IconAlertTriangle className="h-3 w-3 text-red-500" />;
|
||||
}
|
||||
if (pr.mergeable_state === "behind") {
|
||||
return <IconAlertTriangle className="h-3 w-3 text-yellow-500" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function PRStatusIcon({ pr }: { pr: TaskPR }) {
|
||||
// Terminal states take priority
|
||||
if (pr.state === "merged") {
|
||||
@@ -49,6 +65,8 @@ function PRStatusIcon({ pr }: { pr: TaskPR }) {
|
||||
if (pr.checks_state === "failure" || pr.review_state === "changes_requested") {
|
||||
return <IconX className="h-3 w-3 text-red-500" />;
|
||||
}
|
||||
const blockerBadge = mergeBlockerBadge(pr);
|
||||
if (blockerBadge) return blockerBadge;
|
||||
if (isPRReadyToMerge(pr)) {
|
||||
return <IconCheck className="h-3 w-3 text-emerald-400" />;
|
||||
}
|
||||
@@ -57,6 +75,11 @@ function PRStatusIcon({ pr }: { pr: TaskPR }) {
|
||||
if (isPRAwaitingReview(pr)) {
|
||||
return <IconClock className="h-3 w-3 text-sky-400" />;
|
||||
}
|
||||
// Blocked by branch protection (not just an outstanding review) → amber,
|
||||
// never the green "all good" check.
|
||||
if (pr.state === "open" && pr.mergeable_state === "blocked") {
|
||||
return <IconAlertTriangle className="h-3 w-3 text-yellow-500" />;
|
||||
}
|
||||
if (pr.checks_state === "success" && pr.review_state === "approved") {
|
||||
return <IconCheck className="h-3 w-3 text-green-500" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user