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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user