fix(github): scope PR CI automation switches per linked PR (#2512)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Alassane
2026-08-12 13:36:11 -04:00
committed by GitHub
parent 76ccc667ce
commit 7855a4e087
41 changed files with 2803 additions and 790 deletions
@@ -1,522 +1,19 @@
"use client";
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,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { Label } from "@kandev/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Switch } from "@kandev/ui/switch";
import { Textarea } from "@kandev/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useCallback, useState } from "react";
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 {
TaskCIAutomationOptions,
TaskCIAutomationPatch,
TaskCIPRAutomationState,
TaskPR,
} from "@/lib/types/github";
import { Trans, useTranslation } from "react-i18next";
const PR_FEEDBACK_PLACEHOLDER = "{{pr.feedback}}";
function CIAutomationInfoButton() {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 cursor-help text-muted-foreground hover:text-foreground"
aria-label={t("github:explainCiAutomationOptions")}
>
<IconInfoCircle className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="end" className="max-w-[280px] text-xs leading-relaxed">
{t("github:watchesThisTaskSLinkedPull")}
</TooltipContent>
</Tooltip>
);
}
function insertPRFeedbackPlaceholder(prompt: string) {
if (prompt.includes(PR_FEEDBACK_PLACEHOLDER)) return prompt;
const trimmedEnd = prompt.trimEnd();
if (!trimmedEnd) return PR_FEEDBACK_PLACEHOLDER;
return `${trimmedEnd}\n\n${PR_FEEDBACK_PLACEHOLDER}`;
}
function CIAutomationPromptDialog({
open,
prompt,
saving,
onPromptChange,
onClose,
onSave,
onReset,
}: {
open: boolean;
prompt: string;
saving: boolean;
onPromptChange: (value: string) => void;
onClose: () => void;
onSave: () => void;
onReset: () => void;
}) {
const { t } = useTranslation();
const trimmed = prompt.trim();
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("github:autoFixPrompt")}</DialogTitle>
<DialogDescription>
<Trans
i18nKey="github:autoFixPromptDescription"
values={{ placeholder: PR_FEEDBACK_PLACEHOLDER }}
>
<code
data-testid="ci-auto-fix-pr-feedback-placeholder"
className="rounded bg-muted px-1 py-0.5 text-[11px]"
/>
</Trans>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label htmlFor="task-ci-auto-fix-prompt" className="text-xs">
{t("github:taskAutoFixPrompt")}
</Label>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 cursor-pointer px-2 text-xs"
onClick={() => onPromptChange(insertPRFeedbackPlaceholder(prompt))}
>
{t("github:insertPrFeedback")}
</Button>
<a
href="/settings/prompts"
className="cursor-pointer text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{t("github:editDefaultPrompt")}
</a>
</div>
</div>
<div
data-testid="ci-auto-fix-pr-feedback-help"
className="rounded-md border border-border/70 bg-muted/30 p-3 text-xs leading-relaxed text-muted-foreground"
>
<p>{t("github:thePlaceholderInsertsTheCurrentPr")}</p>
<p className="mt-2">{t("github:omitThePlaceholderIfYouWant")}</p>
</div>
<Textarea
id="task-ci-auto-fix-prompt"
value={prompt}
onChange={(event) => onPromptChange(event.target.value)}
rows={10}
className="max-h-[50vh] min-h-48 resize-y font-mono text-xs"
/>
</div>
<DialogFooter>
<Button variant="ghost" className="cursor-pointer" disabled={saving} onClick={onClose}>
{t("common:cancel")}
</Button>
<Button variant="outline" className="cursor-pointer" disabled={saving} onClick={onReset}>
{t("github:useDefault")}
</Button>
<Button
className="cursor-pointer"
disabled={saving || trimmed.length === 0}
onClick={onSave}
>
{t("github:savePrompt")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function CIAutomationRow({
id,
label,
checked,
disabled,
onCheckedChange,
help,
describedBy,
}: {
id: string;
label: string;
checked: boolean;
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 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 text-xs leading-5">
{label}
</Label>
{help}
</div>
<Switch
id={id}
aria-label={label}
aria-describedby={describedBy}
checked={checked}
disabled={disabled}
onCheckedChange={onCheckedChange}
/>
</div>
);
}
function CIAutomationErrorRow({
error,
loading,
onRetry,
}: {
error: string;
loading: boolean;
onRetry: () => void;
}) {
const { t } = useTranslation();
return (
<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"
variant="ghost"
size="sm"
className="h-6 cursor-pointer gap-1 px-2 text-[11px]"
disabled={loading}
onClick={onRetry}
>
<IconRefresh className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
{t("github:retry")}
</Button>
</div>
);
}
function CIAutomationHelpButton({
ariaLabel,
testId,
children,
}: {
ariaLabel: string;
testId: string;
children: ReactNode;
}) {
const { isFinePointer } = useResponsiveBreakpoint();
const [open, setOpen] = useState(false);
const trigger = (
<Button
type="button"
variant="ghost"
size="icon"
data-testid={testId}
className="h-5 w-5 cursor-help text-muted-foreground hover:text-foreground"
aria-label={ariaLabel}
>
<IconInfoCircle className="h-3.5 w-3.5" />
</Button>
);
if (!isFinePointer) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
side="top"
align="start"
portal={false}
className="max-w-[280px] text-xs leading-relaxed"
>
{children}
</PopoverContent>
</Popover>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-[280px] text-xs leading-relaxed">
{children}
</TooltipContent>
</Tooltip>
);
}
function CIAutoFixRoundHelpButton({
state,
maxRounds,
}: {
state: TaskCIPRAutomationState | undefined;
maxRounds: number | null | undefined;
}) {
const { t } = useTranslation();
const round = autoFixRoundForState(state, maxRounds);
return (
<CIAutomationHelpButton
testId="ci-auto-fix-round-help"
ariaLabel={t("github:explainAutoFixRounds")}
>
<span data-testid="ci-auto-fix-round-explanation">
{t("github:autoFixRoundExplanation", { current: round.current, max: round.max })}
</span>
</CIAutomationHelpButton>
);
}
function PRAgentPromptRows({
taskId,
options,
disabled,
patchOption,
}: {
taskId: string;
options: TaskCIAutomationOptions | null;
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
}) {
const { t } = useTranslation();
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={t("github:prMerged")}
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={t("github:explainFinalPrStateNotifications")}
>
{terminalHelp}
</CIAutomationHelpButton>
}
/>
<CIAutomationRow
id={`task-pr-closed-prompt-${taskId}`}
label={t("github:prClosedWithoutMerging")}
describedBy={terminalHelpID}
checked={Boolean(options?.prompt_on_closed)}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ prompt_on_closed: checked })}
/>
</>
);
}
function PREventsSection({
taskId,
options,
disabled,
patchOption,
}: {
taskId: string;
options: TaskCIAutomationOptions | null;
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
}) {
const { t } = useTranslation();
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-pr-events-trigger"
data-legacy-testid="ci-review-follow-up-trigger"
aria-label={t("github:togglePrEventPromptAutomation")}
className={`w-full cursor-pointer justify-between px-1 text-xs text-muted-foreground ${minHeight}`}
>
{t("github:prEvents")}
<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 { t } = useTranslation();
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={t("github:yourReviewIsRequested")}
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={t("github:explainReviewRequestNotifications")}
>
{help}
</CIAutomationHelpButton>
}
/>
</>
);
}
function CIAutomationHeader({
disabled,
onEditPrompt,
}: {
disabled: boolean;
onEditPrompt: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between gap-2 px-1">
<div className="text-xs font-medium text-foreground">{t("github: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={t("github:editAutoFixPromptForThis")}
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;
}) {
const { t } = useTranslation();
return (
<>
<CIAutomationRow
id={`task-ci-auto-fix-${pr.task_id}`}
label={t("github:autoFixCiAndAddressComments")}
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={t("github:autoMergeWhenReady")}
checked={Boolean(options?.auto_merge_enabled)}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ auto_merge_enabled: checked })}
/>
<PREventsSection
taskId={pr.task_id}
options={options}
disabled={disabled}
patchOption={patchOption}
/>
</>
);
}
import {
findCIAutomationStateForPR,
findPRAutomationOptionsForPR,
} from "@/lib/github/ci-automation";
import type { TaskCIAutomationPatch, TaskPR } from "@/lib/types/github";
import { CIAutomationPromptDialog } from "@/components/github/pr-ci-automation-prompt-dialog";
import {
CIAutomationErrorRow,
CIAutomationHeader,
CIAutomationOptionRows,
} from "@/components/github/pr-ci-automation-rows";
export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
const { options, loading, saving, error, refresh, update, resetPrompt } =
@@ -525,6 +22,7 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
const [promptOpen, setPromptOpen] = useState(false);
const [promptDraft, setPromptDraft] = useState("");
const automationState = findCIAutomationStateForPR(options?.pr_states, pr);
const prOptions = findPRAutomationOptionsForPR(options?.pr_options, pr);
const openPromptEditor = useCallback(() => {
setPromptDraft(options?.auto_fix_prompt_override ?? options?.effective_auto_fix_prompt ?? "");
@@ -538,13 +36,23 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
[toast],
);
// The five automation switches are scoped to this PR; every patch from the
// switch rows below carries this PR's identity so the backend applies it
// to this PR only instead of fanning out to every linked PR.
const patchOption = useCallback(
(patch: TaskCIAutomationPatch) => {
Promise.resolve(update(patch)).catch(() => reportError("Failed to update CI automation."));
const scoped: TaskCIAutomationPatch = {
...patch,
repository_id: pr.repository_id ?? "",
pr_number: pr.pr_number,
};
Promise.resolve(update(scoped)).catch(() => reportError("Failed to update CI automation."));
},
[reportError, update],
[pr, reportError, update],
);
// The auto-fix prompt override stays task-level (applies to every linked
// PR), so this patch intentionally omits PR identity.
const savePrompt = useCallback(() => {
const value = promptDraft.trim();
if (!value) return;
@@ -569,10 +77,11 @@ 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"
>
<CIAutomationHeader disabled={!options} onEditPrompt={openPromptEditor} />
<CIAutomationHeader pr={pr} disabled={!options} onEditPrompt={openPromptEditor} />
<CIAutomationOptionRows
pr={pr}
options={options}
prOptions={prOptions}
autoFixMaxRounds={options?.auto_fix_max_rounds}
disabled={disabled}
patchOption={patchOption}
automationState={automationState}
@@ -0,0 +1,124 @@
"use client";
import { Button } from "@kandev/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { Label } from "@kandev/ui/label";
import { Textarea } from "@kandev/ui/textarea";
import AppLink from "@/components/routing/app-link";
import { Trans, useTranslation } from "react-i18next";
const PR_FEEDBACK_PLACEHOLDER = "{{pr.feedback}}";
function insertPRFeedbackPlaceholder(prompt: string) {
if (prompt.includes(PR_FEEDBACK_PLACEHOLDER)) return prompt;
const trimmedEnd = prompt.trimEnd();
if (!trimmedEnd) return PR_FEEDBACK_PLACEHOLDER;
return `${trimmedEnd}\n\n${PR_FEEDBACK_PLACEHOLDER}`;
}
/**
* Auto-fix prompt override editor. This override remains task-level (it
* applies to every linked PR's auto-fix prompt) even though the auto-fix
* enable/disable switch itself is now per-PR.
*/
export function CIAutomationPromptDialog({
open,
prompt,
saving,
onPromptChange,
onClose,
onSave,
onReset,
}: {
open: boolean;
prompt: string;
saving: boolean;
onPromptChange: (value: string) => void;
onClose: () => void;
onSave: () => void;
onReset: () => void;
}) {
const { t } = useTranslation();
const trimmed = prompt.trim();
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("github:autoFixPrompt")}</DialogTitle>
<DialogDescription>
<Trans
i18nKey="github:autoFixPromptDescription"
values={{ placeholder: PR_FEEDBACK_PLACEHOLDER }}
>
<code
data-testid="ci-auto-fix-pr-feedback-placeholder"
className="rounded bg-muted px-1 py-0.5 text-[11px]"
/>
</Trans>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label htmlFor="task-ci-auto-fix-prompt" className="text-xs">
{t("github:taskAutoFixPrompt")}
</Label>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 cursor-pointer px-2 text-xs"
onClick={() => onPromptChange(insertPRFeedbackPlaceholder(prompt))}
>
{t("github:insertPrFeedback")}
</Button>
<AppLink
href="/settings/prompts"
className="cursor-pointer text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{t("github:editDefaultPrompt")}
</AppLink>
</div>
</div>
<div
data-testid="ci-auto-fix-pr-feedback-help"
className="rounded-md border border-border/70 bg-muted/30 p-3 text-xs leading-relaxed text-muted-foreground"
>
<p>{t("github:thePlaceholderInsertsTheCurrentPr")}</p>
<p className="mt-2">{t("github:omitThePlaceholderIfYouWant")}</p>
</div>
<Textarea
id="task-ci-auto-fix-prompt"
value={prompt}
onChange={(event) => onPromptChange(event.target.value)}
rows={10}
className="max-h-[50vh] min-h-48 resize-y font-mono text-xs"
/>
</div>
<DialogFooter>
<Button variant="ghost" className="cursor-pointer" disabled={saving} onClick={onClose}>
{t("common:cancel")}
</Button>
<Button variant="outline" className="cursor-pointer" disabled={saving} onClick={onReset}>
{t("github:useDefault")}
</Button>
<Button
className="cursor-pointer"
disabled={saving || trimmed.length === 0}
onClick={onSave}
>
{t("github:savePrompt")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,419 @@
"use client";
import { 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 { Label } from "@kandev/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Switch } from "@kandev/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { autoFixRoundForState } from "@/lib/github/ci-automation";
import type {
TaskCIAutomationPatch,
TaskCIPRAutomationState,
TaskPR,
TaskPRAutomationOptions,
} from "@/lib/types/github";
import { useTranslation } from "react-i18next";
/**
* Element-id scope unique per (task, repository, PR) so simultaneously
* mounted PR tabs in MultiPRCIPopover never share a Switch id/htmlFor pair.
*/
export function prAutomationScopeKey(pr: TaskPR): string {
return `${pr.task_id}-${pr.repository_id ?? "none"}-${pr.pr_number}`;
}
export function CIAutomationInfoButton() {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 cursor-help text-muted-foreground hover:text-foreground"
aria-label={t("github:explainCiAutomationOptions")}
>
<IconInfoCircle className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="end" className="max-w-[280px] text-xs leading-relaxed">
{t("github:watchesThisTaskSLinkedPull")}
</TooltipContent>
</Tooltip>
);
}
export function CIAutomationRow({
id,
label,
checked,
disabled,
onCheckedChange,
help,
describedBy,
}: {
id: string;
label: string;
checked: boolean;
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 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 text-xs leading-5">
{label}
</Label>
{help}
</div>
<Switch
id={id}
aria-label={label}
aria-describedby={describedBy}
checked={checked}
disabled={disabled}
onCheckedChange={onCheckedChange}
/>
</div>
);
}
export function CIAutomationErrorRow({
error,
loading,
onRetry,
}: {
error: string;
loading: boolean;
onRetry: () => void;
}) {
const { t } = useTranslation();
return (
<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"
variant="ghost"
size="sm"
className="h-6 cursor-pointer gap-1 px-2 text-[11px]"
disabled={loading}
onClick={onRetry}
>
<IconRefresh className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
{t("github:retry")}
</Button>
</div>
);
}
export function CIAutomationHelpButton({
ariaLabel,
testId,
children,
}: {
ariaLabel: string;
testId: string;
children: ReactNode;
}) {
const { isFinePointer } = useResponsiveBreakpoint();
const [open, setOpen] = useState(false);
const trigger = (
<Button
type="button"
variant="ghost"
size="icon"
data-testid={testId}
className="h-5 w-5 cursor-help text-muted-foreground hover:text-foreground"
aria-label={ariaLabel}
>
<IconInfoCircle className="h-3.5 w-3.5" />
</Button>
);
if (!isFinePointer) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
side="top"
align="start"
portal={false}
className="max-w-[280px] text-xs leading-relaxed"
>
{children}
</PopoverContent>
</Popover>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-[280px] text-xs leading-relaxed">
{children}
</TooltipContent>
</Tooltip>
);
}
function CIAutoFixRoundHelpButton({
state,
maxRounds,
}: {
state: TaskCIPRAutomationState | undefined;
maxRounds: number | null | undefined;
}) {
const { t } = useTranslation();
const round = autoFixRoundForState(state, maxRounds);
return (
<CIAutomationHelpButton
testId="ci-auto-fix-round-help"
ariaLabel={t("github:explainAutoFixRounds")}
>
<span data-testid="ci-auto-fix-round-explanation">
{t("github:autoFixRoundExplanation", { current: round.current, max: round.max })}
</span>
</CIAutomationHelpButton>
);
}
function PRAgentPromptRows({
scopeKey,
prOptions,
disabled,
patchOption,
}: {
scopeKey: string;
prOptions: TaskPRAutomationOptions;
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
}) {
const { t } = useTranslation();
const terminalHelpID = `task-pr-terminal-help-${scopeKey}`;
const terminalHelp = t("github:wakeAgentOnTerminalPrState");
return (
<>
<ReviewRequestedPromptRow
scopeKey={scopeKey}
prOptions={prOptions}
disabled={disabled}
patchOption={patchOption}
/>
<span id={terminalHelpID} className="sr-only">
{terminalHelp}
</span>
<CIAutomationRow
id={`task-pr-merged-prompt-${scopeKey}`}
label={t("github:prMerged")}
describedBy={terminalHelpID}
checked={prOptions.prompt_on_merged}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ prompt_on_merged: checked })}
help={
<CIAutomationHelpButton
testId="ci-pr-terminal-help"
ariaLabel={t("github:explainFinalPrStateNotifications")}
>
{terminalHelp}
</CIAutomationHelpButton>
}
/>
<CIAutomationRow
id={`task-pr-closed-prompt-${scopeKey}`}
label={t("github:prClosedWithoutMerging")}
describedBy={terminalHelpID}
checked={prOptions.prompt_on_closed}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ prompt_on_closed: checked })}
/>
</>
);
}
export function ReviewFollowUpSection({
scopeKey,
prOptions,
disabled,
patchOption,
}: {
scopeKey: string;
prOptions: TaskPRAutomationOptions;
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
}) {
const { t } = useTranslation();
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const [open, setOpen] = useState(false);
const lifecycleEnabled =
prOptions.prompt_on_review_requested ||
prOptions.prompt_on_merged ||
prOptions.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={t("github:toggleReviewFollowUpAutomation")}
className={`w-full cursor-pointer justify-between px-1 text-xs text-muted-foreground ${minHeight}`}
>
{t("github:reviewFollowUp")}
<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
scopeKey={scopeKey}
prOptions={prOptions}
disabled={disabled}
patchOption={patchOption}
/>
</CollapsibleContent>
</Collapsible>
);
}
function ReviewRequestedPromptRow({
scopeKey,
prOptions,
disabled,
patchOption,
}: {
scopeKey: string;
prOptions: TaskPRAutomationOptions;
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
}) {
const { t } = useTranslation();
const helpID = `task-pr-review-requested-prompt-${scopeKey}-description`;
const help = t("github:wakeAgentOnReviewRequest");
return (
<>
<span id={helpID} className="sr-only">
{help}
</span>
<CIAutomationRow
id={`task-pr-review-requested-prompt-${scopeKey}`}
label={t("github:yourReviewIsRequested")}
describedBy={helpID}
checked={prOptions.prompt_on_review_requested}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ prompt_on_review_requested: checked })}
help={
<CIAutomationHelpButton
testId="ci-review-requested-help"
ariaLabel={t("github:explainReviewRequestNotifications")}
>
{help}
</CIAutomationHelpButton>
}
/>
</>
);
}
export function CIAutomationHeader({
pr,
disabled,
onEditPrompt,
}: {
pr: TaskPR;
disabled: boolean;
onEditPrompt: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between gap-2 px-1">
<div className="flex min-w-0 flex-col">
<div className="text-xs font-medium text-foreground">{t("github:automation")}</div>
<div className="truncate text-[11px] text-muted-foreground">
{t("github:automationForPr", { number: pr.pr_number })}
</div>
</div>
<div className="flex shrink-0 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={t("github:editAutoFixPromptForThis")}
disabled={disabled}
onClick={onEditPrompt}
>
<IconEdit className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
export function CIAutomationOptionRows({
pr,
prOptions,
autoFixMaxRounds,
disabled,
patchOption,
automationState,
}: {
pr: TaskPR;
prOptions: TaskPRAutomationOptions;
autoFixMaxRounds: number | null | undefined;
disabled: boolean;
patchOption: (patch: TaskCIAutomationPatch) => void;
automationState: TaskCIPRAutomationState | undefined;
}) {
const { t } = useTranslation();
const scopeKey = prAutomationScopeKey(pr);
return (
<>
<CIAutomationRow
id={`task-ci-auto-fix-${scopeKey}`}
label={t("github:autoFixCiAndAddressComments")}
checked={prOptions.auto_fix_enabled}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ auto_fix_enabled: checked })}
help={
prOptions.auto_fix_enabled ? (
<CIAutoFixRoundHelpButton state={automationState} maxRounds={autoFixMaxRounds} />
) : null
}
/>
<CIAutomationRow
id={`task-ci-auto-merge-${scopeKey}`}
label={t("github:autoMergeWhenReady")}
checked={prOptions.auto_merge_enabled}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ auto_merge_enabled: checked })}
/>
<ReviewFollowUpSection
scopeKey={scopeKey}
prOptions={prOptions}
disabled={disabled}
patchOption={patchOption}
/>
</>
);
}
@@ -52,25 +52,52 @@ vi.mock("@/hooks/domains/github/use-task-ci-options", () => ({
import { PRCIPopover } from "./pr-ci-popover";
import { MultiPRCIPopover } from "./multi-pr-ci-popover";
const AUTO_FIX_LABEL = "Auto-fix CI and address comments";
const AUTO_MERGE_LABEL = "Auto-merge when ready";
const MERGED_PROMPT_LABEL = "PR merged";
const CLOSED_PROMPT_LABEL = "PR closed without merging";
const REVIEW_REQUEST_PROMPT_LABEL = "Your review is requested";
const PR_EVENTS_TRIGGER = "ci-pr-events-trigger";
const REVIEW_FOLLOW_UP_TRIGGER = "ci-review-follow-up-trigger";
const REMOVE_FIRST_PR_LABEL = "Remove r #1 from task";
const BACKEND_UNAVAILABLE = "backend unavailable";
// The five automation switches are per-PR; pr_options defaults to one entry
// for makePR()'s default identity (task-1 / repository_id "" / #1) mirroring
// whichever top-level switch overrides the caller passed, so existing
// `makeOptions({ prompt_on_merged: true })`-style overrides still drive the
// rendered switch state without every call site building pr_options by hand.
function makeOptions(overrides: Partial<TaskCIAutomationOptions> = {}): TaskCIAutomationOptions {
return {
const base = {
task_id: "task-1",
auto_fix_enabled: false,
auto_merge_enabled: false,
auto_fix_prompt_override: null,
effective_auto_fix_prompt: "Default CI fix prompt",
using_default_prompt: true,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
updated_at: "2026-06-18T10:00:00Z",
pr_states: [],
...overrides,
};
return {
...base,
pr_options: overrides.pr_options ?? [
{
task_id: base.task_id,
repository_id: "",
pr_number: 1,
auto_fix_enabled: base.auto_fix_enabled,
auto_merge_enabled: base.auto_merge_enabled,
prompt_on_review_requested: base.prompt_on_review_requested,
prompt_on_merged: base.prompt_on_merged,
prompt_on_closed: base.prompt_on_closed,
created_at: "",
updated_at: "",
},
],
};
}
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
@@ -118,6 +145,21 @@ function renderPopover() {
);
}
function makePROption(prNumber: number, enabled: boolean) {
return {
task_id: "task-1",
repository_id: "",
pr_number: prNumber,
auto_fix_enabled: enabled,
auto_merge_enabled: enabled,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
created_at: "",
updated_at: "",
};
}
function resetHookMocks() {
hookMocks.error = null;
hookMocks.options = null;
@@ -141,27 +183,33 @@ describe("PRCIPopover automation toggles", () => {
renderPopover();
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(PR_EVENTS_TRIGGER));
fireEvent.click(screen.getByLabelText(AUTO_FIX_LABEL));
fireEvent.click(screen.getByLabelText(AUTO_MERGE_LABEL));
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 });
// Every switch patch carries this PR's identity so the backend applies
// the change to this PR only, instead of fanning out to every linked PR.
const identity = { repository_id: "", pr_number: 1 };
expect(hookMocks.updateMock).toHaveBeenCalledWith({ ...identity, auto_fix_enabled: true });
expect(hookMocks.updateMock).toHaveBeenCalledWith({ ...identity, auto_merge_enabled: true });
expect(hookMocks.updateMock).toHaveBeenCalledWith({
...identity,
prompt_on_review_requested: true,
});
expect(hookMocks.updateMock).toHaveBeenCalledWith({ ...identity, prompt_on_merged: true });
expect(hookMocks.updateMock).toHaveBeenCalledWith({ ...identity, prompt_on_closed: true });
});
it("keeps PR events collapsed until opened while auto-fix and auto-merge stay primary", () => {
it("keeps review follow-up collapsed until opened while auto-fix and auto-merge stay primary", () => {
renderPopover();
const trigger = screen.getByTestId(PR_EVENTS_TRIGGER);
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.getByLabelText(AUTO_FIX_LABEL)).not.toBeNull();
expect(screen.getByLabelText(AUTO_MERGE_LABEL)).not.toBeNull();
expect(screen.queryByLabelText(REVIEW_REQUEST_PROMPT_LABEL)).toBeNull();
expect(screen.queryByLabelText(MERGED_PROMPT_LABEL)).toBeNull();
expect(screen.queryByLabelText(CLOSED_PROMPT_LABEL)).toBeNull();
@@ -173,12 +221,12 @@ describe("PRCIPopover automation toggles", () => {
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");
).toBe("task-pr-review-requested-prompt-task-1-none-1-description");
expect(screen.getByLabelText(MERGED_PROMPT_LABEL).getAttribute("aria-describedby")).toBe(
"task-pr-terminal-help-task-1",
"task-pr-terminal-help-task-1-none-1",
);
expect(screen.getByLabelText(CLOSED_PROMPT_LABEL).getAttribute("aria-describedby")).toBe(
"task-pr-terminal-help-task-1",
"task-pr-terminal-help-task-1-none-1",
);
expect(
screen.getByText("Wake the agent for any new request, including re-review after changes."),
@@ -190,30 +238,30 @@ describe("PRCIPopover automation toggles", () => {
expect(screen.getByTestId("ci-pr-terminal-help")).not.toBeNull();
});
it("opens PR events when lifecycle automation is enabled", () => {
it("opens review follow-up when lifecycle automation is enabled", () => {
hookMocks.options = makeOptions({ prompt_on_merged: true });
renderPopover();
expect(screen.getByTestId(PR_EVENTS_TRIGGER).getAttribute("aria-expanded")).toBe("true");
expect(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER).getAttribute("aria-expanded")).toBe("true");
expect(screen.getByLabelText(MERGED_PROMPT_LABEL)).not.toBeNull();
});
it("keeps the PR events trigger compact for desktop and touch-sized otherwise", () => {
it("keeps the review follow-up trigger compact for desktop and touch-sized otherwise", () => {
renderPopover();
const desktopTrigger = screen.getByTestId(PR_EVENTS_TRIGGER);
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(PR_EVENTS_TRIGGER).classList.contains("min-h-11")).toBe(true);
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(PR_EVENTS_TRIGGER).classList.contains("min-h-11")).toBe(true);
expect(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER).classList.contains("min-h-11")).toBe(true);
});
});
@@ -252,7 +300,7 @@ describe("PRCIPopover automation status", () => {
expect(screen.getByRole("alert").textContent).toContain(
"No promptable task session is available.",
);
fireEvent.click(screen.getByTestId(PR_EVENTS_TRIGGER));
fireEvent.click(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER));
expect(screen.getByLabelText(REVIEW_REQUEST_PROMPT_LABEL)).not.toBeNull();
expect(screen.queryByLabelText(/edit.*review/i)).toBeNull();
});
@@ -292,6 +340,38 @@ describe("PRCIPopover automation status", () => {
expect(onOpenDetailPanel).toHaveBeenCalledWith(expect.objectContaining({ id: "b" }));
expect(screen.queryByText("Open PR details")).toBeNull();
});
it("keeps each linked PR's automation switches independent across tabs (AC1-AC3)", () => {
hookMocks.options = makeOptions({
pr_options: [makePROption(1, true), makePROption(2, false)],
});
render(
<TooltipProvider>
<StateProvider>
<ToastProvider>
<MultiPRCIPopover
prs={[
makePR({ id: "a", pr_number: 1, pr_title: "First PR" }),
makePR({ id: "b", pr_number: 2, pr_title: "Second PR" }),
]}
enabled={true}
/>
</ToastProvider>
</StateProvider>
</TooltipProvider>,
);
const checkedState = () => [
screen.getByLabelText(AUTO_FIX_LABEL).getAttribute("aria-checked"),
screen.getByLabelText(AUTO_MERGE_LABEL).getAttribute("aria-checked"),
];
// PR #1's tab is selected by default (worst status) — its switches are on.
expect(checkedState()).toEqual(["true", "true"]);
// Switching to PR #2's tab shows its own, independently off, state.
fireEvent.click(screen.getByTestId("pr-popover-tab-o-r-2"));
expect(checkedState()).toEqual(["false", "false"]);
});
});
describe("MultiPRCIPopover unlink", () => {
@@ -23,6 +23,7 @@ function makeOptions(overrides: Partial<TaskCIAutomationOptions> = {}): TaskCIAu
using_default_prompt: true,
updated_at: "2026-08-10T10:00:00Z",
pr_states: [],
pr_options: [],
...overrides,
};
}
@@ -81,11 +82,37 @@ describe("AutomationFlagBadges", () => {
);
});
it("derives the same task-wide PR events for single and multiple PR chips", () => {
it("derives the same enabled PR events for single and multiple PR chips", () => {
const options = makeOptions({
prompt_on_review_requested: true,
prompt_on_merged: true,
prompt_on_closed: true,
pr_options: [
{
task_id: "task-1",
repository_id: "repo-1",
pr_number: 42,
auto_fix_enabled: false,
auto_merge_enabled: false,
prompt_on_review_requested: true,
prompt_on_merged: true,
prompt_on_closed: true,
created_at: "",
updated_at: "",
},
{
task_id: "task-1",
repository_id: "repo-1",
pr_number: 43,
auto_fix_enabled: false,
auto_merge_enabled: false,
prompt_on_review_requested: true,
prompt_on_merged: true,
prompt_on_closed: true,
created_at: "",
updated_at: "",
},
],
});
const single = automationForPR(options, PR);
@@ -2,7 +2,11 @@
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { autoFixRoundForState, findCIAutomationStateForPR } from "@/lib/github/ci-automation";
import {
autoFixRoundForState,
findCIAutomationStateForPR,
findPRAutomationOptionsForPR,
} from "@/lib/github/ci-automation";
import type { AutoFixRoundInfo } from "@/lib/github/ci-automation";
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
@@ -31,12 +35,24 @@ export function automationForPR(
options: TaskCIAutomationOptions | null | undefined,
pr: TaskPR,
): AutomationFlags {
const prOptions = options?.pr_options
? findPRAutomationOptionsForPR(options.pr_options, pr)
: null;
const flags = prOptions
? {
autoFix: prOptions.auto_fix_enabled,
autoMerge: prOptions.auto_merge_enabled,
promptOnReviewRequested: prOptions.prompt_on_review_requested,
promptOnMerged: prOptions.prompt_on_merged,
promptOnClosed: prOptions.prompt_on_closed,
}
: taskWideAutomationFlags(options);
return {
...taskWideAutomationFlags(options),
autoFixRound: options?.auto_fix_enabled
...flags,
autoFixRound: (prOptions ? prOptions.auto_fix_enabled : options?.auto_fix_enabled)
? autoFixRoundForState(
findCIAutomationStateForPR(options.pr_states, pr),
options.auto_fix_max_rounds,
findCIAutomationStateForPR(options?.pr_states, pr),
options?.auto_fix_max_rounds,
)
: null,
};
@@ -46,16 +62,37 @@ export function automationForPRs(
options: TaskCIAutomationOptions | null | undefined,
prs: TaskPR[],
): AutomationFlags {
const roundInfos = options?.auto_fix_enabled
? prs.map((pr) =>
autoFixRoundForState(
findCIAutomationStateForPR(options.pr_states, pr),
options.auto_fix_max_rounds,
),
)
: [];
if (!options?.pr_options) {
const roundInfos = options?.auto_fix_enabled
? prs.map((pr) =>
autoFixRoundForState(
findCIAutomationStateForPR(options.pr_states, pr),
options.auto_fix_max_rounds,
),
)
: [];
return {
...taskWideAutomationFlags(options),
autoFixRound: pickAttentionRound(roundInfos),
};
}
const perPR = prs.map((pr) => findPRAutomationOptionsForPR(options.pr_options, pr));
const roundInfos = perPR
.map((prOptions, index) =>
prOptions.auto_fix_enabled
? autoFixRoundForState(
findCIAutomationStateForPR(options.pr_states, prs[index]),
options.auto_fix_max_rounds,
)
: null,
)
.filter((round): round is AutoFixRoundInfo => round !== null);
return {
...taskWideAutomationFlags(options),
autoFix: perPR.some((prOptions) => prOptions.auto_fix_enabled),
autoMerge: perPR.some((prOptions) => prOptions.auto_merge_enabled),
promptOnReviewRequested: perPR.some((prOptions) => prOptions.prompt_on_review_requested),
promptOnMerged: perPR.some((prOptions) => prOptions.prompt_on_merged),
promptOnClosed: perPR.some((prOptions) => prOptions.prompt_on_closed),
autoFixRound: pickAttentionRound(roundInfos),
};
}
@@ -94,8 +94,11 @@ function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
};
}
// auto_fix_enabled is per-PR; pr_options defaults to one entry matching
// makePR()'s identity (task-1 / repository_id "" / #42) so overrides like
// `makeCIOptions({ auto_fix_enabled: true })` still drive the rendered chip.
function makeCIOptions(overrides: Partial<TaskCIAutomationOptions> = {}): TaskCIAutomationOptions {
return {
const base = {
task_id: "task-1",
auto_fix_enabled: false,
auto_merge_enabled: false,
@@ -107,6 +110,23 @@ function makeCIOptions(overrides: Partial<TaskCIAutomationOptions> = {}): TaskCI
pr_states: [],
...overrides,
};
return {
...base,
pr_options: overrides.pr_options ?? [
{
task_id: base.task_id,
repository_id: "",
pr_number: 42,
auto_fix_enabled: base.auto_fix_enabled,
auto_merge_enabled: base.auto_merge_enabled,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
created_at: "",
updated_at: "",
},
],
};
}
function makeCIPrState(roundCount: number, exhausted = false) {
@@ -0,0 +1,70 @@
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
export function makeTestPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-id",
task_id: "task-1",
owner: "acme",
repo: "demo",
pr_number: 42,
pr_url: "https://github.com/acme/demo/pull/42",
pr_title: "Test PR",
head_branch: "feat",
base_branch: "main",
author_login: "alice",
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "clean",
review_count: 1,
pending_review_count: 0,
comment_count: 0,
unresolved_review_threads: 0,
checks_total: 2,
checks_passing: 2,
additions: 0,
deletions: 0,
created_at: "",
merged_at: null,
closed_at: null,
last_synced_at: null,
updated_at: "",
...overrides,
};
}
// auto_fix_enabled/auto_merge_enabled are per-PR; pr_options mirrors them
// into one entry matching makeTestPR()'s identity (task-1/""/42) so overrides
// like `makeTestCIOptions({ auto_fix_enabled: true })` still drive the chip.
export function makeTestCIOptions(
overrides: Partial<TaskCIAutomationOptions> = {},
): TaskCIAutomationOptions {
const autoFix = overrides.auto_fix_enabled ?? false;
const autoMerge = overrides.auto_merge_enabled ?? false;
return {
task_id: "task-1",
auto_fix_enabled: autoFix,
auto_merge_enabled: autoMerge,
auto_fix_prompt_override: null,
effective_auto_fix_prompt: "Default CI fix prompt",
using_default_prompt: true,
updated_at: "2026-06-18T10:00:00Z",
auto_fix_max_rounds: 10,
pr_states: [],
pr_options: [
{
task_id: "task-1",
repository_id: "",
pr_number: 42,
auto_fix_enabled: autoFix,
auto_merge_enabled: autoMerge,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
created_at: "",
updated_at: "",
},
],
...overrides,
};
}
@@ -6,8 +6,12 @@ import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import { PRStatusChip, aggregateChipStatus } from "./pr-status-chip";
import { PR_CI_DESKTOP_POPOVER_SCROLL_CLASS } from "./pr-ci-popover";
import {
makeTestCIOptions as makeCIOptions,
makeTestPR as makePR,
} from "./pr-status-chip.test-fixtures";
import type { AppState } from "@/lib/state/store";
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
import type { TaskPR } from "@/lib/types/github";
const AUTO_FIX_BADGE_TESTID = "pr-status-auto-fix-chip";
@@ -51,6 +55,7 @@ vi.mock("@/lib/api/domains/github-api", async (importOriginal) => {
using_default_prompt: true,
updated_at: "2026-06-18T10:00:00Z",
pr_states: [],
pr_options: [],
}),
listWorkspaceTaskPRs: vi.fn().mockResolvedValue({ task_prs: {} }),
};
@@ -70,53 +75,6 @@ function renderWithStore(initialState: Partial<AppState> | undefined, ui: ReactN
);
}
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-id",
task_id: "task-1",
owner: "acme",
repo: "demo",
pr_number: 42,
pr_url: "https://github.com/acme/demo/pull/42",
pr_title: "Test PR",
head_branch: "feat",
base_branch: "main",
author_login: "alice",
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "clean",
review_count: 1,
pending_review_count: 0,
comment_count: 0,
unresolved_review_threads: 0,
checks_total: 2,
checks_passing: 2,
additions: 0,
deletions: 0,
created_at: "",
merged_at: null,
closed_at: null,
last_synced_at: null,
updated_at: "",
...overrides,
};
}
function makeCIOptions(overrides: Partial<TaskCIAutomationOptions> = {}): TaskCIAutomationOptions {
return {
task_id: "task-1",
auto_fix_enabled: false,
auto_merge_enabled: false,
auto_fix_prompt_override: null,
effective_auto_fix_prompt: testConstants.defaultCIFixPrompt,
using_default_prompt: true,
updated_at: "2026-06-18T10:00:00Z",
pr_states: [],
...overrides,
};
}
beforeEach(() => {
responsiveMock.breakpoint = "desktop";
responsiveMock.isFinePointer = true;