@@ -9,55 +9,54 @@ How this CLI talks to `api.github.com`. These rules exist because violating them
99silently returns ** empty/zero results** (a confident, wrong $0) instead of failing
1010loudly — the worst outcome for a diagnostic tool. Most were learned the hard way.
1111
12- ## Batch with GraphQL ` nodes(ids: […]) ` ; never one request per repo
13-
14- A 200-repo org crawled with per-repo REST/GraphQL calls blows past secondary rate
15- limits. Collect repo-scoped data in ** batched GraphQL queries** keyed on repo node
16- IDs (` RepoMeta.nodeId ` ), the way ` repoMetadata.ts ` and ` dependabotPrs.ts ` do. One
17- query covers many repos. This is the whole point of PR #29 ("use more performant
18- GitHub APIs to avoid rate limiting").
19-
20- - GraphQL and REST share ** one** throttled Octokit client (the retry and throttling
21- plugins are configured in ` GithubClient.ts ` ), so they share a rate-limit budget.
22- Don't construct a second client or bypass ` GithubClient ` .
23-
24- ## Never use the search API for private-repo data
25-
26- The GraphQL/REST ** ` search ` ** API silently omits private repositories when called
27- with a ** fine-grained token** — it returns ` 200 OK ` with the private matches missing,
28- no error. Fine-grained tokens are the least-privilege option the CLI recommends, so
29- this path produces a confident $0. Use ** direct repo access** instead
30- (` nodes(ids:) { ... on Repository { pullRequests } } ` ), which works for both classic
31- and fine-grained tokens. Cross-check counts against a classic token and the search
32- API when changing collection logic (` gh auth token ` ); note search's ` issueCount ` is
33- approximate/eventually-consistent, so expect ±1.
34-
35- ## Tolerate partial GraphQL responses
36-
37- GitHub returns usable ` data ` ** alongside** an ` errors ` array when a token can read
38- some fields but not others — e.g. a fine-grained token without ** Checks** access
39- hitting ` statusCheckRollup ` gets ` FORBIDDEN ` on those leaves while the PR list comes
40- back fine. Octokit's ` graphql() ` throws on _ any_ ` errors ` , which would discard the
41- whole payload. ` GithubClient.graphql ` recovers the partial ` data ` from the thrown
42- ` GraphqlResponseError ` (` partialGraphqlData ` ). When you select an optional/permission-
43- gated field, assume some tokens can't read it and handle null sub-fields defensively.
44-
45- ## Keep GraphQL queries light, or they time out (502/504)
46-
47- Deeply nested PR queries (PRs × reviews × comments × status-check contexts) exhaust
48- GitHub's resolver budget and return a gateway timeout — which, under partial-data
49- recovery, can come back as an empty ` data ` and ** silently drop those repos** . Keep
50- the per-request work small: modest ` BATCH_SIZE ` (~ 10 repos), modest connection
51- ` first: ` counts (PRs ~ 30, nested ~ 20). Bigger isn't faster — it fails.
52-
53- - ** Any change to batch size or page ` first: ` counts must be verified against a real
54- PR-heavy org, not just unit tests.** The mocks can't reproduce a timeout; a config
55- that drops repos still passes ` bun run test ` . Confirm the live count is unchanged.
56-
57- ## Fail loudly, never silently empty
58-
59- When the data genuinely can't be read, surface it (a warning, a degraded report
60- banner, or a pre-flight prompt) rather than returning ` [] ` . A diagnostic that
61- under-reports is worse than one that errors. Partial-failure boundaries log/warn and
62- proceed with what succeeded (see ` error-handling-neverthrow.md ` ); they do not
63- manufacture a clean-looking zero.
12+ ## Classic tokens only; validate scopes up front
13+
14+ The CLI requires a ** classic PAT** with ` repo ` and ` read:org ` (` repo ` also covers
15+ ` security_events ` ). Fine-grained tokens are unsupported: GitHub's ` search ` API
16+ silently omits private repos under a fine-grained token, which would surface as a
17+ confident $0. The scope pre-flight (` cli.ts runPreflight ` → ` GithubClient.getOAuthScopes ` ,
18+ reading the ` x-oauth-scopes ` header) rejects fine-grained/unscoped tokens before any
19+ crawl. Don't add token-type branching elsewhere — gate once, up front.
20+
21+ ## One throttled client; share its rate-limit budget
22+
23+ GraphQL and REST share ** one** Octokit instance (the retry and throttling plugins are
24+ configured in ` GithubClient.ts ` ), so they share a rate-limit budget and bounded
25+ secondary-rate-limit backoff. Don't construct a second client or bypass ` GithubClient ` .
26+ This is what keeps a large org off the secondary rate limit (PR #29 ).
27+
28+ ## PR collection: ` search ` , bisected to beat the 1000-result cap
29+
30+ Dependabot PRs come from org-wide GraphQL ` search ` (` dependabotPrs.ts ` ), not per-repo
31+ fan-out — search returns the per-PR review/merge data the cost model needs in one
32+ stream. But ** search returns at most 1000 results per query** (` hasNextPage ` stops at
33+ 1000 even when ` issueCount ` is larger). A single ` created:>= ` /` closed:>= ` query would
34+ silently undercount a busy org, so ` searchAllPrs ` ** bisects the date range** until every
35+ sub-query fits under the cap. Never replace this with a single capped query. A single
36+ day that still exceeds 1000 is ` logger.error ` 'd (truncation we can't avoid), not dropped.
37+
38+ ## CVEs: the org-level endpoint, with a per-repo fallback
39+
40+ CVE alerts come from ` GET /orgs/{org}/dependabot/alerts ` (one call), falling back to
41+ per-repo only on scope-missing. Keep it that way — per-repo CVE crawls inflate request
42+ count.
43+
44+ ## Defensively read every GraphQL response; never trust codegen's non-null types
45+
46+ GitHub omits ` search ` /` nodes ` or returns null leaves on timeouts and gateway hiccups,
47+ even though codegen types them non-null. Read every level through guards
48+ (` res?.search?.nodes ?? [] ` , ` pageInfo?.endCursor ?? null ` , skip a node missing
49+ ` repository ` ) — never dereference a field the server may omit. ` GithubClient.graphql `
50+ also recovers the partial ` data ` Octokit would otherwise discard from a thrown
51+ ` GraphqlResponseError ` . The cautionary case is the crash
52+ ` TypeError: undefined is not an object (evaluating 'res.nodes') ` .
53+
54+ ## Fail loudly via ` logger.error ` , never silently empty
55+
56+ When data can't be read or a response is malformed, ** ` logger.error ` ** it — that's the
57+ only level wired to Sentry (` pinoIntegration ` ), so it both surfaces the problem and lets
58+ us track how often collection degrades. This covers would-be crashes (missing non-null
59+ fields, an ` issueCount > 0 ` but ` 0 PRs mapped ` mismatch) and data-completeness
60+ degradation (a repo 403, scope-missing CVE, a salvaged partial response). Then degrade
61+ gracefully — record a ` CollectorWarning ` and proceed with what succeeded; do not
62+ manufacture a clean-looking zero. ` logger.warn ` is for incidental noise only.
0 commit comments