feat(github): add task PR lifecycle notifications (#2038)

This commit is contained in:
Luan Martins
2026-07-29 18:13:02 +01:00
committed by GitHub
parent 6317bf9426
commit 04a5c4e4ee
96 changed files with 9890 additions and 1534 deletions
@@ -1,8 +1,9 @@
"use client";
import { useCallback, useState, type ReactNode } from "react";
import { IconEdit, IconInfoCircle, IconRefresh } from "@tabler/icons-react";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { IconChevronDown, IconEdit, IconInfoCircle, IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import {
Dialog,
DialogContent,
@@ -20,7 +21,12 @@ import { useToast } from "@/components/toast-provider";
import { useTaskCIAutomationOptions } from "@/hooks/domains/github/use-task-ci-options";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { autoFixRoundForState, findCIAutomationStateForPR } from "@/lib/github/ci-automation";
import type { TaskCIAutomationPatch, TaskCIPRAutomationState, TaskPR } from "@/lib/types/github";
import type {
TaskCIAutomationOptions,
TaskCIAutomationPatch,
TaskCIPRAutomationState,
TaskPR,
} from "@/lib/types/github";
const PR_FEEDBACK_PLACEHOLDER = "{{pr.feedback}}";
@@ -40,9 +46,10 @@ function CIAutomationInfoButton() {
</TooltipTrigger>
<TooltipContent side="top" align="end" className="max-w-[280px] text-xs leading-relaxed">
Watches this task's linked pull request during the 1 minute PR refresh loop. Auto-fix queues
a task prompt for new failed checks and unresolved review comments when the prompt includes
the PR feedback placeholder, then snapshots what was handled so the next round only sends
newly observed issues. Auto-merge runs only after CI, review, and mergeability are ready.
task prompts for newly observed feedback, auto-merge waits for the PR to be ready, and the
notification switches wake the task's agent when the workspace's connected GitHub account is
requested for review or the PR reaches a terminal state. Review requests silently baseline
the account's current request state before notifying on a later request.
</TooltipContent>
</Tooltip>
);
@@ -161,6 +168,7 @@ function CIAutomationRow({
disabled,
onCheckedChange,
help,
describedBy,
}: {
id: string;
label: string;
@@ -168,11 +176,15 @@ function CIAutomationRow({
disabled: boolean;
onCheckedChange: (checked: boolean) => void;
help?: ReactNode;
describedBy?: string;
}) {
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const minHeight = isMobile || !isFinePointer ? "min-h-11" : "min-h-7";
return (
<div className="flex min-h-7 items-center justify-between gap-3 px-1">
<div className={`flex items-center justify-between gap-3 px-1 ${minHeight}`}>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<Label htmlFor={id} className="min-w-0 cursor-pointer truncate text-xs">
<Label htmlFor={id} className="min-w-0 cursor-pointer text-xs leading-5">
{label}
</Label>
{help}
@@ -180,6 +192,7 @@ function CIAutomationRow({
<Switch
id={id}
aria-label={label}
aria-describedby={describedBy}
checked={checked}
disabled={disabled}
onCheckedChange={onCheckedChange}
@@ -198,7 +211,10 @@ function CIAutomationErrorRow({
onRetry: () => void;
}) {
return (
<div className="flex items-center justify-between gap-2 px-1 text-[11px] text-destructive">
<div
role="alert"
className="flex items-center justify-between gap-2 px-1 text-[11px] text-destructive"
>
<span className="min-w-0 flex-1 truncate">{error}</span>
<Button
type="button"
@@ -215,14 +231,15 @@ function CIAutomationErrorRow({
);
}
function CIAutoFixRoundHelpButton({
state,
maxRounds,
function CIAutomationHelpButton({
ariaLabel,
testId,
children,
}: {
state: TaskCIPRAutomationState | undefined;
maxRounds: number | null | undefined;
ariaLabel: string;
testId: string;
children: ReactNode;
}) {
const round = autoFixRoundForState(state, maxRounds);
const { isFinePointer } = useResponsiveBreakpoint();
const [open, setOpen] = useState(false);
const trigger = (
@@ -230,24 +247,13 @@ function CIAutoFixRoundHelpButton({
type="button"
variant="ghost"
size="icon"
data-testid="ci-auto-fix-round-help"
data-testid={testId}
className="h-5 w-5 cursor-help text-muted-foreground hover:text-foreground"
aria-label="Explain auto-fix rounds"
aria-label={ariaLabel}
>
<IconInfoCircle className="h-3.5 w-3.5" />
</Button>
);
const explanation = (
<span data-testid="ci-auto-fix-round-explanation">
Auto-fix has used {round.current} of {round.max} rounds for this PR. A round is counted when
Kandev sends or queues a CI auto-fix message. Kandev waits for all PR checks to finish before
starting a new CI auto-fix turn, so the agent gets the final failed checks and current
comments together. Updating an already queued auto-fix message does not use another round.
When this is at {round.max}/{round.max} and there is no pending auto-fix message left to
update, Kandev pauses auto-fix for this PR so it cannot loop forever. Disable and re-enable
auto-fix after manual review to start over.
</span>
);
if (!isFinePointer) {
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -258,7 +264,7 @@ function CIAutoFixRoundHelpButton({
portal={false}
className="max-w-[280px] text-xs leading-relaxed"
>
{explanation}
{children}
</PopoverContent>
</Popover>
);
@@ -267,12 +273,252 @@ function CIAutoFixRoundHelpButton({
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-[280px] text-xs leading-relaxed">
{explanation}
{children}
</TooltipContent>
</Tooltip>
);
}
function CIAutoFixRoundHelpButton({
state,
maxRounds,
}: {
state: TaskCIPRAutomationState | undefined;
maxRounds: number | null | undefined;
}) {
const round = autoFixRoundForState(state, maxRounds);
return (
<CIAutomationHelpButton testId="ci-auto-fix-round-help" ariaLabel="Explain auto-fix rounds">
<span data-testid="ci-auto-fix-round-explanation">
Auto-fix has used {round.current} of {round.max} rounds for this PR. A round is counted when
Kandev sends or queues a CI auto-fix message. Kandev waits for all PR checks to finish
before starting a new CI auto-fix turn, so the agent gets the final failed checks and
current comments together. Updating an already queued auto-fix message does not use another
round. When this is at {round.max}/{round.max} and there is no pending auto-fix message left
to update, Kandev pauses auto-fix for this PR so it cannot loop forever. Disable and
re-enable auto-fix after manual review to start over.
</span>
</CIAutomationHelpButton>
);
}
function PRAgentPromptRows({
taskId,
options,
disabled,
patchOption,
}: {
taskId: string;
options: TaskCIAutomationOptions | null;
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
}) {
const terminalHelpID = `task-pr-terminal-help-${taskId}`;
const terminalHelp = "Wake the agent when review work ends. Choose either or both outcomes.";
return (
<>
<ReviewRequestedPromptRow
taskId={taskId}
options={options}
disabled={disabled}
patchOption={patchOption}
/>
<span id={terminalHelpID} className="sr-only">
{terminalHelp}
</span>
<CIAutomationRow
id={`task-pr-merged-prompt-${taskId}`}
label="PR merged"
describedBy={terminalHelpID}
checked={Boolean(options?.prompt_on_merged)}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ prompt_on_merged: checked })}
help={
<CIAutomationHelpButton
testId="ci-pr-terminal-help"
ariaLabel="Explain final PR state notifications"
>
{terminalHelp}
</CIAutomationHelpButton>
}
/>
<CIAutomationRow
id={`task-pr-closed-prompt-${taskId}`}
label="PR closed without merging"
describedBy={terminalHelpID}
checked={Boolean(options?.prompt_on_closed)}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ prompt_on_closed: checked })}
/>
</>
);
}
function ReviewFollowUpSection({
taskId,
options,
disabled,
patchOption,
}: {
taskId: string;
options: TaskCIAutomationOptions | null;
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
}) {
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const [open, setOpen] = useState(false);
const lifecycleEnabled = Boolean(
options?.prompt_on_review_requested || options?.prompt_on_merged || options?.prompt_on_closed,
);
const minHeight = isMobile || !isFinePointer ? "min-h-11" : "min-h-7";
useEffect(() => {
if (lifecycleEnabled) setOpen(true);
}, [lifecycleEnabled]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="ci-review-follow-up-trigger"
aria-label="Toggle review follow-up automation"
className={`w-full cursor-pointer justify-between px-1 text-xs text-muted-foreground ${minHeight}`}
>
Review follow-up
<IconChevronDown
aria-hidden="true"
className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-180" : ""}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="flex flex-col gap-1">
<PRAgentPromptRows
taskId={taskId}
options={options}
disabled={disabled}
patchOption={patchOption}
/>
</CollapsibleContent>
</Collapsible>
);
}
function ReviewRequestedPromptRow({
taskId,
options,
disabled,
patchOption,
}: {
taskId: string;
options: TaskCIAutomationOptions | null;
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
}) {
const helpID = `task-pr-review-requested-prompt-${taskId}-description`;
const help = "Wake the agent for any new request, including re-review after changes.";
return (
<>
<span id={helpID} className="sr-only">
{help}
</span>
<CIAutomationRow
id={`task-pr-review-requested-prompt-${taskId}`}
label="Your review is requested"
describedBy={helpID}
checked={Boolean(options?.prompt_on_review_requested)}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ prompt_on_review_requested: checked })}
help={
<CIAutomationHelpButton
testId="ci-review-requested-help"
ariaLabel="Explain review request notifications"
>
{help}
</CIAutomationHelpButton>
}
/>
</>
);
}
function CIAutomationHeader({
disabled,
onEditPrompt,
}: {
disabled: boolean;
onEditPrompt: () => void;
}) {
return (
<div className="flex items-center justify-between gap-2 px-1">
<div className="text-xs font-medium text-foreground">Automation</div>
<div className="flex items-center gap-1">
<CIAutomationInfoButton />
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 cursor-pointer text-muted-foreground hover:text-foreground"
aria-label="Edit auto-fix prompt for this task"
disabled={disabled}
onClick={onEditPrompt}
>
<IconEdit className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
function CIAutomationOptionRows({
pr,
options,
disabled,
patchOption,
automationState,
}: {
pr: TaskPR;
options: TaskCIAutomationOptions | null;
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
automationState: TaskCIPRAutomationState | undefined;
}) {
return (
<>
<CIAutomationRow
id={`task-ci-auto-fix-${pr.task_id}`}
label="Auto-fix CI and address comments"
checked={Boolean(options?.auto_fix_enabled)}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ auto_fix_enabled: checked })}
help={
options?.auto_fix_enabled ? (
<CIAutoFixRoundHelpButton
state={automationState}
maxRounds={options.auto_fix_max_rounds}
/>
) : null
}
/>
<CIAutomationRow
id={`task-ci-auto-merge-${pr.task_id}`}
label="Auto-merge when ready"
checked={Boolean(options?.auto_merge_enabled)}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ auto_merge_enabled: checked })}
/>
<ReviewFollowUpSection
taskId={pr.task_id}
options={options}
disabled={disabled}
patchOption={patchOption}
/>
</>
);
}
export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
const { options, loading, saving, error, refresh, update, resetPrompt } =
useTaskCIAutomationOptions(pr.task_id);
@@ -324,45 +570,21 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
data-testid="pr-ci-automation-controls"
className="flex flex-col gap-1 border-t border-border/50 pt-2"
>
<div className="flex items-center justify-between gap-2 px-1">
<div className="text-xs font-medium text-foreground">Automation</div>
<div className="flex items-center gap-1">
<CIAutomationInfoButton />
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 cursor-pointer text-muted-foreground hover:text-foreground"
aria-label="Edit auto-fix prompt for this task"
disabled={!options}
onClick={openPromptEditor}
>
<IconEdit className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<CIAutomationRow
id={`task-ci-auto-fix-${pr.task_id}`}
label="Auto-fix CI and address comments"
checked={Boolean(options?.auto_fix_enabled)}
<CIAutomationHeader disabled={!options} onEditPrompt={openPromptEditor} />
<CIAutomationOptionRows
pr={pr}
options={options}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ auto_fix_enabled: checked })}
help={
options?.auto_fix_enabled ? (
<CIAutoFixRoundHelpButton
state={automationState}
maxRounds={options.auto_fix_max_rounds}
/>
) : null
}
/>
<CIAutomationRow
id={`task-ci-auto-merge-${pr.task_id}`}
label="Auto-merge when ready"
checked={Boolean(options?.auto_merge_enabled)}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ auto_merge_enabled: checked })}
patchOption={patchOption}
automationState={automationState}
/>
{automationState?.last_error && (
<CIAutomationErrorRow
error={automationState.last_error}
loading={loading}
onRetry={retryLoad}
/>
)}
{error && <CIAutomationErrorRow error={error} loading={loading} onRetry={retryLoad} />}
<CIAutomationPromptDialog
open={promptOpen}
@@ -7,13 +7,22 @@ import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
const hookMocks = vi.hoisted(() => ({
error: null as string | null,
options: null as TaskCIAutomationOptions | null,
refreshMock: vi.fn(),
updateMock: vi.fn(),
resetPromptMock: vi.fn(),
}));
vi.mock("@/hooks/domains/github/use-github-status", () => ({
useGitHubStatus: () => ({ status: null }),
const responsiveMock = vi.hoisted(() => ({
isFinePointer: true,
isMobile: false,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({
isFinePointer: responsiveMock.isFinePointer,
isMobile: responsiveMock.isMobile,
}),
}));
vi.mock("@/hooks/domains/github/use-pr-ci-popover", () => ({
@@ -28,7 +37,7 @@ vi.mock("@/hooks/domains/github/use-pr-ci-popover", () => ({
vi.mock("@/hooks/domains/github/use-task-ci-options", () => ({
useTaskCIAutomationOptions: () => ({
options: makeOptions(),
options: hookMocks.options ?? makeOptions(),
loading: false,
saving: false,
error: hookMocks.error,
@@ -41,6 +50,11 @@ vi.mock("@/hooks/domains/github/use-task-ci-options", () => ({
import { PRCIPopover } from "./pr-ci-popover";
import { MultiPRCIPopover } from "./multi-pr-ci-popover";
const MERGED_PROMPT_LABEL = "PR merged";
const CLOSED_PROMPT_LABEL = "PR closed without merging";
const REVIEW_REQUEST_PROMPT_LABEL = "Your review is requested";
const REVIEW_FOLLOW_UP_TRIGGER = "ci-review-follow-up-trigger";
function makeOptions(overrides: Partial<TaskCIAutomationOptions> = {}): TaskCIAutomationOptions {
return {
task_id: "task-1",
@@ -100,12 +114,19 @@ function renderPopover() {
);
}
describe("PRCIPopover CI automation controls", () => {
function resetHookMocks() {
hookMocks.error = null;
hookMocks.options = null;
hookMocks.refreshMock.mockReset();
hookMocks.updateMock.mockReset();
hookMocks.resetPromptMock.mockReset();
responsiveMock.isFinePointer = true;
responsiveMock.isMobile = false;
}
describe("PRCIPopover automation toggles", () => {
beforeEach(() => {
hookMocks.error = null;
hookMocks.refreshMock.mockReset();
hookMocks.updateMock.mockReset();
hookMocks.resetPromptMock.mockReset();
resetHookMocks();
});
afterEach(() => {
@@ -118,9 +139,118 @@ describe("PRCIPopover CI automation controls", () => {
expect(screen.getByLabelText("Explain CI automation options")).not.toBeNull();
fireEvent.click(screen.getByLabelText("Auto-fix CI and address comments"));
fireEvent.click(screen.getByLabelText("Auto-merge when ready"));
fireEvent.click(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER));
fireEvent.click(screen.getByLabelText(REVIEW_REQUEST_PROMPT_LABEL));
fireEvent.click(screen.getByLabelText(MERGED_PROMPT_LABEL));
fireEvent.click(screen.getByLabelText(CLOSED_PROMPT_LABEL));
expect(hookMocks.updateMock).toHaveBeenCalledWith({ auto_fix_enabled: true });
expect(hookMocks.updateMock).toHaveBeenCalledWith({ auto_merge_enabled: true });
expect(hookMocks.updateMock).toHaveBeenCalledWith({ prompt_on_review_requested: true });
expect(hookMocks.updateMock).toHaveBeenCalledWith({ prompt_on_merged: true });
expect(hookMocks.updateMock).toHaveBeenCalledWith({ prompt_on_closed: true });
});
it("keeps review follow-up collapsed until opened while auto-fix and auto-merge stay primary", () => {
renderPopover();
const trigger = screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.getByLabelText("Auto-fix CI and address comments")).not.toBeNull();
expect(screen.getByLabelText("Auto-merge when ready")).not.toBeNull();
expect(screen.queryByLabelText(REVIEW_REQUEST_PROMPT_LABEL)).toBeNull();
expect(screen.queryByLabelText(MERGED_PROMPT_LABEL)).toBeNull();
expect(screen.queryByLabelText(CLOSED_PROMPT_LABEL)).toBeNull();
fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(screen.getByLabelText(REVIEW_REQUEST_PROMPT_LABEL)).not.toBeNull();
expect(screen.getByLabelText(MERGED_PROMPT_LABEL)).not.toBeNull();
expect(screen.getByLabelText(CLOSED_PROMPT_LABEL)).not.toBeNull();
expect(
screen.getByLabelText(REVIEW_REQUEST_PROMPT_LABEL).getAttribute("aria-describedby"),
).toBe("task-pr-review-requested-prompt-task-1-description");
expect(screen.getByLabelText(MERGED_PROMPT_LABEL).getAttribute("aria-describedby")).toBe(
"task-pr-terminal-help-task-1",
);
expect(screen.getByLabelText(CLOSED_PROMPT_LABEL).getAttribute("aria-describedby")).toBe(
"task-pr-terminal-help-task-1",
);
expect(
screen.getByText("Wake the agent for any new request, including re-review after changes."),
).not.toBeNull();
expect(
screen.getByText("Wake the agent when review work ends. Choose either or both outcomes."),
).not.toBeNull();
expect(screen.getByTestId("ci-review-requested-help")).not.toBeNull();
expect(screen.getByTestId("ci-pr-terminal-help")).not.toBeNull();
});
it("opens review follow-up when lifecycle automation is enabled", () => {
hookMocks.options = makeOptions({ prompt_on_merged: true });
renderPopover();
expect(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER).getAttribute("aria-expanded")).toBe("true");
expect(screen.getByLabelText(MERGED_PROMPT_LABEL)).not.toBeNull();
});
it("keeps the review follow-up trigger compact for desktop and touch-sized otherwise", () => {
renderPopover();
const desktopTrigger = screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER);
expect(desktopTrigger.classList.contains("min-h-7")).toBe(true);
expect(desktopTrigger.classList.contains("min-h-11")).toBe(false);
cleanup();
responsiveMock.isMobile = true;
renderPopover();
expect(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER).classList.contains("min-h-11")).toBe(true);
cleanup();
responsiveMock.isMobile = false;
responsiveMock.isFinePointer = false;
renderPopover();
expect(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER).classList.contains("min-h-11")).toBe(true);
});
});
describe("PRCIPopover automation status", () => {
beforeEach(() => {
resetHookMocks();
});
afterEach(() => {
cleanup();
});
it("renders the connected-account review label and selected PR error", () => {
hookMocks.options = makeOptions({
pr_states: [
{
task_id: "task-1",
repository_id: "",
pr_number: 1,
last_fix_signature: "",
last_fix_checkpoint_json: "",
last_fix_enqueued_at: null,
last_fix_session_id: null,
auto_fix_round_count: 0,
auto_fix_exhausted_at: null,
last_merge_signature: "",
last_merge_attempt_at: null,
last_error: "No promptable task session is available.",
created_at: "",
updated_at: "",
},
],
});
renderPopover();
expect(screen.getByRole("alert").textContent).toContain(
"No promptable task session is available.",
);
fireEvent.click(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER));
expect(screen.getByLabelText(REVIEW_REQUEST_PROMPT_LABEL)).not.toBeNull();
expect(screen.queryByLabelText(/edit.*review/i)).toBeNull();
});
it("uses the PR title in the header and omits the redundant detail link", () => {
@@ -158,6 +288,16 @@ describe("PRCIPopover CI automation controls", () => {
expect(onOpenDetailPanel).toHaveBeenCalledWith(expect.objectContaining({ id: "b" }));
expect(screen.queryByText("Open PR details")).toBeNull();
});
});
describe("PRCIPopover task prompts", () => {
beforeEach(() => {
resetHookMocks();
});
afterEach(() => {
cleanup();
});
it("opens a task prompt dialog with a settings link and saves overrides", async () => {
renderPopover();
@@ -0,0 +1,49 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ReviewWatchDialog } from "./review-watch-dialog";
const store = {
workspaces: { activeId: "ws-1", items: [] },
workflows: { items: [{ id: "workflow", name: "Delivery", hidden: false }] },
agentProfiles: { items: [] },
executors: { items: [] },
prompts: { items: [], loaded: true, loading: false },
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (value: typeof store) => unknown) => selector(store),
}));
vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ useSettingsData: vi.fn() }));
vi.mock("@/hooks/use-workflows", () => ({ useWorkflows: vi.fn() }));
vi.mock("@/hooks/use-workflow-steps", () => ({
useWorkflowSteps: () => ({ steps: [], loading: false }),
stepPlaceholder: () => "Select step",
}));
vi.mock("@/components/github/repo-filter-selector", () => ({
RepoFilterSelector: () => <div>Repository filters</div>,
}));
afterEach(cleanup);
describe("ReviewWatchDialog", () => {
it("explains lifecycle retention for Auto and the Always delete override", () => {
render(
<ReviewWatchDialog
open
onOpenChange={vi.fn()}
watch={null}
workspaceId="ws-1"
onCreate={vi.fn()}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByText(/user engagement or enabled PR lifecycle prompts/i)).not.toBeNull();
const cleanupField = screen.getByText("Cleanup behavior").parentElement;
const cleanupSelect = cleanupField?.querySelector("button");
if (!cleanupSelect) throw new Error("Cleanup policy selector was not rendered");
fireEvent.click(cleanupSelect);
fireEvent.click(screen.getByRole("option", { name: "Always delete" }));
expect(screen.getByText(/Always delete.*overrides retention/i)).not.toBeNull();
});
});
@@ -108,12 +108,14 @@ const CLEANUP_POLICY_OPTIONS: Array<{ id: CleanupPolicy; label: string; descript
{
id: "auto",
label: "Auto (recommended)",
description: "Delete merged/closed PR tasks unless you typed a message in them.",
description:
"Retain merged/closed PR tasks when they have user engagement or enabled PR lifecycle prompts; otherwise delete them.",
},
{
id: "always",
label: "Always delete",
description: "Delete on merge/close even if you engaged with the task.",
description:
"Always delete overrides retention: delete on merge/close even if the task has user engagement or enabled PR lifecycle prompts.",
},
{
id: "never",