feat(github): recover pull requests removed from merge queues (#3042)

This commit is contained in:
Carlos Florêncio
2026-08-26 10:06:41 +00:00
committed by GitHub
parent 653eed950a
commit 4dbc7afc53
49 changed files with 4097 additions and 304 deletions
@@ -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"));