@@ -19,6 +19,11 @@ import constants from '../../constants.mts'
1919import { apiFetch } from '../../utils/api.mts'
2020import { debugApiRequest , debugApiResponse } from '../../utils/debug.mts'
2121import { formatErrorWithDetail } from '../../utils/errors.mts'
22+ import {
23+ classifyGitHubResponse ,
24+ githubApiRequest ,
25+ isGitHubBlockingError ,
26+ } from '../../utils/github-errors.mts'
2227import { isReportSupportedFile } from '../../utils/glob.mts'
2328import { fetchListAllRepos } from '../repository/fetch-list-all-repos.mts'
2429
@@ -94,26 +99,91 @@ export async function createScanFromGithub({
9499 }
95100 }
96101
97- let scansCreated = 0
98- for ( const repoSlug of targetRepos ) {
99- // eslint-disable-next-line no-await-in-loop
100- const scanCResult = await scanRepo ( repoSlug , {
102+ return await runGithubScanLoop ( targetRepos , repoSlug =>
103+ scanRepo ( repoSlug , {
101104 githubApiUrl,
102105 githubToken,
103106 orgSlug,
104107 orgGithub,
105108 outputKind,
106109 repos,
107- } )
110+ } ) ,
111+ )
112+ }
113+
114+ /**
115+ * Drive the per-repo scan loop and decide the overall run result.
116+ *
117+ * The loop stops early on a blocking GitHub error (rate limit / auth / abuse
118+ * detection) because every remaining repo would fail the same way. Previously
119+ * a rate-limited token made every repo fail its API calls, the loop swallowed
120+ * each failure, and the final "N repos / 0 manifests" summary misled users and
121+ * CI into thinking the scan succeeded when nothing was uploaded. A run where
122+ * every attempted repo failed for a non-blocking reason is also surfaced as an
123+ * error rather than a silent ok:true.
124+ *
125+ * `scanRepoFn` is injected so this decision logic can be tested without the
126+ * GitHub network path.
127+ */
128+ export async function runGithubScanLoop (
129+ targetRepos : string [ ] ,
130+ scanRepoFn : ( repoSlug : string ) => Promise < CResult < { scanCreated : boolean } > > ,
131+ ) : Promise < CResult < undefined > > {
132+ let scansCreated = 0
133+ let reposScanned = 0
134+ let blockingError : CResult < undefined > | undefined
135+ const perRepoFailures : Array < { repo : string ; message : string } > = [ ]
136+ for ( const repoSlug of targetRepos ) {
137+ reposScanned += 1
138+ // eslint-disable-next-line no-await-in-loop
139+ const scanCResult = await scanRepoFn ( repoSlug )
108140 if ( scanCResult . ok ) {
109141 const { scanCreated } = scanCResult . data
110142 if ( scanCreated ) {
111143 scansCreated += 1
112144 }
145+ continue
146+ }
147+ perRepoFailures . push ( { repo : repoSlug , message : scanCResult . message } )
148+ // Stop on rate-limit / auth / abuse failures: every remaining repo will
149+ // fail for the same reason, and continuing only burns more quota while
150+ // delaying the real error.
151+ if ( isGitHubBlockingError ( scanCResult . message ) ) {
152+ blockingError = {
153+ ok : false ,
154+ message : scanCResult . message ,
155+ cause : scanCResult . cause ,
156+ }
157+ break
158+ }
159+ }
160+
161+ if ( blockingError ) {
162+ logger . fail ( blockingError . message )
163+ return blockingError
164+ }
165+
166+ // If every attempted repo failed (but not for a known blocking reason),
167+ // treat the run as an error so scripts do not infer success from an
168+ // ok:true with zero scans created. Checked before the success lines below
169+ // so an all-failed run never prints green "processed" output that a script
170+ // reading log lines could mistake for success.
171+ if (
172+ reposScanned > 0 &&
173+ scansCreated === 0 &&
174+ perRepoFailures . length === reposScanned
175+ ) {
176+ const firstFailure = perRepoFailures [ 0 ] !
177+ return {
178+ ok : false ,
179+ message : 'All repos failed to scan' ,
180+ cause :
181+ `All ${ reposScanned } repos failed to scan. First failure for ${ firstFailure . repo } : ${ firstFailure . message } . ` +
182+ 'See the log above for per-repo details.' ,
113183 }
114184 }
115185
116- logger . success ( targetRepos . length , 'GitHub repos detected ' )
186+ logger . success ( reposScanned , 'GitHub repos processed ' )
117187 logger . success ( scansCreated , 'with supported Manifest files' )
118188
119189 return {
@@ -328,8 +398,20 @@ async function testAndDownloadManifestFiles({
328398 if ( result . data . isManifest ) {
329399 fileCount += 1
330400 }
331- } else if ( ! firstFailureResult ) {
332- firstFailureResult = result
401+ } else {
402+ // A blocking error (rate limit / auth / abuse detection) is token-wide:
403+ // every remaining download would fail the same way, and an earlier
404+ // successful download must not mask it into an ok:true scan of a
405+ // partial manifest set. Surface it immediately, the same way the repo
406+ // loop short-circuits on blocking errors.
407+ if ( isGitHubBlockingError ( result . message ) ) {
408+ logger . groupEnd ( )
409+ logger . fail ( result . message )
410+ return result
411+ }
412+ if ( ! firstFailureResult ) {
413+ firstFailureResult = result
414+ }
333415 }
334416 }
335417 logger . groupEnd ( )
@@ -415,23 +497,22 @@ async function downloadManifestFile({
415497 const fileUrl = `${ repoApiUrl } /contents/${ file } ?ref=${ defaultBranch } `
416498 debugDir ( 'inspect' , { fileUrl } )
417499
418- debugApiRequest ( 'GET' , fileUrl )
419- let downloadUrlResponse : Response
420- try {
421- downloadUrlResponse = await apiFetch ( fileUrl , {
500+ const reqResult = await githubApiRequest (
501+ fileUrl ,
502+ {
422503 method : 'GET' ,
423504 headers : {
424505 Authorization : `Bearer ${ githubToken } ` ,
425506 } ,
426- } )
427- debugApiResponse ( 'GET' , fileUrl , downloadUrlResponse . status )
428- } catch ( e ) {
429- debugApiResponse ( 'GET' , fileUrl , undefined , e )
430- throw e
507+ } ,
508+ `fetching the download URL for ${ file } ` ,
509+ )
510+ if ( ! reqResult . ok ) {
511+ return reqResult
431512 }
432513 debugFn ( 'notice' , 'complete: request' )
433514
434- const downloadUrlText = await downloadUrlResponse . text ( )
515+ const downloadUrlText = reqResult . data . bodyText
435516 debugFn ( 'inspect' , 'response: raw download url' , downloadUrlText )
436517
437518 let downloadUrl
@@ -486,6 +567,20 @@ async function streamDownloadWithFetch(
486567 debugApiResponse ( 'GET' , downloadUrl , response . status )
487568
488569 if ( ! response . ok ) {
570+ // Surface rate-limit / auth failures on the raw download host too, so a
571+ // bulk run that trips the limit mid-download fails loudly instead of
572+ // being counted as just another skipped file. Header/status-only check;
573+ // the stream body is left unconsumed.
574+ const blocking = classifyGitHubResponse (
575+ response . status ,
576+ response . headers ,
577+ '' ,
578+ 'downloading a manifest file' ,
579+ )
580+ if ( blocking ) {
581+ logger . fail ( blocking . message )
582+ return blocking
583+ }
489584 const errorMsg = `Download failed due to bad server response: ${ response . status } ${ response . statusText } for ${ downloadUrl } `
490585 logger . fail ( errorMsg )
491586 return { ok : false , message : 'Download Failed' , cause : errorMsg }
@@ -580,21 +675,20 @@ async function getLastCommitDetails({
580675 const commitApiUrl = `${ repoApiUrl } /commits?sha=${ defaultBranch } &per_page=1`
581676 debugFn ( 'inspect' , 'url: commit' , commitApiUrl )
582677
583- debugApiRequest ( 'GET' , commitApiUrl )
584- let commitResponse : Response
585- try {
586- commitResponse = await apiFetch ( commitApiUrl , {
678+ const reqResult = await githubApiRequest (
679+ commitApiUrl ,
680+ {
587681 headers : {
588682 Authorization : `Bearer ${ githubToken } ` ,
589683 } ,
590- } )
591- debugApiResponse ( 'GET' , commitApiUrl , commitResponse . status )
592- } catch ( e ) {
593- debugApiResponse ( 'GET' , commitApiUrl , undefined , e )
594- throw e
684+ } ,
685+ `fetching the latest commit for ${ orgGithub } / ${ repoSlug } ` ,
686+ )
687+ if ( ! reqResult . ok ) {
688+ return reqResult
595689 }
596690
597- const commitText = await commitResponse . text ( )
691+ const commitText = reqResult . data . bodyText
598692 debugFn ( 'inspect' , 'response: commit' , commitText )
599693
600694 let lastCommit
@@ -692,23 +786,22 @@ async function getRepoDetails({
692786 const repoApiUrl = `${ githubApiUrl } /repos/${ orgGithub } /${ repoSlug } `
693787 debugDir ( 'inspect' , { repoApiUrl } )
694788
695- let repoDetailsResponse : Response
696- try {
697- debugApiRequest ( 'GET' , repoApiUrl )
698- repoDetailsResponse = await apiFetch ( repoApiUrl , {
789+ const reqResult = await githubApiRequest (
790+ repoApiUrl ,
791+ {
699792 method : 'GET' ,
700793 headers : {
701794 Authorization : `Bearer ${ githubToken } ` ,
702795 } ,
703- } )
704- debugApiResponse ( 'GET' , repoApiUrl , repoDetailsResponse . status )
705- } catch ( e ) {
706- debugApiResponse ( 'GET' , repoApiUrl , undefined , e )
707- throw e
796+ } ,
797+ `fetching repo details for ${ orgGithub } / ${ repoSlug } ` ,
798+ )
799+ if ( ! reqResult . ok ) {
800+ return reqResult
708801 }
709802 logger . success ( `Request completed.` )
710803
711- const repoDetailsText = await repoDetailsResponse . text ( )
804+ const repoDetailsText = reqResult . data . bodyText
712805 debugFn ( 'inspect' , 'response: repo' , repoDetailsText )
713806
714807 let repoDetails
@@ -756,22 +849,21 @@ async function getRepoBranchTree({
756849 const treeApiUrl = `${ repoApiUrl } /git/trees/${ defaultBranch } ?recursive=1`
757850 debugFn ( 'inspect' , 'url: tree' , treeApiUrl )
758851
759- let treeResponse : Response
760- try {
761- debugApiRequest ( 'GET' , treeApiUrl )
762- treeResponse = await apiFetch ( treeApiUrl , {
852+ const reqResult = await githubApiRequest (
853+ treeApiUrl ,
854+ {
763855 method : 'GET' ,
764856 headers : {
765857 Authorization : `Bearer ${ githubToken } ` ,
766858 } ,
767- } )
768- debugApiResponse ( 'GET' , treeApiUrl , treeResponse . status )
769- } catch ( e ) {
770- debugApiResponse ( 'GET' , treeApiUrl , undefined , e )
771- throw e
859+ } ,
860+ `fetching the file tree for ${ orgGithub } / ${ repoSlug } ` ,
861+ )
862+ if ( ! reqResult . ok ) {
863+ return reqResult
772864 }
773865
774- const treeText = await treeResponse . text ( )
866+ const treeText = reqResult . data . bodyText
775867 debugFn ( 'inspect' , 'response: tree' , treeText )
776868
777869 let treeDetails
0 commit comments