11import { getServiceChildLogger } from '@crowd/logging'
22
33import { FetchError , LightRepoResult } from './types'
4+ import {
5+ RepoTrees ,
6+ TreeEntryNode ,
7+ classifyWellKnownFiles ,
8+ deriveSecurityFileEnabled ,
9+ } from './wellKnownFiles'
410
511const log = getServiceChildLogger ( 'fetch-light-repo' )
612
@@ -24,6 +30,15 @@ const REPO_QUERY = `
2430 createdAt
2531 isSecurityPolicyEnabled
2632 defaultBranchRef { name }
33+ rootTree: object(expression: "HEAD:") {
34+ ... on Tree { entries { name type oid } }
35+ }
36+ githubTree: object(expression: "HEAD:.github") {
37+ ... on Tree { entries { name type oid } }
38+ }
39+ docsTree: object(expression: "HEAD:docs") {
40+ ... on Tree { entries { name type oid } }
41+ }
2742 }
2843 }
2944`
@@ -34,67 +49,6 @@ export function parseGithubUrl(url: string): { owner: string; name: string } {
3449 return { owner : match [ 1 ] , name : match [ 2 ] }
3550}
3651
37- // community/profile API doesn't reliably return files.security — use Contents API instead.
38- async function fetchSecurityFileEnabled (
39- url : string ,
40- owner : string ,
41- name : string ,
42- token : string ,
43- timeoutMs : number ,
44- ) : Promise < boolean | null > {
45- const headers = { Authorization : `bearer ${ token } ` , Accept : 'application/vnd.github+json' }
46- const check = async ( path : string ) : Promise < boolean > => {
47- const controller = new AbortController ( )
48- const timeoutId = setTimeout ( ( ) => controller . abort ( ) , timeoutMs )
49- try {
50- const response = await fetch ( `${ GITHUB_API_URL } /repos/${ owner } /${ name } /contents/${ path } ` , {
51- headers,
52- signal : controller . signal ,
53- } )
54- if ( response . status === 200 ) return true
55- if ( response . status === 404 ) return false
56- if ( response . status === 403 ) {
57- const body = await response . text ( )
58- if ( body . toLowerCase ( ) . includes ( 'rate limit' ) ) {
59- // REST secondary limits send retry-after; primary limits send x-ratelimit-reset
60- const retryAfterSec = parseInt ( response . headers . get ( 'retry-after' ) ?? '0' , 10 )
61- const resetSec = parseInt ( response . headers . get ( 'x-ratelimit-reset' ) ?? '0' , 10 )
62- const resetMs = retryAfterSec
63- ? Date . now ( ) + retryAfterSec * 1000
64- : resetSec
65- ? resetSec * 1000 + 5_000
66- : Date . now ( ) + 65_000
67- throw new FetchError ( 'RATE_LIMIT' , `Contents API rate limited on ${ path } ` , resetMs )
68- }
69- }
70- throw new Error ( `Unexpected status ${ response . status } for ${ path } ` )
71- } finally {
72- clearTimeout ( timeoutId )
73- }
74- }
75-
76- try {
77- const [ root , dotGithub ] = await Promise . all ( [
78- check ( 'SECURITY.md' ) ,
79- check ( '.github/SECURITY.md' ) ,
80- ] )
81- return root || dotGithub
82- } catch ( err ) {
83- // Rate limits propagate so the caller can park the installation and requeue the repo
84- if ( err instanceof FetchError && err . kind === 'RATE_LIMIT' ) throw err
85- log . warn (
86- {
87- url,
88- errName : ( err as Error ) . name ,
89- errMsg : ( err as Error ) . message ,
90- errStack : ( err as Error ) . stack ,
91- } ,
92- 'Security file check failed — securityFileEnabled will be null' ,
93- )
94- return null
95- }
96- }
97-
9852interface BranchProtection {
9953 enabled : boolean | null
10054 requiredReviews : number | null
@@ -214,6 +168,9 @@ interface RepoGraphqlResponse {
214168 createdAt : string
215169 isSecurityPolicyEnabled : boolean
216170 defaultBranchRef : { name : string } | null
171+ rootTree : { entries ?: TreeEntryNode [ ] } | null
172+ githubTree : { entries ?: TreeEntryNode [ ] } | null
173+ docsTree : { entries ?: TreeEntryNode [ ] } | null
217174 } | null
218175 }
219176 errors ?: Array < { type ?: string ; message ?: string } >
@@ -262,10 +219,7 @@ export async function fetchLightRepo(
262219 if ( response . status === 404 ) throw new FetchError ( 'NOT_FOUND' , `404 for ${ url } ` )
263220 if ( response . status >= 500 ) throw new FetchError ( 'TRANSIENT' , `${ response . status } for ${ url } ` )
264221
265- const [ json , securityFileEnabled ] = await Promise . all ( [
266- response . json ( ) as Promise < RepoGraphqlResponse > ,
267- fetchSecurityFileEnabled ( url , owner , name , token , timeoutMs ) ,
268- ] )
222+ const json = ( await response . json ( ) ) as RepoGraphqlResponse
269223
270224 if ( json . errors ?. length ) {
271225 const err = json . errors [ 0 ]
@@ -280,6 +234,12 @@ export async function fetchLightRepo(
280234 const repo = json . data ?. repository
281235 if ( ! repo ) throw new FetchError ( 'NOT_FOUND' , `No repository data for ${ url } ` )
282236
237+ const trees : RepoTrees = {
238+ root : repo . rootTree ?. entries ?? null ,
239+ github : repo . githubTree ?. entries ?? null ,
240+ docs : repo . docsTree ?. entries ?? null ,
241+ }
242+
283243 const defaultBranch = repo . defaultBranchRef ?. name ?? null
284244 const branchProtection = defaultBranch
285245 ? await fetchBranchProtection ( url , owner , name , defaultBranch , token , timeoutMs )
@@ -305,7 +265,8 @@ export async function fetchLightRepo(
305265 isFork : repo . isFork ?? null ,
306266 createdAt : repo . createdAt ?? null ,
307267 securityPolicyEnabled : repo . isSecurityPolicyEnabled ?? null ,
308- securityFileEnabled,
268+ securityFileEnabled : deriveSecurityFileEnabled ( trees ) ,
269+ wellKnownFiles : classifyWellKnownFiles ( trees ) ,
309270 branchProtectionEnabled : branchProtection . enabled ,
310271 branchProtectionRequiredReviews : branchProtection . requiredReviews ,
311272 branchProtectionRequiresStatusChecks : branchProtection . requiresStatusChecks ,
0 commit comments