fix(ci): bound real behavior proof API waits

This commit is contained in:
Vincent Koc
2026-05-27 15:12:53 +02:00
parent dc5954b0f8
commit 4a8d89f8b5
4 changed files with 231 additions and 79 deletions

View File

@@ -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);

View File

@@ -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";
}