diff --git a/README.md b/README.md index 24af50e..d752abc 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 @@ -56,12 +56,11 @@ 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` - 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/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/cli.test.ts b/src/cli.test.ts index cef6792..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,17 +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}/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', - }); - 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.onPaginate('GET /repos/{owner}/{repo}/commits', {}).resolves([]); + githubClient.onGraphql('RepoMetadataBatch').resolves(repoBatchResponse([npmConfigBlob])); + githubClient.onPaginate('GET /orgs/{org}/dependabot/alerts', {}).resolves([]); githubClient.onGraphql('DependabotPrs').resolves({ search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, }); @@ -127,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', @@ -138,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', @@ -148,17 +153,8 @@ 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', - }); - 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.onPaginate('GET /repos/{owner}/{repo}/commits', {}).resolves([]); + githubClient.onGraphql('RepoMetadataBatch').resolves(repoBatchResponse([npmConfigBlob])); + githubClient.onPaginate('GET /orgs/{org}/dependabot/alerts', {}).resolves([]); githubClient.onGraphql('DependabotPrs').resolves({ search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, }); @@ -172,7 +168,76 @@ 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', + node_id: 'R_kgDOsolo', + owner: { login: 'blimmer' }, + private: false, + visibility: 'public', + archived: false, + fork: false, + default_branch: 'main', + language: 'TypeScript', + pushed_at: '2026-04-01T00:00:00Z', + }, + ]); + 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.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' }); @@ -182,7 +247,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 c127784..2d62740 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,13 +1,11 @@ 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 pMap from 'p-map'; +import { getCveAlerts, getOrgCveAlerts } from './collectors/cve.ts'; import { listDependabotPrs } from './collectors/dependabotPrs.ts'; -import { getRepoLanguages, listOrgRepos } from './collectors/repos.ts'; -import { mapWithConcurrency } from './concurrency.ts'; +import { listRepoMetadataBatched } from './collectors/repoMetadata.ts'; +import { type TargetKind, listTargetRepos } from './collectors/repos.ts'; import type { Context } from './context.ts'; import { getErrorMessage } from './errors.ts'; import { formatFsError } from './FileSystem.ts'; @@ -22,11 +20,9 @@ import type { BranchProtectionSlice, CollectedData, CollectorWarning, - ContributorSlice, CveSlice, DependabotConfigSlice, DependabotPr, - RepoLanguages, RepoMeta, } from './types.ts'; @@ -201,7 +197,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 }, @@ -211,7 +207,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 }, @@ -243,6 +239,7 @@ async function collectAll( ctx: Context, repos: RepoMeta[], target: string, + targetKind: TargetKind, windowDays: number, windowStart: Instant, now: Instant, @@ -251,41 +248,32 @@ async function collectAll( const warnings: CollectorWarning[] = []; const windowStartIso = windowStart.toString(); - const [languages, dependabotConfig, cve, branchProtection, contributors, 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 })), - ), - crawlPerRepo( - repos, - (r) => getDependabotConfig(client, { owner: r.owner, name: r.name }), - warnings, - 'dependabotConfig', - ), - crawlPerRepo(repos, (r) => getCveAlerts(client, { owner: r.owner, name: r.name }), warnings, 'cve'), - crawlPerRepo( - repos, - (r) => getBranchProtection(client, { owner: r.owner, name: r.name }, r.defaultBranch), - warnings, - 'branchProtection', - ), - crawlPerRepo( - repos, - (r) => listActiveCommitters(client, { owner: r.owner, name: r.name }, windowStartIso), - warnings, - 'contributors', - ), + 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 [metadata, cve, dependabotPrs] = await Promise.all([ + runRepoMetadata(listRepoMetadataBatched(client, repos), warnings), + cvePromise, runResultAsync(listDependabotPrs(client, target, windowStartIso), [], warnings, 'dependabotPrs'), ]); return { ctx: { org: target, windowDays, windowStart, now }, repos, - languages, - dependabotConfig, + dependabotConfig: metadata.dependabotConfig, dependabotPrs, cve, - branchProtection, - contributors, + 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/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/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}`; +} 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/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/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; -} 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); } } 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 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 600f299..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,10 +27,9 @@ 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; - activeHumanCommitters: number; reposWithBranchProtection: number; } @@ -143,31 +135,24 @@ 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( (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 +164,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..81353da 100644 --- a/src/report/testFactories.ts +++ b/src/report/testFactories.ts @@ -28,10 +28,9 @@ 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, - activeHumanCommitters: 17, 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 2142d67..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'; @@ -158,7 +157,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()], ]} /> @@ -254,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 a614221..4c85203 100644 --- a/src/testFactories.ts +++ b/src/testFactories.ts @@ -6,13 +6,11 @@ import type { CollectedData, CollectionContext, CollectorWarning, - ContributorSlice, CveAlert, CveSlice, DependabotConfigSlice, DependabotPr, DependabotUpdateEntry, - RepoLanguages, RepoMeta, RepoRef, } from './types.ts'; @@ -25,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, @@ -32,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(() => ({ @@ -89,12 +89,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', @@ -111,12 +105,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' }, @@ -133,11 +121,9 @@ 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()], branchProtection: [branchProtectionSlice.build()], - contributors: [contributorSlice.build()], errors: [], })); diff --git a/src/types.ts b/src/types.ts index 2f0297e..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,14 +16,7 @@ export interface RepoMeta extends RepoRef { primaryLanguage: string | null; pushedAt: string | null; dependabotSecurityUpdates: boolean | null; -} - -export interface LanguageBytes { - [language: string]: number; -} - -export interface RepoLanguages extends RepoRef { - bytes: LanguageBytes; + dependabotAlertsEnabled: boolean | null; } export type DependabotEcosystem = string; @@ -98,10 +92,6 @@ export interface BranchProtectionSlice extends RepoRef { requiresStatusChecks: boolean; } -export interface ContributorSlice extends RepoRef { - activeHumanLogins: string[]; -} - export interface CollectionContext { org: string; windowDays: number; @@ -112,12 +102,10 @@ export interface CollectionContext { export interface CollectedData { ctx: CollectionContext; repos: RepoMeta[]; - languages: RepoLanguages[]; dependabotConfig: DependabotConfigSlice[]; dependabotPrs: DependabotPr[]; cve: CveSlice[]; branchProtection: BranchProtectionSlice[]; - contributors: ContributorSlice[]; errors: CollectorWarning[]; }