feat(github): recover pull requests removed from merge queues (#3042)
This commit is contained in:
@@ -6,6 +6,7 @@ import { useTaskCIAutomationOptions } from "@/hooks/domains/github/use-task-ci-o
|
||||
import {
|
||||
findCIAutomationStateForPR,
|
||||
findPRAutomationOptionsForPR,
|
||||
deriveCIAutomationQueueState,
|
||||
} from "@/lib/github/ci-automation";
|
||||
import type { TaskCIAutomationPatch, TaskPR } from "@/lib/types/github";
|
||||
import { CIAutomationPromptDialog } from "@/components/github/pr-ci-automation-prompt-dialog";
|
||||
@@ -23,6 +24,7 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
|
||||
const [promptDraft, setPromptDraft] = useState("");
|
||||
const automationState = findCIAutomationStateForPR(options?.pr_states, pr);
|
||||
const prOptions = findPRAutomationOptionsForPR(options?.pr_options, pr);
|
||||
const queueState = deriveCIAutomationQueueState(pr, prOptions, automationState);
|
||||
|
||||
const openPromptEditor = useCallback(() => {
|
||||
setPromptDraft(options?.auto_fix_prompt_override ?? options?.effective_auto_fix_prompt ?? "");
|
||||
@@ -77,7 +79,12 @@ 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 pr={pr} disabled={!options} onEditPrompt={openPromptEditor} />
|
||||
<CIAutomationHeader
|
||||
pr={pr}
|
||||
queueState={queueState}
|
||||
disabled={!options}
|
||||
onEditPrompt={openPromptEditor}
|
||||
/>
|
||||
<CIAutomationOptionRows
|
||||
pr={pr}
|
||||
prOptions={prOptions}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { CIAutomationQueueStatusRow } from "./pr-ci-automation-rows";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("CI automation queue recovery status", () => {
|
||||
it("renders active queue status as non-interactive status text", () => {
|
||||
const { getByRole } = render(
|
||||
<CIAutomationQueueStatusRow
|
||||
queueState={{
|
||||
context: "queued",
|
||||
status: "queued",
|
||||
removalCause: "unknown",
|
||||
repairAccepted: false,
|
||||
waitingForCommit: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByRole("status").textContent).toContain("Active merge queue attempt");
|
||||
});
|
||||
|
||||
it("renders the same-head guard status after removal", () => {
|
||||
const { getByRole } = render(
|
||||
<CIAutomationQueueStatusRow
|
||||
queueState={{
|
||||
context: "recovery",
|
||||
status: "waiting_for_commit",
|
||||
removalCause: "checks_failed",
|
||||
repairAccepted: false,
|
||||
waitingForCommit: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByRole("status").textContent).toContain("Waiting for a new commit");
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,11 @@ 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 {
|
||||
autoFixRoundForState,
|
||||
type CIAutomationQueueRecoveryState,
|
||||
deriveCIAutomationQueueState,
|
||||
} from "@/lib/github/ci-automation";
|
||||
import type {
|
||||
TaskCIAutomationPatch,
|
||||
TaskCIPRAutomationState,
|
||||
@@ -42,7 +46,7 @@ export function CIAutomationInfoButton() {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="end" className="max-w-[280px] text-xs leading-relaxed">
|
||||
{t("github:watchesThisTaskSLinkedPull")}
|
||||
{t("github:ciAutomationQueueRecoveryHelp")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -55,6 +59,7 @@ export function CIAutomationRow({
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
help,
|
||||
supportingText,
|
||||
describedBy,
|
||||
}: {
|
||||
id: string;
|
||||
@@ -63,6 +68,7 @@ export function CIAutomationRow({
|
||||
disabled: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
help?: ReactNode;
|
||||
supportingText?: ReactNode;
|
||||
describedBy?: string;
|
||||
}) {
|
||||
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||
@@ -70,11 +76,18 @@ export function CIAutomationRow({
|
||||
|
||||
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 className="flex min-w-0 flex-1 flex-col items-start">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Label htmlFor={id} className="min-w-0 cursor-pointer text-xs leading-5">
|
||||
{label}
|
||||
</Label>
|
||||
{help}
|
||||
</div>
|
||||
{supportingText ? (
|
||||
<span className="max-w-full break-words text-[10px] leading-4 text-muted-foreground">
|
||||
{supportingText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
@@ -334,23 +347,109 @@ function ReviewRequestedPromptRow({
|
||||
);
|
||||
}
|
||||
|
||||
type CIAutomationTranslate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function queueContextTitle(
|
||||
t: CIAutomationTranslate,
|
||||
context: CIAutomationQueueRecoveryState["context"],
|
||||
): string {
|
||||
switch (context) {
|
||||
case "queued":
|
||||
return t("github:mergeQueueAutomation");
|
||||
case "recovery":
|
||||
return t("github:mergeQueueRecovery");
|
||||
default:
|
||||
return t("github:automation");
|
||||
}
|
||||
}
|
||||
|
||||
function queueRemovalCauseLabel(
|
||||
t: CIAutomationTranslate,
|
||||
cause: CIAutomationQueueRecoveryState["removalCause"],
|
||||
): string {
|
||||
switch (cause) {
|
||||
case "checks_failed":
|
||||
return t("github:mergeQueueRemovalCauseChecksFailed");
|
||||
case "checks_timed_out":
|
||||
return t("github:mergeQueueRemovalCauseChecksTimedOut");
|
||||
case "conflict":
|
||||
return t("github:mergeQueueRemovalCauseConflict");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function autoFixQueueSupport(
|
||||
t: CIAutomationTranslate,
|
||||
queueState: CIAutomationQueueRecoveryState,
|
||||
enabled: boolean,
|
||||
): string | undefined {
|
||||
switch (queueState.status) {
|
||||
case "queued":
|
||||
return t("github:autoFixQueueSupportQueued");
|
||||
case "removed_actionable":
|
||||
return enabled ? undefined : t("github:autoFixQueueSupportEnable");
|
||||
case "repair_requested":
|
||||
return t("github:autoFixQueueSupportRequested");
|
||||
case "removed_not_actionable":
|
||||
return t("github:autoFixQueueSupportNotActionable");
|
||||
case "waiting_for_checks":
|
||||
return t("github:autoFixQueueSupportNewHead");
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function autoMergeQueueSupport(
|
||||
t: CIAutomationTranslate,
|
||||
queueState: CIAutomationQueueRecoveryState,
|
||||
enabled: boolean,
|
||||
): string | undefined {
|
||||
switch (queueState.status) {
|
||||
case "queued":
|
||||
return t("github:autoMergeQueueSupportQueued");
|
||||
case "repair_requested":
|
||||
case "waiting_for_commit":
|
||||
return t("github:autoMergeQueueSupportWaitingForCommit");
|
||||
case "waiting_for_checks":
|
||||
return t("github:autoMergeQueueSupportWaitingForChecks");
|
||||
default:
|
||||
if (queueState.context !== "recovery") return undefined;
|
||||
return enabled
|
||||
? t("github:autoMergeQueueSupportNewHead")
|
||||
: t("github:autoMergeQueueSupportDisabled");
|
||||
}
|
||||
}
|
||||
|
||||
export function CIAutomationHeader({
|
||||
pr,
|
||||
queueState,
|
||||
disabled,
|
||||
onEditPrompt,
|
||||
}: {
|
||||
pr: TaskPR;
|
||||
queueState: CIAutomationQueueRecoveryState;
|
||||
disabled: boolean;
|
||||
onEditPrompt: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const title = queueContextTitle(t, queueState.context);
|
||||
let subtitle = t("github:automationForPr", { number: pr.pr_number });
|
||||
if (queueState.context === "queued") {
|
||||
subtitle = t("github:automationForPrQueued", { number: pr.pr_number });
|
||||
} else if (queueState.context === "recovery") {
|
||||
const cause = queueRemovalCauseLabel(t, queueState.removalCause);
|
||||
if (cause) {
|
||||
subtitle = t("github:automationForPrRemoved", { number: pr.pr_number, cause });
|
||||
} else {
|
||||
subtitle = t("github:automationForPrRemovedGeneric", { number: pr.pr_number });
|
||||
}
|
||||
}
|
||||
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 className="text-xs font-medium text-foreground">{title}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{subtitle}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<CIAutomationInfoButton />
|
||||
@@ -370,6 +469,49 @@ export function CIAutomationHeader({
|
||||
);
|
||||
}
|
||||
|
||||
export function CIAutomationQueueStatusRow({
|
||||
queueState,
|
||||
}: {
|
||||
queueState: CIAutomationQueueRecoveryState;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (queueState.status === "none") return null;
|
||||
let message: string;
|
||||
switch (queueState.status) {
|
||||
case "queued":
|
||||
message = t("github:mergeQueueRecoveryStatusQueued");
|
||||
break;
|
||||
case "removed_actionable":
|
||||
message = t("github:mergeQueueRecoveryStatusActionable");
|
||||
break;
|
||||
case "removed_not_actionable":
|
||||
message = t("github:mergeQueueRecoveryStatusNotActionable");
|
||||
break;
|
||||
case "repair_requested":
|
||||
if (queueState.waitingForCommit) {
|
||||
message = t("github:mergeQueueRecoveryStatusRepairRequestedWaiting");
|
||||
} else {
|
||||
message = t("github:mergeQueueRecoveryStatusRepairRequested");
|
||||
}
|
||||
break;
|
||||
case "waiting_for_commit":
|
||||
message = t("github:mergeQueueRecoveryStatusWaitingForCommit");
|
||||
break;
|
||||
case "waiting_for_checks":
|
||||
message = t("github:mergeQueueRecoveryStatusWaitingForChecks");
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-testid="ci-merge-queue-recovery-status"
|
||||
role="status"
|
||||
className="break-words px-1 text-[11px] leading-4 text-muted-foreground"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CIAutomationOptionRows({
|
||||
pr,
|
||||
prOptions,
|
||||
@@ -387,6 +529,13 @@ export function CIAutomationOptionRows({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scopeKey = prAutomationScopeKey(pr);
|
||||
const queueState = deriveCIAutomationQueueState(pr, prOptions, automationState);
|
||||
const autoFixSupportingText = autoFixQueueSupport(t, queueState, prOptions.auto_fix_enabled);
|
||||
const autoMergeSupportingText = autoMergeQueueSupport(
|
||||
t,
|
||||
queueState,
|
||||
prOptions.auto_merge_enabled,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<CIAutomationRow
|
||||
@@ -395,6 +544,7 @@ export function CIAutomationOptionRows({
|
||||
checked={prOptions.auto_fix_enabled}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => patchOption({ auto_fix_enabled: checked })}
|
||||
supportingText={autoFixSupportingText}
|
||||
help={
|
||||
prOptions.auto_fix_enabled ? (
|
||||
<CIAutoFixRoundHelpButton state={automationState} maxRounds={autoFixMaxRounds} />
|
||||
@@ -403,11 +553,13 @@ export function CIAutomationOptionRows({
|
||||
/>
|
||||
<CIAutomationRow
|
||||
id={`task-ci-auto-merge-${scopeKey}`}
|
||||
label={t("github:autoMergeWhenReady")}
|
||||
label={t("github:autoMergeOrRequeueWhenReady")}
|
||||
checked={prOptions.auto_merge_enabled}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => patchOption({ auto_merge_enabled: checked })}
|
||||
supportingText={autoMergeSupportingText}
|
||||
/>
|
||||
<CIAutomationQueueStatusRow queueState={queueState} />
|
||||
<ReviewFollowUpSection
|
||||
scopeKey={scopeKey}
|
||||
prOptions={prOptions}
|
||||
|
||||
@@ -53,7 +53,7 @@ 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 AUTO_MERGE_LABEL = "Auto-merge or requeue 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";
|
||||
@@ -133,12 +133,12 @@ function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
|
||||
};
|
||||
}
|
||||
|
||||
function renderPopover() {
|
||||
function renderPopover(pr: TaskPR = makePR()) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<StateProvider>
|
||||
<ToastProvider>
|
||||
<PRCIPopover pr={makePR()} enabled={true} />
|
||||
<PRCIPopover pr={pr} enabled={true} />
|
||||
</ToastProvider>
|
||||
</StateProvider>
|
||||
</TooltipProvider>,
|
||||
@@ -305,6 +305,73 @@ describe("PRCIPopover automation status", () => {
|
||||
expect(screen.queryByLabelText(/edit.*review/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows active queue context without adding another switch", () => {
|
||||
renderPopover(
|
||||
makePR({
|
||||
merge_queue_state: "queued",
|
||||
merge_queue_entry_id: "entry-a",
|
||||
head_sha: "head-a",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Merge queue automation")).not.toBeNull();
|
||||
expect(screen.getByText("PR #1 is in the merge queue")).not.toBeNull();
|
||||
expect(screen.getByTestId("ci-merge-queue-recovery-status").textContent).toContain(
|
||||
"Active merge queue attempt",
|
||||
);
|
||||
expect(screen.getAllByRole("switch")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows classified recovery state and generic copy for unknown causes", () => {
|
||||
hookMocks.options = makeOptions({
|
||||
pr_states: [
|
||||
{
|
||||
task_id: "task-1",
|
||||
repository_id: "",
|
||||
pr_number: 1,
|
||||
last_fix_signature: "",
|
||||
last_fix_checkpoint_json: "",
|
||||
last_fix_enqueued_at: null,
|
||||
last_fix_session_id: null,
|
||||
auto_fix_round_count: 0,
|
||||
auto_fix_exhausted_at: null,
|
||||
last_merge_signature: "",
|
||||
last_merge_attempt_at: null,
|
||||
last_queue_attempt_head_sha: "head-a",
|
||||
last_queue_fix_event_id: "",
|
||||
last_queue_removal_cause: "provider_changed",
|
||||
last_error: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
renderPopover(
|
||||
makePR({
|
||||
head_sha: "head-a",
|
||||
merge_queue_last_removal_id: "removal-a",
|
||||
merge_queue_last_removal_reason: "provider changed this",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Merge queue recovery")).not.toBeNull();
|
||||
expect(screen.getByText("PR #1 was removed from the merge queue")).not.toBeNull();
|
||||
expect(screen.getByTestId("ci-merge-queue-recovery-status").textContent).toContain(
|
||||
"No automatic repair",
|
||||
);
|
||||
expect(screen.queryByText("provider changed this")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PRCIPopover linked PR status", () => {
|
||||
beforeEach(() => {
|
||||
resetHookMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("uses the PR title in the header and omits the redundant detail link", () => {
|
||||
renderPopover();
|
||||
|
||||
|
||||
@@ -533,7 +533,7 @@ describe("PRStatusChip CI automation mobile parity", () => {
|
||||
|
||||
const drawer = document.querySelector(DRAWER_SELECTOR);
|
||||
expect(drawer?.textContent).toContain("Auto-fix CI and address comments");
|
||||
expect(drawer?.textContent).toContain("Auto-merge when ready");
|
||||
expect(drawer?.textContent).toContain("Auto-merge or requeue when ready");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByLabelText("Edit auto-fix prompt for this task"));
|
||||
|
||||
Reference in New Issue
Block a user