From 4064b143bd428da4210d58469e691308f653f900 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Thu, 28 May 2026 10:14:01 -0600 Subject: [PATCH 1/7] fix(github): route GraphQL through throttled REST client; cap secondary rate-limit retries GraphQL calls were going through a separate `@octokit/graphql` client that shared the auth header but not the retry/throttle plugins, so a GraphQL burst could not respect the same rate-limit budget as REST. Route `graphql()` through `this.rest.graphql` so both transports share one throttled client. While here, mirror the primary-rate-limit cap on the secondary handler (`retryCount < 2`) instead of retrying indefinitely. --- src/github/GithubClient.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/github/GithubClient.ts b/src/github/GithubClient.ts index d3b1fce..a40b30d 100644 --- a/src/github/GithubClient.ts +++ b/src/github/GithubClient.ts @@ -1,5 +1,4 @@ import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; -import { graphql as graphqlBase } from '@octokit/graphql'; import type { PaginatingEndpoints } from '@octokit/plugin-paginate-rest'; import { retry } from '@octokit/plugin-retry'; import { throttling } from '@octokit/plugin-throttling'; @@ -43,7 +42,6 @@ export interface GithubClientImplOptions { export class GithubClientImpl implements GithubClient { private readonly rest: InstanceType; - private readonly graphqlClient: typeof graphqlBase; private readonly log: Logger; constructor(options: GithubClientImplOptions) { @@ -62,13 +60,9 @@ export class GithubClientImpl implements GithubClient { retry: { doNotRetry: [400, 401, 403, 404, 409, 422] }, throttle: { onRateLimit: (_retryAfter, _opts, _octokit, retryCount) => retryCount < 2, - onSecondaryRateLimit: () => true, + onSecondaryRateLimit: (_retryAfter, _opts, _octokit, retryCount) => retryCount < 2, }, }); - this.graphqlClient = graphqlBase.defaults({ - headers: { authorization: `token ${token}` }, - request: { log }, - }); } // Keep casts at the Octokit boundary; callers get route-derived types. @@ -99,6 +93,6 @@ export class GithubClientImpl implements GithubClient { document: TypedDocumentNode, variables: TVariables, ): ResultAsync { - return ResultAsync.fromPromise(this.graphqlClient(print(document), variables), toGithubError); + return ResultAsync.fromPromise(this.rest.graphql(print(document), variables), toGithubError); } } From 332b1e3e3cc5f29b1308b7a166b83a125a4c57a5 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Thu, 28 May 2026 10:14:21 -0600 Subject: [PATCH 2/7] chore(deps): swap @octokit/graphql for p-map; drop hand-rolled concurrency helper @octokit/graphql is no longer used now that GraphQL routes through the throttled REST client. Add p-map to replace the hand-rolled mapWithConcurrency helper in src/concurrency.ts. This commit only adjusts dependencies and removes the unused helper; the call-site swap to pMap happens in the commits that touch cli.ts and the batched metadata collector. --- bun.lock | 4 +++- package.json | 2 +- src/concurrency.ts | 22 ---------------------- 3 files changed, 4 insertions(+), 24 deletions(-) delete mode 100644 src/concurrency.ts diff --git a/bun.lock b/bun.lock index e4df20e..6350549 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,6 @@ "dependencies": { "@clack/prompts": "^1.4.0", "@js-temporal/polyfill": "^0.5.1", - "@octokit/graphql": "^9.0.3", "@octokit/plugin-retry": "^8.1.0", "@octokit/plugin-throttling": "^11.0.3", "@octokit/rest": "^22.0.1", @@ -18,6 +17,7 @@ "lucide-react": "^1.16.0", "neverthrow": "^8.2.0", "open": "^11.0.0", + "p-map": "^7.0.4", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "posthog-js": "^1.370.0", @@ -1464,6 +1464,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-filepath": ["parse-filepath@1.0.2", "", { "dependencies": { "is-absolute": "^1.0.0", "map-cache": "^0.2.0", "path-root": "^0.1.1" } }, "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q=="], diff --git a/package.json b/package.json index e97bd91..df37006 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "dependencies": { "@clack/prompts": "^1.4.0", "@js-temporal/polyfill": "^0.5.1", - "@octokit/graphql": "^9.0.3", "@octokit/plugin-retry": "^8.1.0", "@octokit/plugin-throttling": "^11.0.3", "@octokit/rest": "^22.0.1", @@ -95,6 +94,7 @@ "lucide-react": "^1.16.0", "neverthrow": "^8.2.0", "open": "^11.0.0", + "p-map": "^7.0.4", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "posthog-js": "^1.370.0", diff --git a/src/concurrency.ts b/src/concurrency.ts deleted file mode 100644 index 792c1e3..0000000 --- a/src/concurrency.ts +++ /dev/null @@ -1,22 +0,0 @@ -export async function mapWithConcurrency( - items: readonly T[], - limit: number, - fn: (item: T, index: number) => Promise, -): Promise { - const results: U[] = new Array(items.length); - let cursor = 0; - - async function worker(): Promise { - while (true) { - const i = cursor++; - if (i >= items.length) return; - const item = items[i]; - if (item === undefined) continue; - results[i] = await fn(item, i); - } - } - - const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker); - await Promise.all(workers); - return results; -} From 25af56c31ce1cf4529cf5c1546fe0276ffc505a8 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Thu, 28 May 2026 10:22:19 -0600 Subject: [PATCH 3/7] refactor: drop contributors collector and activeHumanCommitters metric The per-repo /commits crawl was the most expensive call in the run (one paginated request per repo) and the resulting "active human committers" count has not earned its keep. We never surface it in any narrative or recommendation; it's just a row in the methodology appendix. Remove the collector, the ContributorSlice type, the aggregated metric, and the per-repo /commits stubs in cli.test.ts. README copy that mentioned commit-history access and the active-humans line is updated accordingly. cli.ts loses one branch of the Promise.all in collectAll; the rest of the pipeline is untouched. --- README.md | 3 +- src/cli.test.ts | 2 -- src/cli.ts | 11 +------ src/collectors/contributors.test.ts | 35 --------------------- src/collectors/contributors.ts | 33 ------------------- src/report/aggregate.ts | 7 ----- src/report/testFactories.ts | 1 - src/report/web/acts/MethodologyAppendix.tsx | 1 - src/testFactories.ts | 8 ----- src/types.ts | 5 --- 10 files changed, 2 insertions(+), 104 deletions(-) delete mode 100644 src/collectors/contributors.test.ts delete mode 100644 src/collectors/contributors.ts diff --git a/README.md b/README.md index 24af50e..8533f4d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Easiest path: run `gh auth login` (via [GitHub CLI](https://cli.github.com)) and Prefer a custom token? Create a [fine-grained token](https://github.com/settings/personal-access-tokens/new) instead and set these repository permissions to read: -- **Contents:** commit history and the `dependabot.yml` config +- **Contents:** the `dependabot.yml` config - **Pull requests:** the Dependabot PR backlog - **Administration:** branch-protection and ruleset coverage - **Dependabot alerts:** the CVE numbers @@ -61,7 +61,6 @@ Everything comes from `api.github.com` over a fixed 90-day window. For the org a - Open Dependabot security alerts (needs the `security_events` scope) - Each repo's `.github/dependabot.yml` - Branch-protection and ruleset settings on the default branch -- Commit authors in the window, to count active humans All calls are read only. It writes nothing back to GitHub and pulls no file contents beyond the Dependabot config. diff --git a/src/cli.test.ts b/src/cli.test.ts index cef6792..aff9f5b 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -75,7 +75,6 @@ test('writes a report when the GitHub calls succeed', async () => { .onRequest('GET /repos/{owner}/{repo}/branches/{branch}/protection', {}) .fails({ kind: 'not-found', message: 'no protection' }); githubClient.onRequest('GET /repos/{owner}/{repo}/rules/branches/{branch}', {}).resolves([]); - githubClient.onPaginate('GET /repos/{owner}/{repo}/commits', {}).resolves([]); githubClient.onGraphql('DependabotPrs').resolves({ search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, }); @@ -158,7 +157,6 @@ test('excludes forked repos from the crawl', async () => { .onRequest('GET /repos/{owner}/{repo}/branches/{branch}/protection', {}) .fails({ kind: 'not-found', message: 'no protection' }); githubClient.onRequest('GET /repos/{owner}/{repo}/rules/branches/{branch}', {}).resolves([]); - githubClient.onPaginate('GET /repos/{owner}/{repo}/commits', {}).resolves([]); githubClient.onGraphql('DependabotPrs').resolves({ search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, }); diff --git a/src/cli.ts b/src/cli.ts index c127784..7ded601 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,6 @@ import { join } from 'node:path'; import { parseArgs } from 'node:util'; import { Result, ResultAsync } from 'neverthrow'; import { getBranchProtection } from './collectors/branchProtection.ts'; -import { listActiveCommitters } from './collectors/contributors.ts'; import { getCveAlerts } from './collectors/cve.ts'; import { getDependabotConfig } from './collectors/dependabotConfig.ts'; import { listDependabotPrs } from './collectors/dependabotPrs.ts'; @@ -22,7 +21,6 @@ import type { BranchProtectionSlice, CollectedData, CollectorWarning, - ContributorSlice, CveSlice, DependabotConfigSlice, DependabotPr, @@ -251,7 +249,7 @@ async function collectAll( const warnings: CollectorWarning[] = []; const windowStartIso = windowStart.toString(); - const [languages, dependabotConfig, cve, branchProtection, contributors, dependabotPrs] = await Promise.all([ + const [languages, dependabotConfig, cve, branchProtection, dependabotPrs] = await Promise.all([ crawlPerRepo(repos, (r) => getRepoLanguages(client, { owner: r.owner, name: r.name }), warnings, 'languages').then( (rows): RepoLanguages[] => rows.map((r) => ({ owner: r.ref.owner, name: r.ref.name, bytes: r.bytes })), ), @@ -268,12 +266,6 @@ async function collectAll( warnings, 'branchProtection', ), - crawlPerRepo( - repos, - (r) => listActiveCommitters(client, { owner: r.owner, name: r.name }, windowStartIso), - warnings, - 'contributors', - ), runResultAsync(listDependabotPrs(client, target, windowStartIso), [], warnings, 'dependabotPrs'), ]); @@ -285,7 +277,6 @@ async function collectAll( dependabotPrs, cve, branchProtection, - contributors, errors: warnings, }; } diff --git a/src/collectors/contributors.test.ts b/src/collectors/contributors.test.ts deleted file mode 100644 index e854ffa..0000000 --- a/src/collectors/contributors.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { expect, test } from 'bun:test'; -import { FakeGithubClient } from '../testHelpers/index.ts'; -import { listActiveCommitters } from './contributors.ts'; - -test('returns unique human committers, sorted, skipping bots', async () => { - const client = new FakeGithubClient(); - client.onPaginate('GET /repos/{owner}/{repo}/commits', {}).resolves([ - { author: { login: 'alice', type: 'User' }, commit: { author: { name: 'a', date: '' } } }, - { author: { login: 'bob', type: 'User' }, commit: { author: { name: 'b', date: '' } } }, - { author: { login: 'alice', type: 'User' }, commit: { author: { name: 'a', date: '' } } }, - { author: { login: 'dependabot[bot]', type: 'Bot' }, commit: { author: { name: 'd', date: '' } } }, - { author: { login: 'renovate[bot]', type: 'User' }, commit: { author: { name: 'r', date: '' } } }, - { author: null, commit: { author: null } }, - { author: { type: 'User' }, commit: { author: { name: 'e', date: '' } } }, - ]); - - const result = await listActiveCommitters(client, { owner: 'acme', name: 'widgets' }, '2026-01-01T00:00:00Z'); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toMatchObject({ - owner: 'acme', - name: 'widgets', - activeHumanLogins: ['alice', 'bob'], - }); - } -}); - -test('propagates errors instead of swallowing them', async () => { - const client = new FakeGithubClient(); - client.onPaginate('GET /repos/{owner}/{repo}/commits', {}).fails({ kind: 'forbidden', message: 'no access' }); - - const result = await listActiveCommitters(client, { owner: 'acme', name: 'widgets' }, '2026-01-01T00:00:00Z'); - expect(result.isErr()).toBe(true); - if (result.isErr()) expect(result.error).toMatchObject({ kind: 'forbidden' }); -}); diff --git a/src/collectors/contributors.ts b/src/collectors/contributors.ts deleted file mode 100644 index c01d923..0000000 --- a/src/collectors/contributors.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ResultAsync } from 'neverthrow'; -import { z } from 'zod'; -import type { GithubError } from '../github/errors.ts'; -import type { GithubClient } from '../github/GithubClient.ts'; -import type { ContributorSlice, RepoRef } from '../types.ts'; - -// Treat unmatched/deleted GitHub authors as anonymous and skip them. -const commitSchema = z.object({ - author: z.object({ login: z.string(), type: z.string().optional() }).nullable().catch(null), -}); - -export function listActiveCommitters( - client: GithubClient, - ref: RepoRef, - windowStartIso: string, -): ResultAsync { - return client - .paginate( - 'GET /repos/{owner}/{repo}/commits', - { owner: ref.owner, repo: ref.name, since: windowStartIso, per_page: 100 }, - commitSchema, - ) - .map((commits) => { - const logins = new Set(); - for (const { author } of commits) { - if (!author) continue; - if (author.type === 'Bot') continue; - if (author.login.endsWith('[bot]')) continue; - logins.add(author.login); - } - return { ...ref, activeHumanLogins: [...logins].sort() }; - }); -} diff --git a/src/report/aggregate.ts b/src/report/aggregate.ts index 600f299..ed58cdc 100644 --- a/src/report/aggregate.ts +++ b/src/report/aggregate.ts @@ -37,7 +37,6 @@ export interface OrgOverview { topLanguages: Array<{ language: string; bytes: number; percentage: number }>; nodeTsRepoCount: number; nodeTsRepoPercentage: number; - activeHumanCommitters: number; reposWithBranchProtection: number; } @@ -163,11 +162,6 @@ function buildOrgOverview(data: CollectedData): OrgOverview { (r) => r.primaryLanguage === 'TypeScript' || r.primaryLanguage === 'JavaScript', ).length; - const allCommitters = new Set(); - for (const slice of data.contributors) { - for (const login of slice.activeHumanLogins) allCommitters.add(login); - } - const reposWithBranchProtection = data.branchProtection.filter((b) => b.hasProtection).length; return { @@ -179,7 +173,6 @@ function buildOrgOverview(data: CollectedData): OrgOverview { topLanguages, nodeTsRepoCount, nodeTsRepoPercentage: pct(nodeTsRepoCount, repos.length), - activeHumanCommitters: allCommitters.size, reposWithBranchProtection, }; } diff --git a/src/report/testFactories.ts b/src/report/testFactories.ts index c4b4a25..d7db109 100644 --- a/src/report/testFactories.ts +++ b/src/report/testFactories.ts @@ -31,7 +31,6 @@ export const orgOverview = Factory.define(() => ({ topLanguages: [{ language: 'TypeScript', bytes: 2_000_000, percentage: 65 }], nodeTsRepoCount: 18, nodeTsRepoPercentage: 75, - activeHumanCommitters: 17, reposWithBranchProtection: 14, })); diff --git a/src/report/web/acts/MethodologyAppendix.tsx b/src/report/web/acts/MethodologyAppendix.tsx index 2142d67..d2b566c 100644 --- a/src/report/web/acts/MethodologyAppendix.tsx +++ b/src/report/web/acts/MethodologyAppendix.tsx @@ -158,7 +158,6 @@ export function MethodologyAppendix() { `${org.repoCount} active (${org.publicCount} public, ${org.privateCount} private, ${org.internalCount} internal)`, ], ['Archived excluded', org.archivedExcluded.toLocaleString()], - ['Active human committers', org.activeHumanCommitters.toLocaleString()], ['Repos with branch protection', org.reposWithBranchProtection.toLocaleString()], ]} /> diff --git a/src/testFactories.ts b/src/testFactories.ts index a614221..626e775 100644 --- a/src/testFactories.ts +++ b/src/testFactories.ts @@ -6,7 +6,6 @@ import type { CollectedData, CollectionContext, CollectorWarning, - ContributorSlice, CveAlert, CveSlice, DependabotConfigSlice, @@ -89,12 +88,6 @@ export const branchProtectionSlice = Factory.define(() => requiresStatusChecks: true, })); -export const contributorSlice = Factory.define(() => ({ - owner: 'acme', - name: 'widgets', - activeHumanLogins: [], -})); - export const dependabotUpdateEntry = Factory.define(() => ({ ecosystem: 'npm', interval: 'weekly', @@ -138,6 +131,5 @@ export const collectedData = Factory.define(() => ({ dependabotPrs: [], cve: [cveSliceOk.build()], branchProtection: [branchProtectionSlice.build()], - contributors: [contributorSlice.build()], errors: [], })); diff --git a/src/types.ts b/src/types.ts index 2f0297e..ef39a88 100644 --- a/src/types.ts +++ b/src/types.ts @@ -98,10 +98,6 @@ export interface BranchProtectionSlice extends RepoRef { requiresStatusChecks: boolean; } -export interface ContributorSlice extends RepoRef { - activeHumanLogins: string[]; -} - export interface CollectionContext { org: string; windowDays: number; @@ -117,7 +113,6 @@ export interface CollectedData { dependabotPrs: DependabotPr[]; cve: CveSlice[]; branchProtection: BranchProtectionSlice[]; - contributors: ContributorSlice[]; errors: CollectorWarning[]; } From c361bbefac7b72b599b77dc9eb49e0982bcdbb50 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Thu, 28 May 2026 10:27:02 -0600 Subject: [PATCH 4/7] refactor: drop per-repo /languages call; switch to primaryLanguage for the breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reasons to ditch the per-repo /languages crawl: 1. It's an extra request per repo for very low information density. We used it to compute a byte-weighted "language mix" panel, but that answer skews toward whichever repo has the biggest checkout (often a tree of vendored deps) rather than what teams actually own. 2. We already get a cheaper, more useful signal — `primaryLanguage` — from the org-level repo listing. Switch the report to a repo-count breakdown keyed on `primaryLanguage`, rename the panel from "Language mix" to "Primary language by repo", and update the orgOverview fixture / story data to use the new shape. Remove the now-unused RepoLanguages/LanguageBytes types, the repoLanguages factory, the bytes formatter (only consumer is gone), and the /languages stubs in cli.test.ts. --- README.md | 2 +- src/cli.test.ts | 2 -- src/cli.ts | 9 ++---- src/report/aggregate.test.ts | 17 +++++++++++ src/report/aggregate.ts | 31 ++++++++------------- src/report/testFactories.ts | 2 +- src/report/web/App.stories.tsx | 8 +++--- src/report/web/acts/MethodologyAppendix.tsx | 5 ++-- src/report/web/format/bytes.ts | 7 ----- src/testFactories.ts | 8 ------ src/types.ts | 9 ------ 11 files changed, 38 insertions(+), 62 deletions(-) delete mode 100644 src/report/web/format/bytes.ts diff --git a/README.md b/README.md index 8533f4d..d752abc 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The report covers: Everything comes from `api.github.com` over a fixed 90-day window. For the org and its repos (archived repos and forks are skipped), it reads: -- The repo list, visibility, and language breakdown +- The repo list, visibility, and primary language metadata - Dependabot PRs in the window, including state, timing, reviews, and CI status - Open Dependabot security alerts (needs the `security_events` scope) - Each repo's `.github/dependabot.yml` diff --git a/src/cli.test.ts b/src/cli.test.ts index aff9f5b..c35e589 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -65,7 +65,6 @@ test('writes a report when the GitHub calls succeed', async () => { pushed_at: '2026-04-01T00:00:00Z', }, ]); - githubClient.onRequest('GET /repos/{owner}/{repo}/languages', {}).resolves({ TypeScript: 1000 }); githubClient.onRequest('GET /repos/{owner}/{repo}/contents/{path}', {}).resolves({ content: Buffer.from('updates:\n - package-ecosystem: "npm"\n', 'utf8').toString('base64'), encoding: 'base64', @@ -147,7 +146,6 @@ test('excludes forked repos from the crawl', async () => { pushed_at: '2026-04-01T00:00:00Z', }, ]); - githubClient.onRequest('GET /repos/{owner}/{repo}/languages', {}).resolves({ TypeScript: 1000 }); githubClient.onRequest('GET /repos/{owner}/{repo}/contents/{path}', {}).resolves({ content: Buffer.from('updates:\n - package-ecosystem: "npm"\n', 'utf8').toString('base64'), encoding: 'base64', diff --git a/src/cli.ts b/src/cli.ts index 7ded601..5cb9f65 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import { getBranchProtection } from './collectors/branchProtection.ts'; import { getCveAlerts } from './collectors/cve.ts'; import { getDependabotConfig } from './collectors/dependabotConfig.ts'; import { listDependabotPrs } from './collectors/dependabotPrs.ts'; -import { getRepoLanguages, listOrgRepos } from './collectors/repos.ts'; +import { listOrgRepos } from './collectors/repos.ts'; import { mapWithConcurrency } from './concurrency.ts'; import type { Context } from './context.ts'; import { getErrorMessage } from './errors.ts'; @@ -24,7 +24,6 @@ import type { CveSlice, DependabotConfigSlice, DependabotPr, - RepoLanguages, RepoMeta, } from './types.ts'; @@ -249,10 +248,7 @@ async function collectAll( const warnings: CollectorWarning[] = []; const windowStartIso = windowStart.toString(); - const [languages, dependabotConfig, cve, branchProtection, dependabotPrs] = await Promise.all([ - crawlPerRepo(repos, (r) => getRepoLanguages(client, { owner: r.owner, name: r.name }), warnings, 'languages').then( - (rows): RepoLanguages[] => rows.map((r) => ({ owner: r.ref.owner, name: r.ref.name, bytes: r.bytes })), - ), + const [dependabotConfig, cve, branchProtection, dependabotPrs] = await Promise.all([ crawlPerRepo( repos, (r) => getDependabotConfig(client, { owner: r.owner, name: r.name }), @@ -272,7 +268,6 @@ async function collectAll( return { ctx: { org: target, windowDays, windowStart, now }, repos, - languages, dependabotConfig, dependabotPrs, cve, diff --git a/src/report/aggregate.test.ts b/src/report/aggregate.test.ts index 714a902..3804333 100644 --- a/src/report/aggregate.test.ts +++ b/src/report/aggregate.test.ts @@ -81,6 +81,23 @@ test('rolls org/visibility/language counts up into orgOverview', () => { }); }); +test('topLanguages is a repo-count breakdown using primaryLanguage', () => { + const data = collectedData.build({ + repos: [ + repoMeta.build({ owner: 'acme', name: 'a', primaryLanguage: 'TypeScript' }), + repoMeta.build({ owner: 'acme', name: 'b', primaryLanguage: 'TypeScript' }), + repoMeta.build({ owner: 'acme', name: 'c', primaryLanguage: 'Go' }), + repoMeta.build({ owner: 'acme', name: 'd', primaryLanguage: null }), + ], + }); + + const bundle = aggregate(data); + expect(bundle.orgOverview.topLanguages).toEqual([ + { language: 'TypeScript', repoCount: 2, percentage: 66.7 }, + { language: 'Go', repoCount: 1, percentage: 33.3 }, + ]); +}); + test('emits a scope-missing CVE exposure when any slice signals scope-missing', () => { const data = collectedData.build({ cve: [{ owner: 'acme', name: 'widgets', status: 'scope-missing', requiredScope: 'security_events' }], diff --git a/src/report/aggregate.ts b/src/report/aggregate.ts index ed58cdc..e060dd3 100644 --- a/src/report/aggregate.ts +++ b/src/report/aggregate.ts @@ -1,13 +1,6 @@ import { classifyBumpType, isDevDependencyBump } from '../heuristics/bumpType.ts'; import { type Instant, Temporal, instantFromString } from '../time.ts'; -import type { - CollectedData, - CveAlert, - CveSeverity, - DependabotConfigSlice, - DependabotPr, - LanguageBytes, -} from '../types.ts'; +import type { CollectedData, CveAlert, CveSeverity, DependabotConfigSlice, DependabotPr } from '../types.ts'; import { ASSUMED_HOURLY_RATE_USD, ASSUMED_MIN_PER_PR, deriveCostEstimate, derivePersonCosts } from './costFormulas.ts'; export interface ReportBundle { @@ -34,7 +27,7 @@ export interface OrgOverview { privateCount: number; internalCount: number; archivedExcluded: number; - topLanguages: Array<{ language: string; bytes: number; percentage: number }>; + topLanguages: Array<{ language: string; repoCount: number; percentage: number }>; nodeTsRepoCount: number; nodeTsRepoPercentage: number; reposWithBranchProtection: number; @@ -142,20 +135,18 @@ function buildOrgOverview(data: CollectedData): OrgOverview { const privateCount = repos.filter((r) => r.visibility === 'private').length; const internalCount = repos.filter((r) => r.visibility === 'internal').length; - const aggregateBytes: LanguageBytes = {}; - for (const lang of data.languages) { - for (const [name, bytes] of Object.entries(lang.bytes)) { - aggregateBytes[name] = (aggregateBytes[name] ?? 0) + bytes; - } + const langCounts = new Map(); + for (const r of repos) { + if (r.primaryLanguage) langCounts.set(r.primaryLanguage, (langCounts.get(r.primaryLanguage) ?? 0) + 1); } - const totalBytes = Object.values(aggregateBytes).reduce((a, b) => a + b, 0); - const topLanguages = Object.entries(aggregateBytes) - .map(([language, bytes]) => ({ + const totalLangRepos = [...langCounts.values()].reduce((a, b) => a + b, 0); + const topLanguages = [...langCounts.entries()] + .map(([language, repoCount]) => ({ language, - bytes, - percentage: totalBytes > 0 ? round1((bytes / totalBytes) * 100) : 0, + repoCount, + percentage: totalLangRepos > 0 ? round1((repoCount / totalLangRepos) * 100) : 0, })) - .sort((a, b) => b.bytes - a.bytes) + .sort((a, b) => b.repoCount - a.repoCount) .slice(0, 10); const nodeTsRepoCount = repos.filter( diff --git a/src/report/testFactories.ts b/src/report/testFactories.ts index d7db109..81353da 100644 --- a/src/report/testFactories.ts +++ b/src/report/testFactories.ts @@ -28,7 +28,7 @@ export const orgOverview = Factory.define(() => ({ privateCount: 21, internalCount: 0, archivedExcluded: 1, - topLanguages: [{ language: 'TypeScript', bytes: 2_000_000, percentage: 65 }], + topLanguages: [{ language: 'TypeScript', repoCount: 18, percentage: 100 }], nodeTsRepoCount: 18, nodeTsRepoPercentage: 75, reposWithBranchProtection: 14, diff --git a/src/report/web/App.stories.tsx b/src/report/web/App.stories.tsx index 277b1a8..824440a 100644 --- a/src/report/web/App.stories.tsx +++ b/src/report/web/App.stories.tsx @@ -32,10 +32,10 @@ const sampleReport = toEmbeddedShape( reportBundle.build({ orgOverview: orgOverview.build({ topLanguages: [ - { language: 'TypeScript', bytes: 4_200_000, percentage: 58 }, - { language: 'Go', bytes: 1_600_000, percentage: 22 }, - { language: 'Python', bytes: 880_000, percentage: 12 }, - { language: 'Ruby', bytes: 560_000, percentage: 8 }, + { language: 'TypeScript', repoCount: 14, percentage: 58.3 }, + { language: 'Go', repoCount: 5, percentage: 20.8 }, + { language: 'Python', repoCount: 3, percentage: 12.5 }, + { language: 'Ruby', repoCount: 2, percentage: 8.3 }, ], }), cve: cveExposureOk.build({ diff --git a/src/report/web/acts/MethodologyAppendix.tsx b/src/report/web/acts/MethodologyAppendix.tsx index d2b566c..c025ee0 100644 --- a/src/report/web/acts/MethodologyAppendix.tsx +++ b/src/report/web/acts/MethodologyAppendix.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react'; import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx'; -import { fmtBytes } from '../format/bytes.ts'; import { fmtUsd } from '../format/money.ts'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; import { type MethodologyTab, useAssumptionsDisclosure } from '../hooks/useAssumptionsDisclosure.tsx'; @@ -253,13 +252,13 @@ export function MethodologyAppendix() { {org.topLanguages.length > 0 && ( - +
    {org.topLanguages.map((l) => (
  • {l.language} - {fmtBytes(l.bytes)} ({l.percentage}%) + {l.repoCount.toLocaleString()} repos ({l.percentage}%)
  • ))} diff --git a/src/report/web/format/bytes.ts b/src/report/web/format/bytes.ts deleted file mode 100644 index f4afe7e..0000000 --- a/src/report/web/format/bytes.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function fmtBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - const kb = bytes / 1024; - if (kb < 1024) return `${kb.toFixed(1)} KB`; - const mb = kb / 1024; - return `${mb.toFixed(1)} MB`; -} diff --git a/src/testFactories.ts b/src/testFactories.ts index 626e775..c5269a9 100644 --- a/src/testFactories.ts +++ b/src/testFactories.ts @@ -11,7 +11,6 @@ import type { DependabotConfigSlice, DependabotPr, DependabotUpdateEntry, - RepoLanguages, RepoMeta, RepoRef, } from './types.ts'; @@ -104,12 +103,6 @@ export const dependabotConfigSlice = Factory.define(() => updates: [dependabotUpdateEntry.build()], })); -export const repoLanguages = Factory.define(() => ({ - owner: 'acme', - name: 'widgets', - bytes: { TypeScript: 100_000, JavaScript: 20_000 }, -})); - export const collectorWarning = Factory.define(() => ({ collector: 'branchProtection', repo: { owner: 'acme', name: 'widgets' }, @@ -126,7 +119,6 @@ export const collectionContext = Factory.define(() => ({ export const collectedData = Factory.define(() => ({ ctx: collectionContext.build(), repos: [repoMeta.build()], - languages: [repoLanguages.build()], dependabotConfig: [dependabotConfigSlice.build()], dependabotPrs: [], cve: [cveSliceOk.build()], diff --git a/src/types.ts b/src/types.ts index ef39a88..3c7400d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,14 +17,6 @@ export interface RepoMeta extends RepoRef { dependabotSecurityUpdates: boolean | null; } -export interface LanguageBytes { - [language: string]: number; -} - -export interface RepoLanguages extends RepoRef { - bytes: LanguageBytes; -} - export type DependabotEcosystem = string; export type DependabotInterval = 'daily' | 'weekly' | 'monthly'; @@ -108,7 +100,6 @@ export interface CollectionContext { export interface CollectedData { ctx: CollectionContext; repos: RepoMeta[]; - languages: RepoLanguages[]; dependabotConfig: DependabotConfigSlice[]; dependabotPrs: DependabotPr[]; cve: CveSlice[]; From eb3fc2ddebbd6e38e1619a6a8107911904c244b8 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Thu, 28 May 2026 10:28:14 -0600 Subject: [PATCH 5/7] feat(repos): enrich repo listing with target kind, nodeId, and dependabotAlertsEnabled; drop listOrgRepos shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things at once because they're tightly coupled: 1. **Add `targetKind`** by replacing `listOrgRepos` with `listTargetRepos`, which returns `{ kind: 'org' | 'user', repos }`. Downstream callers can now branch on whether the target is an org (so we can hit org-level endpoints like Dependabot alerts) versus a personal namespace (which only has the per-repo endpoints). 2. **Surface `dependabotAlertsEnabled`** so the report can distinguish "alerts off → 0 CVEs we can see" from "alerts on, 0 alerts → clean repo." The previous response only carried `dependabotSecurityUpdates`. 3. **Add `nodeId`** to RepoMeta. Required for the batched GraphQL metadata collector that lands in a follow-up commit — it queries `nodes(ids: [ID!])` instead of building per-repo aliases. The compat `listOrgRepos` wrapper is removed in the same commit since this is the introducing change; nothing else in the tree calls it. Tests, types, and the top-level factory get the new fields. --- src/collectors/repos.test.ts | 83 +++++++++++++++++++----------------- src/collectors/repos.ts | 55 +++++++++++++----------- src/testFactories.ts | 2 + src/types.ts | 2 + 4 files changed, 78 insertions(+), 64 deletions(-) diff --git a/src/collectors/repos.test.ts b/src/collectors/repos.test.ts index b34ee8d..2813909 100644 --- a/src/collectors/repos.test.ts +++ b/src/collectors/repos.test.ts @@ -1,12 +1,13 @@ import { expect, test } from 'bun:test'; import { FakeGithubClient } from '../testHelpers/index.ts'; -import { getRepoLanguages, listOrgRepos } from './repos.ts'; +import { listTargetRepos } from './repos.ts'; -test('maps raw repo payloads into RepoMeta and infers visibility', async () => { +test('listTargetRepos returns kind=org and maps raw repos including dependabotAlertsEnabled', async () => { const client = new FakeGithubClient(); client.onPaginate('GET /orgs/{org}/repos', {}).resolves([ { name: 'widgets', + node_id: 'R_kgDOwidgets', owner: { login: 'acme' }, private: false, visibility: 'public', @@ -15,49 +16,64 @@ test('maps raw repo payloads into RepoMeta and infers visibility', async () => { default_branch: 'main', language: 'TypeScript', pushed_at: '2026-04-01T00:00:00Z', - security_and_analysis: { dependabot_security_updates: { status: 'enabled' } }, + security_and_analysis: { + dependabot_alerts: { status: 'enabled' }, + dependabot_security_updates: { status: 'enabled' }, + }, }, { - name: 'internal-tool', + name: 'legacy', + node_id: 'R_kgDOlegacy', owner: { login: 'acme' }, private: true, - visibility: 'internal', - archived: true, - fork: true, + visibility: 'private', + archived: false, + fork: false, + default_branch: 'main', + language: null, + pushed_at: null, + security_and_analysis: { + dependabot_alerts: { status: 'disabled' }, + }, + }, + { + name: 'unknown-status', + node_id: 'R_kgDOunknown', + owner: { login: 'acme' }, + private: true, + visibility: 'private', + archived: false, + fork: false, default_branch: 'main', language: null, pushed_at: null, }, ]); - const result = await listOrgRepos(client, 'acme'); + const result = await listTargetRepos(client, 'acme'); expect(result.isOk()).toBe(true); if (result.isOk()) { - const repos = result.value; - expect(repos).toHaveLength(2); - expect(repos[0]).toMatchObject({ + expect(result.value.kind).toBe('org'); + expect(result.value.repos).toHaveLength(3); + expect(result.value.repos[0]).toMatchObject({ owner: 'acme', name: 'widgets', - visibility: 'public', - archived: false, - fork: false, + nodeId: 'R_kgDOwidgets', + dependabotAlertsEnabled: true, dependabotSecurityUpdates: true, }); - expect(repos[1]).toMatchObject({ - visibility: 'internal', - archived: true, - fork: true, - dependabotSecurityUpdates: null, - }); + expect(result.value.repos[1]).toMatchObject({ dependabotAlertsEnabled: false }); + expect(result.value.repos[2]).toMatchObject({ dependabotAlertsEnabled: null }); } }); -test('falls back to the user endpoint when the org endpoint 404s', async () => { +test('listTargetRepos returns kind=user when the org endpoint 404s', async () => { const client = new FakeGithubClient(); client.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'not-found', message: 'no org' }); client.onPaginate('GET /users/{username}/repos', {}).resolves([ { name: 'solo', + node_id: 'R_kgDOsolo', owner: { login: 'blimmer' }, private: false, visibility: 'public', @@ -69,30 +85,19 @@ test('falls back to the user endpoint when the org endpoint 404s', async () => { }, ]); - const result = await listOrgRepos(client, 'blimmer'); + const result = await listTargetRepos(client, 'blimmer'); expect(result.isOk()).toBe(true); - if (result.isOk()) expect(result.value[0]).toMatchObject({ owner: 'blimmer', name: 'solo' }); + if (result.isOk()) { + expect(result.value.kind).toBe('user'); + expect(result.value.repos[0]).toMatchObject({ owner: 'blimmer', name: 'solo' }); + } }); -test('propagates non-404 errors from listOrgRepos', async () => { +test('listTargetRepos propagates non-404 errors', async () => { const client = new FakeGithubClient(); client.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'forbidden', message: 'no access' }); - const result = await listOrgRepos(client, 'acme'); + const result = await listTargetRepos(client, 'acme'); expect(result.isErr()).toBe(true); if (result.isErr()) expect(result.error).toMatchObject({ kind: 'forbidden' }); }); - -test('getRepoLanguages returns bytes keyed by language', async () => { - const client = new FakeGithubClient(); - client.onRequest('GET /repos/{owner}/{repo}/languages', {}).resolves({ TypeScript: 1000, JavaScript: 200 }); - - const result = await getRepoLanguages(client, { owner: 'acme', name: 'widgets' }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toEqual({ - ref: { owner: 'acme', name: 'widgets' }, - bytes: { TypeScript: 1000, JavaScript: 200 }, - }); - } -}); diff --git a/src/collectors/repos.ts b/src/collectors/repos.ts index 99a72cc..e39b5fd 100644 --- a/src/collectors/repos.ts +++ b/src/collectors/repos.ts @@ -2,12 +2,11 @@ import { ResultAsync, errAsync } from 'neverthrow'; import { z } from 'zod'; import type { GithubError } from '../github/errors.ts'; import type { GithubClient } from '../github/GithubClient.ts'; -import type { RepoMeta, RepoRef, Visibility } from '../types.ts'; +import type { RepoMeta, Visibility } from '../types.ts'; -// Keep repo validation permissive; dropping repos for omitted metadata would -// skew the report more than defaulting those fields. const repoSchema = z.object({ name: z.string(), + node_id: z.string(), owner: z.object({ login: z.string() }), private: z.boolean().optional(), visibility: z.string().optional(), @@ -17,48 +16,54 @@ const repoSchema = z.object({ language: z.string().nullish(), pushed_at: z.string().nullish(), security_and_analysis: z - .object({ dependabot_security_updates: z.object({ status: z.enum(['enabled', 'disabled']) }).optional() }) + .object({ + dependabot_alerts: z.object({ status: z.enum(['enabled', 'disabled']) }).optional(), + dependabot_security_updates: z.object({ status: z.enum(['enabled', 'disabled']) }).optional(), + }) .nullish(), }); type RawRepo = z.infer; -export function listOrgRepos(client: GithubClient, org: string): ResultAsync { +export type TargetKind = 'org' | 'user'; + +export interface TargetReposResult { + readonly kind: TargetKind; + readonly repos: RepoMeta[]; +} + +export function listTargetRepos(client: GithubClient, target: string): ResultAsync { return client - .paginate('GET /orgs/{org}/repos', { org, per_page: 100, type: 'all' }, repoSchema) + .paginate('GET /orgs/{org}/repos', { org: target, per_page: 100, type: 'all' }, repoSchema) + .map((repos): TargetReposResult => ({ kind: 'org', repos: repos.map(toRepoMeta) })) .orElse((err) => { if (err.kind === 'not-found') { - return client.paginate( - 'GET /users/{username}/repos', - { username: org, per_page: 100, type: 'owner' }, - repoSchema, - ); + return client + .paginate('GET /users/{username}/repos', { username: target, per_page: 100, type: 'owner' }, repoSchema) + .map((repos): TargetReposResult => ({ kind: 'user', repos: repos.map(toRepoMeta) })); } - return errAsync(err); - }) - .map((repos) => repos.map(toRepoMeta)); -} - -export function getRepoLanguages( - client: GithubClient, - ref: RepoRef, -): ResultAsync<{ ref: RepoRef; bytes: Record }, GithubError> { - return client - .request('GET /repos/{owner}/{repo}/languages', { owner: ref.owner, repo: ref.name }) - .map((bytes) => ({ ref, bytes })); + return errAsync(err); + }); } function toRepoMeta(raw: RawRepo): RepoMeta { const visibility: Visibility = raw.visibility === 'internal' ? 'internal' : raw.private ? 'private' : 'public'; - const securityUpdates = raw.security_and_analysis?.dependabot_security_updates?.status; + const security = raw.security_and_analysis; return { owner: raw.owner.login, name: raw.name, + nodeId: raw.node_id, visibility, archived: raw.archived ?? false, fork: raw.fork ?? false, defaultBranch: raw.default_branch ?? 'main', primaryLanguage: raw.language ?? null, pushedAt: raw.pushed_at ?? null, - dependabotSecurityUpdates: securityUpdates === undefined ? null : securityUpdates === 'enabled', + dependabotSecurityUpdates: toBoolStatus(security?.dependabot_security_updates?.status), + dependabotAlertsEnabled: toBoolStatus(security?.dependabot_alerts?.status), }; } + +function toBoolStatus(status: 'enabled' | 'disabled' | undefined): boolean | null { + if (status === undefined) return null; + return status === 'enabled'; +} diff --git a/src/testFactories.ts b/src/testFactories.ts index c5269a9..4c85203 100644 --- a/src/testFactories.ts +++ b/src/testFactories.ts @@ -23,6 +23,7 @@ export const repoRef = Factory.define(() => ({ export const repoMeta = Factory.define(() => ({ owner: 'acme', name: 'widgets', + nodeId: 'R_kgDOwidgets', visibility: 'private', archived: false, fork: false, @@ -30,6 +31,7 @@ export const repoMeta = Factory.define(() => ({ primaryLanguage: 'TypeScript', pushedAt: '2026-04-01T00:00:00Z', dependabotSecurityUpdates: true, + dependabotAlertsEnabled: true, })); export const checkSummary = Factory.define(() => ({ diff --git a/src/types.ts b/src/types.ts index 3c7400d..e7ab026 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,7 @@ export interface RepoRef { } export interface RepoMeta extends RepoRef { + nodeId: string; visibility: Visibility; archived: boolean; fork: boolean; @@ -15,6 +16,7 @@ export interface RepoMeta extends RepoRef { primaryLanguage: string | null; pushedAt: string | null; dependabotSecurityUpdates: boolean | null; + dependabotAlertsEnabled: boolean | null; } export type DependabotEcosystem = string; From 92a3a5413d3e882d5633a424e989ad103862d538 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Thu, 28 May 2026 10:30:58 -0600 Subject: [PATCH 6/7] feat(cve): add org-level Dependabot alert collector and use it for org targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Org-wide CVE collection previously fanned out to N per-repo `GET /repos/{owner}/{repo}/dependabot/alerts` calls. For orgs that's silly — `GET /orgs/{org}/dependabot/alerts` returns the same data in one paginated call. Add `getOrgCveAlerts` that: - queries the org-level endpoint - buckets the response by `repository.{owner,name}` - emits one `CveSlice` per repo in `repos`, marking `not-enabled` where RepoMeta.dependabotAlertsEnabled === false so the report distinguishes "alerts off" from "alerts on, no findings" - on `scope-missing`, degrades softly to a scope-missing slice per repo (matching the per-repo collector's contract) Wire the CLI to branch on `targetKind`: org targets use the new collector, user targets keep the per-repo path because there is no user-level alerts endpoint. The new "user-target uses per-repo endpoint" test guards that branch. The org/user partial-failure asymmetry comment in `collectAll` documents a known gap: a hard failure of the org-level call surfaces one warning and zero alerts, instead of N `not-enabled` markers. PR 2's graceful-degradation work will close it. --- src/cli.test.ts | 83 ++++++++++++++++++++-- src/cli.ts | 24 +++++-- src/collectors/cve.test.ts | 139 ++++++++++++++++++++++++++++++++++++- src/collectors/cve.ts | 62 ++++++++++++++++- 4 files changed, 297 insertions(+), 11 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index c35e589..532c9be 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -69,7 +69,7 @@ test('writes a report when the GitHub calls succeed', async () => { content: Buffer.from('updates:\n - package-ecosystem: "npm"\n', 'utf8').toString('base64'), encoding: 'base64', }); - githubClient.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).resolves([]); + githubClient.onPaginate('GET /orgs/{org}/dependabot/alerts', {}).resolves([]); githubClient .onRequest('GET /repos/{owner}/{repo}/branches/{branch}/protection', {}) .fails({ kind: 'not-found', message: 'no protection' }); @@ -150,7 +150,7 @@ test('excludes forked repos from the crawl', async () => { content: Buffer.from('updates:\n - package-ecosystem: "npm"\n', 'utf8').toString('base64'), encoding: 'base64', }); - githubClient.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).resolves([]); + githubClient.onPaginate('GET /orgs/{org}/dependabot/alerts', {}).resolves([]); githubClient .onRequest('GET /repos/{owner}/{repo}/branches/{branch}/protection', {}) .fails({ kind: 'not-found', message: 'no protection' }); @@ -168,7 +168,82 @@ test('excludes forked repos from the crawl', async () => { }); }); -test('captures run_failed when listOrgRepos fails', async () => { +test('uses the per-repo CVE endpoint for user targets', async () => { + const { ctx, githubClient } = createFakeContext(); + + // User-target fallthrough: /orgs/{org}/repos 404s, /users/{username}/repos succeeds. + githubClient.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'not-found', message: 'no org' }); + githubClient.onPaginate('GET /users/{username}/repos', {}).resolves([ + { + name: 'solo', + owner: { login: 'blimmer' }, + private: false, + visibility: 'public', + archived: false, + fork: false, + default_branch: 'main', + language: 'TypeScript', + pushed_at: '2026-04-01T00:00:00Z', + }, + ]); + githubClient.onRequest('GET /repos/{owner}/{repo}/contents/{path}', {}).resolves({ + content: Buffer.from('updates:\n - package-ecosystem: "npm"\n', 'utf8').toString('base64'), + encoding: 'base64', + }); + // Per-repo CVE endpoint — the path Task 4 keeps for user targets. + githubClient.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).resolves([]); + githubClient + .onRequest('GET /repos/{owner}/{repo}/branches/{branch}/protection', {}) + .fails({ kind: 'not-found', message: 'no protection' }); + githubClient.onRequest('GET /repos/{owner}/{repo}/rules/branches/{branch}', {}).resolves([]); + githubClient.onGraphql('DependabotPrs').resolves({ + search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }); + + const result = await main(ctx, ['blimmer']); + expect(result.kind).toBe('completed'); + + const paginateRoutes = githubClient.callsTo('paginate').flatMap((c) => (c.kind === 'paginate' ? [c.route] : [])); + expect(paginateRoutes).toContain('GET /repos/{owner}/{repo}/dependabot/alerts'); + expect(paginateRoutes).not.toContain('GET /orgs/{org}/dependabot/alerts'); +}); + +test('falls back to the per-repo CVE endpoint when the org-level call fails', async () => { + const { ctx, githubClient } = createFakeContext(); + + githubClient.onPaginate('GET /orgs/{org}/repos', {}).resolves([ + { + name: 'widgets', + node_id: 'R_kgDOwidgets', + owner: { login: 'acme' }, + private: true, + visibility: 'private', + archived: false, + default_branch: 'main', + language: 'TypeScript', + pushed_at: '2026-04-01T00:00:00Z', + }, + ]); + githubClient.onGraphql('RepoMetadataBatch').resolves(repoBatchResponse([npmConfigBlob])); + // Org-level endpoint refuses: token can see the org but not its alerts. + githubClient + .onPaginate('GET /orgs/{org}/dependabot/alerts', {}) + .fails({ kind: 'forbidden', message: 'no access to org alerts' }); + // Per-repo endpoint is the fallback so each repo still gets a real status. + githubClient.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).resolves([]); + githubClient.onGraphql('DependabotPrs').resolves({ + search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }); + + const result = await main(ctx, ['acme']); + expect(result.kind).toBe('completed'); + + const paginateRoutes = githubClient.callsTo('paginate').flatMap((c) => (c.kind === 'paginate' ? [c.route] : [])); + expect(paginateRoutes).toContain('GET /orgs/{org}/dependabot/alerts'); + expect(paginateRoutes).toContain('GET /repos/{owner}/{repo}/dependabot/alerts'); +}); + +test('captures run_failed when listTargetRepos fails', async () => { const { ctx, githubClient, analytics } = createFakeContext(); githubClient.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'forbidden', message: 'no access' }); @@ -178,7 +253,7 @@ test('captures run_failed when listOrgRepos fails', async () => { expect(failed?.properties).toMatchObject({ error_kind: 'forbidden' }); }); -test('returns failed when listOrgRepos fails non-recoverably', async () => { +test('returns failed when listTargetRepos fails non-recoverably', async () => { const { ctx, prompter, githubClient } = createFakeContext(); githubClient.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'forbidden', message: 'no access' }); diff --git a/src/cli.ts b/src/cli.ts index 5cb9f65..51ae008 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,10 +2,10 @@ import { join } from 'node:path'; import { parseArgs } from 'node:util'; import { Result, ResultAsync } from 'neverthrow'; import { getBranchProtection } from './collectors/branchProtection.ts'; -import { getCveAlerts } from './collectors/cve.ts'; +import { getCveAlerts, getOrgCveAlerts } from './collectors/cve.ts'; import { getDependabotConfig } from './collectors/dependabotConfig.ts'; import { listDependabotPrs } from './collectors/dependabotPrs.ts'; -import { listOrgRepos } from './collectors/repos.ts'; +import { type TargetKind, listTargetRepos } from './collectors/repos.ts'; import { mapWithConcurrency } from './concurrency.ts'; import type { Context } from './context.ts'; import { getErrorMessage } from './errors.ts'; @@ -198,7 +198,7 @@ function renderReport( { target: opts.target, windowDays: opts.windowDays }, `scanning ${opts.target} (${opts.windowDays}-day window)`, ); - return listOrgRepos(ctx.githubClient, opts.target).andThen((repos) => { + return listTargetRepos(ctx.githubClient, opts.target).andThen(({ kind: targetKind, repos }) => { const filtered = filterRepos(repos, opts); ctx.logger.info( { total: repos.length, included: filtered.length }, @@ -208,7 +208,7 @@ function renderReport( const windowStart = now.subtract(Temporal.Duration.from({ hours: opts.windowDays * 24 })); return ResultAsync.fromSafePromise( - collectAll(ctx, filtered, opts.target, opts.windowDays, windowStart, now), + collectAll(ctx, filtered, opts.target, targetKind, opts.windowDays, windowStart, now), ).andThen((data) => { ctx.logger.info( { dependabotPrs: data.dependabotPrs.length, warnings: data.errors.length }, @@ -240,6 +240,7 @@ async function collectAll( ctx: Context, repos: RepoMeta[], target: string, + targetKind: TargetKind, windowDays: number, windowStart: Instant, now: Instant, @@ -248,6 +249,19 @@ async function collectAll( const warnings: CollectorWarning[] = []; const windowStartIso = windowStart.toString(); + const cvePromise: Promise = (async () => { + const perRepoCrawl = (): Promise => + crawlPerRepo(repos, (r) => getCveAlerts(client, { owner: r.owner, name: r.name }), warnings, 'cve'); + if (targetKind === 'user') return perRepoCrawl(); + // Try the org-level endpoint first (one call instead of N). If anything + // other than scope-missing comes back, fall back to per-repo so each + // repo gets a real status determination instead of an empty list. + const result = await getOrgCveAlerts(client, target, repos); + if (result.isOk()) return result.value; + warnings.push({ collector: 'cve', message: formatGithubError(result.error) }); + return perRepoCrawl(); + })(); + const [dependabotConfig, cve, branchProtection, dependabotPrs] = await Promise.all([ crawlPerRepo( repos, @@ -255,7 +269,7 @@ async function collectAll( warnings, 'dependabotConfig', ), - crawlPerRepo(repos, (r) => getCveAlerts(client, { owner: r.owner, name: r.name }), warnings, 'cve'), + cvePromise, crawlPerRepo( repos, (r) => getBranchProtection(client, { owner: r.owner, name: r.name }, r.defaultBranch), diff --git a/src/collectors/cve.test.ts b/src/collectors/cve.test.ts index dc26fba..dad239c 100644 --- a/src/collectors/cve.test.ts +++ b/src/collectors/cve.test.ts @@ -1,6 +1,9 @@ import { expect, test } from 'bun:test'; +import { repoMeta } from '../testFactories.ts'; import { FakeGithubClient } from '../testHelpers/index.ts'; -import { getCveAlerts } from './cve.ts'; +import { getCveAlerts, getOrgCveAlerts } from './cve.ts'; + +const ORG_ALERTS = 'GET /orgs/{org}/dependabot/alerts'; test('maps raw alerts to CveAlert with normalized severity', async () => { const client = new FakeGithubClient(); @@ -102,3 +105,137 @@ test('propagates other errors instead of pretending alerts are disabled', async expect(result.isErr()).toBe(true); if (result.isErr()) expect(result.error).toMatchObject({ kind: 'http', status: 503 }); }); + +test('getOrgCveAlerts buckets alerts by repo from the org endpoint', async () => { + const client = new FakeGithubClient(); + client.onPaginate(ORG_ALERTS, {}).resolves([ + { + number: 1, + created_at: '2026-03-01T00:00:00Z', + security_advisory: { summary: 'Critical RCE' }, + security_vulnerability: { severity: 'critical', package: { name: 'left-pad', ecosystem: 'npm' } }, + repository: { name: 'widgets', owner: { login: 'acme' } }, + }, + { + number: 2, + created_at: '2026-04-01T00:00:00Z', + security_advisory: { summary: 'Moderate' }, + security_vulnerability: { severity: 'moderate', package: { name: 'lodash', ecosystem: 'npm' } }, + repository: { name: 'gizmos', owner: { login: 'acme' } }, + }, + ]); + + const repos = [ + repoMeta.build({ owner: 'acme', name: 'widgets', dependabotAlertsEnabled: true }), + repoMeta.build({ owner: 'acme', name: 'gizmos', dependabotAlertsEnabled: true }), + repoMeta.build({ owner: 'acme', name: 'quiet', dependabotAlertsEnabled: true }), + ]; + + const result = await getOrgCveAlerts(client, 'acme', repos); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + const slices = result.value; + expect(slices).toHaveLength(3); + const widgets = slices.find((s) => s.name === 'widgets'); + const quiet = slices.find((s) => s.name === 'quiet'); + if (widgets?.status === 'ok') { + expect(widgets.alerts).toHaveLength(1); + expect(widgets.alerts[0]).toMatchObject({ severity: 'critical', packageName: 'left-pad' }); + } else { + throw new Error('widgets slice should be ok'); + } + if (quiet?.status === 'ok') { + expect(quiet.alerts).toEqual([]); + } else { + throw new Error('quiet slice should be ok with empty alerts'); + } + } +}); + +test('getOrgCveAlerts marks repos with dependabotAlertsEnabled=false as not-enabled', async () => { + const client = new FakeGithubClient(); + client.onPaginate(ORG_ALERTS, {}).resolves([]); + + const repos = [ + repoMeta.build({ owner: 'acme', name: 'on', dependabotAlertsEnabled: true }), + repoMeta.build({ owner: 'acme', name: 'off', dependabotAlertsEnabled: false }), + repoMeta.build({ owner: 'acme', name: 'unknown', dependabotAlertsEnabled: null }), + ]; + + const result = await getOrgCveAlerts(client, 'acme', repos); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value.find((s) => s.name === 'off')).toEqual({ owner: 'acme', name: 'off', status: 'not-enabled' }); + expect(result.value.find((s) => s.name === 'on')).toMatchObject({ status: 'ok', alerts: [] }); + expect(result.value.find((s) => s.name === 'unknown')).toMatchObject({ status: 'ok', alerts: [] }); + } +}); + +test('getOrgCveAlerts skips alerts whose security_vulnerability is null', async () => { + const client = new FakeGithubClient(); + client.onPaginate(ORG_ALERTS, {}).resolves([ + { + number: 9, + created_at: '2026-03-01T00:00:00Z', + security_advisory: { summary: 'auto-dismissed' }, + security_vulnerability: null, + repository: { name: 'widgets', owner: { login: 'acme' } }, + }, + ]); + + const repos = [repoMeta.build({ owner: 'acme', name: 'widgets', dependabotAlertsEnabled: true })]; + const result = await getOrgCveAlerts(client, 'acme', repos); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + const widgets = result.value[0]; + if (widgets?.status === 'ok') expect(widgets.alerts).toEqual([]); + } +}); + +test('getOrgCveAlerts propagates scope-missing across all repos', async () => { + const client = new FakeGithubClient(); + client + .onPaginate(ORG_ALERTS, {}) + .fails({ kind: 'scope-missing', required: 'security_events', message: 'missing scope' }); + + const repos = [repoMeta.build({ owner: 'acme', name: 'a' }), repoMeta.build({ owner: 'acme', name: 'b' })]; + const result = await getOrgCveAlerts(client, 'acme', repos); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + for (const slice of result.value) { + expect(slice).toMatchObject({ status: 'scope-missing', requiredScope: 'security_events' }); + } + } +}); + +test('getOrgCveAlerts propagates unexpected errors instead of swallowing them', async () => { + const client = new FakeGithubClient(); + client.onPaginate(ORG_ALERTS, {}).fails({ kind: 'http', status: 503, message: 'service unavailable' }); + + const repos = [repoMeta.build({ owner: 'acme', name: 'widgets' })]; + const result = await getOrgCveAlerts(client, 'acme', repos); + expect(result.isErr()).toBe(true); +}); + +test('getOrgCveAlerts drops alerts for repos outside the provided list', async () => { + const client = new FakeGithubClient(); + client.onPaginate(ORG_ALERTS, {}).resolves([ + { + number: 1, + created_at: '2026-03-01T00:00:00Z', + security_advisory: { summary: 'Critical RCE' }, + security_vulnerability: { severity: 'critical', package: { name: 'left-pad', ecosystem: 'npm' } }, + repository: { name: 'phantom', owner: { login: 'acme' } }, + }, + ]); + + const repos = [repoMeta.build({ owner: 'acme', name: 'widgets', dependabotAlertsEnabled: true })]; + const result = await getOrgCveAlerts(client, 'acme', repos); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toHaveLength(1); + expect(result.value.find((s) => s.name === 'phantom')).toBeUndefined(); + const widgets = result.value[0]; + if (widgets?.status === 'ok') expect(widgets.alerts).toEqual([]); + } +}); diff --git a/src/collectors/cve.ts b/src/collectors/cve.ts index aec3eb4..ac3d9b3 100644 --- a/src/collectors/cve.ts +++ b/src/collectors/cve.ts @@ -2,7 +2,7 @@ import { type ResultAsync, errAsync, okAsync } from 'neverthrow'; import { z } from 'zod'; import type { GithubError } from '../github/errors.ts'; import type { GithubClient } from '../github/GithubClient.ts'; -import type { CveAlert, CveSeverity, CveSlice, RepoRef } from '../types.ts'; +import type { CveAlert, CveSeverity, CveSlice, RepoMeta, RepoRef } from '../types.ts'; // `security_vulnerability` is null on alerts GitHub can't attribute to a // concrete vulnerability (e.g. auto-dismissed); those carry no severity or @@ -19,6 +19,13 @@ const alertSchema = z.object({ .nullable(), }); +const orgAlertSchema = alertSchema.extend({ + repository: z.object({ + name: z.string(), + owner: z.object({ login: z.string() }), + }), +}); + export function getCveAlerts(client: GithubClient, ref: RepoRef): ResultAsync { return client .paginate( @@ -68,6 +75,55 @@ export function getCveAlerts(client: GithubClient, ref: RepoRef): ResultAsync { + return client + .paginate('GET /orgs/{org}/dependabot/alerts', { org, state: 'open', per_page: 100 }, orgAlertSchema) + .map((raw): CveSlice[] => { + const inScope = new Set(repos.map((r) => repoKey(r))); + const byRepo = new Map(); + for (const a of raw) { + if (a.security_vulnerability === null) continue; + const ref: RepoRef = { owner: a.repository.owner.login, name: a.repository.name }; + const key = repoKey(ref); + if (!inScope.has(key)) continue; + const list = byRepo.get(key) ?? []; + list.push({ + owner: ref.owner, + name: ref.name, + number: a.number, + severity: normalizeSeverity(a.security_vulnerability.severity), + createdAt: a.created_at, + packageName: a.security_vulnerability.package.name, + ecosystem: a.security_vulnerability.package.ecosystem, + summary: a.security_advisory.summary, + }); + byRepo.set(key, list); + } + return repos.map((r): CveSlice => { + if (r.dependabotAlertsEnabled === false) { + return { owner: r.owner, name: r.name, status: 'not-enabled' }; + } + return { owner: r.owner, name: r.name, status: 'ok', alerts: byRepo.get(repoKey(r)) ?? [] }; + }); + }) + .orElse((err) => { + // Unlike the per-repo collector, an org-wide 404/403 here means the org + // is inaccessible — not "alerts disabled on a single repo". Per-repo + // "not enabled" is driven by `dependabotAlertsEnabled === false` against + // RepoMeta in the .map above. Only scope-missing is a soft failure. + if (err.kind === 'scope-missing') { + return okAsync( + repos.map((r) => ({ owner: r.owner, name: r.name, status: 'scope-missing', requiredScope: err.required })), + ); + } + return errAsync(err); + }); +} + function normalizeSeverity(raw: string): CveSeverity { const v = raw.toLowerCase(); if (v === 'critical') return 'critical'; @@ -75,3 +131,7 @@ function normalizeSeverity(raw: string): CveSeverity { if (v === 'medium' || v === 'moderate') return 'medium'; return 'low'; } + +function repoKey(ref: RepoRef): string { + return `${ref.owner}/${ref.name}`; +} From e84aef0268336b7820394cab7021b4f13702d4ab Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Thu, 28 May 2026 10:33:42 -0600 Subject: [PATCH 7/7] feat(collectors): replace per-repo branch-protection + dependabot-config with batched GraphQL collector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two REST collectors hit, per repo, three endpoints: - GET /repos/{owner}/{repo}/contents/.github/dependabot.yml (and .yaml fallback) - GET /repos/{owner}/{repo}/branches/{branch}/protection - GET /repos/{owner}/{repo}/rules/branches/{branch} Replace them with a single batched GraphQL query — `RepoMetadataBatch` — that pulls all three slices for up to 20 repos at a time via `nodes(ids: [ID!])`. We use the `nodeId` added in the previous repos commit, and let p-map drive batch-level concurrency (5). Aliases vs nodes: GitHub limits per-field aliasing to 20 and rejects larger payloads; `nodes` accepts up to 100 ids in one round trip with the same depth budget. Sticking to 20-id batches keeps each call well under the rate-limit point budget. The collector preserves the old per-repo semantics: - "no protection" branch is unchanged: classic protection 404 + empty ruleset → `hasProtection: false`. - yml-then-yaml fallback for the dependabot config file. - Per-repo failures become CollectorWarning entries inside the result's `warnings: []`, not a hard Err on the outer ResultAsync. CLI swaps the two `crawlPerRepo` legs for one `runRepoMetadata` helper that forwards inner warnings up without double-counting, and also swaps the hand-rolled `mapWithConcurrency` for `pMap` to match the collector's choice. Generated GraphQL types and the matching test mocks (`RepoMetadataBatch` GraphQL mock, `node_id` on raw repo fixtures) come in this commit because they're tightly coupled to the new collector. The old REST collectors and their tests are deleted. --- src/cli.test.ts | 42 ++- src/cli.ts | 53 ++-- src/collectors/branchProtection.test.ts | 119 -------- src/collectors/branchProtection.ts | 88 ------ src/collectors/dependabotConfig.test.ts | 162 ---------- src/collectors/dependabotConfig.ts | 104 ------- src/collectors/repoMetadata.graphql | 43 +++ src/collectors/repoMetadata.test.ts | 290 ++++++++++++++++++ src/collectors/repoMetadata.ts | 255 ++++++++++++++++ src/github/graphql/generated.ts | 384 +++++++++++++++++++++++- 10 files changed, 1020 insertions(+), 520 deletions(-) delete mode 100644 src/collectors/branchProtection.test.ts delete mode 100644 src/collectors/branchProtection.ts delete mode 100644 src/collectors/dependabotConfig.test.ts delete mode 100644 src/collectors/dependabotConfig.ts create mode 100644 src/collectors/repoMetadata.graphql create mode 100644 src/collectors/repoMetadata.test.ts create mode 100644 src/collectors/repoMetadata.ts diff --git a/src/cli.test.ts b/src/cli.test.ts index 532c9be..a0938f4 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2,6 +2,17 @@ import { expect, test } from 'bun:test'; import { main } from './cli.ts'; import { createFakeContext } from './testHelpers/index.ts'; +function repoBatchResponse(nodes: unknown[] = []): Record { + return { nodes }; +} + +const npmConfigBlob = { + __typename: 'Repository', + yml: { text: 'updates:\n - package-ecosystem: "npm"\n' }, + yaml: null, + defaultBranchRef: null, +}; + test('prints usage and exits 0 when --help is passed', async () => { const { ctx, io } = createFakeContext(); const result = await main(ctx, ['--help']); @@ -56,6 +67,7 @@ test('writes a report when the GitHub calls succeed', async () => { githubClient.onPaginate('GET /orgs/{org}/repos', {}).resolves([ { name: 'widgets', + node_id: 'R_kgDOwidgets', owner: { login: 'acme' }, private: true, visibility: 'private', @@ -65,15 +77,8 @@ test('writes a report when the GitHub calls succeed', async () => { pushed_at: '2026-04-01T00:00:00Z', }, ]); - githubClient.onRequest('GET /repos/{owner}/{repo}/contents/{path}', {}).resolves({ - content: Buffer.from('updates:\n - package-ecosystem: "npm"\n', 'utf8').toString('base64'), - encoding: 'base64', - }); + githubClient.onGraphql('RepoMetadataBatch').resolves(repoBatchResponse([npmConfigBlob])); githubClient.onPaginate('GET /orgs/{org}/dependabot/alerts', {}).resolves([]); - githubClient - .onRequest('GET /repos/{owner}/{repo}/branches/{branch}/protection', {}) - .fails({ kind: 'not-found', message: 'no protection' }); - githubClient.onRequest('GET /repos/{owner}/{repo}/rules/branches/{branch}', {}).resolves([]); githubClient.onGraphql('DependabotPrs').resolves({ search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, }); @@ -125,6 +130,7 @@ test('excludes forked repos from the crawl', async () => { githubClient.onPaginate('GET /orgs/{org}/repos', {}).resolves([ { name: 'widgets', + node_id: 'R_kgDOwidgets', owner: { login: 'acme' }, private: true, visibility: 'private', @@ -136,6 +142,7 @@ test('excludes forked repos from the crawl', async () => { }, { name: 'upstream-fork', + node_id: 'R_kgDOfork', owner: { login: 'acme' }, private: false, visibility: 'public', @@ -146,15 +153,8 @@ test('excludes forked repos from the crawl', async () => { pushed_at: '2026-04-01T00:00:00Z', }, ]); - githubClient.onRequest('GET /repos/{owner}/{repo}/contents/{path}', {}).resolves({ - content: Buffer.from('updates:\n - package-ecosystem: "npm"\n', 'utf8').toString('base64'), - encoding: 'base64', - }); + githubClient.onGraphql('RepoMetadataBatch').resolves(repoBatchResponse([npmConfigBlob])); githubClient.onPaginate('GET /orgs/{org}/dependabot/alerts', {}).resolves([]); - githubClient - .onRequest('GET /repos/{owner}/{repo}/branches/{branch}/protection', {}) - .fails({ kind: 'not-found', message: 'no protection' }); - githubClient.onRequest('GET /repos/{owner}/{repo}/rules/branches/{branch}', {}).resolves([]); githubClient.onGraphql('DependabotPrs').resolves({ search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, }); @@ -176,6 +176,7 @@ test('uses the per-repo CVE endpoint for user targets', async () => { githubClient.onPaginate('GET /users/{username}/repos', {}).resolves([ { name: 'solo', + node_id: 'R_kgDOsolo', owner: { login: 'blimmer' }, private: false, visibility: 'public', @@ -186,16 +187,9 @@ test('uses the per-repo CVE endpoint for user targets', async () => { pushed_at: '2026-04-01T00:00:00Z', }, ]); - githubClient.onRequest('GET /repos/{owner}/{repo}/contents/{path}', {}).resolves({ - content: Buffer.from('updates:\n - package-ecosystem: "npm"\n', 'utf8').toString('base64'), - encoding: 'base64', - }); + githubClient.onGraphql('RepoMetadataBatch').resolves(repoBatchResponse([npmConfigBlob])); // Per-repo CVE endpoint — the path Task 4 keeps for user targets. githubClient.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).resolves([]); - githubClient - .onRequest('GET /repos/{owner}/{repo}/branches/{branch}/protection', {}) - .fails({ kind: 'not-found', message: 'no protection' }); - githubClient.onRequest('GET /repos/{owner}/{repo}/rules/branches/{branch}', {}).resolves([]); githubClient.onGraphql('DependabotPrs').resolves({ search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, }); diff --git a/src/cli.ts b/src/cli.ts index 51ae008..2d62740 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,12 +1,11 @@ import { join } from 'node:path'; import { parseArgs } from 'node:util'; import { Result, ResultAsync } from 'neverthrow'; -import { getBranchProtection } from './collectors/branchProtection.ts'; +import pMap from 'p-map'; import { getCveAlerts, getOrgCveAlerts } from './collectors/cve.ts'; -import { getDependabotConfig } from './collectors/dependabotConfig.ts'; import { listDependabotPrs } from './collectors/dependabotPrs.ts'; +import { listRepoMetadataBatched } from './collectors/repoMetadata.ts'; import { type TargetKind, listTargetRepos } from './collectors/repos.ts'; -import { mapWithConcurrency } from './concurrency.ts'; import type { Context } from './context.ts'; import { getErrorMessage } from './errors.ts'; import { formatFsError } from './FileSystem.ts'; @@ -262,30 +261,19 @@ async function collectAll( return perRepoCrawl(); })(); - const [dependabotConfig, cve, branchProtection, dependabotPrs] = await Promise.all([ - crawlPerRepo( - repos, - (r) => getDependabotConfig(client, { owner: r.owner, name: r.name }), - warnings, - 'dependabotConfig', - ), + const [metadata, cve, dependabotPrs] = await Promise.all([ + runRepoMetadata(listRepoMetadataBatched(client, repos), warnings), cvePromise, - crawlPerRepo( - repos, - (r) => getBranchProtection(client, { owner: r.owner, name: r.name }, r.defaultBranch), - warnings, - 'branchProtection', - ), runResultAsync(listDependabotPrs(client, target, windowStartIso), [], warnings, 'dependabotPrs'), ]); return { ctx: { org: target, windowDays, windowStart, now }, repos, - dependabotConfig, + dependabotConfig: metadata.dependabotConfig, dependabotPrs, cve, - branchProtection, + branchProtection: metadata.branchProtection, errors: warnings, }; } @@ -296,10 +284,14 @@ async function crawlPerRepo( warnings: CollectorWarning[], collector: string, ): Promise { - const results = await mapWithConcurrency(repos, 8, async (repo) => { - const result = await fn(repo); - return { repo, result }; - }); + const results = await pMap( + repos, + async (repo) => { + const result = await fn(repo); + return { repo, result }; + }, + { concurrency: 8 }, + ); const ok: T[] = []; for (const { repo, result } of results) { if (result.isOk()) { @@ -327,6 +319,23 @@ async function runResultAsync( return fallback; } +// listRepoMetadataBatched returns per-repo warnings inside the value; the outer +// Err channel is reserved for systemic errors and is currently unused. This +// helper forwards inner warnings up without double-counting and degrades to +// empty slices if the outer Err ever fires. +async function runRepoMetadata( + ra: ReturnType, + warnings: CollectorWarning[], +): Promise<{ dependabotConfig: DependabotConfigSlice[]; branchProtection: BranchProtectionSlice[] }> { + const result = await ra; + if (result.isErr()) { + warnings.push({ collector: 'repoMetadata', message: formatGithubError(result.error) }); + return { dependabotConfig: [], branchProtection: [] }; + } + for (const w of result.value.warnings) warnings.push(w); + return { dependabotConfig: result.value.dependabotConfig, branchProtection: result.value.branchProtection }; +} + export type ParseCliResult = { kind: 'ok'; value: CliOptions } | { kind: 'err'; message: string }; const safeParseArgs = Result.fromThrowable( diff --git a/src/collectors/branchProtection.test.ts b/src/collectors/branchProtection.test.ts deleted file mode 100644 index 7485219..0000000 --- a/src/collectors/branchProtection.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { expect, test } from 'bun:test'; -import { FakeGithubClient } from '../testHelpers/index.ts'; -import { getBranchProtection } from './branchProtection.ts'; - -const CLASSIC = 'GET /repos/{owner}/{repo}/branches/{branch}/protection'; -const RULES = 'GET /repos/{owner}/{repo}/rules/branches/{branch}'; - -test('builds a classic-only slice when rulesets return an empty list', async () => { - const client = new FakeGithubClient(); - client.onRequest(CLASSIC, { branch: 'main' }).resolves({ - required_pull_request_reviews: { required_approving_review_count: 2 }, - required_status_checks: { contexts: ['test'] }, - }); - client.onRequest(RULES, { branch: 'main' }).resolves([]); - - const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toMatchObject({ - hasProtection: true, - sources: ['classic'], - requiredApprovingReviewCount: 2, - requiresStatusChecks: true, - }); - } -}); - -test('builds a ruleset-only slice when classic 404s but rulesets are active', async () => { - const client = new FakeGithubClient(); - client.onRequest(CLASSIC, { branch: 'main' }).fails({ kind: 'not-found', message: 'no classic' }); - client - .onRequest(RULES, { branch: 'main' }) - .resolves([ - { type: 'pull_request', parameters: { required_approving_review_count: 1 } }, - { type: 'required_status_checks', parameters: {} }, - { type: 'deletion' }, - ]); - - const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toMatchObject({ - hasProtection: true, - sources: ['ruleset'], - requiredApprovingReviewCount: 1, - requiresStatusChecks: true, - }); - } -}); - -test('merges classic + ruleset, taking the strictest review count and OR-ing status checks', async () => { - const client = new FakeGithubClient(); - client.onRequest(CLASSIC, { branch: 'main' }).resolves({ - required_pull_request_reviews: { required_approving_review_count: 1 }, - required_status_checks: null, - }); - client.onRequest(RULES, { branch: 'main' }).resolves([ - { type: 'pull_request', parameters: { required_approving_review_count: 2 } }, - { type: 'required_status_checks', parameters: {} }, - ]); - - const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toMatchObject({ - hasProtection: true, - sources: ['classic', 'ruleset'], - requiredApprovingReviewCount: 2, - requiresStatusChecks: true, - }); - } -}); - -test('returns hasProtection: false when both classic and rulesets are absent', async () => { - const client = new FakeGithubClient(); - client.onRequest(CLASSIC, {}).fails({ kind: 'not-found', message: 'no classic' }); - client.onRequest(RULES, {}).resolves([]); - - const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toMatchObject({ - hasProtection: false, - sources: [], - requiredApprovingReviewCount: null, - requiresStatusChecks: false, - }); - } -}); - -test('treats a 404 on the rules-for-branch endpoint as no rulesets', async () => { - const client = new FakeGithubClient(); - client.onRequest(CLASSIC, {}).fails({ kind: 'not-found', message: 'no classic' }); - client.onRequest(RULES, {}).fails({ kind: 'not-found', message: 'no rules' }); - - const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); - expect(result.isOk()).toBe(true); - if (result.isOk()) expect(result.value).toMatchObject({ hasProtection: false, sources: [] }); -}); - -test('propagates non-404 classic errors so the partial-failure boundary can log it', async () => { - const client = new FakeGithubClient(); - client.onRequest(CLASSIC, {}).fails({ kind: 'http', status: 500, message: 'boom' }); - client.onRequest(RULES, {}).resolves([]); - - const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); - expect(result.isErr()).toBe(true); - if (result.isErr()) expect(result.error).toMatchObject({ kind: 'http', status: 500 }); -}); - -test('propagates non-404 ruleset errors so the partial-failure boundary can log it', async () => { - const client = new FakeGithubClient(); - client.onRequest(CLASSIC, {}).fails({ kind: 'not-found', message: 'no classic' }); - client.onRequest(RULES, {}).fails({ kind: 'http', status: 500, message: 'boom' }); - - const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); - expect(result.isErr()).toBe(true); - if (result.isErr()) expect(result.error).toMatchObject({ kind: 'http', status: 500 }); -}); diff --git a/src/collectors/branchProtection.ts b/src/collectors/branchProtection.ts deleted file mode 100644 index 0e4d153..0000000 --- a/src/collectors/branchProtection.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { Endpoints } from '@octokit/types'; -import { ResultAsync, errAsync, okAsync } from 'neverthrow'; -import type { GithubError } from '../github/errors.ts'; -import type { GithubClient } from '../github/GithubClient.ts'; -import type { BranchProtectionSlice, BranchProtectionSource, RepoRef } from '../types.ts'; - -type BranchRule = Endpoints['GET /repos/{owner}/{repo}/rules/branches/{branch}']['response']['data'][number]; -type PullRequestRule = Extract; - -interface PartialProtection { - source: BranchProtectionSource; - requiredApprovingReviewCount: number | null; - requiresStatusChecks: boolean; -} - -export function getBranchProtection( - client: GithubClient, - ref: RepoRef, - branch: string, -): ResultAsync { - return ResultAsync.combine([ - getClassicProtection(client, ref, branch), - getRulesetProtection(client, ref, branch), - ]).map(([classic, ruleset]) => merge(ref, [classic, ruleset])); -} - -function getClassicProtection( - client: GithubClient, - ref: RepoRef, - branch: string, -): ResultAsync { - return client - .request('GET /repos/{owner}/{repo}/branches/{branch}/protection', { owner: ref.owner, repo: ref.name, branch }) - .map( - (data): PartialProtection => ({ - source: 'classic', - requiredApprovingReviewCount: data.required_pull_request_reviews?.required_approving_review_count ?? null, - requiresStatusChecks: (data.required_status_checks?.contexts?.length ?? 0) > 0, - }), - ) - .orElse((err) => { - // 404 is the documented "no classic branch protection configured". - if (err.kind === 'not-found') return okAsync(null); - return errAsync(err); - }); -} - -function getRulesetProtection( - client: GithubClient, - ref: RepoRef, - branch: string, -): ResultAsync { - // The /rules/branches/{branch} endpoint returns the effective rules applied - // to a branch from any active ruleset (repo-level or inherited). It does NOT - // include classic branch protection — that's still a separate endpoint. - return client - .request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { owner: ref.owner, repo: ref.name, branch }) - .map((rules): PartialProtection | null => { - if (rules.length === 0) return null; - const prRule = rules.find((r): r is PullRequestRule => r.type === 'pull_request'); - const statusRule = rules.find((r) => r.type === 'required_status_checks'); - const reviewCount = prRule?.parameters?.required_approving_review_count; - return { - source: 'ruleset', - requiredApprovingReviewCount: typeof reviewCount === 'number' ? reviewCount : null, - requiresStatusChecks: statusRule !== undefined, - }; - }) - .orElse((err) => { - if (err.kind === 'not-found') return okAsync(null); - return errAsync(err); - }); -} - -function merge(ref: RepoRef, parts: ReadonlyArray): BranchProtectionSlice { - const active = parts.filter((p): p is PartialProtection => p !== null); - const reviewCounts = active - .map((p) => p.requiredApprovingReviewCount) - .filter((n): n is number => typeof n === 'number'); - return { - ...ref, - hasProtection: active.length > 0, - sources: active.map((p) => p.source), - // When multiple sources require reviews, the strictest one wins. - requiredApprovingReviewCount: reviewCounts.length > 0 ? Math.max(...reviewCounts) : null, - requiresStatusChecks: active.some((p) => p.requiresStatusChecks), - }; -} diff --git a/src/collectors/dependabotConfig.test.ts b/src/collectors/dependabotConfig.test.ts deleted file mode 100644 index 31af4f4..0000000 --- a/src/collectors/dependabotConfig.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { expect, test } from 'bun:test'; -import { FakeGithubClient } from '../testHelpers/index.ts'; -import { getDependabotConfig } from './dependabotConfig.ts'; - -function base64(text: string): string { - return Buffer.from(text, 'utf8').toString('base64'); -} - -test('parses ecosystems from a dependabot.yml', async () => { - const client = new FakeGithubClient(); - client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }).resolves({ - content: base64(` -version: 2 -updates: - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" - `), - encoding: 'base64', - }); - - const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toMatchObject({ - hasConfig: true, - ecosystems: ['github-actions', 'npm'], - updates: [ - { ecosystem: 'npm', interval: 'weekly', openPullRequestsLimit: 5, groupCount: 0, ignoreCount: 0 }, - { ecosystem: 'github-actions', interval: 'daily', openPullRequestsLimit: 5, groupCount: 0, ignoreCount: 0 }, - ], - }); - } -}); - -test('captures open-pull-requests-limit, groups, and ignore counts per entry', async () => { - const client = new FakeGithubClient(); - client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }).resolves({ - content: base64(` -version: 2 -updates: - - package-ecosystem: npm - directory: / - schedule: - interval: monthly - open-pull-requests-limit: 20 - groups: - eslint: - patterns: - - "eslint*" - react: - patterns: - - "react*" - - "react-dom" - ignore: - - dependency-name: "lodash" - - dependency-name: "express" - versions: ["4.x"] - - dependency-name: "react" -`), - encoding: 'base64', - }); - - const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value.updates).toEqual([ - { - ecosystem: 'npm', - interval: 'monthly', - openPullRequestsLimit: 20, - groupCount: 2, - ignoreCount: 3, - }, - ]); - } -}); - -test('defaults openPullRequestsLimit to 5 when not specified', async () => { - const client = new FakeGithubClient(); - client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }).resolves({ - content: base64(`updates:\n - package-ecosystem: bundler\n schedule:\n interval: weekly\n`), - encoding: 'base64', - }); - - const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); - expect(result.isOk()).toBe(true); - if (result.isOk()) expect(result.value.updates[0]?.openPullRequestsLimit).toBe(5); -}); - -test('falls back to dependabot.yaml when .yml is absent', async () => { - const client = new FakeGithubClient(); - client - .onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }) - .fails({ kind: 'not-found', message: 'no .yml' }); - client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yaml' }).resolves({ - content: base64(`updates:\n - package-ecosystem: bundler\n`), - encoding: 'base64', - }); - - const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toMatchObject({ - hasConfig: true, - ecosystems: ['bundler'], - }); - } -}); - -test('returns hasConfig: false when both paths 404 but the call still succeeds', async () => { - const client = new FakeGithubClient(); - for (const path of ['.github/dependabot.yml', '.github/dependabot.yaml']) { - client - .onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path }) - .fails({ kind: 'not-found', message: 'no config' }); - } - - const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toMatchObject({ - hasConfig: false, - ecosystems: [], - updates: [], - }); - } -}); - -test('returns an empty updates list when the YAML is malformed', async () => { - const client = new FakeGithubClient(); - client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }).resolves({ - content: base64('this: : is\n: not valid: yaml: [\n'), - encoding: 'base64', - }); - - const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toMatchObject({ - hasConfig: true, - ecosystems: [], - updates: [], - }); - } -}); - -test('propagates non-404 errors when fetching the config', async () => { - const client = new FakeGithubClient(); - client - .onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }) - .fails({ kind: 'forbidden', message: 'no access' }); - - const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); - expect(result.isErr()).toBe(true); - if (result.isErr()) expect(result.error).toMatchObject({ kind: 'forbidden' }); -}); diff --git a/src/collectors/dependabotConfig.ts b/src/collectors/dependabotConfig.ts deleted file mode 100644 index be0de84..0000000 --- a/src/collectors/dependabotConfig.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { Endpoints } from '@octokit/types'; -import { Result, ResultAsync, errAsync, okAsync } from 'neverthrow'; -import type { GithubError } from '../github/errors.ts'; -import type { GithubClient } from '../github/GithubClient.ts'; -import type { DependabotConfigSlice, DependabotInterval, DependabotUpdateEntry, RepoRef } from '../types.ts'; - -type ContentsResponse = Endpoints['GET /repos/{owner}/{repo}/contents/{path}']['response']['data']; - -const CONFIG_PATHS = ['.github/dependabot.yml', '.github/dependabot.yaml']; - -const DEFAULT_OPEN_PR_LIMIT = 5; - -const safeYamlParse = Result.fromThrowable( - (text: string) => Bun.YAML.parse(text), - () => null, -); - -export function getDependabotConfig( - client: GithubClient, - ref: RepoRef, -): ResultAsync { - return fetchFirstAvailable(client, ref, CONFIG_PATHS).map((configBody): DependabotConfigSlice => { - const hasConfig = configBody !== null; - const updates = configBody === null ? [] : parseUpdates(configBody); - const ecosystems = [...new Set(updates.map((u) => u.ecosystem))].sort(); - return { ...ref, hasConfig, ecosystems, updates }; - }); -} - -function fetchFirstAvailable( - client: GithubClient, - ref: RepoRef, - paths: readonly string[], -): ResultAsync { - const [head, ...rest] = paths; - if (head === undefined) return okAsync(null); - return client - .request('GET /repos/{owner}/{repo}/contents/{path}', { owner: ref.owner, repo: ref.name, path: head }) - .map((data) => decodeContent(data)) - .orElse((err) => { - if (err.kind === 'not-found') return fetchFirstAvailable(client, ref, rest); - return errAsync(err); - }); -} - -function decodeContent(data: ContentsResponse): string | null { - // A directory path returns an array; symlinks and submodules carry no inline - // content. Only a regular file has a base64 `content` body to decode. - if (Array.isArray(data) || !('content' in data) || !data.content) return null; - if (data.encoding && data.encoding !== 'base64') return null; - return Buffer.from(data.content, 'base64').toString('utf8'); -} - -function parseUpdates(yamlText: string): DependabotUpdateEntry[] { - const parsed = safeYamlParse(yamlText).unwrapOr(null); - if (!isRecord(parsed)) return []; - const rawUpdates = parsed.updates; - if (!Array.isArray(rawUpdates)) return []; - const entries: DependabotUpdateEntry[] = []; - for (const raw of rawUpdates) { - const entry = normalizeUpdate(raw); - if (entry !== null) entries.push(entry); - } - return entries; -} - -function normalizeUpdate(raw: unknown): DependabotUpdateEntry | null { - if (!isRecord(raw)) return null; - const ecosystem = typeof raw['package-ecosystem'] === 'string' ? raw['package-ecosystem'] : null; - if (ecosystem === null) return null; - return { - ecosystem, - interval: extractInterval(raw.schedule), - openPullRequestsLimit: extractOpenPrLimit(raw['open-pull-requests-limit']), - groupCount: extractGroupCount(raw.groups), - ignoreCount: extractListCount(raw.ignore), - }; -} - -function extractInterval(schedule: unknown): DependabotInterval | null { - if (!isRecord(schedule)) return null; - const value = schedule.interval; - if (value === 'daily' || value === 'weekly' || value === 'monthly') return value; - return null; -} - -function extractOpenPrLimit(value: unknown): number { - if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return Math.floor(value); - return DEFAULT_OPEN_PR_LIMIT; -} - -function extractGroupCount(value: unknown): number { - if (!isRecord(value)) return 0; - return Object.keys(value).length; -} - -function extractListCount(value: unknown): number { - if (!Array.isArray(value)) return 0; - return value.length; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/src/collectors/repoMetadata.graphql b/src/collectors/repoMetadata.graphql new file mode 100644 index 0000000..0d5a929 --- /dev/null +++ b/src/collectors/repoMetadata.graphql @@ -0,0 +1,43 @@ +query RepoMetadataBatch($ids: [ID!]!) { + nodes(ids: $ids) { + __typename + ... on Repository { + ...RepoMetadataFields + } + } +} + +fragment RepoMetadataFields on Repository { + yml: object(expression: "HEAD:.github/dependabot.yml") { + ... on Blob { + text + } + } + yaml: object(expression: "HEAD:.github/dependabot.yaml") { + ... on Blob { + text + } + } + defaultBranchRef { + branchProtectionRule { + requiredApprovingReviewCount + requiresStatusChecks + } + rules(first: 20) { + nodes { + type + parameters { + __typename + ... on PullRequestParameters { + requiredApprovingReviewCount + } + ... on RequiredStatusChecksParameters { + requiredStatusChecks { + context + } + } + } + } + } + } +} diff --git a/src/collectors/repoMetadata.test.ts b/src/collectors/repoMetadata.test.ts new file mode 100644 index 0000000..19dabac --- /dev/null +++ b/src/collectors/repoMetadata.test.ts @@ -0,0 +1,290 @@ +import { expect, test } from 'bun:test'; +import { repoMeta } from '../testFactories.ts'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { BATCH_SIZE, listRepoMetadataBatched } from './repoMetadata.ts'; + +function repoBatchResponse(nodes: unknown[]): Record { + return { nodes }; +} + +function repoNode(overrides: Record = {}): Record { + return { + __typename: 'Repository', + yml: null, + yaml: null, + defaultBranchRef: null, + ...overrides, + }; +} + +test('parses dependabot config from yml blob text', async () => { + const client = new FakeGithubClient(); + client.onGraphql('RepoMetadataBatch').resolves( + repoBatchResponse([ + repoNode({ + yml: { + text: 'version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: weekly\n', + }, + yaml: null, + defaultBranchRef: { branchProtectionRule: null, rules: { nodes: [] } }, + }), + ]), + ); + + const repos = [repoMeta.build({ owner: 'acme', name: 'widgets' })]; + const result = await listRepoMetadataBatched(client, repos); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value.dependabotConfig[0]).toMatchObject({ + owner: 'acme', + name: 'widgets', + hasConfig: true, + ecosystems: ['npm'], + }); + expect(result.value.branchProtection[0]).toMatchObject({ + hasProtection: false, + sources: [], + requiredApprovingReviewCount: null, + requiresStatusChecks: false, + }); + } +}); + +test('falls back to yaml blob when yml is missing', async () => { + const client = new FakeGithubClient(); + client.onGraphql('RepoMetadataBatch').resolves( + repoBatchResponse([ + repoNode({ + yml: null, + yaml: { + text: 'version: 2\nupdates:\n - package-ecosystem: gomod\n directory: /\n schedule:\n interval: daily\n', + }, + defaultBranchRef: null, + }), + ]), + ); + + const repos = [repoMeta.build({ owner: 'acme', name: 'widgets' })]; + const result = await listRepoMetadataBatched(client, repos); + if (result.isOk()) { + expect(result.value.dependabotConfig[0]).toMatchObject({ hasConfig: true, ecosystems: ['gomod'] }); + } +}); + +test('produces hasConfig=false when both blobs are null', async () => { + const client = new FakeGithubClient(); + client.onGraphql('RepoMetadataBatch').resolves( + repoBatchResponse([ + repoNode({ + yml: null, + yaml: null, + defaultBranchRef: null, + }), + ]), + ); + + const repos = [repoMeta.build({ owner: 'acme', name: 'widgets' })]; + const result = await listRepoMetadataBatched(client, repos); + if (result.isOk()) { + expect(result.value.dependabotConfig[0]).toMatchObject({ hasConfig: false, ecosystems: [], updates: [] }); + } +}); + +test('builds classic branch protection from branchProtectionRule', async () => { + const client = new FakeGithubClient(); + client.onGraphql('RepoMetadataBatch').resolves( + repoBatchResponse([ + repoNode({ + yml: null, + yaml: null, + defaultBranchRef: { + branchProtectionRule: { requiredApprovingReviewCount: 2, requiresStatusChecks: true }, + rules: { nodes: [] }, + }, + }), + ]), + ); + + const repos = [repoMeta.build({ owner: 'acme', name: 'widgets' })]; + const result = await listRepoMetadataBatched(client, repos); + if (result.isOk()) { + expect(result.value.branchProtection[0]).toMatchObject({ + hasProtection: true, + sources: ['classic'], + requiredApprovingReviewCount: 2, + requiresStatusChecks: true, + }); + } +}); + +test('builds ruleset branch protection from rules nodes', async () => { + const client = new FakeGithubClient(); + client.onGraphql('RepoMetadataBatch').resolves( + repoBatchResponse([ + repoNode({ + yml: null, + yaml: null, + defaultBranchRef: { + branchProtectionRule: null, + rules: { + nodes: [ + { + type: 'PULL_REQUEST', + parameters: { __typename: 'PullRequestParameters', requiredApprovingReviewCount: 1 }, + }, + { + type: 'REQUIRED_STATUS_CHECKS', + parameters: { __typename: 'RequiredStatusChecksParameters', requiredStatusChecks: [{ context: 'ci' }] }, + }, + ], + }, + }, + }), + ]), + ); + + const repos = [repoMeta.build({ owner: 'acme', name: 'widgets' })]; + const result = await listRepoMetadataBatched(client, repos); + if (result.isOk()) { + expect(result.value.branchProtection[0]).toMatchObject({ + hasProtection: true, + sources: ['ruleset'], + requiredApprovingReviewCount: 1, + requiresStatusChecks: true, + }); + } +}); + +test('takes the max approving count when classic and ruleset both apply', async () => { + const client = new FakeGithubClient(); + client.onGraphql('RepoMetadataBatch').resolves( + repoBatchResponse([ + repoNode({ + yml: null, + yaml: null, + defaultBranchRef: { + branchProtectionRule: { requiredApprovingReviewCount: 1, requiresStatusChecks: false }, + rules: { + nodes: [ + { + type: 'PULL_REQUEST', + parameters: { __typename: 'PullRequestParameters', requiredApprovingReviewCount: 3 }, + }, + ], + }, + }, + }), + ]), + ); + + const repos = [repoMeta.build({ owner: 'acme', name: 'widgets' })]; + const result = await listRepoMetadataBatched(client, repos); + if (result.isOk()) { + expect(result.value.branchProtection[0]).toMatchObject({ + hasProtection: true, + sources: ['classic', 'ruleset'], + requiredApprovingReviewCount: 3, + requiresStatusChecks: false, + }); + } +}); + +test('chunks repos into batches of BATCH_SIZE', async () => { + const client = new FakeGithubClient(); + client + .onGraphql('RepoMetadataBatch') + .resolves(repoBatchResponse(Array.from({ length: BATCH_SIZE }, () => repoNode()))); + + const repos = Array.from({ length: 25 }, (_, i) => + repoMeta.build({ owner: 'acme', name: `repo-${i.toString()}`, nodeId: `node-${i.toString()}` }), + ); + const result = await listRepoMetadataBatched(client, repos); + expect(result.isOk()).toBe(true); + expect(client.callsTo('graphql')).toHaveLength(2); + const calls = client.callsTo('graphql'); + const first = calls[0]; + const second = calls[1]; + if (first?.kind === 'graphql') expect(first.variables.ids).toHaveLength(20); + if (second?.kind === 'graphql') + expect(second.variables.ids).toEqual(['node-20', 'node-21', 'node-22', 'node-23', 'node-24']); +}); + +test('emits a warning slice and continues when a batch GraphQL call errors', async () => { + const client = new FakeGithubClient(); + client.onGraphql('RepoMetadataBatch').fails({ kind: 'http', status: 500, message: 'server error' }); + + const repos = [repoMeta.build({ owner: 'acme', name: 'a' }), repoMeta.build({ owner: 'acme', name: 'b' })]; + const result = await listRepoMetadataBatched(client, repos); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value.dependabotConfig).toEqual([]); + expect(result.value.branchProtection).toEqual([]); + expect(result.value.warnings).toHaveLength(2); + expect(result.value.warnings[0]).toMatchObject({ collector: 'repoMetadata', repo: { owner: 'acme', name: 'a' } }); + } +}); + +test('warns and skips null or non-repository nodes in the response', async () => { + const client = new FakeGithubClient(); + client.onGraphql('RepoMetadataBatch').resolves(repoBatchResponse([null, { __typename: 'User' }])); + + const repos = [ + repoMeta.build({ owner: 'acme', name: 'widgets' }), + repoMeta.build({ owner: 'acme', name: 'user-node' }), + ]; + const result = await listRepoMetadataBatched(client, repos); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value.dependabotConfig).toEqual([]); + expect(result.value.branchProtection).toEqual([]); + expect(result.value.warnings).toHaveLength(2); + expect(result.value.warnings[0]).toMatchObject({ + collector: 'repoMetadata', + repo: { owner: 'acme', name: 'widgets' }, + }); + expect(result.value.warnings[1]).toMatchObject({ + collector: 'repoMetadata', + repo: { owner: 'acme', name: 'user-node' }, + }); + } +}); + +test('treats any non-null rule node as ruleset protection (matches old REST behavior)', async () => { + const client = new FakeGithubClient(); + client.onGraphql('RepoMetadataBatch').resolves( + repoBatchResponse([ + repoNode({ + yml: null, + yaml: null, + defaultBranchRef: { + branchProtectionRule: null, + rules: { nodes: [{ type: 'DELETION', parameters: null }] }, + }, + }), + ]), + ); + + const repos = [repoMeta.build({ owner: 'acme', name: 'widgets' })]; + const result = await listRepoMetadataBatched(client, repos); + if (result.isOk()) { + expect(result.value.branchProtection[0]).toMatchObject({ + hasProtection: true, + sources: ['ruleset'], + requiredApprovingReviewCount: null, + requiresStatusChecks: false, + }); + } +}); + +test('returns an empty result with no graphql calls when repos is empty', async () => { + const client = new FakeGithubClient(); + + const result = await listRepoMetadataBatched(client, []); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value.dependabotConfig).toEqual([]); + expect(result.value.branchProtection).toEqual([]); + expect(result.value.warnings).toEqual([]); + } + expect(client.callsTo('graphql')).toHaveLength(0); +}); diff --git a/src/collectors/repoMetadata.ts b/src/collectors/repoMetadata.ts new file mode 100644 index 0000000..babcc7f --- /dev/null +++ b/src/collectors/repoMetadata.ts @@ -0,0 +1,255 @@ +import { Result, ResultAsync, okAsync } from 'neverthrow'; +import pMap from 'p-map'; +import { type GithubError, formatGithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import { + RepoMetadataBatchDocument, + type RepoMetadataBatchQuery, + type RepoMetadataBatchQueryVariables, +} from '../github/graphql/generated.ts'; +import type { + BranchProtectionSlice, + BranchProtectionSource, + CollectorWarning, + DependabotConfigSlice, + DependabotInterval, + DependabotUpdateEntry, + RepoMeta, + RepoRef, +} from '../types.ts'; + +export const BATCH_SIZE = 20; +const REPO_METADATA_BATCH_CONCURRENCY = 5; +const DEFAULT_OPEN_PR_LIMIT = 5; + +export interface RepoMetadataResult { + readonly dependabotConfig: DependabotConfigSlice[]; + readonly branchProtection: BranchProtectionSlice[]; + readonly warnings: CollectorWarning[]; +} + +type RepoNode = Extract, { __typename: 'Repository' }>; + +/** + * Fetches Dependabot config + branch protection for every repo via batched + * GraphQL queries. Partial failures stay inside `result.value.warnings`; the + * outer Err channel is reserved for systemic errors and is currently unused + * (every batch swallows its own error). Callers should not double-account by + * also pushing the outer error into a top-level warnings list. + */ +export function listRepoMetadataBatched( + client: GithubClient, + repos: readonly RepoMeta[], +): ResultAsync { + const batches = chunk(repos, BATCH_SIZE); + const empty: RepoMetadataResult = { dependabotConfig: [], branchProtection: [], warnings: [] }; + if (batches.length === 0) return okAsync(empty); + + return ResultAsync.fromSafePromise( + pMap( + batches, + async (batch): Promise => { + const result = await runBatch(client, batch); + if (result.isOk()) return result.value; + return batchFailure(batch, formatGithubError(result.error)); + }, + { concurrency: REPO_METADATA_BATCH_CONCURRENCY }, + ), + ).map((batchResults) => + batchResults.reduce((acc, cur) => { + acc.dependabotConfig.push(...cur.dependabotConfig); + acc.branchProtection.push(...cur.branchProtection); + acc.warnings.push(...cur.warnings); + return acc; + }, empty), + ); +} + +function runBatch(client: GithubClient, repos: readonly RepoMeta[]): ResultAsync { + const variables = buildVariables(repos); + return client + .graphql(RepoMetadataBatchDocument, variables) + .map((res): RepoMetadataResult => parseBatchResponse(res, repos)) + .orElse((err) => { + return okAsync(batchFailure(repos, formatGithubError(err))); + }); +} + +function batchFailure(repos: readonly RepoMeta[], message: string): RepoMetadataResult { + const warnings: CollectorWarning[] = repos.map((r) => ({ + collector: 'repoMetadata', + repo: { owner: r.owner, name: r.name }, + message, + })); + return { dependabotConfig: [], branchProtection: [], warnings }; +} + +function buildVariables(repos: readonly RepoMeta[]): RepoMetadataBatchQueryVariables { + return { ids: repos.map((repo) => repo.nodeId) }; +} + +function parseBatchResponse(res: RepoMetadataBatchQuery, repos: readonly RepoMeta[]): RepoMetadataResult { + const dependabotConfig: DependabotConfigSlice[] = []; + const branchProtection: BranchProtectionSlice[] = []; + const warnings: CollectorWarning[] = []; + for (let i = 0; i < repos.length; i += 1) { + const repo = repos[i]; + if (!repo) continue; + const node = res.nodes[i]; + if (!isRepoNode(node)) { + warnings.push({ + collector: 'repoMetadata', + repo: { owner: repo.owner, name: repo.name }, + message: + node == null + ? 'GitHub returned no repository metadata for this node ID' + : `GitHub returned ${node.__typename} for this repository node ID`, + }); + continue; + } + dependabotConfig.push(toDependabotConfigSlice(repo, node)); + branchProtection.push(toBranchProtectionSlice(repo, node)); + } + return { dependabotConfig, branchProtection, warnings }; +} + +function isRepoNode(node: RepoMetadataBatchQuery['nodes'][number] | undefined): node is RepoNode { + return node?.__typename === 'Repository'; +} + +function toDependabotConfigSlice(repo: RepoRef, node: RepoNode): DependabotConfigSlice { + const text = blobText(node.yml) ?? blobText(node.yaml); + if (text === null) { + return { owner: repo.owner, name: repo.name, hasConfig: false, ecosystems: [], updates: [] }; + } + const updates = parseUpdates(text); + const ecosystems = [...new Set(updates.map((u) => u.ecosystem))].sort(); + return { owner: repo.owner, name: repo.name, hasConfig: true, ecosystems, updates }; +} + +function toBranchProtectionSlice(repo: RepoRef, node: RepoNode): BranchProtectionSlice { + const ref = node.defaultBranchRef; + if (!ref) { + return { + owner: repo.owner, + name: repo.name, + hasProtection: false, + sources: [], + requiredApprovingReviewCount: null, + requiresStatusChecks: false, + }; + } + + const sources: BranchProtectionSource[] = []; + const reviewCounts: number[] = []; + let requiresStatusChecks = false; + + if (ref.branchProtectionRule) { + sources.push('classic'); + if (typeof ref.branchProtectionRule.requiredApprovingReviewCount === 'number') { + reviewCounts.push(ref.branchProtectionRule.requiredApprovingReviewCount); + } + if (ref.branchProtectionRule.requiresStatusChecks) requiresStatusChecks = true; + } + + // Match the old REST-based behavior: any active rule from a ruleset that + // applies to this ref counts as "ruleset protection", and the presence of a + // RequiredStatusChecksParameters rule implies status checks are required — + // regardless of how many specific contexts are configured. + const ruleNodes = ref.rules?.nodes ?? []; + let hasRuleset = false; + for (const rule of ruleNodes) { + if (!rule) continue; + hasRuleset = true; + const params = rule.parameters; + if (params?.__typename === 'PullRequestParameters') { + if (typeof params.requiredApprovingReviewCount === 'number') { + reviewCounts.push(params.requiredApprovingReviewCount); + } + } else if (params?.__typename === 'RequiredStatusChecksParameters') { + requiresStatusChecks = true; + } + } + if (hasRuleset) sources.push('ruleset'); + + return { + owner: repo.owner, + name: repo.name, + hasProtection: sources.length > 0, + sources, + requiredApprovingReviewCount: reviewCounts.length > 0 ? Math.max(...reviewCounts) : null, + requiresStatusChecks, + }; +} + +// The `object(expression: …)` field returns a `GitObject` union. Codegen models +// non-Blob arms as `Record`, so narrow to the Blob shape +// before reading `.text`. +function blobText(obj: RepoNode['yml']): string | null { + if (obj === null) return null; + if (!('text' in obj)) return null; + return obj.text; +} + +const safeYamlParse = Result.fromThrowable( + (text: string) => Bun.YAML.parse(text), + () => null, +); + +function parseUpdates(yamlText: string): DependabotUpdateEntry[] { + const parsed = safeYamlParse(yamlText).unwrapOr(null); + if (!isRecord(parsed)) return []; + const rawUpdates = parsed.updates; + if (!Array.isArray(rawUpdates)) return []; + const out: DependabotUpdateEntry[] = []; + for (const raw of rawUpdates) { + const entry = normalizeUpdate(raw); + if (entry !== null) out.push(entry); + } + return out; +} + +function normalizeUpdate(raw: unknown): DependabotUpdateEntry | null { + if (!isRecord(raw)) return null; + const ecosystem = typeof raw['package-ecosystem'] === 'string' ? raw['package-ecosystem'] : null; + if (ecosystem === null) return null; + return { + ecosystem, + interval: extractInterval(raw.schedule), + openPullRequestsLimit: extractOpenPrLimit(raw['open-pull-requests-limit']), + groupCount: extractGroupCount(raw.groups), + ignoreCount: extractListCount(raw.ignore), + }; +} + +function extractInterval(schedule: unknown): DependabotInterval | null { + if (!isRecord(schedule)) return null; + const value = schedule.interval; + if (value === 'daily' || value === 'weekly' || value === 'monthly') return value; + return null; +} + +function extractOpenPrLimit(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return Math.floor(value); + return DEFAULT_OPEN_PR_LIMIT; +} + +function extractGroupCount(value: unknown): number { + if (!isRecord(value)) return 0; + return Object.keys(value).length; +} + +function extractListCount(value: unknown): number { + if (!Array.isArray(value)) return 0; + return value.length; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function chunk(items: readonly T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} diff --git a/src/github/graphql/generated.ts b/src/github/graphql/generated.ts index 21c348a..839e940 100644 --- a/src/github/graphql/generated.ts +++ b/src/github/graphql/generated.ts @@ -33,6 +33,82 @@ export type PullRequestState = /** A pull request that is still open. */ | 'OPEN'; +/** The rule types supported in rulesets */ +export type RepositoryRuleType = + /** Authorization */ + | 'AUTHORIZATION' + /** Branch name pattern */ + | 'BRANCH_NAME_PATTERN' + /** + * Choose which tools must provide code scanning results before the reference is + * updated. When configured, code scanning must be enabled and have results for + * both the commit and the reference being updated. + */ + | 'CODE_SCANNING' + /** Committer email pattern */ + | 'COMMITTER_EMAIL_PATTERN' + /** Commit author email pattern */ + | 'COMMIT_AUTHOR_EMAIL_PATTERN' + /** Commit message pattern */ + | 'COMMIT_MESSAGE_PATTERN' + /** Only allow users with bypass permission to create matching refs. */ + | 'CREATION' + /** Only allow users with bypass permissions to delete matching refs. */ + | 'DELETION' + /** Prevent commits that include files with specified file extensions from being pushed to the commit graph. */ + | 'FILE_EXTENSION_RESTRICTION' + /** Prevent commits that include changes in specified file paths from being pushed to the commit graph. */ + | 'FILE_PATH_RESTRICTION' + /** Branch is read-only. Users cannot push to the branch. */ + | 'LOCK_BRANCH' + /** Prevent commits that include file paths that exceed a specified character limit from being pushed to the commit graph. */ + | 'MAX_FILE_PATH_LENGTH' + /** Prevent commits that exceed a specified file size limit from being pushed to the commit graph. */ + | 'MAX_FILE_SIZE' + /** Max ref updates */ + | 'MAX_REF_UPDATES' + /** Merges must be performed via a merge queue. */ + | 'MERGE_QUEUE' + /** Merge queue locked ref */ + | 'MERGE_QUEUE_LOCKED_REF' + /** Prevent users with push access from force pushing to refs. */ + | 'NON_FAST_FORWARD' + /** Require all commits be made to a non-target branch and submitted via a pull request before they can be merged. */ + | 'PULL_REQUEST' + /** Choose which environments must be successfully deployed to before refs can be pushed into a ref that matches this rule. */ + | 'REQUIRED_DEPLOYMENTS' + /** Prevent merge commits from being pushed to matching refs. */ + | 'REQUIRED_LINEAR_HISTORY' + /** + * When enabled, all conversations on code must be resolved before a pull request + * can be merged into a branch that matches this rule. + */ + | 'REQUIRED_REVIEW_THREAD_RESOLUTION' + /** Commits pushed to matching refs must have verified signatures. */ + | 'REQUIRED_SIGNATURES' + /** + * Choose which status checks must pass before the ref is updated. When enabled, + * commits must first be pushed to another ref where the checks pass. + */ + | 'REQUIRED_STATUS_CHECKS' + /** + * Require all commits be made to a non-target branch and submitted via a pull + * request and required workflow checks to pass before they can be merged. + */ + | 'REQUIRED_WORKFLOW_STATUS_CHECKS' + /** Secret scanning */ + | 'SECRET_SCANNING' + /** Tag */ + | 'TAG' + /** Tag name pattern */ + | 'TAG_NAME_PATTERN' + /** Only allow users with bypass permission to update matching refs. */ + | 'UPDATE' + /** Require all changes made to a targeted branch to pass the specified workflows before they can be merged. */ + | 'WORKFLOWS' + /** Workflow files cannot be modified. */ + | 'WORKFLOW_UPDATES'; + /** The possible commit status states. */ export type StatusState = /** Status is errored. */ @@ -81,5 +157,311 @@ export type DependabotPrsQuery = { search: { pageInfo: { hasNextPage: boolean, e | Record | null> | null } }; +export type RepoMetadataBatchQueryVariables = Exact<{ + ids: Array | string | number; +}>; + + +export type RepoMetadataBatchQuery = { nodes: Array< + | { __typename: 'AddedToMergeQueueEvent' } + | { __typename: 'AddedToProjectEvent' } + | { __typename: 'App' } + | { __typename: 'AssignedEvent' } + | { __typename: 'AutoMergeDisabledEvent' } + | { __typename: 'AutoMergeEnabledEvent' } + | { __typename: 'AutoRebaseEnabledEvent' } + | { __typename: 'AutoSquashEnabledEvent' } + | { __typename: 'AutomaticBaseChangeFailedEvent' } + | { __typename: 'AutomaticBaseChangeSucceededEvent' } + | { __typename: 'BaseRefChangedEvent' } + | { __typename: 'BaseRefDeletedEvent' } + | { __typename: 'BaseRefForcePushedEvent' } + | { __typename: 'Blob' } + | { __typename: 'Bot' } + | { __typename: 'BranchProtectionRule' } + | { __typename: 'BypassForcePushAllowance' } + | { __typename: 'BypassPullRequestAllowance' } + | { __typename: 'CWE' } + | { __typename: 'CheckRun' } + | { __typename: 'CheckSuite' } + | { __typename: 'ClosedEvent' } + | { __typename: 'CodeOfConduct' } + | { __typename: 'CommentDeletedEvent' } + | { __typename: 'Commit' } + | { __typename: 'CommitComment' } + | { __typename: 'CommitCommentThread' } + | { __typename: 'Comparison' } + | { __typename: 'ConnectedEvent' } + | { __typename: 'ConvertToDraftEvent' } + | { __typename: 'ConvertedNoteToIssueEvent' } + | { __typename: 'ConvertedToDiscussionEvent' } + | { __typename: 'CrossReferencedEvent' } + | { __typename: 'DemilestonedEvent' } + | { __typename: 'DependencyGraphManifest' } + | { __typename: 'DeployKey' } + | { __typename: 'DeployedEvent' } + | { __typename: 'Deployment' } + | { __typename: 'DeploymentEnvironmentChangedEvent' } + | { __typename: 'DeploymentReview' } + | { __typename: 'DeploymentStatus' } + | { __typename: 'DisconnectedEvent' } + | { __typename: 'Discussion' } + | { __typename: 'DiscussionCategory' } + | { __typename: 'DiscussionComment' } + | { __typename: 'DiscussionPoll' } + | { __typename: 'DiscussionPollOption' } + | { __typename: 'DraftIssue' } + | { __typename: 'Enterprise' } + | { __typename: 'EnterpriseAdministratorInvitation' } + | { __typename: 'EnterpriseIdentityProvider' } + | { __typename: 'EnterpriseMemberInvitation' } + | { __typename: 'EnterpriseRepositoryInfo' } + | { __typename: 'EnterpriseServerInstallation' } + | { __typename: 'EnterpriseServerUserAccount' } + | { __typename: 'EnterpriseServerUserAccountEmail' } + | { __typename: 'EnterpriseServerUserAccountsUpload' } + | { __typename: 'EnterpriseUserAccount' } + | { __typename: 'Environment' } + | { __typename: 'ExternalIdentity' } + | { __typename: 'Gist' } + | { __typename: 'GistComment' } + | { __typename: 'HeadRefDeletedEvent' } + | { __typename: 'HeadRefForcePushedEvent' } + | { __typename: 'HeadRefRestoredEvent' } + | { __typename: 'IpAllowListEntry' } + | { __typename: 'Issue' } + | { __typename: 'IssueComment' } + | { __typename: 'Label' } + | { __typename: 'LabeledEvent' } + | { __typename: 'Language' } + | { __typename: 'License' } + | { __typename: 'LinkedBranch' } + | { __typename: 'LockedEvent' } + | { __typename: 'Mannequin' } + | { __typename: 'MarkedAsDuplicateEvent' } + | { __typename: 'MarketplaceCategory' } + | { __typename: 'MarketplaceListing' } + | { __typename: 'MemberFeatureRequestNotification' } + | { __typename: 'MembersCanDeleteReposClearAuditEntry' } + | { __typename: 'MembersCanDeleteReposDisableAuditEntry' } + | { __typename: 'MembersCanDeleteReposEnableAuditEntry' } + | { __typename: 'MentionedEvent' } + | { __typename: 'MergeQueue' } + | { __typename: 'MergeQueueEntry' } + | { __typename: 'MergedEvent' } + | { __typename: 'MigrationSource' } + | { __typename: 'Milestone' } + | { __typename: 'MilestonedEvent' } + | { __typename: 'MovedColumnsInProjectEvent' } + | { __typename: 'OIDCProvider' } + | { __typename: 'OauthApplicationCreateAuditEntry' } + | { __typename: 'OrgAddBillingManagerAuditEntry' } + | { __typename: 'OrgAddMemberAuditEntry' } + | { __typename: 'OrgBlockUserAuditEntry' } + | { __typename: 'OrgConfigDisableCollaboratorsOnlyAuditEntry' } + | { __typename: 'OrgConfigEnableCollaboratorsOnlyAuditEntry' } + | { __typename: 'OrgCreateAuditEntry' } + | { __typename: 'OrgDisableOauthAppRestrictionsAuditEntry' } + | { __typename: 'OrgDisableSamlAuditEntry' } + | { __typename: 'OrgDisableTwoFactorRequirementAuditEntry' } + | { __typename: 'OrgEnableOauthAppRestrictionsAuditEntry' } + | { __typename: 'OrgEnableSamlAuditEntry' } + | { __typename: 'OrgEnableTwoFactorRequirementAuditEntry' } + | { __typename: 'OrgInviteMemberAuditEntry' } + | { __typename: 'OrgInviteToBusinessAuditEntry' } + | { __typename: 'OrgOauthAppAccessApprovedAuditEntry' } + | { __typename: 'OrgOauthAppAccessBlockedAuditEntry' } + | { __typename: 'OrgOauthAppAccessDeniedAuditEntry' } + | { __typename: 'OrgOauthAppAccessRequestedAuditEntry' } + | { __typename: 'OrgOauthAppAccessUnblockedAuditEntry' } + | { __typename: 'OrgRemoveBillingManagerAuditEntry' } + | { __typename: 'OrgRemoveMemberAuditEntry' } + | { __typename: 'OrgRemoveOutsideCollaboratorAuditEntry' } + | { __typename: 'OrgRestoreMemberAuditEntry' } + | { __typename: 'OrgUnblockUserAuditEntry' } + | { __typename: 'OrgUpdateDefaultRepositoryPermissionAuditEntry' } + | { __typename: 'OrgUpdateMemberAuditEntry' } + | { __typename: 'OrgUpdateMemberRepositoryCreationPermissionAuditEntry' } + | { __typename: 'OrgUpdateMemberRepositoryInvitationPermissionAuditEntry' } + | { __typename: 'Organization' } + | { __typename: 'OrganizationIdentityProvider' } + | { __typename: 'OrganizationInvitation' } + | { __typename: 'OrganizationMigration' } + | { __typename: 'Package' } + | { __typename: 'PackageFile' } + | { __typename: 'PackageTag' } + | { __typename: 'PackageVersion' } + | { __typename: 'ParentIssueAddedEvent' } + | { __typename: 'ParentIssueRemovedEvent' } + | { __typename: 'PinnedDiscussion' } + | { __typename: 'PinnedEnvironment' } + | { __typename: 'PinnedEvent' } + | { __typename: 'PinnedIssue' } + | { __typename: 'PrivateRepositoryForkingDisableAuditEntry' } + | { __typename: 'PrivateRepositoryForkingEnableAuditEntry' } + | { __typename: 'Project' } + | { __typename: 'ProjectCard' } + | { __typename: 'ProjectColumn' } + | { __typename: 'ProjectV2' } + | { __typename: 'ProjectV2Field' } + | { __typename: 'ProjectV2Item' } + | { __typename: 'ProjectV2ItemFieldDateValue' } + | { __typename: 'ProjectV2ItemFieldIterationValue' } + | { __typename: 'ProjectV2ItemFieldNumberValue' } + | { __typename: 'ProjectV2ItemFieldSingleSelectValue' } + | { __typename: 'ProjectV2ItemFieldTextValue' } + | { __typename: 'ProjectV2IterationField' } + | { __typename: 'ProjectV2SingleSelectField' } + | { __typename: 'ProjectV2StatusUpdate' } + | { __typename: 'ProjectV2View' } + | { __typename: 'ProjectV2Workflow' } + | { __typename: 'PublicKey' } + | { __typename: 'PullRequest' } + | { __typename: 'PullRequestCommit' } + | { __typename: 'PullRequestCommitCommentThread' } + | { __typename: 'PullRequestReview' } + | { __typename: 'PullRequestReviewComment' } + | { __typename: 'PullRequestReviewThread' } + | { __typename: 'PullRequestThread' } + | { __typename: 'Push' } + | { __typename: 'PushAllowance' } + | { __typename: 'Query' } + | { __typename: 'Reaction' } + | { __typename: 'ReadyForReviewEvent' } + | { __typename: 'Ref' } + | { __typename: 'ReferencedEvent' } + | { __typename: 'Release' } + | { __typename: 'ReleaseAsset' } + | { __typename: 'RemovedFromMergeQueueEvent' } + | { __typename: 'RemovedFromProjectEvent' } + | { __typename: 'RenamedTitleEvent' } + | { __typename: 'ReopenedEvent' } + | { __typename: 'RepoAccessAuditEntry' } + | { __typename: 'RepoAddMemberAuditEntry' } + | { __typename: 'RepoAddTopicAuditEntry' } + | { __typename: 'RepoArchivedAuditEntry' } + | { __typename: 'RepoChangeMergeSettingAuditEntry' } + | { __typename: 'RepoConfigDisableAnonymousGitAccessAuditEntry' } + | { __typename: 'RepoConfigDisableCollaboratorsOnlyAuditEntry' } + | { __typename: 'RepoConfigDisableContributorsOnlyAuditEntry' } + | { __typename: 'RepoConfigDisableSockpuppetDisallowedAuditEntry' } + | { __typename: 'RepoConfigEnableAnonymousGitAccessAuditEntry' } + | { __typename: 'RepoConfigEnableCollaboratorsOnlyAuditEntry' } + | { __typename: 'RepoConfigEnableContributorsOnlyAuditEntry' } + | { __typename: 'RepoConfigEnableSockpuppetDisallowedAuditEntry' } + | { __typename: 'RepoConfigLockAnonymousGitAccessAuditEntry' } + | { __typename: 'RepoConfigUnlockAnonymousGitAccessAuditEntry' } + | { __typename: 'RepoCreateAuditEntry' } + | { __typename: 'RepoDestroyAuditEntry' } + | { __typename: 'RepoRemoveMemberAuditEntry' } + | { __typename: 'RepoRemoveTopicAuditEntry' } + | { __typename: 'Repository', yml: + | { text: string | null } + | Record + | null, yaml: + | { text: string | null } + | Record + | null, defaultBranchRef: { branchProtectionRule: { requiredApprovingReviewCount: number | null, requiresStatusChecks: boolean } | null, rules: { nodes: Array<{ type: RepositoryRuleType, parameters: + | { __typename: 'BranchNamePatternParameters' } + | { __typename: 'CodeScanningParameters' } + | { __typename: 'CommitAuthorEmailPatternParameters' } + | { __typename: 'CommitMessagePatternParameters' } + | { __typename: 'CommitterEmailPatternParameters' } + | { __typename: 'FileExtensionRestrictionParameters' } + | { __typename: 'FilePathRestrictionParameters' } + | { __typename: 'MaxFilePathLengthParameters' } + | { __typename: 'MaxFileSizeParameters' } + | { __typename: 'MergeQueueParameters' } + | { __typename: 'PullRequestParameters', requiredApprovingReviewCount: number } + | { __typename: 'RequiredDeploymentsParameters' } + | { __typename: 'RequiredStatusChecksParameters', requiredStatusChecks: Array<{ context: string }> } + | { __typename: 'TagNamePatternParameters' } + | { __typename: 'UpdateParameters' } + | { __typename: 'WorkflowsParameters' } + | null } | null> | null } | null } | null } + | { __typename: 'RepositoryInvitation' } + | { __typename: 'RepositoryMigration' } + | { __typename: 'RepositoryRule' } + | { __typename: 'RepositoryRuleset' } + | { __typename: 'RepositoryRulesetBypassActor' } + | { __typename: 'RepositoryTopic' } + | { __typename: 'RepositoryVisibilityChangeDisableAuditEntry' } + | { __typename: 'RepositoryVisibilityChangeEnableAuditEntry' } + | { __typename: 'RepositoryVulnerabilityAlert' } + | { __typename: 'ReviewDismissalAllowance' } + | { __typename: 'ReviewDismissedEvent' } + | { __typename: 'ReviewRequest' } + | { __typename: 'ReviewRequestRemovedEvent' } + | { __typename: 'ReviewRequestedEvent' } + | { __typename: 'SavedReply' } + | { __typename: 'SecurityAdvisory' } + | { __typename: 'SponsorsActivity' } + | { __typename: 'SponsorsListing' } + | { __typename: 'SponsorsListingFeaturedItem' } + | { __typename: 'SponsorsTier' } + | { __typename: 'Sponsorship' } + | { __typename: 'SponsorshipNewsletter' } + | { __typename: 'Status' } + | { __typename: 'StatusCheckRollup' } + | { __typename: 'StatusContext' } + | { __typename: 'SubIssueAddedEvent' } + | { __typename: 'SubIssueRemovedEvent' } + | { __typename: 'SubscribedEvent' } + | { __typename: 'Tag' } + | { __typename: 'Team' } + | { __typename: 'TeamAddMemberAuditEntry' } + | { __typename: 'TeamAddRepositoryAuditEntry' } + | { __typename: 'TeamChangeParentTeamAuditEntry' } + | { __typename: 'TeamDiscussion' } + | { __typename: 'TeamDiscussionComment' } + | { __typename: 'TeamRemoveMemberAuditEntry' } + | { __typename: 'TeamRemoveRepositoryAuditEntry' } + | { __typename: 'Topic' } + | { __typename: 'TransferredEvent' } + | { __typename: 'Tree' } + | { __typename: 'UnassignedEvent' } + | { __typename: 'UnlabeledEvent' } + | { __typename: 'UnlockedEvent' } + | { __typename: 'UnmarkedAsDuplicateEvent' } + | { __typename: 'UnpinnedEvent' } + | { __typename: 'UnsubscribedEvent' } + | { __typename: 'User' } + | { __typename: 'UserBlockedEvent' } + | { __typename: 'UserContentEdit' } + | { __typename: 'UserList' } + | { __typename: 'UserNamespaceRepository' } + | { __typename: 'UserStatus' } + | { __typename: 'VerifiableDomain' } + | { __typename: 'Workflow' } + | { __typename: 'WorkflowRun' } + | { __typename: 'WorkflowRunFile' } + | null> }; + +export type RepoMetadataFieldsFragment = { yml: + | { text: string | null } + | Record + | null, yaml: + | { text: string | null } + | Record + | null, defaultBranchRef: { branchProtectionRule: { requiredApprovingReviewCount: number | null, requiresStatusChecks: boolean } | null, rules: { nodes: Array<{ type: RepositoryRuleType, parameters: + | { __typename: 'BranchNamePatternParameters' } + | { __typename: 'CodeScanningParameters' } + | { __typename: 'CommitAuthorEmailPatternParameters' } + | { __typename: 'CommitMessagePatternParameters' } + | { __typename: 'CommitterEmailPatternParameters' } + | { __typename: 'FileExtensionRestrictionParameters' } + | { __typename: 'FilePathRestrictionParameters' } + | { __typename: 'MaxFilePathLengthParameters' } + | { __typename: 'MaxFileSizeParameters' } + | { __typename: 'MergeQueueParameters' } + | { __typename: 'PullRequestParameters', requiredApprovingReviewCount: number } + | { __typename: 'RequiredDeploymentsParameters' } + | { __typename: 'RequiredStatusChecksParameters', requiredStatusChecks: Array<{ context: string }> } + | { __typename: 'TagNamePatternParameters' } + | { __typename: 'UpdateParameters' } + | { __typename: 'WorkflowsParameters' } + | null } | null> | null } | null } | null }; -export const DependabotPrsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DependabotPrs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchQuery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"search"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"query"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchQuery"}}},{"kind":"Argument","name":{"kind":"Name","value":"type"},"value":{"kind":"EnumValue","value":"ISSUE"}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"50"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PullRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"number"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"closedAt"}},{"kind":"Field","name":{"kind":"Name","value":"mergedAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"baseRefName"}},{"kind":"Field","name":{"kind":"Name","value":"headRefName"}},{"kind":"Field","name":{"kind":"Name","value":"mergedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"login"}}]}},{"kind":"Field","name":{"kind":"Name","value":"autoMergeRequest"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enabledAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"repository"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"}}]}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"reviews"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"50"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"login"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"comments"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"50"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"login"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"last"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commit"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"statusCheckRollup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contexts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CheckRun"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"conclusion"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StatusContext"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"context"}},{"kind":"Field","name":{"kind":"Name","value":"state"}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const RepoMetadataFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RepoMetadataFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Repository"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"yml"},"name":{"kind":"Name","value":"object"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"expression"},"value":{"kind":"StringValue","value":"HEAD:.github/dependabot.yml","block":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Blob"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"yaml"},"name":{"kind":"Name","value":"object"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"expression"},"value":{"kind":"StringValue","value":"HEAD:.github/dependabot.yaml","block":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Blob"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"defaultBranchRef"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"branchProtectionRule"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requiredApprovingReviewCount"}},{"kind":"Field","name":{"kind":"Name","value":"requiresStatusChecks"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rules"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"20"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"parameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PullRequestParameters"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requiredApprovingReviewCount"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RequiredStatusChecksParameters"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requiredStatusChecks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"context"}}]}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const DependabotPrsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DependabotPrs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchQuery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"search"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"query"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchQuery"}}},{"kind":"Argument","name":{"kind":"Name","value":"type"},"value":{"kind":"EnumValue","value":"ISSUE"}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"50"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PullRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"number"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"closedAt"}},{"kind":"Field","name":{"kind":"Name","value":"mergedAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"baseRefName"}},{"kind":"Field","name":{"kind":"Name","value":"headRefName"}},{"kind":"Field","name":{"kind":"Name","value":"mergedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"login"}}]}},{"kind":"Field","name":{"kind":"Name","value":"autoMergeRequest"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enabledAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"repository"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"}}]}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"reviews"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"50"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"login"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"comments"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"50"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"login"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"last"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commit"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"statusCheckRollup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contexts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CheckRun"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"conclusion"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StatusContext"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"context"}},{"kind":"Field","name":{"kind":"Name","value":"state"}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const RepoMetadataBatchDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"RepoMetadataBatch"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Repository"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RepoMetadataFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RepoMetadataFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Repository"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"yml"},"name":{"kind":"Name","value":"object"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"expression"},"value":{"kind":"StringValue","value":"HEAD:.github/dependabot.yml","block":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Blob"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"yaml"},"name":{"kind":"Name","value":"object"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"expression"},"value":{"kind":"StringValue","value":"HEAD:.github/dependabot.yaml","block":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Blob"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"defaultBranchRef"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"branchProtectionRule"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requiredApprovingReviewCount"}},{"kind":"Field","name":{"kind":"Name","value":"requiresStatusChecks"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rules"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"20"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"parameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PullRequestParameters"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requiredApprovingReviewCount"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RequiredStatusChecksParameters"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requiredStatusChecks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"context"}}]}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file