Files
openclaw/scripts/watch-pr-ci.mjs
Peter Steinberger 80449f9a8f refactor(scripts): retire the STALE-CANCELLED watcher verdict (#113242)
Run-identity supersession (#113230) resolves the scenario this heuristic
softened: superseded runs and rerun-attempt leftovers are filtered before
classification, so aggregate-FAILURE-with-green-duplicates now classifies
as GREEN or PENDING on evidence. The only remaining reachable case —
identity-less app check runs posting duplicate same-name results — is
genuinely ambiguous and now honestly exits FAILING (15) instead of the
special verify-manually exit 17.
2026-07-23 23:11:41 -07:00

466 lines
16 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";
import { parseArgs as parseNodeArgs } from "node:util";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
const USAGE =
"Usage: node scripts/watch-pr-ci.mjs <pr-number> <head-sha> [--repo owner/repo] [--after run-id] [--attach-timeout 900] [--timeout 3600] [--interval 120]";
const FAILURE_CONCLUSIONS = new Set([
"ACTION_REQUIRED",
"CANCELLED",
"FAILURE",
"STARTUP_FAILURE",
"STALE",
"TIMED_OUT",
]);
const ROLLUP_QUERY = `query($owner:String!,$name:String!,$pr:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$pr){state mergeable headRefOid statusCheckRollup{state contexts(first:100,after:$cursor){totalCount pageInfo{hasNextPage endCursor} nodes{kind:__typename ... on CheckRun{name status conclusion databaseId checkSuite{workflowRun{databaseId workflow{databaseId}}}} ... on StatusContext{context state}}}}}}}`;
// Adapted from Node's MIT-licensed util.stripVTControlCharacters implementation.
const ANSI_ESCAPE_SEQUENCE = new RegExp(
"[\\u001B\\u009B][[\\]()#;?]*" +
"(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*" +
"|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?" +
"(?:\\u0007|\\u001B\\u005C|\\u009C))" +
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?" +
"[\\dA-PR-TZcf-nq-uy=><~]))",
"g",
);
const UNSAFE_CHECK_NAME_RUN = /[^\u0020-\u007E\p{L}\p{M}\p{N}]+/gu;
function positiveInteger(value, name) {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return parsed;
}
export function parseArgs(argv) {
let parsed;
try {
parsed = parseNodeArgs({
args: argv,
allowPositionals: true,
options: {
repo: { type: "string", default: "openclaw/openclaw" },
after: { type: "string" },
"attach-timeout": { type: "string", default: "900" },
timeout: { type: "string", default: "3600" },
interval: { type: "string", default: "120" },
},
});
} catch {
throw new Error(USAGE);
}
const [prValue, rawSha, ...extra] = parsed.positionals;
if (!prValue || !rawSha || extra.length > 0) {
throw new Error(USAGE);
}
const args = {
pr: positiveInteger(prValue, "pr-number"),
headSha: rawSha.toLowerCase(),
repo: parsed.values.repo,
attachTimeout: positiveInteger(parsed.values["attach-timeout"], "--attach-timeout"),
timeout: positiveInteger(parsed.values.timeout, "--timeout"),
interval: positiveInteger(parsed.values.interval, "--interval"),
};
if (parsed.values.after !== undefined) {
args.after = positiveInteger(parsed.values.after, "--after");
}
if (!/^[0-9a-f]{40}$/u.test(args.headSha)) {
throw new Error("head-sha must be a full 40-character commit SHA");
}
if (!/^[^/\s]+\/[^/\s]+$/u.test(args.repo)) {
throw new Error("--repo must be owner/repo");
}
return args;
}
const checkName = (check) => (check.kind === "StatusContext" ? check.context : check.name);
export const sanitizeCheckName = (name) =>
name.replaceAll(ANSI_ESCAPE_SEQUENCE, "\u0000").replaceAll(UNSAFE_CHECK_NAME_RUN, "?");
const isAutoResponse = (check) =>
checkName(check)
?.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, " ")
.trim() === "auto response";
// Run identity for supersession; undefined when any id is missing so ambiguous
// nodes are never dropped (fails toward FAILING, never toward false GREEN).
// These databaseIds are GraphQL Int fields, but GitHub serializes Actions-scale
// 64-bit values in them (live-verified; no fullDatabaseId exists on these types).
// If GitHub ever nulls them, filtering degrades to pre-supersession behavior.
function checkRunIdentity(check) {
if (check.kind !== "CheckRun") {
return undefined;
}
const runId = check.checkSuite?.workflowRun?.databaseId;
const workflowId = check.checkSuite?.workflowRun?.workflow?.databaseId;
if (typeof runId !== "number" || typeof workflowId !== "number") {
return undefined;
}
return { runId, workflowId };
}
// Strict recency ordering for same-name checks: newest run wins; within one run
// (rerun attempts reuse the run id) the newest check-run id wins.
const newerJob = (a, b) => (a.runId !== b.runId ? a.runId > b.runId : a.checkId > b.checkId);
export function classifyRollup(rollup) {
const rawNodes = rollup?.contexts?.nodes ?? [];
const hiddenContextCount = Math.max(
0,
(rollup?.contexts?.totalCount ?? rawNodes.length) - rawNodes.length,
);
const newestRunByWorkflow = new Map();
const bestByJob = new Map();
for (const check of rawNodes) {
const identity = checkRunIdentity(check);
if (!identity) {
continue;
}
newestRunByWorkflow.set(
identity.workflowId,
Math.max(newestRunByWorkflow.get(identity.workflowId) ?? 0, identity.runId),
);
if (check.name && typeof check.databaseId === "number") {
const key = `${identity.workflowId}:${check.name}`;
const candidate = { runId: identity.runId, checkId: check.databaseId };
const best = bestByJob.get(key);
if (!best || newerJob(candidate, best)) {
bestByJob.set(key, candidate);
}
}
}
let supersededCount = 0;
// Re-triggers leave every prior run's check runs on the SHA forever and GitHub's aggregate
// counts them. A check is superseded when a newer same-workflow check shares its name
// (GitHub's latest-name-wins semantics), or when it was cancelled and its workflow has a
// newer run (draft->ready cancels the old run before the replacement posts check runs).
// Older-run checks with unique names stay visible so distinct invocations are not dropped.
const nodes = rawNodes.filter((check) => {
const identity = checkRunIdentity(check);
if (!identity) {
return true;
}
if (check.name && typeof check.databaseId === "number") {
const best = bestByJob.get(`${identity.workflowId}:${check.name}`);
if (best && newerJob(best, { runId: identity.runId, checkId: check.databaseId })) {
supersededCount += 1;
return false;
}
}
const newestRun = newestRunByWorkflow.get(identity.workflowId);
if (check.conclusion === "CANCELLED" && newestRun > identity.runId) {
supersededCount += 1;
return false;
}
return true;
});
const checks = nodes.filter((check) => !isAutoResponse(check));
const pendingCount = checks.filter((check) =>
check.kind === "StatusContext"
? check.state === "PENDING" || check.state === "EXPECTED"
: check.status !== "COMPLETED",
).length;
const failingChecks = checks.filter((check) => {
if (check.kind === "StatusContext") {
return check.state === "ERROR" || check.state === "FAILURE";
}
return FAILURE_CONCLUSIONS.has(check.conclusion);
});
const failingNames = failingChecks
.map(checkName)
.filter(Boolean)
.map(sanitizeCheckName)
.toSorted()
.filter((name, index, names) => name !== names[index - 1]);
if (rollup?.state === "SUCCESS") {
return { verdict: "GREEN", pendingCount, failingNames: [], supersededCount };
}
if (rollup?.state === "ERROR" || rollup?.state === "FAILURE") {
if (failingChecks.length > 0) {
return {
verdict: "FAILING",
pendingCount,
failingNames: [
...(failingNames.length > 0 ? failingNames : ["status rollup"]),
...(hiddenContextCount > 0 ? [`+${hiddenContextCount} more contexts not shown`] : []),
],
supersededCount,
};
}
if (hiddenContextCount > 0) {
return {
verdict: "FAILING",
pendingCount,
failingNames: ["status rollup", `+${hiddenContextCount} more contexts not shown`],
supersededCount,
};
}
if (pendingCount > 0) {
return { verdict: "PENDING", pendingCount, failingNames: [], supersededCount };
}
// GitHub's aggregate permanently counts superseded cancellations. With full visibility,
// an all-green newest-run set is green; main() also requires the attached run to succeed.
return { verdict: "GREEN", pendingCount, failingNames: [], supersededCount };
}
return { verdict: "PENDING", pendingCount, failingNames: [], supersededCount };
}
function ghJson(...args) {
return JSON.parse(
execFileSync("gh", args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 60_000,
}),
);
}
const readPr = (pr, repo) =>
ghJson(...`pr view ${pr} --repo ${repo} --json state,mergeable,headRefOid`.split(" "));
export const buildFindRunArgs = (repo, sha) => [
"run",
"list",
"--repo",
repo,
"--commit",
sha,
"--workflow",
"ci.yml",
"--event",
"pull_request",
"--limit",
"1",
"--json",
"createdAt,databaseId",
];
export const selectRunAfter = (runs, after) =>
runs.find((run) => after === undefined || run.databaseId > after);
const findRun = (repo, sha, after) => selectRunAfter(ghJson(...buildFindRunArgs(repo, sha)), after);
const readRun = (repo, runId) =>
ghJson(...`run view ${runId} --repo ${repo} --json status,conclusion`.split(" "));
export function classifyRunAttachment(runId, run, after) {
if (run.conclusion === "skipped") {
return { attach: false };
}
return {
attach: true,
warning:
after === undefined && String(run.status).toLowerCase() === "completed"
? `WARN attaching to already-completed run ${runId} (started before watcher); pass --after ${runId} to require a fresh run`
: undefined,
};
}
export function collectRollupContexts(fetchPage) {
const firstPage = fetchPage(null);
const firstContexts = firstPage?.statusCheckRollup?.contexts;
if (!firstContexts) {
return firstPage;
}
const nodes = [...(firstContexts.nodes ?? [])];
let pageInfo = firstContexts.pageInfo;
let pageCount = 1;
// Polling work stays bounded at 1,000 contexts. Any truncation remains visible through
// totalCount and must classify conservatively rather than reading as success.
while (pageInfo?.hasNextPage && pageCount < 10) {
if (typeof pageInfo.endCursor !== "string") {
throw new Error("rollup page advertised a next page without a cursor");
}
const page = fetchPage(pageInfo.endCursor);
const contexts = page?.statusCheckRollup?.contexts;
pageCount += 1;
// Losing an advertised page (head moved, transient API gap) or reading a changed snapshot
// must not pass off the partial first page as complete; the watch loop catches this error
// and re-reads the rollup on its next bounded poll.
if (!contexts) {
throw new Error("rollup snapshot changed during pagination");
}
if (
page.headRefOid !== firstPage.headRefOid ||
page.statusCheckRollup?.state !== firstPage.statusCheckRollup?.state ||
contexts.totalCount !== firstContexts.totalCount
) {
throw new Error("rollup snapshot changed during pagination");
}
nodes.push(...(contexts.nodes ?? []));
pageInfo = contexts.pageInfo;
}
return {
...firstPage,
statusCheckRollup: {
...firstPage.statusCheckRollup,
contexts: { ...firstContexts, nodes },
},
};
}
function readRollup(pr, repo) {
const [owner, name] = repo.split("/");
return collectRollupContexts((cursor) => {
const queryArgs = [
"api",
"graphql",
"-f",
`query=${ROLLUP_QUERY}`,
"-f",
`owner=${owner}`,
"-f",
`name=${name}`,
"-F",
`pr=${pr}`,
];
if (cursor !== null) {
queryArgs.push("-f", `cursor=${cursor}`);
}
return ghJson(...queryArgs).data?.repository?.pullRequest;
});
}
const emit = (line, code) => {
console.log(line);
return code;
};
export async function pollUntilDeadline({
deadline,
interval,
poll,
now = Date.now,
wait = sleep,
}) {
while (true) {
const result = await poll();
if (result !== undefined) {
return result;
}
const remaining = deadline - now();
if (remaining <= 0) {
return undefined;
}
await wait(Math.min(interval * 1000, remaining));
}
}
const retry = (phase, error) =>
console.log(
`RETRY phase=${phase} error=${(error instanceof Error ? error.message : String(error)).replaceAll(/\s+/gu, " ")}`,
);
function precheck(pr, sha, midWait = false) {
const state = String(pr?.state ?? "MISSING").toUpperCase();
if (state !== "OPEN") {
return emit(`PR-CLOSED state=${state}`, 10);
}
if (pr.headRefOid !== sha) {
return emit(`HEAD-MOVED expected=${sha} actual=${pr.headRefOid}`, 11);
}
if (pr.mergeable === false || String(pr.mergeable).toUpperCase() === "CONFLICTING") {
return emit(
`${midWait ? "CONFLICTING-MID-WAIT" : "CONFLICTING"} mergeable=CONFLICTING`,
midWait ? 14 : 12,
);
}
return null;
}
async function main(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
const attachDeadline = Date.now() + args.attachTimeout * 1000;
const attachment = await pollUntilDeadline({
deadline: attachDeadline,
interval: args.interval,
poll: () => {
try {
const blocked = precheck(readPr(args.pr, args.repo), args.headSha);
if (blocked !== null) {
return { exitCode: blocked };
}
const candidate = findRun(args.repo, args.headSha, args.after);
if (candidate) {
const classification = classifyRunAttachment(
candidate.databaseId,
readRun(args.repo, candidate.databaseId),
args.after,
);
if (classification.attach) {
if (classification.warning) {
console.log(classification.warning);
}
return { runId: candidate.databaseId };
}
}
} catch (error) {
retry("attach", error);
}
return undefined;
},
});
if (attachment === undefined) {
return emit(
'NO-RUN-ATTACHED hint="close/reopen re-fires CI; pr-ci-sweeper re-fires hourly at :07"',
13,
);
}
if ("exitCode" in attachment) {
return attachment.exitCode;
}
const { runId } = attachment;
console.log(`ATTACHED run=${runId} url=https://github.com/${args.repo}/actions/runs/${runId}`);
const watchDeadline = Date.now() + args.timeout * 1000;
let lastState = "NONE";
let lastPending = 0;
const watchResult = await pollUntilDeadline({
deadline: watchDeadline,
interval: args.interval,
poll: () => {
try {
const pr = readRollup(args.pr, args.repo);
const blocked = precheck(pr, args.headSha, true);
if (blocked !== null) {
return blocked;
}
const result = classifyRollup(pr.statusCheckRollup);
lastState = pr.statusCheckRollup?.state ?? "NONE";
lastPending = result.pendingCount;
console.log(
`STATUS state=${lastState} pending=${lastPending} superseded=${result.supersededCount}`,
);
if (result.verdict === "FAILING") {
return emit(`FAILING checks=${result.failingNames.join(", ")}`, 15);
}
const run = readRun(args.repo, runId);
if (run.status === "completed" && run.conclusion !== "success") {
return emit(`FAILING checks=CI workflow (${run.conclusion ?? "unknown"})`, 15);
}
if (
result.verdict === "GREEN" &&
run.status === "completed" &&
run.conclusion === "success"
) {
return emit("GREEN", 0);
}
} catch (error) {
retry("watch", error);
}
return undefined;
},
});
if (watchResult !== undefined) {
return watchResult;
}
return emit(`TIMEOUT state=${lastState} pending=${lastPending}`, 16);
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
try {
process.exitCode = await main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 2;
}
}