Skip to content

Commit 4d57eb0

Browse files
committed
refactor: simplify GitHub data collection to org-wide search
Replace the per-repo Dependabot PR crawl with a single org-wide GraphQL search, bisected by date to beat the 1000-result cap (see dates.ts). Drop the now-dead repoMetadata and prReadProbe collectors along with the branch-protection and Dependabot-config slices they fed, and remove the report's config-derived coverage metrics. Introduce collectAll to orchestrate the remaining collectors with partial-failure handling and buildReport to wrap crawl -> aggregate -> render. Regenerate GraphQL types and refresh the github-api-interaction rule to match.
1 parent 47ad3a3 commit 4d57eb0

28 files changed

Lines changed: 720 additions & 2036 deletions

.claude/rules/github-api-interaction.md

Lines changed: 51 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -9,55 +9,54 @@ How this CLI talks to `api.github.com`. These rules exist because violating them
99
silently returns **empty/zero results** (a confident, wrong $0) instead of failing
1010
loudly — 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.

src/collectors/collectAll.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { ResultAsync } from 'neverthrow';
2+
import pMap from 'p-map';
3+
import type { Context } from '../context/index.ts';
4+
import { type GithubError, formatGithubError } from '../github/errors.ts';
5+
import type { GithubClient } from '../github/GithubClient.ts';
6+
import type { Instant } from '../time.ts';
7+
import {
8+
type CollectedData,
9+
type CollectorWarning,
10+
type CveSlice,
11+
type DependabotPr,
12+
type RepoMeta,
13+
isActiveRepo,
14+
} from '../types.ts';
15+
import { getCveAlerts, getOrgCveAlerts } from './cve.ts';
16+
import { listDependabotPrs } from './dependabotPrs.ts';
17+
import type { TargetKind } from './repos.ts';
18+
19+
interface CollectInput {
20+
readonly repos: RepoMeta[];
21+
readonly target: string;
22+
readonly targetKind: TargetKind;
23+
readonly windowDays: number;
24+
readonly windowStart: Instant;
25+
readonly now: Instant;
26+
}
27+
28+
// Crawls every data slice for the target, tolerating per-slice failures: a failed
29+
// collector records a `CollectorWarning` and degrades to empty rather than aborting
30+
// the whole run (see error-handling-neverthrow.md's partial-failure boundary).
31+
export async function collectAll(ctx: Context, input: CollectInput): Promise<CollectedData> {
32+
const { repos, target, targetKind, windowDays, windowStart, now } = input;
33+
const { githubClient } = ctx;
34+
const warnings: CollectorWarning[] = [];
35+
36+
// `repos` is the raw listing; the CVE crawl scopes to active repos so we don't
37+
// spend calls on archived/forked ones. The report keeps the full list and does
38+
// its own active/excluded accounting.
39+
const cvePromise = collectCve(githubClient, target, targetKind, repos.filter(isActiveRepo), warnings);
40+
const prsPromise = collectFallible(
41+
listDependabotPrs(ctx, target, targetKind, windowStart.toString(), now.toString()),
42+
[] as DependabotPr[],
43+
warnings,
44+
'dependabotPrs',
45+
);
46+
const [cve, dependabotPrs] = await Promise.all([cvePromise, prsPromise]);
47+
48+
return {
49+
ctx: { org: target, windowDays, windowStart, now },
50+
repos,
51+
dependabotPrs,
52+
cve,
53+
errors: warnings,
54+
};
55+
}
56+
57+
async function collectCve(
58+
client: GithubClient,
59+
target: string,
60+
targetKind: TargetKind,
61+
repos: readonly RepoMeta[],
62+
warnings: CollectorWarning[],
63+
): Promise<CveSlice[]> {
64+
const perRepo = (): Promise<CveSlice[]> =>
65+
crawlPerRepo(repos, (r) => getCveAlerts(client, { owner: r.owner, name: r.name }), warnings, 'cve');
66+
67+
if (targetKind === 'user') return perRepo();
68+
// Try the org-level endpoint first (one call instead of N). On anything other
69+
// than scope-missing, fall back to per-repo so each repo gets a real status
70+
// determination instead of an empty list.
71+
const orgResult = await getOrgCveAlerts(client, target, repos);
72+
if (orgResult.isOk()) return orgResult.value;
73+
warnings.push({ collector: 'cve', message: formatGithubError(orgResult.error) });
74+
return perRepo();
75+
}
76+
77+
async function crawlPerRepo<T>(
78+
repos: readonly RepoMeta[],
79+
fn: (repo: RepoMeta) => ResultAsync<T, GithubError>,
80+
warnings: CollectorWarning[],
81+
collector: string,
82+
): Promise<T[]> {
83+
const results = await pMap(repos, async (repo) => ({ repo, result: await fn(repo) }), { concurrency: 8 });
84+
const ok: T[] = [];
85+
for (const { repo, result } of results) {
86+
if (result.isOk()) {
87+
ok.push(result.value);
88+
} else {
89+
warnings.push({
90+
collector,
91+
repo: { owner: repo.owner, name: repo.name },
92+
message: formatGithubError(result.error),
93+
});
94+
}
95+
}
96+
return ok;
97+
}
98+
99+
function collectFallible<T>(
100+
ra: ResultAsync<T, GithubError>,
101+
fallback: T,
102+
warnings: CollectorWarning[],
103+
collector: string,
104+
): Promise<T> {
105+
return ra.match(
106+
(value) => value,
107+
(error) => {
108+
warnings.push({ collector, message: formatGithubError(error) });
109+
return fallback;
110+
},
111+
);
112+
}

src/collectors/dependabotPrs.graphql

Lines changed: 0 additions & 62 deletions
This file was deleted.

0 commit comments

Comments
 (0)