Skip to content

Commit aa479ac

Browse files
committed
refactor(github): share guard request helpers
1 parent d6cefe2 commit aa479ac

3 files changed

Lines changed: 250 additions & 348 deletions

File tree

scripts/github/dependency-guard.mjs

Lines changed: 29 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,29 @@
33
// GitHub dependency-change guard: detects dependency files, manages override
44
// comments/labels, and can autoscrub lockfile-only PR changes.
55
import { appendFile, readFile } from "node:fs/promises";
6-
import { readBoundedResponseText } from "../lib/bounded-response.mjs";
6+
import {
7+
GITHUB_API_REQUEST_TIMEOUT_MS,
8+
GITHUB_ERROR_BODY_MAX_BYTES,
9+
GITHUB_RESPONSE_BODY_MAX_BYTES,
10+
createGitHubApi,
11+
createGuardApproverChecks,
12+
guardTrustedActorCandidates,
13+
readBoundedGitHubErrorText,
14+
readBoundedGitHubJson,
15+
} from "./guard-shared.mjs";
716

817
/** Marker used to identify dependency guard comments. */
918
export const dependencyChangeMarker = "<!-- openclaw:dependency-guard -->";
1019
export const dependencyGraphGuardMarker = "<!-- openclaw:dependency-graph-guard -->";
1120
export const dependencyChangedLabel = "dependencies-changed";
1221
export const allowDependenciesCommand = "/allow-dependencies-change";
13-
export const GITHUB_ERROR_BODY_MAX_BYTES = 64 * 1024;
14-
export const GITHUB_RESPONSE_BODY_MAX_BYTES = 4 * 1024 * 1024;
15-
export const GITHUB_API_REQUEST_TIMEOUT_MS = 30_000;
22+
export {
23+
GITHUB_API_REQUEST_TIMEOUT_MS,
24+
GITHUB_ERROR_BODY_MAX_BYTES,
25+
GITHUB_RESPONSE_BODY_MAX_BYTES,
26+
readBoundedGitHubErrorText,
27+
readBoundedGitHubJson,
28+
};
1629

1730
const maxListedFiles = 25;
1831
const autoscrubCommitMessage = "chore: remove dependency lockfile change";
@@ -445,28 +458,7 @@ function renderAutoscrubStatusLines(status) {
445458
}
446459

447460
export function dependencyGuardTrustedActorCandidates({ pullRequest, event, currentHeadSha }) {
448-
const eventHeadSha = event?.pull_request?.head?.sha;
449-
const eventAfterSha = event?.after;
450-
const eventMatchesCurrentHead =
451-
Boolean(currentHeadSha) &&
452-
(eventHeadSha === currentHeadSha || eventAfterSha === currentHeadSha);
453-
if (!eventMatchesCurrentHead) {
454-
return [];
455-
}
456-
const candidates = [];
457-
const seen = new Set();
458-
for (const [source, login] of [["pull request author", pullRequest?.user?.login]]) {
459-
if (typeof login !== "string" || login.length === 0) {
460-
continue;
461-
}
462-
const normalizedLogin = login.toLowerCase();
463-
if (seen.has(normalizedLogin)) {
464-
continue;
465-
}
466-
seen.add(normalizedLogin);
467-
candidates.push({ login, source });
468-
}
469-
return candidates;
461+
return guardTrustedActorCandidates({ pullRequest, event, currentHeadSha });
470462
}
471463

472464
export async function findTrustedDependencyGuardActor({ candidates, isDependencyApprover }) {
@@ -486,112 +478,12 @@ function renderManifestChangeLine(change) {
486478
return `- ${markdownCode(change.path)} changed ${change.fields.map(markdownCode).join(", ")}.`;
487479
}
488480

489-
function githubErrorBodyTooLarge(maxBytes) {
490-
return new Error(`GitHub error response body exceeded ${maxBytes} bytes`);
491-
}
492-
493-
function githubResponseBodyTooLarge(maxBytes) {
494-
return new Error(`GitHub response body exceeded ${maxBytes} bytes`);
495-
}
496-
497-
export async function readBoundedGitHubErrorText(
498-
response,
499-
maxBytes = GITHUB_ERROR_BODY_MAX_BYTES,
500-
options = {},
501-
) {
502-
return await readBoundedResponseText(response, "GitHub error", maxBytes, {
503-
createTooLargeError: () => githubErrorBodyTooLarge(maxBytes),
504-
...options,
505-
});
506-
}
507-
508-
export async function readBoundedGitHubJson(
509-
response,
510-
maxBytes = GITHUB_RESPONSE_BODY_MAX_BYTES,
511-
options = {},
512-
) {
513-
const text = await readBoundedResponseText(response, "GitHub", maxBytes, {
514-
createTooLargeError: () => githubResponseBodyTooLarge(maxBytes),
515-
...options,
516-
});
517-
return JSON.parse(text);
518-
}
519-
520-
function timeoutError(path, method, timeoutMs) {
521-
return new Error(`GitHub API ${method} ${path} exceeded timeout ${timeoutMs}ms`);
522-
}
523-
524-
function combineAbortSignals(signals) {
525-
const activeSignals = signals.filter(Boolean);
526-
if (activeSignals.length === 0) {
527-
return undefined;
528-
}
529-
if (activeSignals.length === 1) {
530-
return activeSignals[0];
531-
}
532-
return AbortSignal.any(activeSignals);
533-
}
534-
535481
export function githubApi(token, options = {}) {
536-
const fetchImpl = options.fetchImpl ?? fetch;
537-
const timeoutMs = options.timeoutMs ?? GITHUB_API_REQUEST_TIMEOUT_MS;
538-
const responseMaxBodyBytes = options.responseMaxBodyBytes ?? GITHUB_RESPONSE_BODY_MAX_BYTES;
539-
const baseHeaders = {
540-
accept: "application/vnd.github+json",
541-
authorization: `Bearer ${token}`,
542-
"user-agent": "openclaw-dependency-guard",
543-
"x-github-api-version": "2022-11-28",
544-
};
545-
const request = async (path, requestOptions = {}) => {
546-
const method = requestOptions.method ?? "GET";
547-
const timeoutController = new AbortController();
548-
let timeout;
549-
const timeoutPromise = new Promise((_, reject) => {
550-
timeout = setTimeout(() => {
551-
timeoutController.abort();
552-
reject(timeoutError(path, method, timeoutMs));
553-
}, timeoutMs);
554-
timeout.unref?.();
555-
});
556-
const operationPromise = (async () => {
557-
const response = await fetchImpl(`https://api.github.com${path}`, {
558-
...requestOptions,
559-
signal: combineAbortSignals([requestOptions.signal, timeoutController.signal]),
560-
headers: { ...baseHeaders, ...requestOptions.headers },
561-
});
562-
if (response.status === 204) {
563-
return null;
564-
}
565-
if (!response.ok) {
566-
let errorText;
567-
try {
568-
errorText = await readBoundedGitHubErrorText(response, GITHUB_ERROR_BODY_MAX_BYTES, {
569-
signal: timeoutController.signal,
570-
timeoutPromise,
571-
});
572-
} catch (bodyError) {
573-
errorText = bodyError instanceof Error ? bodyError.message : String(bodyError);
574-
}
575-
const error = new Error(`${response.status} ${response.statusText}: ${errorText}`);
576-
error.status = response.status;
577-
throw error;
578-
}
579-
return await readBoundedGitHubJson(response, responseMaxBodyBytes, {
580-
signal: timeoutController.signal,
581-
timeoutPromise,
582-
});
583-
})();
584-
operationPromise.catch(() => {});
585-
try {
586-
return await Promise.race([operationPromise, timeoutPromise]);
587-
} finally {
588-
clearTimeout(timeout);
589-
}
590-
};
482+
const api = createGitHubApi(token, { ...options, userAgent: "openclaw-dependency-guard" });
591483
return {
592-
request,
484+
...api,
593485
graphql: async (query, variables) => {
594-
const result = await request("/graphql", {
486+
const result = await api.request("/graphql", {
595487
method: "POST",
596488
headers: { "content-type": "application/json" },
597489
body: JSON.stringify({ query, variables }),
@@ -609,7 +501,7 @@ export function githubApi(token, options = {}) {
609501
const items = [];
610502
for (let page = 1; ; page += 1) {
611503
const separator = path.includes("?") ? "&" : "?";
612-
const pageItems = await request(`${path}${separator}per_page=100&page=${page}`);
504+
const pageItems = await api.request(`${path}${separator}per_page=100&page=${page}`);
613505
items.push(...pageItems);
614506
if (pageItems.length < 100) {
615507
return items;
@@ -925,51 +817,13 @@ async function main() {
925817
return;
926818
}
927819

928-
const membershipCache = new Map();
929-
const permissionCache = new Map();
930-
const isSecurityMember = async (login) => {
931-
const normalizedLogin = login.toLowerCase();
932-
if (explicitSecurityApprovers.has(normalizedLogin)) {
933-
return true;
934-
}
935-
if (membershipCache.has(normalizedLogin)) {
936-
return membershipCache.get(normalizedLogin);
937-
}
938-
try {
939-
const membership = await api.request(
940-
`/orgs/${owner}/teams/${securityTeamSlug}/memberships/${encodeURIComponent(login)}`,
941-
);
942-
const allowed = membership?.state === "active";
943-
membershipCache.set(normalizedLogin, allowed);
944-
return allowed;
945-
} catch (error) {
946-
if (error?.status !== 404) {
947-
console.warn(`Could not verify ${login} against ${securityTeamSlug}: ${error.message}`);
948-
}
949-
membershipCache.set(normalizedLogin, false);
950-
return false;
951-
}
952-
};
953-
const isRepositoryAdmin = async (login) => {
954-
const normalizedLogin = login.toLowerCase();
955-
if (permissionCache.has(normalizedLogin)) {
956-
return permissionCache.get(normalizedLogin);
957-
}
958-
try {
959-
const result = await api.request(
960-
`/repos/${owner}/${repo}/collaborators/${encodeURIComponent(login)}/permission`,
961-
);
962-
const allowed = result?.permission === "admin";
963-
permissionCache.set(normalizedLogin, allowed);
964-
return allowed;
965-
} catch (error) {
966-
if (error?.status !== 404) {
967-
console.warn(`Could not verify repository permission for ${login}: ${error.message}`);
968-
}
969-
permissionCache.set(normalizedLogin, false);
970-
return false;
971-
}
972-
};
820+
const { isSecurityMember, isRepositoryAdmin } = createGuardApproverChecks({
821+
api,
822+
owner,
823+
repo,
824+
securityTeamSlug,
825+
explicitSecurityApprovers,
826+
});
973827
const isDependencyApprover = async (login) => {
974828
if (await isSecurityMember(login)) {
975829
return securityTeamSlug;

0 commit comments

Comments
 (0)