From 0c355bf549408fbb10f6da47fca6c5ab351f3af3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 01:20:35 -0700 Subject: [PATCH] fix(ci): sweeper revives cancelled required checks on auto-merge PRs (#112599) * fix(ci): sweeper revives cancelled required checks on auto-merge PRs * fix(ci): avoid shadowed identifier in sweeper revive lane --- .github/workflows/pr-ci-sweeper.yml | 9 +- scripts/github/pr-ci-sweeper.d.mts | 13 + scripts/github/pr-ci-sweeper.mjs | 236 ++++++++++++++- test/scripts/pr-ci-sweeper.test.ts | 432 +++++++++++++++++++++++++++- 4 files changed, 670 insertions(+), 20 deletions(-) diff --git a/.github/workflows/pr-ci-sweeper.yml b/.github/workflows/pr-ci-sweeper.yml index 347136644e91..228cb50b9934 100644 --- a/.github/workflows/pr-ci-sweeper.yml +++ b/.github/workflows/pr-ci-sweeper.yml @@ -1,9 +1,8 @@ name: PR CI Sweeper -# Re-fires pull_request CI runs that GitHub dropped at PR open (merge-ref -# race: no CI run attaches, or an un-rerunnable startup_failure is created). -# Close/reopen with an app token re-fires the event; GITHUB_TOKEN events -# would not trigger workflows. +# Repairs dropped pull_request CI via close/reopen and non-destructively revives +# cancelled GitHub Actions checks attached to auto-merge PR heads. The app token +# authorizes reruns; GITHUB_TOKEN close/reopen events would not trigger CI. on: schedule: @@ -11,7 +10,7 @@ on: workflow_dispatch: inputs: dry_run: - description: Log intended re-fires without closing/reopening. + description: Log intended repairs without closing/reopening or rerunning workflows. required: false default: false type: boolean diff --git a/scripts/github/pr-ci-sweeper.d.mts b/scripts/github/pr-ci-sweeper.d.mts index 98894583365b..6f4b97c3feb7 100644 --- a/scripts/github/pr-ci-sweeper.d.mts +++ b/scripts/github/pr-ci-sweeper.d.mts @@ -10,6 +10,19 @@ export function classifyPrForSweep(params: { botCloseCount: number; now: number; }): { action: "refire" | "skip"; reason: string }; +export function classifyRunForRevive(params: { + run: { + conclusion: string | null; + event: string; + run_attempt: number; + created_at: string; + head_branch?: string | null; + head_repository?: { full_name?: string }; + }; + prCreatedAt: string; + prHeadBranch?: string; + repoFullName?: string; +}): { action: "revive" | "skip"; reason: string }; export function runPrCiSweeper(params: { github: Record; context: Record; diff --git a/scripts/github/pr-ci-sweeper.mjs b/scripts/github/pr-ci-sweeper.mjs index 94515d979542..32bb6757f75c 100644 --- a/scripts/github/pr-ci-sweeper.mjs +++ b/scripts/github/pr-ci-sweeper.mjs @@ -1,9 +1,10 @@ -// Re-fires pull_request CI runs that GitHub dropped at PR open. Fresh PRs can -// race merge-ref computation: the open event's CI run either never attaches to -// the head SHA or is created as an un-rerunnable startup_failure. Closing and -// reopening the PR re-fires the event once the merge ref exists. The workflow -// authenticates with a GitHub App token because GITHUB_TOKEN-authored events -// do not trigger new workflow runs. +// Repairs two GitHub Actions failure modes for fresh PRs. A merge-ref race can +// drop pull_request CI entirely (or create an un-rerunnable startup_failure), +// which close/reopen re-fires once the merge ref exists. Auto-merge PRs cannot +// use that destructive repair, so a second lane finds cancelled Actions checks +// attached to the PR head and reruns their PR-event workflows without disturbing +// auto-merge. The workflow authenticates with a GitHub App token because +// GITHUB_TOKEN-authored events do not trigger new workflow runs. const CI_WORKFLOW_FILE = "ci.yml"; const LOOKBACK_MS = 24 * 60 * 60 * 1000; @@ -14,6 +15,8 @@ const MIN_QUIET_MS = 10 * 60 * 1000; // a human, not an hourly close/reopen loop. const MAX_BOT_CLOSES = 2; const MAX_REFIRES_PER_SWEEP = 10; +const MAX_REVIVES_PER_SWEEP = 10; +const REVIVABLE_EVENTS = new Set(["pull_request", "pull_request_target"]); // Known sweeper identities for the close budget. The fallback app's login is // only recognized while it is the active identity, so an auth failover can at // worst double the budget to four re-fires — still bounded, and the @@ -46,7 +49,7 @@ export function classifyPrForSweep({ pr, ciRuns, botCloseCount, now }) { return { action: "skip", reason: "merge-conflict" }; } // Closing a PR silently cancels enabled auto-merge and reopening does not - // restore it; leave those PRs (e.g. generated locale refreshes) to a human. + // restore it; the non-destructive revive lane serves these PRs instead. if (pr.auto_merge) { return { action: "skip", reason: "auto-merge-enabled" }; } @@ -63,6 +66,37 @@ export function classifyPrForSweep({ pr, ciRuns, botCloseCount, now }) { }; } +export function classifyRunForRevive({ run, prCreatedAt, prHeadBranch, repoFullName }) { + if (run.conclusion !== "cancelled") { + return { action: "skip", reason: "not-cancelled" }; + } + if (!REVIVABLE_EVENTS.has(run.event)) { + return { action: "skip", reason: "unsupported-event" }; + } + // A head SHA can be reused by a later PR. Reruns replay the original event + // context, so a run created before this PR existed cannot safely be revived. + if (Date.parse(run.created_at) < Date.parse(prCreatedAt)) { + return { action: "skip", reason: "predates-pr" }; + } + if (run.run_attempt >= 3) { + return { action: "skip", reason: "revive-budget-exhausted" }; + } + // Trigger identity: even when a target run's head_sha is base-side and its + // pull_requests is empty (observed live), head_branch still names the + // triggering PR's branch. Same-repo branch names are unique, so requiring + // branch + repo match ties the rerun to this PR's event context; fork-headed + // or foreign-branch runs are refused rather than replayed on inference. + if (prHeadBranch !== undefined && run.head_branch !== prHeadBranch) { + return { action: "skip", reason: "different-head-branch" }; + } + // Absent metadata fails closed: an unverifiable head repository must never + // default to "same repo" — that would replay fork-triggered privileged runs. + if (repoFullName !== undefined && run.head_repository?.full_name !== repoFullName) { + return { action: "skip", reason: "fork-head-repository" }; + } + return { action: "revive", reason: "cancelled-pr-event-run" }; +} + async function listPullRequestCiRuns({ github, owner, repo, headSha }) { // Manual dispatches or other events against the same SHA neither prove nor // repair the dropped pull_request run; judge only pull_request-event runs, @@ -80,6 +114,42 @@ async function listPullRequestCiRuns({ github, owner, repo, headSha }) { }); } +async function listLatestChecksForHead({ github, owner, repo, headSha }) { + // Checks are the PR association GitHub's merge box and auto-merge actually + // wait on. pull_request_target workflow runs can have a base-side head_sha + // and an empty pull_requests array, so neither run field identifies the PR. + // filter=latest also omits cancelled checks superseded by a newer same-name + // check, leaving only cancelled work that still blocks this head. + // Accepted tradeoff: checks are commit-scoped, so a second PR sharing this + // exact head (a duplicate PR off the same automation branch) could have its + // run revived under our candidate's eligibility. GitHub exposes no trigger + // identity for these runs (pull_requests is empty on live target runs), and + // requiring one would skip the very runs this lane exists to repair; the + // worst case is duplicated bot activity on a same-branch sibling PR. + return await github.paginate(github.rest.checks.listForRef, { + owner, + repo, + ref: headSha, + filter: "latest", + per_page: 100, + }); +} + +function workflowRunIdForCheck(check) { + if (check.conclusion !== "cancelled" || check.app?.slug !== "github-actions") { + return undefined; + } + const match = check.details_url?.match(/\/actions\/runs\/(\d+)(?:\/|$)/); + return match ? Number(match[1]) : undefined; +} + +function isExpectedReviveSkip(error) { + // Only a positively identified already-active run is an expected race; a bare + // 403 can also be a policy/permission denial that must surface, or the lane + // reports success while permanently unable to repair anything. + return /already (?:running|in progress)/i.test(String(error)); +} + // Our close call succeeded against a verified-open PR, so the sweeper owns the // transition unless a newer close event by someone else is positively visible // (a human close in the millisecond race window makes our update an eventless @@ -148,6 +218,7 @@ export async function runPrCiSweeper({ const { owner, repo } = context.repo; const results = []; let refires = 0; + let revives = 0; const openPrs = await github.paginate(github.rest.pulls.list, { owner, repo, @@ -156,6 +227,155 @@ export async function runPrCiSweeper({ direction: "desc", per_page: 100, }); + const seenRunIds = new Set(); + reviveLane: for (const listed of openPrs) { + if (now - Date.parse(listed.updated_at) > LOOKBACK_MS) { + break; + } + if ( + listed.draft || + !listed.auto_merge || + now - Date.parse(listed.created_at) > LOOKBACK_MS || + now - Date.parse(listed.updated_at) < MIN_QUIET_MS + ) { + continue; + } + const checks = await listLatestChecksForHead({ + github, + owner, + repo, + headSha: listed.head.sha, + }); + // One workflow run fans out to many job checks; inspect each run id once + // per PR or a rejected matrix run costs one API call per job. + const inspectedForPr = new Set(); + for (const check of checks) { + const runId = workflowRunIdForCheck(check); + if (runId === undefined || seenRunIds.has(runId) || inspectedForPr.has(runId)) { + continue; + } + inspectedForPr.add(runId); + const { data: run } = await github.rest.actions.getWorkflowRun({ + owner, + repo, + run_id: runId, + }); + const verdict = classifyRunForRevive({ + run, + prCreatedAt: listed.created_at, + prHeadBranch: listed.head.ref, + repoFullName: `${owner}/${repo}`, + }); + // Global suppression only once this run is actually handled: a + // PR-relative rejection (branch mismatch, predates-pr) must leave the + // run inspectable for the candidate that owns it. + if (verdict.action === "revive") { + seenRunIds.add(runId); + } + if (verdict.action !== "revive") { + if (verdict.reason === "revive-budget-exhausted") { + core.info( + `pr-ci-sweeper: skip cancelled run ${runId} for #${listed.number} (${verdict.reason})`, + ); + } + continue; + } + if (revives >= MAX_REVIVES_PER_SWEEP) { + core.info(`pr-ci-sweeper: per-sweep revive cap (${MAX_REVIVES_PER_SWEEP}) reached`); + break reviveLane; + } + if (!dryRun) { + // Revalidate immediately before mutating, mirroring the re-fire lane: + // a fresh push, merge, close, or disarmed auto-merge in the scan gap + // must win — reviving an old head's run could cancel the new head's + // live run via workflow-level cancel-in-progress. + const { data: fresh } = await github.rest.pulls.get({ + owner, + repo, + pull_number: listed.number, + }); + if ( + fresh.state !== "open" || + fresh.draft || + !fresh.auto_merge || + fresh.head.sha !== listed.head.sha + ) { + core.info(`pr-ci-sweeper: #${listed.number} changed during sweep; skipping revive`); + continue; + } + // The scan can spend minutes across PRs; a manual rerun or new event + // may have replaced this check meanwhile. Rerunning a no-longer-latest + // cancelled run would put stale checks back in flight (or cancel a + // live replacement via workflow concurrency), so require the same + // check to still be the head's latest cancelled entry. + const currentChecks = await listLatestChecksForHead({ + github, + owner, + repo, + headSha: listed.head.sha, + }); + if (!currentChecks.some((current) => workflowRunIdForCheck(current) === runId)) { + core.info( + `pr-ci-sweeper: run ${runId} for #${listed.number} is no longer the latest cancelled check; skipping`, + ); + continue; + } + // Revive only a quiescent head: any queued/in-progress Actions check + // means a replacement may be underway, and rerunning now could cancel + // it via workflow concurrency. Auto-merge waits for every check anyway, + // so deferring to the next sweep loses nothing. + const active = currentChecks.some( + (current) => current.app?.slug === "github-actions" && current.status !== "completed", + ); + if (active) { + core.info( + `pr-ci-sweeper: #${listed.number} head has active checks; deferring revive of run ${runId}`, + ); + continue; + } + // A concurrent rerun can advance the same run id to a fresh attempt in + // the scan gap; reclassify the current attempt so the budget and + // cancelled-state guards judge what the rerun would actually replay. + const { data: currentRun } = await github.rest.actions.getWorkflowRun({ + owner, + repo, + run_id: runId, + }); + const currentVerdict = classifyRunForRevive({ + run: currentRun, + prCreatedAt: listed.created_at, + prHeadBranch: listed.head.ref, + repoFullName: `${owner}/${repo}`, + }); + if (currentVerdict.action !== "revive") { + core.info( + `pr-ci-sweeper: run ${runId} for #${listed.number} changed during sweep (${currentVerdict.reason}); skipping`, + ); + continue; + } + } + // Count only real (or dry-run-logged) revive attempts: stale candidates + // rejected by revalidation must not exhaust the sweep-wide cap. + revives += 1; + if (dryRun) { + core.info( + `pr-ci-sweeper: dry-run, would revive cancelled run ${runId} for #${listed.number}`, + ); + continue; + } + try { + await github.rest.actions.reRunWorkflow({ owner, repo, run_id: runId }); + core.info(`pr-ci-sweeper: revived cancelled run ${runId} for #${listed.number}`); + } catch (error) { + if (!isExpectedReviveSkip(error)) { + throw error; + } + core.info( + `pr-ci-sweeper: run ${runId} for #${listed.number} was not rerun (${String(error)}); skipping`, + ); + } + } + } for (const listed of openPrs) { if (now - Date.parse(listed.updated_at) > LOOKBACK_MS) { break; @@ -255,7 +475,7 @@ export async function runPrCiSweeper({ await reopenWithRetry({ github, core, owner, repo, pullNumber: pr.number }); } core.info( - `pr-ci-sweeper: checked ${openPrs.length} open PRs, ${results.length} candidates, ${refires} re-fire${refires === 1 ? "" : "s"}${dryRun ? " (dry-run)" : ""}`, + `pr-ci-sweeper: checked ${openPrs.length} open PRs, ${results.length} candidates, ${refires} re-fire${refires === 1 ? "" : "s"}, ${revives} revive${revives === 1 ? "" : "s"}${dryRun ? " (dry-run)" : ""}`, ); return results; } diff --git a/test/scripts/pr-ci-sweeper.test.ts b/test/scripts/pr-ci-sweeper.test.ts index b160c7fe69c7..0660c0cb0d81 100644 --- a/test/scripts/pr-ci-sweeper.test.ts +++ b/test/scripts/pr-ci-sweeper.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { classifyPrForSweep, runPrCiSweeper } from "../../scripts/github/pr-ci-sweeper.mjs"; +import { + classifyPrForSweep, + classifyRunForRevive, + runPrCiSweeper, +} from "../../scripts/github/pr-ci-sweeper.mjs"; const NOW = Date.parse("2026-07-18T12:00:00Z"); const MINUTES = 60 * 1000; @@ -124,11 +128,170 @@ describe("classifyPrForSweep", () => { }); }); +describe("classifyRunForRevive", () => { + const prCreatedAt = new Date(NOW - 2 * HOURS).toISOString(); + const cases: Array<{ + name: string; + run: Parameters[0]["run"]; + expected: ReturnType; + }> = [ + { + name: "revives a cancelled pull_request_target run", + run: { + conclusion: "cancelled", + event: "pull_request_target", + run_attempt: 1, + created_at: new Date(NOW - 1 * HOURS).toISOString(), + }, + expected: { action: "revive", reason: "cancelled-pr-event-run" }, + }, + { + name: "skips a run after two revives without progress", + run: { + conclusion: "cancelled", + event: "pull_request", + run_attempt: 3, + created_at: new Date(NOW - 1 * HOURS).toISOString(), + }, + expected: { action: "skip", reason: "revive-budget-exhausted" }, + }, + { + name: "skips a non-cancelled run", + run: { + conclusion: "success", + event: "pull_request_target", + run_attempt: 1, + created_at: new Date(NOW - 1 * HOURS).toISOString(), + }, + expected: { action: "skip", reason: "not-cancelled" }, + }, + { + name: "skips a cancelled run from an unrelated event", + run: { + conclusion: "cancelled", + event: "workflow_dispatch", + run_attempt: 1, + created_at: new Date(NOW - 1 * HOURS).toISOString(), + }, + expected: { action: "skip", reason: "unsupported-event" }, + }, + ]; + + it.each(cases)("$name", ({ run, expected }) => { + expect( + classifyRunForRevive({ + run: { + head_branch: "automation/refresh", + head_repository: { full_name: "openclaw/openclaw" }, + ...run, + }, + prCreatedAt, + prHeadBranch: "automation/refresh", + repoFullName: "openclaw/openclaw", + }), + ).toEqual(expected); + }); + + it("skips a run triggered from a different head branch", () => { + expect( + classifyRunForRevive({ + run: { + conclusion: "cancelled", + event: "pull_request_target", + run_attempt: 1, + created_at: new Date(NOW - 1 * HOURS).toISOString(), + head_branch: "some/other-branch", + }, + prCreatedAt, + prHeadBranch: "automation/refresh", + repoFullName: "openclaw/openclaw", + }), + ).toEqual({ action: "skip", reason: "different-head-branch" }); + }); + + it("skips a run with a null head branch", () => { + expect( + classifyRunForRevive({ + run: { + conclusion: "cancelled", + event: "pull_request", + run_attempt: 1, + created_at: new Date(NOW - 1 * HOURS).toISOString(), + head_branch: null, + head_repository: { full_name: "openclaw/openclaw" }, + }, + prCreatedAt, + prHeadBranch: "automation/refresh", + repoFullName: "openclaw/openclaw", + }), + ).toEqual({ action: "skip", reason: "different-head-branch" }); + }); + + it("skips a run with no head repository metadata", () => { + expect( + classifyRunForRevive({ + run: { + conclusion: "cancelled", + event: "pull_request", + run_attempt: 1, + created_at: new Date(NOW - 1 * HOURS).toISOString(), + head_branch: "automation/refresh", + }, + prCreatedAt, + prHeadBranch: "automation/refresh", + repoFullName: "openclaw/openclaw", + }), + ).toEqual({ action: "skip", reason: "fork-head-repository" }); + }); + + it("skips a run whose head repository is a fork", () => { + expect( + classifyRunForRevive({ + run: { + conclusion: "cancelled", + event: "pull_request", + run_attempt: 1, + created_at: new Date(NOW - 1 * HOURS).toISOString(), + head_branch: "automation/refresh", + head_repository: { full_name: "fork/openclaw" }, + }, + prCreatedAt, + prHeadBranch: "automation/refresh", + repoFullName: "openclaw/openclaw", + }), + ).toEqual({ action: "skip", reason: "fork-head-repository" }); + }); + + it("skips a run created before the current PR existed", () => { + expect( + classifyRunForRevive({ + run: { + conclusion: "cancelled", + event: "pull_request_target", + run_attempt: 1, + created_at: new Date(NOW - 3 * HOURS).toISOString(), + }, + prCreatedAt, + }), + ).toEqual({ action: "skip", reason: "predates-pr" }); + }); +}); + type FakeCall = { method: string; args: Record }; +type FakeWorkflowRun = Parameters[0]["run"] & { id: number }; +type FakeCheckRun = { + status?: string; + conclusion: string | null; + app: { slug: string } | null; + details_url: string | null; +}; function fakeGithub(options: { prs: Array>; runsBySha: Record>; + checksByRef?: Record; + workflowRunsById?: Record; + pullsGetByNumber?: Record>; events?: Array>; }) { const calls: FakeCall[] = []; @@ -143,12 +306,17 @@ function fakeGithub(options: { } if (endpoint.endpointName === "actions.listWorkflowRuns") { return Promise.resolve( - (options.runsBySha[args.head_sha as string] ?? []).map((run) => ({ - event: run.event ?? "pull_request", - conclusion: run.conclusion, - })), + (options.runsBySha[args.head_sha as string] ?? []) + .map((run) => ({ + event: run.event ?? "pull_request", + conclusion: run.conclusion, + })) + .filter((run) => !args.event || run.event === args.event), ); } + if (endpoint.endpointName === "checks.listForRef") { + return Promise.resolve(options.checksByRef?.[args.ref as string] ?? []); + } if (endpoint.endpointName === "issues.listEvents") { return Promise.resolve(options.events ?? []); } @@ -159,7 +327,9 @@ function fakeGithub(options: { list: { endpointName: "pulls.list" }, get: (args: Record) => { record("pulls.get", args); - const match = options.prs.find((entry) => entry.number === args.pull_number); + const match = + options.pullsGetByNumber?.[args.pull_number as number] ?? + options.prs.find((entry) => entry.number === args.pull_number); return Promise.resolve({ data: match }); }, update: (args: Record) => { @@ -167,7 +337,18 @@ function fakeGithub(options: { return Promise.resolve({}); }, }, - actions: { listWorkflowRuns: { endpointName: "actions.listWorkflowRuns" } }, + actions: { + listWorkflowRuns: { endpointName: "actions.listWorkflowRuns" }, + getWorkflowRun: (args: Record) => { + record("actions.getWorkflowRun", args); + return Promise.resolve({ data: options.workflowRunsById?.[args.run_id as number] }); + }, + reRunWorkflow: (args: Record) => { + record("actions.reRunWorkflow", args); + return Promise.resolve({}); + }, + }, + checks: { listForRef: { endpointName: "checks.listForRef" } }, issues: { listEvents: { endpointName: "issues.listEvents" }, createComment: (args: Record) => { @@ -183,6 +364,49 @@ function fakeGithub(options: { const context = { repo: { owner: "openclaw", repo: "openclaw" } }; const core = { info: () => {}, setFailed: () => {} }; +function recordingCore() { + const logs: string[] = []; + return { + core: { + info: (message: string) => logs.push(message), + setFailed: () => {}, + }, + logs, + }; +} + +function autoMergePr(number: number, headSha: string) { + return { + ...pr({ auto_merge: { merge_method: "squash" } }), + number, + state: "open", + head: { sha: headSha, ref: "automation/refresh" }, + }; +} + +function githubActionsCheck(runId: number, overrides: Partial = {}): FakeCheckRun { + return { + conclusion: "cancelled", + status: "completed", + app: { slug: "github-actions" }, + details_url: `https://github.com/openclaw/openclaw/actions/runs/${runId}/job/456`, + ...overrides, + }; +} + +function cancelledRun(runId: number, overrides: Partial = {}): FakeWorkflowRun { + return { + id: runId, + conclusion: "cancelled", + event: "pull_request_target", + run_attempt: 1, + created_at: new Date(NOW - 1 * HOURS).toISOString(), + head_branch: "automation/refresh", + head_repository: { full_name: "openclaw/openclaw" }, + ...overrides, + }; +} + describe("runPrCiSweeper", () => { it("classifies a dropped-CI PR as refire in dry-run without mutating", async () => { const dropped = { @@ -240,4 +464,198 @@ describe("runPrCiSweeper", () => { calls.filter((call) => call.method === "pulls.update").map((call) => call.args.state), ).toEqual(["closed", "open"]); }); + + it("revives a cancelled GitHub Actions check exactly once", async () => { + const generated = autoMergePr(10, "d".repeat(40)); + const { github, calls } = fakeGithub({ + prs: [generated], + runsBySha: {}, + checksByRef: { + // One workflow produces multiple checks; dedupe their shared run id. + [generated.head.sha]: [githubActionsCheck(1234), githubActionsCheck(1234)], + }, + workflowRunsById: { 1234: cancelledRun(1234) }, + }); + + await runPrCiSweeper({ + github: github as never, + context: context as never, + core: core as never, + now: NOW, + }); + + expect(calls.filter((call) => call.method === "actions.reRunWorkflow")).toEqual([ + { + method: "actions.reRunWorkflow", + args: { owner: "openclaw", repo: "openclaw", run_id: 1234 }, + }, + ]); + // Discovery plus the pre-mutation revalidation both list the head's checks. + const checkLists = calls.filter((call) => call.method === "checks.listForRef"); + expect(checkLists).toHaveLength(2); + for (const call of checkLists) { + expect(call.args).toEqual({ + owner: "openclaw", + repo: "openclaw", + ref: generated.head.sha, + filter: "latest", + per_page: 100, + }); + } + // Discovery plus the pre-mutation attempt reclassification both fetch the run. + expect(calls.filter((call) => call.method === "actions.getWorkflowRun")).toHaveLength(2); + expect(calls.filter((call) => call.method === "pulls.update")).toEqual([]); + }); + + it("does not revive a run triggered from a different branch on a shared commit", async () => { + const generated = autoMergePr(14, "f".repeat(40)); + const { github, calls } = fakeGithub({ + prs: [generated], + runsBySha: {}, + checksByRef: { [generated.head.sha]: [githubActionsCheck(4321)] }, + workflowRunsById: { 4321: cancelledRun(4321, { head_branch: "some/foreign-branch" }) }, + }); + + await runPrCiSweeper({ + github: github as never, + context: context as never, + core: core as never, + now: NOW, + }); + + expect(calls.filter((call) => call.method === "actions.reRunWorkflow")).toEqual([]); + }); + + it("defers revive while the head has active checks", async () => { + const generated = autoMergePr(15, "9".repeat(40)); + const { github, calls } = fakeGithub({ + prs: [generated], + runsBySha: {}, + checksByRef: { + [generated.head.sha]: [ + githubActionsCheck(7777), + githubActionsCheck(8888, { status: "in_progress", conclusion: null }), + ], + }, + workflowRunsById: { 7777: cancelledRun(7777) }, + }); + + await runPrCiSweeper({ + github: github as never, + context: context as never, + core: core as never, + now: NOW, + }); + + expect(calls.filter((call) => call.method === "actions.reRunWorkflow")).toEqual([]); + }); + + it("skips cancelled checks from non-GitHub-Actions apps", async () => { + const generated = autoMergePr(11, "e".repeat(40)); + const { github, calls } = fakeGithub({ + prs: [generated], + runsBySha: {}, + checksByRef: { + [generated.head.sha]: [githubActionsCheck(2345, { app: { slug: "external-ci" } })], + }, + workflowRunsById: { 2345: cancelledRun(2345) }, + }); + + await runPrCiSweeper({ + github: github as never, + context: context as never, + core: core as never, + now: NOW, + }); + + expect(calls.filter((call) => call.method === "actions.getWorkflowRun")).toEqual([]); + expect(calls.filter((call) => call.method === "actions.reRunWorkflow")).toEqual([]); + }); + + it("skips a workflow run that predates the PR", async () => { + const generated = autoMergePr(12, "f".repeat(40)); + const { github, calls } = fakeGithub({ + prs: [generated], + runsBySha: {}, + checksByRef: { [generated.head.sha]: [githubActionsCheck(3456)] }, + workflowRunsById: { + 3456: cancelledRun(3456, { + created_at: new Date(Date.parse(generated.created_at) - MINUTES).toISOString(), + }), + }, + }); + + await runPrCiSweeper({ + github: github as never, + context: context as never, + core: core as never, + now: NOW, + }); + + expect(calls.filter((call) => call.method === "actions.reRunWorkflow")).toEqual([]); + }); + + it("skips a workflow run at the revive attempt limit", async () => { + const generated = autoMergePr(13, "1".repeat(40)); + const { github, calls } = fakeGithub({ + prs: [generated], + runsBySha: {}, + checksByRef: { [generated.head.sha]: [githubActionsCheck(4567)] }, + workflowRunsById: { 4567: cancelledRun(4567, { run_attempt: 3 }) }, + }); + + await runPrCiSweeper({ + github: github as never, + context: context as never, + core: core as never, + now: NOW, + }); + + expect(calls.filter((call) => call.method === "actions.reRunWorkflow")).toEqual([]); + }); + + it("logs a dry-run revive without rerunning or closing", async () => { + const generated = autoMergePr(14, "2".repeat(40)); + const { github, calls } = fakeGithub({ + prs: [generated], + runsBySha: {}, + checksByRef: { [generated.head.sha]: [githubActionsCheck(5678)] }, + workflowRunsById: { 5678: cancelledRun(5678, { run_attempt: 2 }) }, + }); + const { core: loggedCore, logs } = recordingCore(); + + await runPrCiSweeper({ + github: github as never, + context: context as never, + core: loggedCore as never, + dryRun: true, + now: NOW, + }); + + expect(calls.filter((call) => call.method === "actions.reRunWorkflow")).toEqual([]); + expect(calls.filter((call) => call.method === "pulls.update")).toEqual([]); + expect(logs).toContain("pr-ci-sweeper: dry-run, would revive cancelled run 5678 for #14"); + }); + + it("does not rerun when the PR head changes during revalidation", async () => { + const generated = autoMergePr(15, "3".repeat(40)); + const { github, calls } = fakeGithub({ + prs: [generated], + runsBySha: {}, + checksByRef: { [generated.head.sha]: [githubActionsCheck(6789)] }, + workflowRunsById: { 6789: cancelledRun(6789) }, + pullsGetByNumber: { + [generated.number]: { ...generated, head: { sha: "4".repeat(40) } }, + }, + }); + + await runPrCiSweeper({ + github: github as never, + context: context as never, + core: core as never, + now: NOW, + }); + + expect(calls.filter((call) => call.method === "actions.reRunWorkflow")).toEqual([]); + }); });