mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 10:34:44 +00:00
refactor(github): share guard request helpers
This commit is contained in:
191
scripts/github/guard-shared.mjs
Normal file
191
scripts/github/guard-shared.mjs
Normal file
@@ -0,0 +1,191 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user