mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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
This commit is contained in:
committed by
GitHub
parent
6f29fc88e9
commit
0c355bf549
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
context: Record<string, unknown>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<typeof classifyRunForRevive>[0]["run"];
|
||||
expected: ReturnType<typeof classifyRunForRevive>;
|
||||
}> = [
|
||||
{
|
||||
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<string, unknown> };
|
||||
type FakeWorkflowRun = Parameters<typeof classifyRunForRevive>[0]["run"] & { id: number };
|
||||
type FakeCheckRun = {
|
||||
status?: string;
|
||||
conclusion: string | null;
|
||||
app: { slug: string } | null;
|
||||
details_url: string | null;
|
||||
};
|
||||
|
||||
function fakeGithub(options: {
|
||||
prs: Array<Record<string, unknown>>;
|
||||
runsBySha: Record<string, Array<{ conclusion: string | null; event?: string }>>;
|
||||
checksByRef?: Record<string, FakeCheckRun[]>;
|
||||
workflowRunsById?: Record<number, FakeWorkflowRun>;
|
||||
pullsGetByNumber?: Record<number, Record<string, unknown>>;
|
||||
events?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
@@ -167,7 +337,18 @@ function fakeGithub(options: {
|
||||
return Promise.resolve({});
|
||||
},
|
||||
},
|
||||
actions: { listWorkflowRuns: { endpointName: "actions.listWorkflowRuns" } },
|
||||
actions: {
|
||||
listWorkflowRuns: { endpointName: "actions.listWorkflowRuns" },
|
||||
getWorkflowRun: (args: Record<string, unknown>) => {
|
||||
record("actions.getWorkflowRun", args);
|
||||
return Promise.resolve({ data: options.workflowRunsById?.[args.run_id as number] });
|
||||
},
|
||||
reRunWorkflow: (args: Record<string, unknown>) => {
|
||||
record("actions.reRunWorkflow", args);
|
||||
return Promise.resolve({});
|
||||
},
|
||||
},
|
||||
checks: { listForRef: { endpointName: "checks.listForRef" } },
|
||||
issues: {
|
||||
listEvents: { endpointName: "issues.listEvents" },
|
||||
createComment: (args: Record<string, unknown>) => {
|
||||
@@ -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> = {}): 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> = {}): 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user