Skip to content

Commit e94eb5a

Browse files
committed
fix(scan github): surface GitHub rate limits instead of silent success
Classify GitHub API failures (rate limit, auth, abuse detection) as blocking and short-circuit the repo scan loop and manifest download instead of swallowing them into an ok:true scan of a partial manifest set. Surface an error when every attempted repo fails so scripts do not infer success from ok:true with zero scans created.
1 parent 5754a02 commit e94eb5a

4 files changed

Lines changed: 786 additions & 48 deletions

File tree

src/commands/scan/create-scan-from-github.mts

Lines changed: 140 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ import constants from '../../constants.mts'
1919
import { apiFetch } from '../../utils/api.mts'
2020
import { debugApiRequest, debugApiResponse } from '../../utils/debug.mts'
2121
import { formatErrorWithDetail } from '../../utils/errors.mts'
22+
import {
23+
classifyGitHubResponse,
24+
githubApiRequest,
25+
isGitHubBlockingError,
26+
} from '../../utils/github-errors.mts'
2227
import { isReportSupportedFile } from '../../utils/glob.mts'
2328
import { 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
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { runGithubScanLoop } from './create-scan-from-github.mts'
4+
import {
5+
GITHUB_ERR_AUTH_FAILED,
6+
GITHUB_ERR_RATE_LIMIT,
7+
} from '../../utils/github-errors.mts'
8+
9+
import type { CResult } from '../../types.mts'
10+
11+
type ScanResult = CResult<{ scanCreated: boolean }>
12+
13+
// Build a scanRepoFn from a map of repo -> canned result, recording the
14+
// order of calls so we can assert the loop stops early on blocking errors.
15+
// No module mocking — this is a plain function injected into the loop.
16+
function fakeScanner(results: Record<string, ScanResult>): {
17+
fn: (repoSlug: string) => Promise<ScanResult>
18+
calls: string[]
19+
} {
20+
const calls: string[] = []
21+
return {
22+
calls,
23+
fn: (repoSlug: string) => {
24+
calls.push(repoSlug)
25+
return Promise.resolve(results[repoSlug]!)
26+
},
27+
}
28+
}
29+
30+
const scanned: ScanResult = { ok: true, data: { scanCreated: true } }
31+
const emptyRepo: ScanResult = { ok: true, data: { scanCreated: false } }
32+
const noManifests: ScanResult = {
33+
ok: false,
34+
message: 'No manifest files found',
35+
cause: 'No supported manifest files were found.',
36+
}
37+
const rateLimited: ScanResult = {
38+
ok: false,
39+
message: GITHUB_ERR_RATE_LIMIT,
40+
cause: 'GitHub API rate limit exceeded on the supplied token.',
41+
}
42+
const authFailed: ScanResult = {
43+
ok: false,
44+
message: GITHUB_ERR_AUTH_FAILED,
45+
cause: 'GitHub authentication failed.',
46+
}
47+
48+
describe('runGithubScanLoop GitHub blocking-error handling', () => {
49+
it('stops and returns ok:false on a GitHub rate limit', async () => {
50+
const scanner = fakeScanner({
51+
'repo-a': rateLimited,
52+
'repo-b': scanned,
53+
'repo-c': scanned,
54+
})
55+
const result = await runGithubScanLoop(
56+
['repo-a', 'repo-b', 'repo-c'],
57+
scanner.fn,
58+
)
59+
expect(result.ok).toBe(false)
60+
expect(result.ok ? '' : result.message).toBe(GITHUB_ERR_RATE_LIMIT)
61+
// Loop stopped after the first repo — no quota burned on the rest.
62+
expect(scanner.calls).toEqual(['repo-a'])
63+
})
64+
65+
it('stops and returns ok:false on a GitHub auth failure', async () => {
66+
const scanner = fakeScanner({ 'repo-a': authFailed, 'repo-b': scanned })
67+
const result = await runGithubScanLoop(['repo-a', 'repo-b'], scanner.fn)
68+
expect(result.ok).toBe(false)
69+
expect(result.ok ? '' : result.message).toBe(GITHUB_ERR_AUTH_FAILED)
70+
expect(scanner.calls).toEqual(['repo-a'])
71+
})
72+
73+
it('carries the rate-limit cause through so the CLI can print it', async () => {
74+
const scanner = fakeScanner({ 'repo-a': rateLimited })
75+
const result = await runGithubScanLoop(['repo-a'], scanner.fn)
76+
expect(result.ok ? undefined : result.cause).toContain('rate limit')
77+
})
78+
79+
it('succeeds when a repo has no manifests but the tree was empty', async () => {
80+
const scanner = fakeScanner({ 'repo-a': emptyRepo, 'repo-b': emptyRepo })
81+
const result = await runGithubScanLoop(['repo-a', 'repo-b'], scanner.fn)
82+
expect(result.ok).toBe(true)
83+
expect(scanner.calls).toEqual(['repo-a', 'repo-b'])
84+
})
85+
86+
it('succeeds when at least one repo produced a scan', async () => {
87+
const scanner = fakeScanner({ 'repo-a': noManifests, 'repo-b': scanned })
88+
const result = await runGithubScanLoop(['repo-a', 'repo-b'], scanner.fn)
89+
expect(result.ok).toBe(true)
90+
// Both repos are attempted; a non-blocking failure does not stop the loop.
91+
expect(scanner.calls).toEqual(['repo-a', 'repo-b'])
92+
})
93+
94+
it('fails when every repo errored for a non-blocking reason', async () => {
95+
const scanner = fakeScanner({
96+
'repo-a': noManifests,
97+
'repo-b': noManifests,
98+
})
99+
const result = await runGithubScanLoop(['repo-a', 'repo-b'], scanner.fn)
100+
expect(result.ok).toBe(false)
101+
expect(result.ok ? '' : result.message).toBe('All repos failed to scan')
102+
})
103+
})

0 commit comments

Comments
 (0)