Skip to content

Commit 4a8d89f

Browse files
committed
fix(ci): bound real behavior proof API waits
1 parent dc5954b commit 4a8d89f

4 files changed

Lines changed: 222 additions & 70 deletions

File tree

Lines changed: 96 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
#!/usr/bin/env node
22
import { readFileSync } from "node:fs";
3+
import { pathToFileURL } from "node:url";
34
import {
5+
DEFAULT_GITHUB_API_TIMEOUT_MS,
46
evaluateClawSweeperExactHeadProof,
57
evaluateRealBehaviorProof,
68
isMaintainerTeamMember,
9+
withGitHubApiTimeout,
710
} from "./real-behavior-proof-policy.mjs";
811

912
function escapeCommandValue(value) {
@@ -14,7 +17,14 @@ function escapeCommandValue(value) {
1417
.replace(/:/g, "%3A");
1518
}
1619

17-
async function fetchProofComments({ owner, repo, issueNumber, tokens }) {
20+
export async function fetchProofComments({
21+
owner,
22+
repo,
23+
issueNumber,
24+
tokens,
25+
fetchImpl = fetch,
26+
timeoutMs = DEFAULT_GITHUB_API_TIMEOUT_MS,
27+
}) {
1828
let lastError;
1929
for (const token of tokens.filter(Boolean)) {
2030
const comments = [];
@@ -25,17 +35,27 @@ async function fetchProofComments({ owner, repo, issueNumber, tokens }) {
2535
);
2636
url.searchParams.set("per_page", "100");
2737
url.searchParams.set("page", String(page));
28-
const response = await fetch(url, {
29-
headers: {
30-
Accept: "application/vnd.github+json",
31-
Authorization: `Bearer ${token}`,
32-
"X-GitHub-Api-Version": "2022-11-28",
33-
},
34-
});
38+
const response = await withGitHubApiTimeout(
39+
`proof comment lookup page ${page}`,
40+
timeoutMs,
41+
(signal) =>
42+
fetchImpl(url, {
43+
headers: {
44+
Accept: "application/vnd.github+json",
45+
Authorization: `Bearer ${token}`,
46+
"X-GitHub-Api-Version": "2022-11-28",
47+
},
48+
signal,
49+
}),
50+
);
3551
if (!response.ok) {
3652
throw new Error(`comments API returned ${response.status}`);
3753
}
38-
const pageComments = await response.json();
54+
const pageComments = await withGitHubApiTimeout(
55+
`proof comment response page ${page}`,
56+
timeoutMs,
57+
() => response.json(),
58+
);
3959
comments.push(...pageComments);
4060
if (pageComments.length < 100) {
4161
break;
@@ -49,69 +69,83 @@ async function fetchProofComments({ owner, repo, issueNumber, tokens }) {
4969
throw lastError ?? new Error("No GitHub token available for proof comment lookup.");
5070
}
5171

52-
const eventPath = process.env.GITHUB_EVENT_PATH;
53-
if (!eventPath) {
54-
console.error("::error title=Real behavior proof failed::GITHUB_EVENT_PATH is not set.");
55-
process.exit(1);
72+
function isMainModule() {
73+
return Boolean(process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href);
5674
}
5775

58-
const event = JSON.parse(readFileSync(eventPath, "utf8"));
59-
const pullRequest = event.pull_request;
60-
if (!pullRequest) {
61-
console.log("No pull_request payload found; skipping real behavior proof gate.");
62-
process.exit(0);
63-
}
76+
async function main(env = process.env) {
77+
const eventPath = env.GITHUB_EVENT_PATH;
78+
if (!eventPath) {
79+
console.error("::error title=Real behavior proof failed::GITHUB_EVENT_PATH is not set.");
80+
process.exit(1);
81+
}
6482

65-
const appToken = process.env.GH_APP_TOKEN;
66-
const org = event.repository?.owner?.login;
67-
const authorLogin = pullRequest.user?.login;
68-
if (appToken && org && authorLogin) {
69-
try {
70-
if (await isMaintainerTeamMember({ token: appToken, org, login: authorLogin })) {
71-
console.log(
72-
`PR author @${authorLogin} is an active member of the ${org}/maintainer team; skipping real behavior proof gate.`,
83+
const event = JSON.parse(readFileSync(eventPath, "utf8"));
84+
const pullRequest = event.pull_request;
85+
if (!pullRequest) {
86+
console.log("No pull_request payload found; skipping real behavior proof gate.");
87+
process.exit(0);
88+
}
89+
90+
const appToken = env.GH_APP_TOKEN;
91+
const org = event.repository?.owner?.login;
92+
const authorLogin = pullRequest.user?.login;
93+
if (appToken && org && authorLogin) {
94+
try {
95+
if (await isMaintainerTeamMember({ token: appToken, org, login: authorLogin })) {
96+
console.log(
97+
`PR author @${authorLogin} is an active member of the ${org}/maintainer team; skipping real behavior proof gate.`,
98+
);
99+
process.exit(0);
100+
}
101+
} catch (error) {
102+
console.warn(
103+
`::warning title=Maintainer membership check failed::${escapeCommandValue(error?.message ?? String(error))}`,
73104
);
74-
process.exit(0);
75105
}
76-
} catch (error) {
77-
console.warn(
78-
`::warning title=Maintainer membership check failed::${escapeCommandValue(error?.message ?? String(error))}`,
79-
);
80106
}
81-
}
82107

83-
const evaluation = evaluateRealBehaviorProof({ pullRequest });
84-
if (evaluation.passed) {
85-
console.log(evaluation.reason);
86-
process.exit(0);
87-
}
108+
const evaluation = evaluateRealBehaviorProof({ pullRequest });
109+
if (evaluation.passed) {
110+
console.log(evaluation.reason);
111+
process.exit(0);
112+
}
88113

89-
const repository = process.env.GITHUB_REPOSITORY;
90-
if ((appToken || process.env.GITHUB_TOKEN) && repository && pullRequest.number) {
91-
const [owner, repo] = repository.split("/");
92-
try {
93-
const comments = await fetchProofComments({
94-
owner,
95-
repo,
96-
issueNumber: pullRequest.number,
97-
tokens: [appToken, process.env.GITHUB_TOKEN],
98-
});
114+
const repository = env.GITHUB_REPOSITORY;
115+
if ((appToken || env.GITHUB_TOKEN) && repository && pullRequest.number) {
116+
const [owner, repo] = repository.split("/");
117+
try {
118+
const comments = await fetchProofComments({
119+
owner,
120+
repo,
121+
issueNumber: pullRequest.number,
122+
tokens: [appToken, env.GITHUB_TOKEN],
123+
});
99124

100-
const clawSweeperEvaluation = evaluateClawSweeperExactHeadProof({
101-
pullRequest,
102-
comments,
103-
});
104-
if (clawSweeperEvaluation.passed) {
105-
console.log(clawSweeperEvaluation.reason);
106-
process.exit(0);
125+
const clawSweeperEvaluation = evaluateClawSweeperExactHeadProof({
126+
pullRequest,
127+
comments,
128+
});
129+
if (clawSweeperEvaluation.passed) {
130+
console.log(clawSweeperEvaluation.reason);
131+
process.exit(0);
132+
}
133+
} catch (error) {
134+
console.warn(
135+
`::warning title=Proof verdict comment lookup failed::${escapeCommandValue(error?.message ?? String(error))}`,
136+
);
107137
}
108-
} catch (error) {
109-
console.warn(
110-
`::warning title=Proof verdict comment lookup failed::${escapeCommandValue(error?.message ?? String(error))}`,
111-
);
112138
}
139+
140+
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.`;
141+
console.error(`::error title=Real behavior proof required::${escapeCommandValue(message)}`);
142+
process.exit(1);
113143
}
114144

115-
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.`;
116-
console.error(`::error title=Real behavior proof required::${escapeCommandValue(message)}`);
117-
process.exit(1);
145+
export const testing = {
146+
fetchProofComments,
147+
};
148+
149+
if (isMainModule()) {
150+
await main();
151+
}

scripts/github/real-behavior-proof-policy.mjs

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const PROOF_SUFFICIENT_LABEL = "proof: sufficient";
44
export const NEEDS_REAL_BEHAVIOR_PROOF_LABEL = "triage: needs-real-behavior-proof";
55
export const MOCK_ONLY_PROOF_LABEL = "triage: mock-only-proof";
66
export const MAINTAINER_TEAM_SLUG = "maintainer";
7+
export const DEFAULT_GITHUB_API_TIMEOUT_MS = 30_000;
78

89
export const CLAWSWEEPER_PROOF_VERDICT_STATUS = "clawsweeper_exact_head_pass";
910
const CLAWSWEEPER_BOT_LOGINS = new Set(["clawsweeper[bot]", "openclaw-clawsweeper[bot]"]);
@@ -81,6 +82,34 @@ function escapeRegex(text) {
8182
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8283
}
8384

85+
function createTimeoutError(label, timeoutMs) {
86+
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
87+
error.code = "ETIMEDOUT";
88+
return error;
89+
}
90+
91+
export async function withGitHubApiTimeout(label, timeoutMs, run) {
92+
const boundedTimeoutMs = Math.max(1, timeoutMs);
93+
const controller = new AbortController();
94+
const timeoutError = createTimeoutError(label, boundedTimeoutMs);
95+
let timeout;
96+
const timeoutPromise = new Promise((_, reject) => {
97+
timeout = setTimeout(() => {
98+
controller.abort(timeoutError);
99+
reject(timeoutError);
100+
}, boundedTimeoutMs);
101+
timeout.unref?.();
102+
});
103+
104+
try {
105+
return await Promise.race([run(controller.signal), timeoutPromise]);
106+
} finally {
107+
if (timeout) {
108+
clearTimeout(timeout);
109+
}
110+
}
111+
}
112+
84113
function normalizeLineEndings(text = "") {
85114
return text.replace(/\r\n?/g, "\n");
86115
}
@@ -121,25 +150,36 @@ export async function isMaintainerTeamMember({
121150
login,
122151
teamSlug = MAINTAINER_TEAM_SLUG,
123152
fetch = globalThis.fetch,
153+
timeoutMs = DEFAULT_GITHUB_API_TIMEOUT_MS,
124154
} = {}) {
125155
if (!token || !org || !login) {
126156
return false;
127157
}
128158
const url = `https://api.github.com/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(login)}`;
129-
const response = await fetch(url, {
130-
headers: {
131-
Authorization: `Bearer ${token}`,
132-
Accept: "application/vnd.github+json",
133-
"X-GitHub-Api-Version": "2022-11-28",
134-
},
135-
});
159+
const response = await withGitHubApiTimeout(
160+
`maintainer membership lookup for ${login}`,
161+
timeoutMs,
162+
(signal) =>
163+
fetch(url, {
164+
headers: {
165+
Authorization: `Bearer ${token}`,
166+
Accept: "application/vnd.github+json",
167+
"X-GitHub-Api-Version": "2022-11-28",
168+
},
169+
signal,
170+
}),
171+
);
136172
if (response.status === 404) {
137173
return false;
138174
}
139175
if (!response.ok) {
140176
throw new Error(`Team membership lookup failed: ${response.status}`);
141177
}
142-
const body = await response.json();
178+
const body = await withGitHubApiTimeout(
179+
`maintainer membership response for ${login}`,
180+
timeoutMs,
181+
() => response.json(),
182+
);
143183
return body?.state === "active";
144184
}
145185

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { fetchProofComments } from "../../scripts/github/real-behavior-proof-check.mjs";
3+
4+
describe("real-behavior-proof-check GitHub lookups", () => {
5+
it("aborts stalled proof comment fetches", async () => {
6+
const fetch = vi.fn((_url: URL, init: RequestInit) => {
7+
return new Promise((_resolve, reject) => {
8+
init.signal?.addEventListener("abort", () => reject(init.signal?.reason));
9+
});
10+
});
11+
12+
await expect(
13+
fetchProofComments({
14+
fetchImpl: fetch as typeof globalThis.fetch,
15+
issueNumber: 123,
16+
owner: "openclaw",
17+
repo: "openclaw",
18+
timeoutMs: 5,
19+
tokens: ["tok"],
20+
}),
21+
).rejects.toThrow(/proof comment lookup page 1 timed out after 5ms/);
22+
});
23+
24+
it("times out stalled proof comment response bodies", async () => {
25+
const fetch = vi.fn().mockResolvedValue({
26+
ok: true,
27+
status: 200,
28+
json: () => new Promise(() => {}),
29+
});
30+
31+
await expect(
32+
fetchProofComments({
33+
fetchImpl: fetch as typeof globalThis.fetch,
34+
issueNumber: 123,
35+
owner: "openclaw",
36+
repo: "openclaw",
37+
timeoutMs: 5,
38+
tokens: ["tok"],
39+
}),
40+
).rejects.toThrow(/proof comment response page 1 timed out after 5ms/);
41+
});
42+
});

test/scripts/real-behavior-proof-policy.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,4 +458,40 @@ describe("isMaintainerTeamMember", () => {
458458
isMaintainerTeamMember({ token: "t", org: "o", login: "u", fetch }),
459459
).rejects.toThrow(/500/);
460460
});
461+
462+
it("aborts stalled membership fetches", async () => {
463+
const fetch = vi.fn((_url: string, init: RequestInit) => {
464+
return new Promise((_resolve, reject) => {
465+
init.signal?.addEventListener("abort", () => reject(init.signal?.reason));
466+
});
467+
});
468+
469+
await expect(
470+
isMaintainerTeamMember({
471+
fetch: fetch as typeof globalThis.fetch,
472+
login: "u",
473+
org: "o",
474+
timeoutMs: 5,
475+
token: "t",
476+
}),
477+
).rejects.toThrow(/maintainer membership lookup for u timed out after 5ms/);
478+
});
479+
480+
it("times out stalled membership response bodies", async () => {
481+
const fetch = vi.fn().mockResolvedValue({
482+
ok: true,
483+
status: 200,
484+
json: () => new Promise(() => {}),
485+
});
486+
487+
await expect(
488+
isMaintainerTeamMember({
489+
fetch: fetch as typeof globalThis.fetch,
490+
login: "u",
491+
org: "o",
492+
timeoutMs: 5,
493+
token: "t",
494+
}),
495+
).rejects.toThrow(/maintainer membership response for u timed out after 5ms/);
496+
});
461497
});

0 commit comments

Comments
 (0)