33// GitHub dependency-change guard: detects dependency files, manages override
44// comments/labels, and can autoscrub lockfile-only PR changes.
55import { 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. */
918export const dependencyChangeMarker = "<!-- openclaw:dependency-guard -->" ;
1019export const dependencyGraphGuardMarker = "<!-- openclaw:dependency-graph-guard -->" ;
1120export const dependencyChangedLabel = "dependencies-changed" ;
1221export 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
1730const maxListedFiles = 25 ;
1831const autoscrubCommitMessage = "chore: remove dependency lockfile change" ;
@@ -445,28 +458,7 @@ function renderAutoscrubStatusLines(status) {
445458}
446459
447460export 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
472464export 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-
535481export 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