diff --git a/AGENTS.md b/AGENTS.md index e43ea4da7732..fe1b9ff42a38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,6 +244,7 @@ Skills own workflows; root owns hard policy and routing. - Contributor PRs: parsed context requires authored `What Problem This Solves` and `Evidence` sections. Do not require field-level proof forms; reviewers inspect code, tests, and CI for correctness. - PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`. - CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need. Never `gh run watch`; its 3s polling exhausts API quota. Use sparse GraphQL rollups. Filter `gh run list` by workflow/branch/commit; broad JSON lists can exceed relay caps. `gh --jq` has no jq `--arg`; use `--commit` for SHA filtering. Reruns need `gh run view --attempt `; default output may show the prior attempt. +- CI waits: node scripts/watch-pr-ci.mjs — prechecks mergeable (CONFLICTING = pull_request CI cannot attach) and run attachment before polling; watchers emit every terminal state; no unbounded polls. - Trusted-workflow release-branch CI: pass `target_ref` + `release_candidate_ref`; never `release_gate` (requires workflow head == target). - Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init `, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init `, validate them with `scripts/pr review-validate-artifacts `, then run `OPENCLAW_TESTBOX=1 scripts/pr prepare-run ` and `scripts/pr merge-run `. The Testbox flag is mandatory for agents so prepare verifies hosted CI/Testbox on the current head or reuses a patch-identical pre-rebase run green within 24 hours instead of running full gates locally. `prepare-run` fails fast; invoke only after exact-head CI is complete and green. For owner-approved reviewed fork code without hosted Testbox, use `OPENCLAW_PR_GATES_REMOTE=testbox` instead. Do not rebase only because `main` advanced; merge drift is advisory unless strict drift is explicitly enabled, while GitHub still blocks conflicts. Do not idle on `auto-response` or `check-docs`. - After GitHub throttling, check core quota before `scripts/pr prepare-run` or `merge-run`. A failed operation can retain its lock; verify no child remains, then recover only with its emitted token. diff --git a/scripts/watch-pr-ci.d.mts b/scripts/watch-pr-ci.d.mts new file mode 100644 index 000000000000..3891878aaa07 --- /dev/null +++ b/scripts/watch-pr-ci.d.mts @@ -0,0 +1,64 @@ +export interface WatchPrCiArgs { + pr: number; + headSha: string; + repo: string; + after?: number; + attachTimeout: number; + timeout: number; + interval: number; +} + +export interface RollupCheck { + kind: "CheckRun" | "StatusContext"; + name?: string; + context?: string; + status?: string; + conclusion?: string | null; + state?: string; +} + +export interface RollupPayload { + state?: string; + contexts?: { totalCount?: number; nodes?: RollupCheck[] }; +} + +export interface RollupClassification { + verdict: "GREEN" | "FAILING" | "PENDING" | "STALE-CANCELLED"; + pendingCount: number; + failingNames: string[]; +} + +export interface RunListItem { + databaseId: number; + createdAt: string; +} + +export interface RunStatus { + status?: string; + conclusion?: string | null; +} + +export interface RunAttachmentClassification { + attach: boolean; + warning?: string; +} + +export interface PollUntilDeadlineOptions { + deadline: number; + interval: number; + poll: () => T | undefined | Promise; + now?: () => number; + wait?: (milliseconds: number) => Promise; +} + +export function parseArgs(argv: string[]): WatchPrCiArgs; +export function sanitizeCheckName(name: string): string; +export function classifyRollup(rollup: RollupPayload | null | undefined): RollupClassification; +export function buildFindRunArgs(repo: string, sha: string): string[]; +export function selectRunAfter(runs: RunListItem[], after?: number): RunListItem | undefined; +export function classifyRunAttachment( + runId: number, + run: RunStatus, + after?: number, +): RunAttachmentClassification; +export function pollUntilDeadline(options: PollUntilDeadlineOptions): Promise; diff --git a/scripts/watch-pr-ci.mjs b/scripts/watch-pr-ci.mjs new file mode 100644 index 000000000000..635a5626570a --- /dev/null +++ b/scripts/watch-pr-ci.mjs @@ -0,0 +1,353 @@ +#!/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 [--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!){repository(owner:$owner,name:$name){pullRequest(number:$pr){state mergeable headRefOid statusCheckRollup{state contexts(first:100){totalCount nodes{kind:__typename ... on CheckRun{name status conclusion} ... 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 isSuccess = (check) => + check.kind === "StatusContext" ? check.state === "SUCCESS" : check.conclusion === "SUCCESS"; +const isAutoResponse = (check) => + checkName(check) + ?.toLowerCase() + .replaceAll(/[^a-z0-9]+/gu, " ") + .trim() === "auto response"; + +export function classifyRollup(rollup) { + const nodes = rollup?.contexts?.nodes ?? []; + const hiddenContextCount = Math.max( + 0, + (rollup?.contexts?.totalCount ?? nodes.length) - nodes.length, + ); + const checks = nodes.filter((check) => !isAutoResponse(check)); + const successfulNames = new Set(checks.filter(isSuccess).map(checkName)); + 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: [] }; + } + if (rollup?.state === "ERROR" || rollup?.state === "FAILURE") { + const staleCancelled = + hiddenContextCount === 0 && + pendingCount === 0 && + failingChecks.length > 0 && + failingChecks.every( + (check) => + check.kind === "CheckRun" && + check.conclusion === "CANCELLED" && + Boolean(check.name) && + successfulNames.has(check.name), + ); + if (staleCancelled) { + return { verdict: "STALE-CANCELLED", pendingCount, failingNames }; + } + return { + verdict: "FAILING", + pendingCount, + failingNames: [ + ...(failingNames.length > 0 ? failingNames : ["status rollup"]), + ...(hiddenContextCount > 0 ? [`+${hiddenContextCount} more contexts not shown`] : []), + ], + }; + } + return { verdict: "PENDING", pendingCount, failingNames: [] }; +} + +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, + }; +} + +function readRollup(pr, repo) { + const [owner, name] = repo.split("/"); + return ghJson( + "api", + "graphql", + "-f", + `query=${ROLLUP_QUERY}`, + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-F", + `pr=${pr}`, + ).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}`); + if (result.verdict === "STALE-CANCELLED") { + return emit( + 'STALE-CANCELLED hint="aggregate FAILURE but every failing context is a CANCELLED check run with a same-name SUCCESS — likely stale attempts; verify manually"', + 17, + ); + } + 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; + } +} diff --git a/test/scripts/watch-pr-ci.test.ts b/test/scripts/watch-pr-ci.test.ts new file mode 100644 index 000000000000..171052f411a2 --- /dev/null +++ b/test/scripts/watch-pr-ci.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vitest"; +import { + buildFindRunArgs, + classifyRollup, + classifyRunAttachment, + parseArgs, + pollUntilDeadline, + sanitizeCheckName, + selectRunAfter, +} from "../../scripts/watch-pr-ci.mjs"; + +const sha = "a".repeat(40); + +describe("watch-pr-ci", () => { + it("parses defaults and overrides", () => { + expect(parseArgs(["42", sha])).toEqual({ + pr: 42, + headSha: sha, + repo: "openclaw/openclaw", + attachTimeout: 900, + timeout: 3600, + interval: 120, + }); + expect( + parseArgs([ + "7", + sha, + "--repo", + "fork/project", + "--after", + "1234", + "--attach-timeout", + "30", + "--timeout", + "90", + "--interval", + "5", + ]), + ).toMatchObject({ + repo: "fork/project", + after: 1234, + attachTimeout: 30, + timeout: 90, + interval: 5, + }); + expect(parseArgs(["1", sha.toUpperCase()]).headSha).toBe(sha); + }); + + it("rejects malformed arguments", () => { + expect(() => parseArgs(["0", sha])).toThrow("pr-number must be a positive integer"); + expect(() => parseArgs(["1", "abc"])).toThrow("full 40-character commit SHA"); + expect(() => parseArgs(["1", sha, "--interval", "0"])).toThrow( + "--interval must be a positive integer", + ); + expect(() => parseArgs(["1", sha, "--after", "0"])).toThrow( + "--after must be a positive integer", + ); + }); + + it("builds a pull-request-only run attachment query", () => { + expect(buildFindRunArgs("openclaw/openclaw", sha)).toEqual([ + "run", + "list", + "--repo", + "openclaw/openclaw", + "--commit", + sha, + "--workflow", + "ci.yml", + "--event", + "pull_request", + "--limit", + "1", + "--json", + "createdAt,databaseId", + ]); + }); + + it("filters run ids at and before --after", () => { + const newer = { databaseId: 102, createdAt: "2026-07-23T02:00:00Z" }; + const runs = [newer, { databaseId: 101, createdAt: "2026-07-23T01:00:00Z" }]; + expect(selectRunAfter(runs, 101)).toBe(newer); + expect(selectRunAfter(runs, 102)).toBeUndefined(); + expect(selectRunAfter(runs)).toBe(newer); + }); + + it("sanitizes untrusted check names for terminal output", () => { + expect(sanitizeCheckName("plain ASCII / check (1)")).toBe("plain ASCII / check (1)"); + expect(sanitizeCheckName("Crème 日本語 123")).toBe("Crème 日本語 123"); + expect(sanitizeCheckName("unit\n\r\t\u0000check")).toBe("unit?check"); + expect(sanitizeCheckName("safe\u001b[31mred\u001b[0m text")).toBe("safe?red? text"); + expect(sanitizeCheckName("link\u001b]8;;https://example.com\u0007text\u001b]8;;\u0007")).toBe( + "link?text?", + ); + expect(sanitizeCheckName("left\u202Eright 😀")).toBe("left?right ?"); + }); + + it("sanitizes failing check and status-context names before classification output", () => { + expect( + classifyRollup({ + state: "FAILURE", + contexts: { + nodes: [ + { + kind: "CheckRun", + name: "unit\u001b[31mowned\u001b[0m", + status: "COMPLETED", + conclusion: "FAILURE", + }, + { kind: "StatusContext", context: "deploy\nprod", state: "ERROR" }, + ], + }, + }).failingNames, + ).toEqual(["deploy?prod", "unit?owned?"]); + }); + + it("polls once more after the deadline-clamped final wait", async () => { + let now = 0; + const waits: number[] = []; + let polls = 0; + const result = await pollUntilDeadline({ + deadline: 1_000, + interval: 120, + now: () => now, + wait: async (milliseconds) => { + waits.push(milliseconds); + now += milliseconds; + }, + poll: () => (++polls === 2 ? "transitioned" : undefined), + }); + + expect(result).toBe("transitioned"); + expect(waits).toEqual([1_000]); + expect(polls).toBe(2); + }); + + it("times out only after polling at the deadline", async () => { + let now = 0; + let polls = 0; + const result = await pollUntilDeadline({ + deadline: 1_000, + interval: 120, + now: () => now, + wait: async (milliseconds) => { + now += milliseconds; + }, + poll: () => { + polls += 1; + return undefined; + }, + }); + + expect(result).toBeUndefined(); + expect(now).toBe(1_000); + expect(polls).toBe(2); + }); + + it("warns for an already-completed late attachment without changing attachment", () => { + expect(classifyRunAttachment(102, { status: "completed", conclusion: "success" })).toEqual({ + attach: true, + warning: + "WARN attaching to already-completed run 102 (started before watcher); pass --after 102 to require a fresh run", + }); + expect(classifyRunAttachment(102, { status: "completed", conclusion: "success" }, 101)).toEqual( + { attach: true, warning: undefined }, + ); + expect(classifyRunAttachment(102, { status: "completed", conclusion: "skipped" })).toEqual({ + attach: false, + }); + }); + + it("requires aggregate success for a green rollup", () => { + expect(classifyRollup({ state: "SUCCESS", contexts: { nodes: [] } }).verdict).toBe("GREEN"); + expect( + classifyRollup({ + state: "PENDING", + contexts: { + nodes: [{ kind: "CheckRun", name: "unit", status: "COMPLETED", conclusion: "SUCCESS" }], + }, + }), + ).toEqual({ verdict: "PENDING", pendingCount: 0, failingNames: [] }); + }); + + it("counts pending contexts without deriving the verdict from them", () => { + expect( + classifyRollup({ + state: "PENDING", + contexts: { + nodes: [{ kind: "CheckRun", name: "unit", status: "IN_PROGRESS", conclusion: null }], + }, + }), + ).toEqual({ verdict: "PENDING", pendingCount: 1, failingNames: [] }); + }); + + it.each(["FAILURE", "ERROR"])( + "classifies stale same-name cancellations for aggregate %s", + (state) => { + expect( + classifyRollup({ + state, + contexts: { + totalCount: 3, + nodes: [ + { + kind: "CheckRun", + name: "Auto response", + status: "COMPLETED", + conclusion: "FAILURE", + }, + { kind: "CheckRun", name: "unit", status: "COMPLETED", conclusion: "CANCELLED" }, + { kind: "CheckRun", name: "unit", status: "COMPLETED", conclusion: "SUCCESS" }, + ], + }, + }), + ).toEqual({ verdict: "STALE-CANCELLED", pendingCount: 0, failingNames: ["unit"] }); + }, + ); + + it("does not soften a truncated failing rollup to stale-cancelled", () => { + expect( + classifyRollup({ + state: "FAILURE", + contexts: { + totalCount: 4, + nodes: [ + { kind: "CheckRun", name: "unit", status: "COMPLETED", conclusion: "CANCELLED" }, + { kind: "CheckRun", name: "unit", status: "COMPLETED", conclusion: "SUCCESS" }, + ], + }, + }), + ).toEqual({ + verdict: "FAILING", + pendingCount: 0, + failingNames: ["unit", "+2 more contexts not shown"], + }); + }); + + it("keeps cancelled attempts in failing-name output", () => { + expect( + classifyRollup({ + state: "FAILURE", + contexts: { + nodes: [ + { kind: "CheckRun", name: "Auto response", status: "COMPLETED", conclusion: "FAILURE" }, + { kind: "CheckRun", name: "unit", status: "COMPLETED", conclusion: "CANCELLED" }, + { kind: "CheckRun", name: "unit", status: "COMPLETED", conclusion: "SUCCESS" }, + { kind: "CheckRun", name: "lint", status: "COMPLETED", conclusion: "TIMED_OUT" }, + ], + }, + }), + ).toEqual({ verdict: "FAILING", pendingCount: 0, failingNames: ["lint", "unit"] }); + }); +});