mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
493 lines
17 KiB
JavaScript
493 lines
17 KiB
JavaScript
import { appendFile } from "node:fs/promises";
|
|
import { setTimeout as wait } from "node:timers/promises";
|
|
import { readBoundedResponseText } from "../lib/bounded-response.mjs";
|
|
|
|
export const GITHUB_ERROR_BODY_MAX_BYTES = 64 * 1024;
|
|
export const GITHUB_RESPONSE_BODY_MAX_BYTES = 4 * 1024 * 1024;
|
|
export const GITHUB_API_REQUEST_TIMEOUT_MS = 30_000;
|
|
export const SECURITY_REVIEW_CHECK_INTERVAL_MS = 30_000;
|
|
|
|
const githubApiRetryStatuses = new Set([500, 502, 503, 504]);
|
|
const githubApiRetryCodes = new Set([
|
|
"ECONNRESET",
|
|
"ECONNREFUSED",
|
|
"EPIPE",
|
|
"ETIMEDOUT",
|
|
"EAI_AGAIN",
|
|
"ENOTFOUND",
|
|
"UND_ERR_CONNECT_TIMEOUT",
|
|
"UND_ERR_HEADERS_TIMEOUT",
|
|
"UND_ERR_SOCKET",
|
|
]);
|
|
const githubApiRetryDelaysMs = [1_000, 2_000, 4_000];
|
|
// One primary quota window plus room for a fresh evaluation. Persist the deadline
|
|
// across detect/autoscrub/enforce so each step cannot start another hour of waits.
|
|
const securityReviewBudgetMs = 65 * 60_000;
|
|
const recoveryDeadlineEnv = "OPENCLAW_SECURITY_REVIEW_DEADLINE_MS";
|
|
|
|
export class GitHubRateLimitError extends Error {
|
|
constructor(message, response) {
|
|
super(message);
|
|
this.status = response.status;
|
|
const remaining = response.headers.get("x-ratelimit-remaining");
|
|
const reset = Number(response.headers.get("x-ratelimit-reset"));
|
|
const retryAfter = response.headers.get("retry-after");
|
|
const retrySeconds = retryAfter === null ? Number.NaN : Number(retryAfter);
|
|
const retryAt = Number.isFinite(retrySeconds)
|
|
? Date.now() + Math.max(0, retrySeconds) * 1_000
|
|
: Date.parse(retryAfter ?? "");
|
|
this.retryAt = Math.max(
|
|
remaining === "0" && Number.isFinite(reset) ? reset * 1_000 : 0,
|
|
Number.isFinite(retryAt) ? retryAt : 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
export class GitHubStatusPublicationError extends Error {
|
|
constructor(cause) {
|
|
super(cause.message, { cause });
|
|
}
|
|
}
|
|
|
|
export class GitHubDiffDataError extends Error {}
|
|
|
|
export class GitHubReadTimeoutError extends Error {}
|
|
|
|
export async function withSecurityReviewRecovery(evaluate, { checkCurrent } = {}) {
|
|
const recorded = process.env[recoveryDeadlineEnv];
|
|
const deadline = recorded === undefined ? Date.now() + securityReviewBudgetMs : Number(recorded);
|
|
if (!Number.isSafeInteger(deadline) || deadline <= 0) {
|
|
throw new Error("Invalid Security Review recovery deadline.");
|
|
}
|
|
if (recorded === undefined && process.env.GITHUB_ENV) {
|
|
await appendFile(process.env.GITHUB_ENV, `${recoveryDeadlineEnv}=${deadline}\n`);
|
|
}
|
|
let resumeAt = 0;
|
|
let checkDuringWait = false;
|
|
for (let attempt = 0; ; attempt += 1) {
|
|
try {
|
|
while (Date.now() < resumeAt) {
|
|
const remaining = resumeAt - Date.now();
|
|
await wait(
|
|
checkDuringWait ? Math.min(remaining, SECURITY_REVIEW_CHECK_INTERVAL_MS) : remaining,
|
|
);
|
|
if (checkDuringWait && Date.now() < resumeAt) {
|
|
await checkCurrent();
|
|
}
|
|
}
|
|
return await evaluate();
|
|
} catch (error) {
|
|
const rateLimited = error instanceof GitHubRateLimitError;
|
|
const inconsistentDiff = error instanceof GitHubDiffDataError;
|
|
const readTimedOut = error instanceof GitHubReadTimeoutError;
|
|
if (
|
|
!rateLimited &&
|
|
!inconsistentDiff &&
|
|
!readTimedOut &&
|
|
!(error instanceof GitHubStatusPublicationError)
|
|
) {
|
|
throw error;
|
|
}
|
|
// Do not resume a status write with stale authority after waiting. The
|
|
// caller restarts from live PR, file, comment, role, and CI observations.
|
|
const delay = rateLimited
|
|
? Math.max(error.retryAt - Date.now(), 60_000 * 2 ** attempt) +
|
|
1_000 +
|
|
Math.floor(Math.random() * 15_000)
|
|
: inconsistentDiff
|
|
? 60_000 * 2 ** attempt
|
|
: githubApiRetryDelaysMs[attempt];
|
|
if (attempt >= 3 || Date.now() + delay + GITHUB_API_REQUEST_TIMEOUT_MS > deadline) {
|
|
throw new Error(
|
|
inconsistentDiff
|
|
? `GitHub diff-data recovery budget exhausted; security review remains incomplete. ${error.message}`
|
|
: "GitHub API recovery budget exhausted; security review remains incomplete.",
|
|
{ cause: error },
|
|
);
|
|
}
|
|
console.warn(
|
|
`${rateLimited ? `GitHub API rate limited (${error.status})` : inconsistentDiff || readTimedOut ? error.message : `GitHub status publication failed (${error.message})`}; retrying the complete evaluation in ${Math.ceil(delay / 1_000)}s (attempt ${attempt + 1}/3).`,
|
|
);
|
|
// Never probe during server-directed quota backoff. A rate limit from a
|
|
// checkpoint joins this same recovery budget instead of starting a poller.
|
|
resumeAt = Date.now() + delay;
|
|
checkDuringWait = !rateLimited && Boolean(checkCurrent);
|
|
}
|
|
}
|
|
}
|
|
|
|
const approvalCommands = new Set([
|
|
"/allow-security-sensitive-change",
|
|
"/allow-dependencies-change",
|
|
]);
|
|
|
|
export function parseApprovalCommands(body) {
|
|
const lines = (body ?? "")
|
|
.split(/\r?\n/u)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
return lines.every((line) => approvalCommands.has(line)) ? lines : [];
|
|
}
|
|
|
|
// Commit statuses are the publisher's durable record of which PRs it evaluated.
|
|
// GitHub's commit-to-PR association index is incomplete across fork repositories.
|
|
export async function readSecurityReviewHistory(api, owner, repo, head) {
|
|
const contexts = new Set([
|
|
"openclaw/ci-gate",
|
|
"openclaw/security-sensitive-review",
|
|
"openclaw/dependency-review",
|
|
]);
|
|
const statuses = await api.paginate(`/repos/${owner}/${repo}/commits/${head}/statuses`);
|
|
const numbers = new Set();
|
|
const counts = new Map();
|
|
for (const status of statuses) {
|
|
const context = typeof status.context === "string" ? status.context.toLowerCase() : "";
|
|
if (!contexts.has(context)) {
|
|
continue;
|
|
}
|
|
counts.set(context, (counts.get(context) ?? 0) + 1);
|
|
if (status.creator?.login !== "github-actions[bot]" || status.creator?.type !== "Bot") {
|
|
continue;
|
|
}
|
|
const recorded = /^PR #([1-9][0-9]*): /u.exec(status.description ?? "");
|
|
if (recorded && Number.isSafeInteger(Number(recorded[1]))) {
|
|
numbers.add(Number(recorded[1]));
|
|
}
|
|
}
|
|
return { pullRequestNumbers: [...numbers].toSorted((left, right) => left - right), counts };
|
|
}
|
|
|
|
export async function publishGuardStatus(guard, state, description) {
|
|
if (state === "success") {
|
|
// GitHub statuses belong to commits, while author and comment authority
|
|
// belong to PRs. Never lend one PR's approval to another PR with that head.
|
|
const history = await readSecurityReviewHistory(
|
|
guard.api,
|
|
guard.owner,
|
|
guard.repo,
|
|
guard.pullRequest.head.sha,
|
|
);
|
|
// GitHub refuses writes after 1,000 statuses per commit/context. Stop
|
|
// successes early so exhaustion cannot leave a permanently green result.
|
|
if ((history.counts.get(guard.context) ?? 0) >= 900) {
|
|
await publishGuardStatus(
|
|
guard,
|
|
"failure",
|
|
"Review status capacity is nearly exhausted; push a new commit",
|
|
);
|
|
throw new Error("Review status capacity is nearly exhausted; push a new commit.");
|
|
}
|
|
if (!history.pullRequestNumbers.includes(guard.pullRequest.number)) {
|
|
throw new Error("The current PR's security review status was not recorded.");
|
|
}
|
|
for (const number of history.pullRequestNumbers) {
|
|
if (number === guard.pullRequest.number) {
|
|
continue;
|
|
}
|
|
const other = await guard.api.request(`/repos/${guard.owner}/${guard.repo}/pulls/${number}`);
|
|
if (
|
|
other.state === "open" &&
|
|
other.base?.ref === guard.pullRequest.base.ref &&
|
|
other.head?.sha === guard.pullRequest.head.sha
|
|
) {
|
|
await publishGuardStatus(
|
|
guard,
|
|
"failure",
|
|
"Multiple PRs use this head; close duplicates or push a distinct commit",
|
|
);
|
|
throw new Error(
|
|
"Multiple open PRs use the same head. Close duplicate PRs or push a distinct commit; security review updates automatically.",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
await guard.api.request(
|
|
`/repos/${guard.owner}/${guard.repo}/statuses/${guard.pullRequest.head.sha}`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
context: guard.context,
|
|
state,
|
|
description: `PR #${guard.pullRequest.number}: ${description}`,
|
|
target_url: guard.runUrl,
|
|
}),
|
|
},
|
|
);
|
|
} catch (error) {
|
|
if (githubApiRetryStatuses.has(error?.status) || githubApiRetryCodes.has(error?.code)) {
|
|
throw new GitHubStatusPublicationError(error);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function sanitizeGuardDisplayValue(value) {
|
|
return String(value)
|
|
.replace(/[\p{Cc}]/gu, "?")
|
|
.slice(0, 240);
|
|
}
|
|
|
|
/**
|
|
* @param {string | null | undefined} value
|
|
* @param {string} [fallback]
|
|
*/
|
|
export function normalizeGuardLoginSet(value, fallback = "") {
|
|
return new Set(
|
|
(value ?? fallback)
|
|
.split(/[\s,]+/u)
|
|
.map((login) => login.trim().toLowerCase())
|
|
.filter(Boolean),
|
|
);
|
|
}
|
|
|
|
export function createIssueMutationHelpers({
|
|
api,
|
|
issuePath,
|
|
owner,
|
|
repo,
|
|
labelNames,
|
|
warn = console.warn,
|
|
}) {
|
|
const ignoreUnavailableWritePermission = (action) => (error) => {
|
|
if (error instanceof GitHubRateLimitError) {
|
|
throw error;
|
|
}
|
|
if (error?.status === 403) {
|
|
warn(
|
|
`Skipping ${action}; GitHub API rejected the request: ${sanitizeGuardDisplayValue(error.message)}`,
|
|
);
|
|
return;
|
|
}
|
|
if (error?.status === 404 || error?.status === 422) {
|
|
warn(`${action} is unavailable.`);
|
|
return;
|
|
}
|
|
throw error;
|
|
};
|
|
const removeLabelIfPresent = async (label) => {
|
|
if (!labelNames.has(label)) {
|
|
return;
|
|
}
|
|
await api
|
|
.request(`${issuePath}/labels/${encodeURIComponent(label)}`, {
|
|
method: "DELETE",
|
|
})
|
|
.catch(ignoreUnavailableWritePermission(`label "${label}" removal`));
|
|
labelNames.delete(label);
|
|
};
|
|
const addLabelIfMissing = async (label) => {
|
|
if (labelNames.has(label)) {
|
|
return;
|
|
}
|
|
await api
|
|
.request(`${issuePath}/labels`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ labels: [label] }),
|
|
})
|
|
.catch(ignoreUnavailableWritePermission(`label "${label}" update`));
|
|
labelNames.add(label);
|
|
};
|
|
const deleteCommentIfPresent = async (comment) => {
|
|
if (!comment) {
|
|
return;
|
|
}
|
|
await api
|
|
.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
|
|
method: "DELETE",
|
|
})
|
|
.catch(ignoreUnavailableWritePermission("comment deletion"));
|
|
};
|
|
const upsertComment = async (comment, body) => {
|
|
if (comment) {
|
|
return await api
|
|
.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ body }),
|
|
})
|
|
.catch(ignoreUnavailableWritePermission("comment update"));
|
|
}
|
|
return await api
|
|
.request(`${issuePath}/comments`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ body }),
|
|
})
|
|
.catch(ignoreUnavailableWritePermission("comment creation"));
|
|
};
|
|
return { removeLabelIfPresent, addLabelIfMissing, deleteCommentIfPresent, upsertComment };
|
|
}
|
|
|
|
function githubErrorBodyTooLarge(maxBytes) {
|
|
return new Error(`GitHub error response body exceeded ${maxBytes} bytes`);
|
|
}
|
|
|
|
function githubResponseBodyTooLarge(maxBytes) {
|
|
return new Error(`GitHub response body exceeded ${maxBytes} bytes`);
|
|
}
|
|
|
|
export async function readBoundedGitHubErrorText(
|
|
response,
|
|
maxBytes = GITHUB_ERROR_BODY_MAX_BYTES,
|
|
options = {},
|
|
) {
|
|
return await readBoundedResponseText(response, "GitHub error", maxBytes, {
|
|
createTooLargeError: () => githubErrorBodyTooLarge(maxBytes),
|
|
...options,
|
|
});
|
|
}
|
|
|
|
export async function readBoundedGitHubJson(
|
|
response,
|
|
maxBytes = GITHUB_RESPONSE_BODY_MAX_BYTES,
|
|
options = {},
|
|
) {
|
|
const text = await readBoundedResponseText(response, "GitHub", maxBytes, {
|
|
createTooLargeError: () => githubResponseBodyTooLarge(maxBytes),
|
|
...options,
|
|
});
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
function timeoutError(path, method, timeoutMs) {
|
|
const message = `GitHub API ${method} ${path} exceeded timeout ${timeoutMs}ms`;
|
|
// An expired write may already have succeeded; only reads can restart review.
|
|
return method === "GET" || method === "HEAD"
|
|
? new GitHubReadTimeoutError(message)
|
|
: new Error(message);
|
|
}
|
|
|
|
function combineAbortSignals(signals) {
|
|
const activeSignals = signals.filter(Boolean);
|
|
if (activeSignals.length === 0) {
|
|
return undefined;
|
|
}
|
|
if (activeSignals.length === 1) {
|
|
return activeSignals[0];
|
|
}
|
|
return AbortSignal.any(activeSignals);
|
|
}
|
|
|
|
export function createGitHubApi(token, options = {}) {
|
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
const timeoutMs = options.timeoutMs ?? GITHUB_API_REQUEST_TIMEOUT_MS;
|
|
const retryDelaysMs = options.retryDelaysMs ?? githubApiRetryDelaysMs;
|
|
const responseMaxBodyBytes = options.responseMaxBodyBytes ?? GITHUB_RESPONSE_BODY_MAX_BYTES;
|
|
const baseHeaders = {
|
|
accept: "application/vnd.github+json",
|
|
authorization: `Bearer ${token}`,
|
|
"user-agent": options.userAgent,
|
|
"x-github-api-version": "2022-11-28",
|
|
};
|
|
const request = async (path, requestOptions = {}) => {
|
|
const method = (requestOptions.method ?? "GET").toUpperCase();
|
|
if (method === "GET" || method === "HEAD") {
|
|
await options.beforeRead?.(path);
|
|
}
|
|
const timeoutController = new AbortController();
|
|
const requestSignal = combineAbortSignals([requestOptions.signal, timeoutController.signal]);
|
|
let timeout;
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
timeout = setTimeout(() => {
|
|
timeoutController.abort();
|
|
reject(timeoutError(path, method, timeoutMs));
|
|
}, timeoutMs);
|
|
timeout.unref?.();
|
|
});
|
|
const operationPromise = (async () => {
|
|
for (let attempt = 0; ; attempt += 1) {
|
|
let response;
|
|
try {
|
|
response = await fetchImpl(`https://api.github.com${path}`, {
|
|
...requestOptions,
|
|
signal: requestSignal,
|
|
headers: { ...baseHeaders, ...requestOptions.headers },
|
|
});
|
|
if (response.ok) {
|
|
return response.status === 204
|
|
? null
|
|
: await readBoundedGitHubJson(response, responseMaxBodyBytes, {
|
|
signal: requestSignal,
|
|
timeoutPromise,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
// Node fetch can report transport failures before headers or while
|
|
// reading the body. Both phases share the same read-only retry budget.
|
|
// Unknown failures, including malformed JSON, must not be retried.
|
|
const code = error?.cause?.code ?? error?.code;
|
|
if (
|
|
(method === "GET" || method === "HEAD") &&
|
|
!requestSignal.aborted &&
|
|
githubApiRetryCodes.has(code) &&
|
|
attempt < retryDelaysMs.length
|
|
) {
|
|
await wait(retryDelaysMs[attempt], undefined, { signal: requestSignal });
|
|
continue;
|
|
}
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
const requestError = new Error(
|
|
`GitHub API ${method} ${path} failed: ${code ? `${code}: ` : ""}${detail}`,
|
|
{ cause: error },
|
|
);
|
|
if (!requestSignal.aborted) {
|
|
requestError.code = code;
|
|
}
|
|
throw requestError;
|
|
}
|
|
if (!response.ok) {
|
|
if (
|
|
(method === "GET" || method === "HEAD") &&
|
|
githubApiRetryStatuses.has(response.status) &&
|
|
attempt < retryDelaysMs.length
|
|
) {
|
|
await response.body?.cancel().catch(() => {});
|
|
await wait(retryDelaysMs[attempt], undefined, { signal: requestSignal });
|
|
continue;
|
|
}
|
|
let errorText;
|
|
try {
|
|
errorText = await readBoundedGitHubErrorText(response, GITHUB_ERROR_BODY_MAX_BYTES, {
|
|
signal: timeoutController.signal,
|
|
timeoutPromise,
|
|
});
|
|
} catch (bodyError) {
|
|
errorText = bodyError instanceof Error ? bodyError.message : String(bodyError);
|
|
}
|
|
const message = `GitHub API ${method} ${path} failed: ${response.status} ${response.statusText}: ${errorText}`;
|
|
if (
|
|
(response.status === 403 || response.status === 429) &&
|
|
(response.status === 429 ||
|
|
response.headers.get("x-ratelimit-remaining") === "0" ||
|
|
response.headers.has("retry-after") ||
|
|
/(?:API rate limit exceeded|secondary rate limit)/iu.test(errorText))
|
|
) {
|
|
throw new GitHubRateLimitError(message, response);
|
|
}
|
|
const error = new Error(message);
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
}
|
|
})();
|
|
operationPromise.catch(() => {});
|
|
try {
|
|
return await Promise.race([operationPromise, timeoutPromise]);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
};
|
|
return {
|
|
request,
|
|
paginate: async (path) => {
|
|
const items = [];
|
|
for (let page = 1; ; page += 1) {
|
|
const separator = path.includes("?") ? "&" : "?";
|
|
const pageItems = await request(`${path}${separator}per_page=100&page=${page}`);
|
|
items.push(...pageItems);
|
|
if (pageItems.length < 100) {
|
|
return items;
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|