Files
openclaw/scripts/github/guard-shared.mjs
2026-06-20 06:07:12 +08:00

192 lines
6.1 KiB
JavaScript

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 function guardTrustedActorCandidates({ pullRequest, event, currentHeadSha }) {
const eventHeadSha = event?.pull_request?.head?.sha;
const eventAfterSha = event?.after;
const eventMatchesCurrentHead =
Boolean(currentHeadSha) &&
(eventHeadSha === currentHeadSha || eventAfterSha === currentHeadSha);
if (!eventMatchesCurrentHead) {
return [];
}
const candidates = [];
const seen = new Set();
for (const [source, login] of [["pull request author", pullRequest?.user?.login]]) {
if (typeof login !== "string" || login.length === 0) {
continue;
}
const normalizedLogin = login.toLowerCase();
if (seen.has(normalizedLogin)) {
continue;
}
seen.add(normalizedLogin);
candidates.push({ login, source });
}
return candidates;
}
export function createGuardApproverChecks({
api,
owner,
repo,
securityTeamSlug,
explicitSecurityApprovers,
warn = console.warn,
}) {
const membershipCache = new Map();
const permissionCache = new Map();
const isSecurityMember = async (login) => {
const normalizedLogin = login.toLowerCase();
if (explicitSecurityApprovers.has(normalizedLogin)) {
return true;
}
if (membershipCache.has(normalizedLogin)) {
return membershipCache.get(normalizedLogin);
}
try {
const membership = await api.request(
`/orgs/${owner}/teams/${securityTeamSlug}/memberships/${encodeURIComponent(login)}`,
);
const allowed = membership?.state === "active";
membershipCache.set(normalizedLogin, allowed);
return allowed;
} catch (error) {
if (error?.status !== 404) {
warn(`Could not verify ${login} against ${securityTeamSlug}: ${error.message}`);
}
membershipCache.set(normalizedLogin, false);
return false;
}
};
const isRepositoryAdmin = async (login) => {
const normalizedLogin = login.toLowerCase();
if (permissionCache.has(normalizedLogin)) {
return permissionCache.get(normalizedLogin);
}
try {
const result = await api.request(
`/repos/${owner}/${repo}/collaborators/${encodeURIComponent(login)}/permission`,
);
const allowed = result?.permission === "admin";
permissionCache.set(normalizedLogin, allowed);
return allowed;
} catch (error) {
if (error?.status !== 404) {
warn(`Could not verify repository permission for ${login}: ${error.message}`);
}
permissionCache.set(normalizedLogin, false);
return false;
}
};
return { isSecurityMember, isRepositoryAdmin };
}
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) {
return new Error(`GitHub API ${method} ${path} exceeded timeout ${timeoutMs}ms`);
}
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 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";
const timeoutController = new AbortController();
let timeout;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
timeoutController.abort();
reject(timeoutError(path, method, timeoutMs));
}, timeoutMs);
timeout.unref?.();
});
const operationPromise = (async () => {
const response = await fetchImpl(`https://api.github.com${path}`, {
...requestOptions,
signal: combineAbortSignals([requestOptions.signal, timeoutController.signal]),
headers: { ...baseHeaders, ...requestOptions.headers },
});
if (response.status === 204) {
return null;
}
if (!response.ok) {
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 error = new Error(`${response.status} ${response.statusText}: ${errorText}`);
error.status = response.status;
throw error;
}
return await readBoundedGitHubJson(response, responseMaxBodyBytes, {
signal: timeoutController.signal,
timeoutPromise,
});
})();
operationPromise.catch(() => {});
try {
return await Promise.race([operationPromise, timeoutPromise]);
} finally {
clearTimeout(timeout);
}
};
return { request };
}