fix(github): harden auto-merge and surface task automation (#3128)

This commit is contained in:
Carlos Florêncio
2026-08-28 22:23:45 +00:00
committed by GitHub
parent 6929536ddc
commit ace26dfd99
91 changed files with 5290 additions and 316 deletions
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
const hookMocks = vi.hoisted(() => ({
error: null as string | null,
options: null as TaskCIAutomationOptions | null,
refresh: vi.fn(),
retryMerge: vi.fn(),
}));
vi.mock("@/hooks/domains/github/use-task-ci-options", () => ({
useTaskCIAutomationOptions: () => ({
options: hookMocks.options,
loading: false,
saving: false,
error: hookMocks.error,
refresh: hookMocks.refresh,
retryMerge: hookMocks.retryMerge,
update: vi.fn(),
resetPrompt: vi.fn(),
}),
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
vi.mock("@/components/github/pr-ci-automation-prompt-dialog", () => ({
CIAutomationPromptDialog: () => null,
}));
vi.mock("@/components/github/pr-ci-automation-rows", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/components/github/pr-ci-automation-rows")>();
return {
...actual,
CIAutomationHeader: () => null,
CIAutomationOptionRows: () => null,
};
});
import { PRCIAutomationControls } from "./pr-ci-automation-controls";
const pr = {
task_id: "task-1",
repository_id: "repo-1",
pr_number: 42,
} as TaskPR;
function makeOptions(errorKind: string): TaskCIAutomationOptions {
return {
task_id: "task-1",
auto_fix_enabled: false,
auto_merge_enabled: true,
auto_fix_prompt_override: null,
effective_auto_fix_prompt: "Default prompt",
using_default_prompt: true,
updated_at: "2026-08-28T10:00:00Z",
pr_options: [],
pr_states: [
{
task_id: "task-1",
repository_id: "repo-1",
pr_number: 42,
last_error: "merge PR: provider unavailable",
last_error_kind: errorKind,
} as TaskCIAutomationOptions["pr_states"][number],
],
};
}
beforeEach(() => {
hookMocks.error = null;
hookMocks.options = makeOptions("auto_merge");
hookMocks.refresh.mockReset().mockResolvedValue(null);
hookMocks.retryMerge.mockReset().mockResolvedValue({ accepted: true });
});
afterEach(cleanup);
describe("PRCIAutomationControls retry actions", () => {
it("rearms only a typed auto-merge error for the exact linked PR", () => {
render(<PRCIAutomationControls pr={pr} />);
const action = screen.getByRole("button", { name: "Retry" });
expect(action.className).toContain("h-11");
fireEvent.click(action);
expect(hookMocks.retryMerge).toHaveBeenCalledWith("repo-1", 42);
expect(hookMocks.refresh).not.toHaveBeenCalled();
});
it("refreshes state for a stored error that is not an auto-merge failure", () => {
hookMocks.options = makeOptions("auto_fix");
render(<PRCIAutomationControls pr={pr} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(hookMocks.refresh).toHaveBeenCalledTimes(1);
expect(hookMocks.retryMerge).not.toHaveBeenCalled();
});
it("refreshes state after a load error and never rearms merge", () => {
hookMocks.options = null;
hookMocks.error = "backend unavailable";
render(<PRCIAutomationControls pr={pr} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(hookMocks.refresh).toHaveBeenCalledTimes(1);
expect(hookMocks.retryMerge).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/components/toast-provider";
import { useTaskCIAutomationOptions } from "@/hooks/domains/github/use-task-ci-options";
import {
@@ -17,9 +18,10 @@ import {
} from "@/components/github/pr-ci-automation-rows";
export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
const { options, loading, saving, error, refresh, update, resetPrompt } =
const { options, loading, saving, error, refresh, update, resetPrompt, retryMerge } =
useTaskCIAutomationOptions(pr.task_id);
const { toast } = useToast();
const { t } = useTranslation();
const [promptOpen, setPromptOpen] = useState(false);
const [promptDraft, setPromptDraft] = useState("");
const automationState = findCIAutomationStateForPR(options?.pr_states, pr);
@@ -48,9 +50,11 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
repository_id: pr.repository_id ?? "",
pr_number: pr.pr_number,
};
Promise.resolve(update(scoped)).catch(() => reportError("Failed to update CI automation."));
Promise.resolve(update(scoped)).catch(() =>
reportError(t("github:failedToUpdateCiAutomation")),
);
},
[pr, reportError, update],
[pr, reportError, t, update],
);
// The auto-fix prompt override stays task-level (applies to every linked
@@ -60,18 +64,26 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
if (!value) return;
Promise.resolve(update({ auto_fix_prompt_override: value }))
.then(() => setPromptOpen(false))
.catch(() => reportError("Failed to save auto-fix prompt."));
}, [promptDraft, reportError, update]);
.catch(() => reportError(t("github:failedToSaveAutoFixPrompt")));
}, [promptDraft, reportError, t, update]);
const useDefaultPrompt = useCallback(() => {
Promise.resolve(resetPrompt())
.then(() => setPromptOpen(false))
.catch(() => reportError("Failed to reset auto-fix prompt."));
}, [reportError, resetPrompt]);
.catch(() => reportError(t("github:failedToResetAutoFixPrompt")));
}, [reportError, resetPrompt, t]);
const retryLoad = useCallback(() => {
Promise.resolve(refresh()).catch(() => reportError("Failed to load CI automation."));
}, [refresh, reportError]);
Promise.resolve(refresh()).catch(() =>
reportError(t("github:failedToLoadCiAutomationOptions")),
);
}, [refresh, reportError, t]);
const retryAutoMerge = useCallback(() => {
Promise.resolve(retryMerge(pr.repository_id ?? "", pr.pr_number))
.then(() => toast({ description: t("github:autoMergeRetryAccepted") }))
.catch(() => reportError(t("github:failedToRetryAutoMerge")));
}, [pr.pr_number, pr.repository_id, reportError, retryMerge, t, toast]);
const disabled = loading || saving || !options;
return (
@@ -93,14 +105,17 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
patchOption={patchOption}
automationState={automationState}
/>
{automationState?.last_error && (
<CIAutomationErrorRow
error={automationState.last_error}
loading={loading}
onRetry={retryLoad}
/>
)}
{error && <CIAutomationErrorRow error={error} loading={loading} onRetry={retryLoad} />}
<PRCIAutomationErrors
storedError={automationState?.last_error}
storedErrorKind={automationState?.last_error_kind}
loadError={error}
loading={loading}
saving={saving}
retryLabel={t("github:retry")}
refreshLabel={t("github:refresh")}
onRetryMerge={retryAutoMerge}
onRefresh={retryLoad}
/>
<CIAutomationPromptDialog
open={promptOpen}
prompt={promptDraft}
@@ -113,3 +128,47 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
</div>
);
}
function PRCIAutomationErrors({
storedError,
storedErrorKind,
loadError,
loading,
saving,
retryLabel,
refreshLabel,
onRetryMerge,
onRefresh,
}: {
storedError?: string | null;
storedErrorKind?: string;
loadError?: string | null;
loading: boolean;
saving: boolean;
retryLabel: string;
refreshLabel: string;
onRetryMerge: () => void;
onRefresh: () => void;
}) {
const mergeError = storedErrorKind === "auto_merge";
return (
<>
{storedError && (
<CIAutomationErrorRow
error={storedError}
loading={loading || saving}
actionLabel={mergeError ? retryLabel : refreshLabel}
onAction={mergeError ? onRetryMerge : onRefresh}
/>
)}
{loadError && (
<CIAutomationErrorRow
error={loadError}
loading={loading}
actionLabel={refreshLabel}
onAction={onRefresh}
/>
)}
</>
);
}
@@ -104,13 +104,14 @@ export function CIAutomationRow({
export function CIAutomationErrorRow({
error,
loading,
onRetry,
actionLabel,
onAction,
}: {
error: string;
loading: boolean;
onRetry: () => void;
actionLabel: string;
onAction: () => void;
}) {
const { t } = useTranslation();
return (
<div
role="alert"
@@ -121,12 +122,12 @@ export function CIAutomationErrorRow({
type="button"
variant="ghost"
size="sm"
className="h-6 cursor-pointer gap-1 px-2 text-[11px]"
className="h-11 min-w-11 cursor-pointer gap-1 px-2 text-[11px] sm:h-6 sm:min-w-0"
disabled={loading}
onClick={onRetry}
onClick={onAction}
>
<IconRefresh className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
{t("github:retry")}
{actionLabel}
</Button>
</div>
);
@@ -290,7 +290,9 @@ describe("PRCIPopover automation status", () => {
auto_fix_exhausted_at: null,
last_merge_signature: "",
last_merge_attempt_at: null,
last_merge_result: "",
last_error: "No promptable task session is available.",
last_error_kind: "",
created_at: "",
updated_at: "",
},
@@ -338,10 +340,12 @@ describe("PRCIPopover automation status", () => {
auto_fix_exhausted_at: null,
last_merge_signature: "",
last_merge_attempt_at: null,
last_merge_result: "",
last_queue_attempt_head_sha: "head-a",
last_queue_fix_event_id: "",
last_queue_removal_cause: "provider_changed",
last_error: null,
last_error_kind: "",
created_at: "",
updated_at: "",
},
@@ -599,12 +603,12 @@ describe("PRCIPopover task prompts", () => {
expect(hookMocks.updateMock).not.toHaveBeenCalled();
});
it("offers retry after CI automation options fail to load", () => {
it("offers refresh after CI automation options fail to load", () => {
hookMocks.error = BACKEND_UNAVAILABLE;
renderPopover();
expect(screen.getByText(BACKEND_UNAVAILABLE)).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(hookMocks.refreshMock).toHaveBeenCalledTimes(1);
});
@@ -143,7 +143,9 @@ function makeCIPrState(roundCount: number, exhausted = false) {
auto_fix_exhausted_at: exhausted ? "2026-06-18T11:00:00Z" : null,
last_merge_signature: "",
last_merge_attempt_at: null,
last_merge_result: "" as const,
last_error: exhausted ? "CI auto-fix paused after 10 rounds for this PR" : null,
last_error_kind: "",
created_at: "2026-06-18T10:00:00Z",
updated_at: "2026-06-18T10:00:00Z",
};
@@ -0,0 +1,51 @@
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
import { automationForPR } from "./pr-status-automation-badges";
import type { TaskPRInfo } from "@/components/task/task-pr-info";
export type { TaskPRInfo } from "@/components/task/task-pr-info";
export type TaskPRAutomationDetail = {
number: number;
repository?: string;
autoFixEnabled: boolean;
autoMergeEnabled: boolean;
};
export type TaskPRAutomationSummary = {
autoFixEnabled: boolean;
autoMergeEnabled: boolean;
details: TaskPRAutomationDetail[];
};
export function getTaskPRAutomationSummary(
prs: TaskPR[],
prInfo?: TaskPRInfo,
options?: TaskCIAutomationOptions | null,
): TaskPRAutomationSummary {
const activePRs = prs.filter((pr) => pr.state.trim().toLowerCase() === "open");
// The bounded task-row projection is the source of truth until the full PR
// list is hydrated. Keep its flags visible even when a previously hydrated
// options response is present but the scoped PR records are not.
if (!options || prs.length === 0) {
return {
autoFixEnabled: prInfo?.autoFixEnabled === true,
autoMergeEnabled: prInfo?.autoMergeEnabled === true,
details: [],
};
}
const details = activePRs.map((pr) => {
const automation = automationForPR(options, pr);
const repository = [pr.owner, pr.repo].filter(Boolean).join("/") || undefined;
return {
number: pr.pr_number,
...(repository ? { repository } : {}),
autoFixEnabled: automation.autoFix,
autoMergeEnabled: automation.autoMerge,
};
});
return {
autoFixEnabled: details.some((detail) => detail.autoFixEnabled),
autoMergeEnabled: details.some((detail) => detail.autoMergeEnabled),
details,
};
}
@@ -0,0 +1,312 @@
"use client";
import { IconGitPullRequest } from "@tabler/icons-react";
import {
forwardRef,
type FocusEventHandler,
type MouseEventHandler,
type PointerEventHandler,
type ReactNode,
type Ref,
} from "react";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@kandev/ui/drawer";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import type { TaskPR } from "@/lib/types/github";
import type { TaskPRTooltipHydrationStatus } from "@/hooks/domains/github/use-task-pr-tooltip-hydration";
import { useChangeRequestTaskTooltipState } from "@/components/integrations/use-change-request-task-tooltip-state";
import type { TaskPRAutomationSummary, TaskPRInfo } from "./pr-task-automation";
export type PRTaskIconDisclosureProps = {
taskId: string;
prInfo?: TaskPRInfo;
prs: TaskPR[];
hasFullData: boolean;
singlePR: TaskPR | null;
readyToMerge: boolean;
allReadyToMerge: boolean;
displayState: string | undefined;
displayCount: number;
iconColor: string;
ariaLabel: string;
icon: ReactNode;
content: ReactNode;
};
export function AutomationIndicatorDots({
autoFixEnabled,
autoMergeEnabled,
}: Pick<TaskPRAutomationSummary, "autoFixEnabled" | "autoMergeEnabled">) {
return (
<>
{autoFixEnabled && (
<span
data-testid="pr-task-automation-auto-fix"
className="absolute left-0 top-0 h-1.5 w-1.5 rounded-full bg-yellow-400 ring-1 ring-background"
aria-hidden="true"
/>
)}
{autoMergeEnabled && (
<span
data-testid="pr-task-automation-auto-merge"
className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-purple-500 ring-1 ring-background"
aria-hidden="true"
/>
)}
</>
);
}
export function PRTaskIconGlyph({ automation }: { automation: TaskPRAutomationSummary }) {
return (
<span className="relative inline-flex h-3.5 w-3.5 shrink-0">
<IconGitPullRequest aria-hidden="true" className="h-3.5 w-3.5" />
<AutomationIndicatorDots
autoFixEnabled={automation.autoFixEnabled}
autoMergeEnabled={automation.autoMergeEnabled}
/>
</span>
);
}
export function PRTaskIconDrawer({
open,
onOpenChange,
t,
...props
}: PRTaskIconDisclosureProps & {
open: boolean;
onOpenChange: (open: boolean) => void;
t: ReturnType<typeof useTranslation>["t"];
}) {
const { prs, prInfo, singlePR, content } = props;
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerTrigger asChild>
<PRTaskIconTrigger
{...props}
touch
open={open}
onClick={(event) => event.stopPropagation()}
/>
</DrawerTrigger>
<DrawerContent
data-testid={`pr-task-automation-drawer-${props.taskId}`}
className="max-h-[80dvh] flex flex-col"
>
<DrawerHeader className="shrink-0 border-b py-2">
<DrawerTitle className="text-sm">
{prs.length > 1
? t("github:pullRequestCount", { count: prs.length })
: t("github:pullRequestStatus", { number: singlePR?.pr_number ?? prInfo?.number })}
</DrawerTitle>
<DrawerDescription className="sr-only">
{t("github:pullRequestCiStatusReviewsAnd")}
</DrawerDescription>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-3" data-vaul-no-drag>
{content}
</div>
</DrawerContent>
</Drawer>
);
}
export function PRTaskIconTooltip({
tooltip,
...props
}: PRTaskIconDisclosureProps & {
tooltip: ReturnType<typeof useChangeRequestTaskTooltipState>;
}) {
return (
<Tooltip open={tooltip.open}>
<TooltipTrigger asChild>
<PRTaskIconTrigger
{...props}
onPointerEnter={tooltip.onPointerEnter}
onPointerLeave={tooltip.onPointerLeave}
onFocus={tooltip.onFocus}
onBlur={tooltip.onBlur}
/>
</TooltipTrigger>
<TooltipContent
sideOffset={6}
onEscapeKeyDown={tooltip.onEscapeKeyDown}
className="w-80 max-w-[calc(100vw-1rem)] p-3"
>
{props.content}
</TooltipContent>
</Tooltip>
);
}
type PRTaskIconTriggerProps = PRTaskIconDisclosureProps & {
touch?: boolean;
open?: boolean;
onClick?: MouseEventHandler<HTMLButtonElement>;
onPointerEnter?: PointerEventHandler<HTMLSpanElement>;
onPointerLeave?: PointerEventHandler<HTMLSpanElement>;
onFocus?: FocusEventHandler<HTMLSpanElement>;
onBlur?: FocusEventHandler<HTMLSpanElement>;
};
export const PRTaskIconTrigger = forwardRef<HTMLElement, PRTaskIconTriggerProps>(
function PRTaskIconTrigger(
{
touch = false,
open = false,
onClick,
taskId,
prInfo: _prInfo,
prs,
hasFullData,
singlePR: _singlePR,
readyToMerge,
allReadyToMerge,
displayState,
displayCount,
iconColor,
ariaLabel,
icon,
content: _content,
onPointerEnter,
onPointerLeave,
onFocus,
onBlur,
...triggerAttributes
},
ref: Ref<HTMLElement>,
) {
const commonAttributes = {
"data-testid": `pr-task-icon-${taskId}`,
"data-pr-state": displayState,
"data-pr-count": displayCount,
"data-pr-ready-to-merge": hasFullData
? String(prs.length === 1 ? readyToMerge : allReadyToMerge)
: undefined,
"aria-label": ariaLabel,
};
const contents = (
<span className="inline-flex items-center gap-0.5">
{icon}
{prs.length > 1 ? (
<span className="text-[9px] font-semibold leading-none tabular-nums">{prs.length}</span>
) : null}
</span>
);
if (touch) {
return (
<button
ref={ref as Ref<HTMLButtonElement>}
type="button"
{...triggerAttributes}
{...commonAttributes}
aria-haspopup="dialog"
aria-expanded={open}
className={cn(
"relative inline-flex h-3.5 w-3.5 shrink-0 cursor-pointer items-center justify-center [@media(pointer:coarse)]:after:absolute [@media(pointer:coarse)]:after:-inset-[15px] [@media(pointer:coarse)]:after:content-['']",
iconColor,
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={onClick}
>
{contents}
</button>
);
}
return (
<span
ref={ref as Ref<HTMLSpanElement>}
{...triggerAttributes}
{...commonAttributes}
role="img"
tabIndex={0}
className={cn("inline-flex shrink-0 items-center", prs.length > 1 && "gap-0.5", iconColor)}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onFocus={onFocus}
onBlur={onBlur}
>
{contents}
</span>
);
},
);
export function TaskPRAutomationDetails({
summary,
status,
}: {
summary: TaskPRAutomationSummary;
status?: TaskPRTooltipHydrationStatus;
}) {
const { t } = useTranslation();
const hasAutomation = summary.autoFixEnabled || summary.autoMergeEnabled;
if (!hasAutomation && summary.details.length === 0) return null;
const detailContent =
summary.details.length > 0 ? (
<div className="mt-1 space-y-1">
{summary.details.map((detail) => (
<div
key={`${detail.repository ?? ""}-${detail.number}`}
className="flex flex-wrap items-center gap-x-2 gap-y-1"
>
<span className="font-medium">
{detail.repository
? t("github:prTaskStatusRepositoryNumber", {
repository: detail.repository,
number: detail.number,
})
: t("github:prTaskStatusNumber", { number: detail.number })}
</span>
{detail.autoFixEnabled && (
<span className="text-yellow-500">{t("github:autoFix")}</span>
)}
{detail.autoMergeEnabled && (
<span className="text-purple-400">{t("github:autoMerge")}</span>
)}
</div>
))}
</div>
) : null;
const loadingContent =
status === "loading" || status === "idle" ? (
<p className="mt-1 text-muted-foreground">{t("github:taskPrDetailsLoading")}</p>
) : null;
return (
<section
data-testid="pr-task-automation-details"
className="mt-2 border-t border-border/60 pt-2 text-xs"
>
<h4 className="font-medium text-foreground">{t("github:automation")}</h4>
{detailContent ?? loadingContent}
</section>
);
}
export function CompactPRTooltipContent({ status }: { status: TaskPRTooltipHydrationStatus }) {
const { t } = useTranslation();
if (status === "loading" || status === "idle") {
return (
<span data-testid="pr-task-tooltip-loading" className="text-sm text-muted-foreground">
{t("github:taskPrDetailsLoading")}
</span>
);
}
if (status === "unavailable") {
return (
<span data-testid="pr-task-tooltip-unavailable" className="text-sm text-muted-foreground">
{t("github:taskPrDetailsUnavailable")}
</span>
);
}
return null;
}
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import { getTaskPRAutomationSummary } from "./pr-task-icon";
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "task",
owner: "o",
repo: "r",
pr_number: 1,
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 makeOptions(): TaskCIAutomationOptions {
return {
task_id: "task",
workspace_id: "workspace-1",
auto_fix_enabled: false,
auto_merge_enabled: false,
auto_fix_prompt_override: null,
effective_auto_fix_prompt: "",
using_default_prompt: true,
updated_at: "2026-08-01T00:00:00Z",
pr_states: [],
pr_options: [
{
task_id: "task",
repository_id: "repo-a",
pr_number: 1,
auto_fix_enabled: true,
auto_merge_enabled: false,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
created_at: "",
updated_at: "",
},
{
task_id: "task",
repository_id: "repo-b",
pr_number: 2,
auto_fix_enabled: false,
auto_merge_enabled: true,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
created_at: "",
updated_at: "",
},
{
task_id: "task",
repository_id: "repo-c",
pr_number: 3,
auto_fix_enabled: true,
auto_merge_enabled: true,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
created_at: "",
updated_at: "",
},
],
};
}
describe("getTaskPRAutomationSummary", () => {
it("aggregates independent active PR settings and ignores terminal PRs", () => {
const summary = getTaskPRAutomationSummary(
[
makePR({ repository_id: "repo-a", pr_number: 1 }),
makePR({ repository_id: "repo-b", pr_number: 2 }),
makePR({ repository_id: "repo-c", pr_number: 3, state: "merged" }),
],
undefined,
makeOptions(),
);
expect(summary).toEqual({
autoFixEnabled: true,
autoMergeEnabled: true,
details: [
{ number: 1, repository: "o/r", autoFixEnabled: true, autoMergeEnabled: false },
{ number: 2, repository: "o/r", autoFixEnabled: false, autoMergeEnabled: true },
],
});
});
it("uses bounded row flags before per-PR details hydrate", () => {
expect(
getTaskPRAutomationSummary([], {
number: 7,
state: "open",
autoFixEnabled: true,
autoMergeEnabled: true,
}),
).toEqual({
autoFixEnabled: true,
autoMergeEnabled: true,
details: [],
});
});
it("keeps bounded row flags while full PR records are temporarily unavailable", () => {
expect(
getTaskPRAutomationSummary(
[],
{ number: 7, state: "open", autoFixEnabled: true, autoMergeEnabled: false },
makeOptions(),
),
).toEqual({
autoFixEnabled: true,
autoMergeEnabled: false,
details: [],
});
});
it("retains repository identity when linked PR numbers overlap", () => {
const summary = getTaskPRAutomationSummary(
[
makePR({ owner: "acme", repo: "frontend", repository_id: "repo-a", pr_number: 7 }),
makePR({ owner: "acme", repo: "backend", repository_id: "repo-b", pr_number: 7 }),
],
undefined,
{
...makeOptions(),
pr_options: [
{ ...makeOptions().pr_options[0], repository_id: "repo-a", pr_number: 7 },
{ ...makeOptions().pr_options[1], repository_id: "repo-b", pr_number: 7 },
],
},
);
expect(summary.details.map(({ repository, number }) => ({ repository, number }))).toEqual([
{ repository: "acme/frontend", number: 7 },
{ repository: "acme/backend", number: 7 },
]);
});
});
@@ -7,13 +7,16 @@ import { TaskContributionIcons } from "@/components/task/task-contribution-icons
import { PRTaskIcon } from "./pr-task-icon";
import type { AppState } from "@/lib/state/store";
import type { TaskPR } from "@/lib/types/github";
import type { TaskCIAutomationOptions } from "@/lib/types/github";
const listTaskPRsMock = vi.hoisted(() => vi.fn());
const getTaskCIAutomationOptionsMock = vi.hoisted(() => vi.fn());
const TASK_ID = "task-1";
const WORKSPACE_ID = "workspace-1";
vi.mock("@/lib/api/domains/github-api", () => ({
listTaskPRs: listTaskPRsMock,
getTaskCIAutomationOptions: getTaskCIAutomationOptionsMock,
}));
function renderWithStore(initialState: Partial<AppState> | undefined, ui: ReactNode) {
@@ -58,8 +61,40 @@ function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
};
}
function makeAutomationOptions(
overrides: Partial<TaskCIAutomationOptions> = {},
): TaskCIAutomationOptions {
return {
task_id: TASK_ID,
workspace_id: WORKSPACE_ID,
auto_fix_enabled: true,
auto_merge_enabled: true,
auto_fix_prompt_override: null,
effective_auto_fix_prompt: "",
using_default_prompt: true,
updated_at: "2026-08-01T00:00:00Z",
pr_states: [],
pr_options: [
{
task_id: TASK_ID,
repository_id: "repo-1",
pr_number: 1,
auto_fix_enabled: true,
auto_merge_enabled: true,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
created_at: "",
updated_at: "",
},
],
...overrides,
};
}
beforeEach(() => {
listTaskPRsMock.mockReset().mockReturnValue(new Promise(() => {}));
getTaskCIAutomationOptionsMock.mockReset().mockResolvedValue(undefined);
});
afterEach(() => cleanup());
@@ -177,3 +212,46 @@ describe("PRTaskIcon corrupted store entry", () => {
expect(document.activeElement).toBe(screen.getByTestId(`pr-task-icon-${TASK_ID}`));
});
});
describe("PRTaskIcon automation indicators", () => {
// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-002.10
it("renders independent automation dots from the bounded row projection", () => {
renderWithStore(
{ workspaces: { items: [], activeId: WORKSPACE_ID } },
<TaskContributionIcons
taskId={TASK_ID}
prInfo={{ number: 7, state: "open", autoFixEnabled: true, autoMergeEnabled: true }}
/>,
);
expect(screen.getByTestId("pr-task-automation-auto-fix")).not.toBeNull();
expect(screen.getByTestId("pr-task-automation-auto-merge")).not.toBeNull();
});
// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-002.11
it("shows per-PR automation details after the icon disclosure hydrates", async () => {
const response = Promise.resolve({
task_prs: { [TASK_ID]: [makePR({ repository_id: "repo-1" })] },
});
listTaskPRsMock.mockReturnValue(response);
getTaskCIAutomationOptionsMock.mockResolvedValue(makeAutomationOptions());
renderWithStore(
{ workspaces: { items: [], activeId: WORKSPACE_ID } },
<TaskContributionIcons taskId={TASK_ID} prInfo={{ number: 1, state: "open" }} />,
);
fireEvent.pointerEnter(screen.getByTestId(`pr-task-icon-${TASK_ID}`), {
pointerType: "mouse",
});
await act(async () => {
await response;
});
await waitFor(() =>
expect(screen.getAllByTestId("pr-task-automation-details").length).toBeGreaterThan(0),
);
expect(screen.getAllByText("o/r PR #1").length).toBeGreaterThan(0);
expect(screen.getAllByText("Auto-fix").length).toBeGreaterThan(0);
expect(screen.getAllByText("Auto-merge").length).toBeGreaterThan(0);
});
});
+85 -80
View File
@@ -1,9 +1,7 @@
"use client";
import { IconGitPullRequest } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/components/state-provider";
import { useChangeRequestTaskTooltipState } from "@/components/integrations/use-change-request-task-tooltip-state";
import {
@@ -14,10 +12,24 @@ import {
import {
getTaskPRsForCurrentWorkspace,
useTaskPRTooltipHydration,
type TaskPRTooltipHydrationStatus,
} from "@/hooks/domains/github/use-task-pr-tooltip-hydration";
import type { TaskPR } from "@/lib/types/github";
import type { TaskCIAutomationOptions } from "@/lib/types/github";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import { derivePRTaskStatusSummary, PRTaskStatusSummary } from "./pr-task-status-summary";
import {
CompactPRTooltipContent,
PRTaskIconDrawer,
PRTaskIconGlyph,
PRTaskIconTooltip,
TaskPRAutomationDetails,
type PRTaskIconDisclosureProps,
} from "./pr-task-icon-disclosure";
import { getTaskPRAutomationSummary, type TaskPRInfo } from "./pr-task-automation";
export type { TaskPRInfo } from "./pr-task-automation";
export { getTaskPRAutomationSummary } from "./pr-task-automation";
export { AutomationIndicatorDots } from "./pr-task-icon-disclosure";
const MUTED_FOREGROUND = CHANGE_REQUEST_STATUS_COLORS.muted;
const PURPLE_500 = CHANGE_REQUEST_STATUS_COLORS.merged;
@@ -238,15 +250,9 @@ export function pickDefaultPR(prs: TaskPR[]): TaskPR | null {
return best;
}
export type TaskPRInfo = {
number: number;
state: string;
aggregateState?: string;
};
export function PRTaskIcon({ taskId, prInfo }: { taskId: string; prInfo?: TaskPRInfo }) {
const prs = useAppStore((state) => getTaskPRsForCurrentWorkspace(state, taskId));
const hydration = useTaskPRTooltipHydration(taskId);
const hydration = useTaskPRTooltipHydration(taskId, { includeAutomation: true });
const fullPRs = Array.isArray(prs) && prs.length > 0 ? prs : [];
// Defensive: an upstream payload may briefly seed byTaskId[taskId] with a
@@ -254,7 +260,15 @@ export function PRTaskIcon({ taskId, prInfo }: { taskId: string; prInfo?: TaskPR
// instead of falling through into a full-data summary, where for-of throws.
if (fullPRs.length === 0 && !prInfo) return null;
return <PRTaskIconView taskId={taskId} prInfo={prInfo} prs={fullPRs} hydration={hydration} />;
return (
<PRTaskIconView
taskId={taskId}
prInfo={prInfo}
prs={fullPRs}
hydration={hydration}
automationOptions={hydration.automationOptions}
/>
);
}
type TaskPRIconPresentation = {
@@ -289,21 +303,27 @@ function PRTaskIconView({
prInfo,
prs,
hydration,
automationOptions,
}: {
taskId: string;
prInfo?: TaskPRInfo;
prs: TaskPR[];
hydration: ReturnType<typeof useTaskPRTooltipHydration>;
automationOptions: TaskCIAutomationOptions | null;
}) {
const { t } = useTranslation();
const { hydrate } = hydration;
const hasFullData = prs.length > 0;
const tooltip = useChangeRequestTaskTooltipState(
!hasFullData && prInfo
? () => {
void hydration.hydrate();
}
: undefined,
);
const usesTouchDrawer = useTouchDrawer();
const [drawerOpen, setDrawerOpen] = useState(false);
const automation = getTaskPRAutomationSummary(prs, prInfo, automationOptions);
const needsHydration =
!hasFullData ||
((automation.autoFixEnabled || automation.autoMergeEnabled) && !automationOptions);
const hydrateOnDisclosure = useCallback(() => {
if (needsHydration) void hydrate();
}, [hydrate, needsHydration]);
const tooltip = useChangeRequestTaskTooltipState(hydrateOnDisclosure);
const {
singlePR,
readyToMerge,
@@ -314,52 +334,56 @@ function PRTaskIconView({
displayCount,
} = getTaskPRIconPresentation(prs, prInfo);
const ariaLabel =
const statusAriaLabel =
prs.length > 1
? t("github:pullRequestStatuses", { count: prs.length })
: t("github:pullRequestStatus", { number: singlePR?.pr_number ?? prInfo?.number });
const automationAria = [
automation.autoFixEnabled ? t("github:autoFixEnabledAria") : null,
automation.autoMergeEnabled ? t("github:autoMergeEnabledAria") : null,
]
.filter(Boolean)
.join(", ");
const ariaLabel = automationAria ? `${statusAriaLabel}, ${automationAria}` : statusAriaLabel;
return (
<Tooltip open={tooltip.open}>
<TooltipTrigger asChild>
<span
data-testid={`pr-task-icon-${taskId}`}
data-pr-state={displayState}
data-pr-count={displayCount}
data-pr-ready-to-merge={
hasFullData ? String(prs.length === 1 ? readyToMerge : allReadyToMerge) : undefined
}
role="img"
tabIndex={0}
aria-label={ariaLabel}
onPointerEnter={tooltip.onPointerEnter}
onPointerLeave={tooltip.onPointerLeave}
onFocus={tooltip.onFocus}
onBlur={tooltip.onBlur}
className={cn(
"inline-flex items-center shrink-0",
prs.length > 1 && "gap-0.5",
iconColor,
)}
>
<IconGitPullRequest aria-hidden="true" className="h-3.5 w-3.5" />
{prs.length > 1 ? (
<span className="text-[9px] font-semibold leading-none tabular-nums">{prs.length}</span>
) : null}
</span>
</TooltipTrigger>
<TooltipContent
sideOffset={6}
onEscapeKeyDown={tooltip.onEscapeKeyDown}
className="w-80 max-w-[calc(100vw-1rem)] p-3"
>
{hasFullData ? (
<PRTaskStatusSummary summaries={summaries} />
) : (
<CompactPRTooltipContent status={hydration.status} />
)}
</TooltipContent>
</Tooltip>
const disclosureProps: PRTaskIconDisclosureProps = {
taskId,
prInfo,
prs,
hasFullData,
singlePR,
readyToMerge,
allReadyToMerge,
displayState,
displayCount,
iconColor,
ariaLabel,
icon: <PRTaskIconGlyph automation={automation} />,
content: hasFullData ? (
<>
<PRTaskStatusSummary summaries={summaries} />
<TaskPRAutomationDetails summary={automation} />
</>
) : (
<>
<CompactPRTooltipContent status={hydration.status} />
<TaskPRAutomationDetails summary={automation} status={hydration.status} />
</>
),
};
return usesTouchDrawer ? (
<PRTaskIconDrawer
{...disclosureProps}
open={drawerOpen}
onOpenChange={(nextOpen) => {
setDrawerOpen(nextOpen);
if (nextOpen) hydrateOnDisclosure();
}}
t={t}
/>
) : (
<PRTaskIconTooltip {...disclosureProps} tooltip={tooltip} />
);
}
@@ -368,22 +392,3 @@ function getTaskPRIconColor(prs: TaskPR[], prInfo?: TaskPRInfo): string {
if (prs.length > 1) return aggregatePRStatusColor(prs);
return getPRAggregateStatusColor(prInfo?.aggregateState ?? prInfo?.state);
}
function CompactPRTooltipContent({ status }: { status: TaskPRTooltipHydrationStatus }) {
const { t } = useTranslation();
if (status === "loading" || status === "idle") {
return (
<span data-testid="pr-task-tooltip-loading" className="text-sm text-muted-foreground">
{t("github:taskPrDetailsLoading")}
</span>
);
}
if (status === "unavailable") {
return (
<span data-testid="pr-task-tooltip-unavailable" className="text-sm text-muted-foreground">
{t("github:taskPrDetailsUnavailable")}
</span>
);
}
return null;
}