diff --git a/scripts/github/real-behavior-proof-check.mjs b/scripts/github/real-behavior-proof-check.mjs index 8c73dfe222c..7d149f396e5 100644 --- a/scripts/github/real-behavior-proof-check.mjs +++ b/scripts/github/real-behavior-proof-check.mjs @@ -1,9 +1,12 @@ #!/usr/bin/env node import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; import { + DEFAULT_GITHUB_API_TIMEOUT_MS, evaluateClawSweeperExactHeadProof, evaluateRealBehaviorProof, isMaintainerTeamMember, + withGitHubApiTimeout, } from "./real-behavior-proof-policy.mjs"; function escapeCommandValue(value) { @@ -14,7 +17,14 @@ function escapeCommandValue(value) { .replace(/:/g, "%3A"); } -async function fetchProofComments({ owner, repo, issueNumber, tokens }) { +export async function fetchProofComments({ + owner, + repo, + issueNumber, + tokens, + fetchImpl = fetch, + timeoutMs = DEFAULT_GITHUB_API_TIMEOUT_MS, +}) { let lastError; for (const token of tokens.filter(Boolean)) { const comments = []; @@ -25,17 +35,27 @@ async function fetchProofComments({ owner, repo, issueNumber, tokens }) { ); url.searchParams.set("per_page", "100"); url.searchParams.set("page", String(page)); - const response = await fetch(url, { - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28", - }, - }); + const response = await withGitHubApiTimeout( + `proof comment lookup page ${page}`, + timeoutMs, + (signal) => + fetchImpl(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + signal, + }), + ); if (!response.ok) { throw new Error(`comments API returned ${response.status}`); } - const pageComments = await response.json(); + const pageComments = await withGitHubApiTimeout( + `proof comment response page ${page}`, + timeoutMs, + () => response.json(), + ); comments.push(...pageComments); if (pageComments.length < 100) { break; @@ -49,69 +69,83 @@ async function fetchProofComments({ owner, repo, issueNumber, tokens }) { throw lastError ?? new Error("No GitHub token available for proof comment lookup."); } -const eventPath = process.env.GITHUB_EVENT_PATH; -if (!eventPath) { - console.error("::error title=Real behavior proof failed::GITHUB_EVENT_PATH is not set."); +function isMainModule() { + return Boolean(process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href); +} + +async function main(env = process.env) { + const eventPath = env.GITHUB_EVENT_PATH; + if (!eventPath) { + console.error("::error title=Real behavior proof failed::GITHUB_EVENT_PATH is not set."); + process.exit(1); + } + + const event = JSON.parse(readFileSync(eventPath, "utf8")); + const pullRequest = event.pull_request; + if (!pullRequest) { + console.log("No pull_request payload found; skipping real behavior proof gate."); + process.exit(0); + } + + const appToken = env.GH_APP_TOKEN; + const org = event.repository?.owner?.login; + const authorLogin = pullRequest.user?.login; + if (appToken && org && authorLogin) { + try { + if (await isMaintainerTeamMember({ token: appToken, org, login: authorLogin })) { + console.log( + `PR author @${authorLogin} is an active member of the ${org}/maintainer team; skipping real behavior proof gate.`, + ); + process.exit(0); + } + } catch (error) { + console.warn( + `::warning title=Maintainer membership check failed::${escapeCommandValue(error?.message ?? String(error))}`, + ); + } + } + + const evaluation = evaluateRealBehaviorProof({ pullRequest }); + if (evaluation.passed) { + console.log(evaluation.reason); + process.exit(0); + } + + const repository = env.GITHUB_REPOSITORY; + if ((appToken || env.GITHUB_TOKEN) && repository && pullRequest.number) { + const [owner, repo] = repository.split("/"); + try { + const comments = await fetchProofComments({ + owner, + repo, + issueNumber: pullRequest.number, + tokens: [appToken, env.GITHUB_TOKEN], + }); + + const clawSweeperEvaluation = evaluateClawSweeperExactHeadProof({ + pullRequest, + comments, + }); + if (clawSweeperEvaluation.passed) { + console.log(clawSweeperEvaluation.reason); + process.exit(0); + } + } catch (error) { + console.warn( + `::warning title=Proof verdict comment lookup failed::${escapeCommandValue(error?.message ?? String(error))}`, + ); + } + } + + const message = `${evaluation.reason} Add after-fix evidence from a real OpenClaw setup in the PR body. Screenshots, recordings, terminal screenshots, console output, redacted runtime logs, linked artifacts, or copied live output count. Unit tests, mocks, snapshots, lint, typechecks, and CI are supplemental only. A maintainer can apply proof: override when appropriate.`; + console.error(`::error title=Real behavior proof required::${escapeCommandValue(message)}`); process.exit(1); } -const event = JSON.parse(readFileSync(eventPath, "utf8")); -const pullRequest = event.pull_request; -if (!pullRequest) { - console.log("No pull_request payload found; skipping real behavior proof gate."); - process.exit(0); +export const testing = { + fetchProofComments, +}; + +if (isMainModule()) { + await main(); } - -const appToken = process.env.GH_APP_TOKEN; -const org = event.repository?.owner?.login; -const authorLogin = pullRequest.user?.login; -if (appToken && org && authorLogin) { - try { - if (await isMaintainerTeamMember({ token: appToken, org, login: authorLogin })) { - console.log( - `PR author @${authorLogin} is an active member of the ${org}/maintainer team; skipping real behavior proof gate.`, - ); - process.exit(0); - } - } catch (error) { - console.warn( - `::warning title=Maintainer membership check failed::${escapeCommandValue(error?.message ?? String(error))}`, - ); - } -} - -const evaluation = evaluateRealBehaviorProof({ pullRequest }); -if (evaluation.passed) { - console.log(evaluation.reason); - process.exit(0); -} - -const repository = process.env.GITHUB_REPOSITORY; -if ((appToken || process.env.GITHUB_TOKEN) && repository && pullRequest.number) { - const [owner, repo] = repository.split("/"); - try { - const comments = await fetchProofComments({ - owner, - repo, - issueNumber: pullRequest.number, - tokens: [appToken, process.env.GITHUB_TOKEN], - }); - - const clawSweeperEvaluation = evaluateClawSweeperExactHeadProof({ - pullRequest, - comments, - }); - if (clawSweeperEvaluation.passed) { - console.log(clawSweeperEvaluation.reason); - process.exit(0); - } - } catch (error) { - console.warn( - `::warning title=Proof verdict comment lookup failed::${escapeCommandValue(error?.message ?? String(error))}`, - ); - } -} - -const message = `${evaluation.reason} Add after-fix evidence from a real OpenClaw setup in the PR body. Screenshots, recordings, terminal screenshots, console output, redacted runtime logs, linked artifacts, or copied live output count. Unit tests, mocks, snapshots, lint, typechecks, and CI are supplemental only. A maintainer can apply proof: override when appropriate.`; -console.error(`::error title=Real behavior proof required::${escapeCommandValue(message)}`); -process.exit(1); diff --git a/scripts/github/real-behavior-proof-policy.mjs b/scripts/github/real-behavior-proof-policy.mjs index 720ca3d7ae8..d5dab4be394 100644 --- a/scripts/github/real-behavior-proof-policy.mjs +++ b/scripts/github/real-behavior-proof-policy.mjs @@ -4,6 +4,7 @@ export const PROOF_SUFFICIENT_LABEL = "proof: sufficient"; export const NEEDS_REAL_BEHAVIOR_PROOF_LABEL = "triage: needs-real-behavior-proof"; export const MOCK_ONLY_PROOF_LABEL = "triage: mock-only-proof"; export const MAINTAINER_TEAM_SLUG = "maintainer"; +export const DEFAULT_GITHUB_API_TIMEOUT_MS = 30_000; export const CLAWSWEEPER_PROOF_VERDICT_STATUS = "clawsweeper_exact_head_pass"; const CLAWSWEEPER_BOT_LOGINS = new Set(["clawsweeper[bot]", "openclaw-clawsweeper[bot]"]); @@ -81,6 +82,34 @@ function escapeRegex(text) { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function createTimeoutError(label, timeoutMs) { + const error = new Error(`${label} timed out after ${timeoutMs}ms`); + error.code = "ETIMEDOUT"; + return error; +} + +export async function withGitHubApiTimeout(label, timeoutMs, run) { + const boundedTimeoutMs = Math.max(1, timeoutMs); + const controller = new AbortController(); + const timeoutError = createTimeoutError(label, boundedTimeoutMs); + let timeout; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + controller.abort(timeoutError); + reject(timeoutError); + }, boundedTimeoutMs); + timeout.unref?.(); + }); + + try { + return await Promise.race([run(controller.signal), timeoutPromise]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + function normalizeLineEndings(text = "") { return text.replace(/\r\n?/g, "\n"); } @@ -121,25 +150,36 @@ export async function isMaintainerTeamMember({ login, teamSlug = MAINTAINER_TEAM_SLUG, fetch = globalThis.fetch, + timeoutMs = DEFAULT_GITHUB_API_TIMEOUT_MS, } = {}) { if (!token || !org || !login) { return false; } const url = `https://api.github.com/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(login)}`; - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - }); + const response = await withGitHubApiTimeout( + `maintainer membership lookup for ${login}`, + timeoutMs, + (signal) => + fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal, + }), + ); if (response.status === 404) { return false; } if (!response.ok) { throw new Error(`Team membership lookup failed: ${response.status}`); } - const body = await response.json(); + const body = await withGitHubApiTimeout( + `maintainer membership response for ${login}`, + timeoutMs, + () => response.json(), + ); return body?.state === "active"; } diff --git a/test/scripts/real-behavior-proof-check.test.ts b/test/scripts/real-behavior-proof-check.test.ts new file mode 100644 index 00000000000..5db739d5ca2 --- /dev/null +++ b/test/scripts/real-behavior-proof-check.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchProofComments } from "../../scripts/github/real-behavior-proof-check.mjs"; + +describe("real-behavior-proof-check GitHub lookups", () => { + it("aborts stalled proof comment fetches", async () => { + const fetch = vi.fn((_url: URL, init: RequestInit) => { + return new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => reject(init.signal?.reason)); + }); + }); + + await expect( + fetchProofComments({ + fetchImpl: fetch as typeof globalThis.fetch, + issueNumber: 123, + owner: "openclaw", + repo: "openclaw", + timeoutMs: 5, + tokens: ["tok"], + }), + ).rejects.toThrow(/proof comment lookup page 1 timed out after 5ms/); + }); + + it("times out stalled proof comment response bodies", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => new Promise(() => {}), + }); + + await expect( + fetchProofComments({ + fetchImpl: fetch as typeof globalThis.fetch, + issueNumber: 123, + owner: "openclaw", + repo: "openclaw", + timeoutMs: 5, + tokens: ["tok"], + }), + ).rejects.toThrow(/proof comment response page 1 timed out after 5ms/); + }); +}); diff --git a/test/scripts/real-behavior-proof-policy.test.ts b/test/scripts/real-behavior-proof-policy.test.ts index a4710104433..4cb0112d352 100644 --- a/test/scripts/real-behavior-proof-policy.test.ts +++ b/test/scripts/real-behavior-proof-policy.test.ts @@ -458,4 +458,40 @@ describe("isMaintainerTeamMember", () => { isMaintainerTeamMember({ token: "t", org: "o", login: "u", fetch }), ).rejects.toThrow(/500/); }); + + it("aborts stalled membership fetches", async () => { + const fetch = vi.fn((_url: string, init: RequestInit) => { + return new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => reject(init.signal?.reason)); + }); + }); + + await expect( + isMaintainerTeamMember({ + fetch: fetch as typeof globalThis.fetch, + login: "u", + org: "o", + timeoutMs: 5, + token: "t", + }), + ).rejects.toThrow(/maintainer membership lookup for u timed out after 5ms/); + }); + + it("times out stalled membership response bodies", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => new Promise(() => {}), + }); + + await expect( + isMaintainerTeamMember({ + fetch: fetch as typeof globalThis.fetch, + login: "u", + org: "o", + timeoutMs: 5, + token: "t", + }), + ).rejects.toThrow(/maintainer membership response for u timed out after 5ms/); + }); });