diff --git a/src/lib/components/AuthPill.svelte b/src/lib/components/AuthPill.svelte index e21681e..4996228 100644 --- a/src/lib/components/AuthPill.svelte +++ b/src/lib/components/AuthPill.svelte @@ -70,6 +70,6 @@ - {label}: {isApp ? 'App' : 'PAT'} - {rateText} + {label} · {isApp ? 'App' : 'PAT'} + {rateText} diff --git a/src/lib/server/core/auth.ts b/src/lib/server/core/auth.ts index 7876cbb..cfc4ded 100644 --- a/src/lib/server/core/auth.ts +++ b/src/lib/server/core/auth.ts @@ -223,11 +223,25 @@ export function getSourceClients(): { gql: typeof graphql; rest: GitHubClient; sourceApiUrl: string; + getApiCalls: () => number; } { const sourceAuth = resolveSourceAuth(); const sourceApiUrl = env.GH_SOURCE_API_URL || "https://api.github.com"; const client = createSingleClient(sourceAuth, sourceApiUrl); - return { gql: client.graphql as typeof graphql, rest: client, sourceApiUrl }; + // Count every HTTP request the crawl makes. The `before` hook fires once per + // actual request — REST and GraphQL (both go through Octokit's request + // pipeline), each pagination page, and each retry — so the tally reflects the + // true API cost of a run, which the runner persists for rate-limit visibility. + let apiCalls = 0; + client.hook.before("request", () => { + apiCalls += 1; + }); + return { + gql: client.graphql as typeof graphql, + rest: client, + sourceApiUrl, + getApiCalls: () => apiCalls, + }; } /** diff --git a/src/lib/server/core/github.test.ts b/src/lib/server/core/github.test.ts index 0532485..a3c964f 100644 --- a/src/lib/server/core/github.test.ts +++ b/src/lib/server/core/github.test.ts @@ -1,5 +1,6 @@ import { describe, expect, spyOn, test } from "bun:test"; import { + countByPagination, doesOrgExist, doesRepoExist, isGhecSource, @@ -201,3 +202,57 @@ describe("doesRepoExist", () => { await expect(doesRepoExist(client, "acme", "widget")).rejects.toThrow(/HTTP 403/); }); }); + +describe("countByPagination", () => { + /** A client whose `request` returns the given headers/data and records args. */ + function client(response: { headers?: Record; data?: unknown }) { + const calls: Array<{ route: string; params: Record }> = []; + const c = { + request: async (route: string, params: Record) => { + calls.push({ route, params }); + return { headers: response.headers ?? {}, data: response.data ?? [] }; + }, + } as unknown as Parameters[0]; + return { c, calls }; + } + + test("reads the rel=last page number as the count", async () => { + const { c } = client({ + headers: { + link: '; rel="next", ; rel="last"', + }, + }); + expect(await countByPagination(c, "GET /x", {})).toBe(987); + }); + + test("forces per_page=1 regardless of caller params", async () => { + const { c, calls } = client({ data: [{}] }); + await countByPagination(c, "GET /repos/{owner}/{repo}/commits", { + owner: "acme", + repo: "widget", + per_page: 100, + }); + expect(calls[0]?.params).toMatchObject({ owner: "acme", repo: "widget", per_page: 1 }); + }); + + test("finds page= regardless of its position in the rel=last URL", async () => { + const { c } = client({ + headers: { link: '; rel="last"' }, + }); + expect(await countByPagination(c, "GET /x", {})).toBe(55); + }); + + test("falls back to the returned item count when there is no rel=last link", async () => { + // Only a `next` link (no `last`) → single page in hand; count what we got. + const { c } = client({ + headers: { link: '; rel="next"' }, + data: [{ id: 1 }], + }); + expect(await countByPagination(c, "GET /x", {})).toBe(1); + }); + + test("returns 0 when there is no link header and no items", async () => { + const { c } = client({ headers: {}, data: [] }); + expect(await countByPagination(c, "GET /x", {})).toBe(0); + }); +}); diff --git a/src/lib/server/core/github.ts b/src/lib/server/core/github.ts index 462ea53..2545430 100644 --- a/src/lib/server/core/github.ts +++ b/src/lib/server/core/github.ts @@ -211,6 +211,46 @@ export async function doesRepoExist( // ── Repo counts (for progress) ──────────────────────────────────────────── +/** + * Count a paginated REST collection cheaply via the `Link` header. + * + * GitHub paginates list endpoints and advertises a `rel="last"` link whenever a + * collection spans more than one page. Requesting a single item per page + * (`per_page=1`) makes that last-page number equal the total item count — so one + * request sizes the collection without walking every page or materializing every + * object. This is the standard, protocol-level way to count commits, webhooks, + * and the like, rather than fetching them or walking a graph (e.g. GraphQL + * `history.totalCount`, which walks the whole commit graph and times out at + * scale). + * + * When there's no `rel="last"` link the collection fits on one page, so the + * count is the number of items actually returned (0 or 1). + * + * @param client REST client. + * @param route Octokit route, e.g. `"GET /repos/{owner}/{repo}/commits"`. + * @param params Route + query params; `per_page` is forced to 1. + * @returns The item count. + */ +export async function countByPagination( + client: InstanceType, + route: string, + params: Record, +): Promise { + const res = await client.request(route, { ...params, per_page: 1 }); + const link = res.headers?.link; + if (typeof link === "string") { + // The header is a comma-separated list of `; rel="…"` parts. Find the + // `last` part and read its `page=N` query param (position-independent). + for (const part of link.split(",")) { + if (/\brel="last"/.test(part)) { + const m = part.match(/[?&]page=(\d+)/); + if (m?.[1]) return Number.parseInt(m[1], 10); + } + } + } + return Array.isArray(res.data) ? res.data.length : 0; +} + /** * Fetch a repository's disk size in kilobytes (GitHub's `size` field). * Returns null if the repo can't be read — callers treat this as "unknown". diff --git a/src/lib/server/profile/analyze.test.ts b/src/lib/server/profile/analyze.test.ts index ca6e412..cac2bce 100644 --- a/src/lib/server/profile/analyze.test.ts +++ b/src/lib/server/profile/analyze.test.ts @@ -23,6 +23,7 @@ function cleanSignals(over: Partial = {}): RepoSignals { hasIssues: true, hasProjects: false, hasDiscussions: false, + hasPages: false, defaultBranch: "main", pushedAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-02T00:00:00Z", @@ -45,6 +46,8 @@ function cleanSignals(over: Partial = {}): RepoSignals { usesLfs: false, releaseAssetBytes: 0, workflowFileCount: 0, + webhooksCount: 0, + hasCodeScanningAlerts: false, ...over, }; } @@ -74,8 +77,8 @@ describe("analyzeRepo", () => { test("considerations without a detector are reported as indeterminate, not clear", () => { const profile = analyzeRepo(cleanSignals()); - // `webhooks` has no signal gathered yet. - expect(finding(profile, "webhooks")?.status).toBe("indeterminate"); + // `commit-size-limit` needs git-sizer — no signal gathered yet. + expect(finding(profile, "commit-size-limit")?.status).toBe("indeterminate"); expect(finding(profile, "discussions")?.status).toBe("clear"); }); @@ -95,6 +98,9 @@ describe("analyzeRepo", () => { usesLfs: true, workflowFileCount: 4, rulesetCount: 3, + webhooksCount: 2, + hasPages: true, + hasCodeScanningAlerts: true, }), ); @@ -118,6 +124,9 @@ describe("analyzeRepo", () => { "4 workflows (run history & artifacts not migrated)", ); expect(finding(profile, "rulesets")?.evidence).toBe("3 rulesets"); + expect(finding(profile, "webhooks")?.evidence).toBe("2 webhooks"); + expect(finding(profile, "pages")?.status).toBe("applies"); + expect(finding(profile, "code-scanning-history")?.status).toBe("applies"); }); test("rolls up applying considerations by severity", () => { diff --git a/src/lib/server/profile/analyze.ts b/src/lib/server/profile/analyze.ts index 12a1f4a..87096c2 100644 --- a/src/lib/server/profile/analyze.ts +++ b/src/lib/server/profile/analyze.ts @@ -100,6 +100,10 @@ const DETECTORS: Record = { ? `${count(s.branchProtectionRulesUsingUnmigratedFeatures, "rule")} using unmigrated features` : null, rulesets: (s) => (s.rulesetCount > 0 ? count(s.rulesetCount, "ruleset") : null), + webhooks: (s) => (s.webhooksCount > 0 ? count(s.webhooksCount, "webhook") : null), + pages: (s) => (s.hasPages ? "GitHub Pages enabled (reconfigure on the target)" : null), + "code-scanning-history": (s) => + s.hasCodeScanningAlerts ? "code scanning alerts present (history & states not migrated)" : null, "stars-watchers": (s) => s.stargazerCount > 0 || s.watcherCount > 0 ? `${count(s.stargazerCount, "star")}, ${count(s.watcherCount, "watcher")}` @@ -128,6 +132,20 @@ const DETECTORS: Record = { /** Consideration ids the engine can evaluate (the rest report indeterminate). */ export const DETECTED_CONSIDERATION_IDS: readonly string[] = Object.keys(DETECTORS); +/** + * Consideration ids evaluated at the ORGANIZATION level, not per repo — gathered + * once per run by the org-resources REST pass (`run.orgResources`). They have no + * per-repo detector, but they ARE evaluated, so the preparation summary counts + * them as crawled (not “not yet evaluated”) and surfaces them via the org tiles. + */ +export const ORG_EVALUATED_CONSIDERATION_IDS: readonly string[] = [ + "actions-secrets", + "self-hosted-runners", + "dependabot-secrets", + "codespaces-secrets", + "custom-properties", +]; + function summarize(findings: ConsiderationFinding[]): ProfileSummary { const summary: ProfileSummary = { applies: 0, diff --git a/src/lib/server/profile/augment.test.ts b/src/lib/server/profile/augment.test.ts index f86424b..adb9f4b 100644 --- a/src/lib/server/profile/augment.test.ts +++ b/src/lib/server/profile/augment.test.ts @@ -24,6 +24,7 @@ function discovered(over: Partial = {}): DiscoveredRepo { hasIssues: true, hasProjects: false, hasDiscussions: true, + hasPages: false, defaultBranch: "main", pushedAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-02T00:00:00Z", @@ -92,7 +93,6 @@ function countsNode(over: { /** Build one repository alias node for the verification details pass. */ function detailsNode(over: { - commits?: number; /** Raw `.gitattributes` text; omit for no file (null blob). */ lfsAttributes?: string; /** Workflow file names under `.github/workflows`; omit for no dir (null tree). */ @@ -100,12 +100,8 @@ function detailsNode(over: { /** Asset byte sizes; placed in a single scanned release node (omit = not scanned). */ releaseAssetSizes?: number[]; rules?: ReturnType[]; - noDefaultBranch?: boolean; }) { return { - defaultBranchRef: over.noDefaultBranch - ? null - : { target: { history: { totalCount: over.commits ?? 0 } } }, gitattributes: over.lfsAttributes === undefined ? null : { text: over.lfsAttributes }, workflows: over.workflowFiles ? { entries: over.workflowFiles.map((name) => ({ name })) } @@ -290,18 +286,6 @@ describe("augmentRepoCounts", () => { }); describe("augmentRepoDetails", () => { - test("maps the commit count from the default branch history", async () => { - const { fn } = mockGql({ r0: detailsNode({ commits: 512 }) }); - const [d] = await augmentRepoDetails(fn, [discovered()]); - expect(d?.commitsCount).toBe(512); - }); - - test("treats a missing default branch as zero commits", async () => { - const { fn } = mockGql({ r0: detailsNode({ noDefaultBranch: true }) }); - const [d] = await augmentRepoDetails(fn, [discovered({ isEmpty: true })]); - expect(d?.commitsCount).toBe(0); - }); - test("detects Git LFS from a .gitattributes with a filter=lfs entry", async () => { const { fn } = mockGql({ r0: detailsNode({ lfsAttributes: "*.psd filter=lfs diff=lfs merge=lfs -text\n" }), @@ -382,14 +366,17 @@ describe("augmentRepoDetails", () => { }); test("degrades a null alias to zeroed details (keeps the repo)", async () => { - const { fn } = mockGql({ r0: detailsNode({ commits: 5 }), r1: null }); + const { fn } = mockGql({ + r0: detailsNode({ workflowFiles: ["ci.yml"] }), + r1: null, + }); const details = await augmentRepoDetails(fn, [ discovered({ nameWithOwner: "o/ok", name: "ok" }), discovered({ nameWithOwner: "o/gone", name: "gone" }), ]); - expect(details[0]?.commitsCount).toBe(5); + expect(details[0]?.workflowFileCount).toBe(1); expect(details[1]?.nameWithOwner).toBe("o/gone"); - expect(details[1]?.commitsCount).toBe(0); + expect(details[1]?.workflowFileCount).toBe(0); }); test("treats a timeout message (not just a status) as splittable", async () => { @@ -398,7 +385,7 @@ describe("augmentRepoDetails", () => { const size = Object.keys(vars).filter((k) => /^o\d+$/.test(k)).length; calls += 1; if (size > 1) throw new Error("Something went wrong while executing your query"); - return { r0: detailsNode({ commits: 1 }) }; + return { r0: detailsNode({ workflowFiles: ["ci.yml"] }) }; }) as unknown as typeof graphql; const repos = [ @@ -406,7 +393,8 @@ describe("augmentRepoDetails", () => { discovered({ name: "b", nameWithOwner: "o/b" }), ]; const details = await augmentRepoDetails(fn, repos); - expect(details.map((d) => d.commitsCount)).toEqual([1, 1]); + expect(details.map((d) => d.workflowFileCount)).toEqual([1, 1]); + expect(details.map((d) => d.nameWithOwner)).toEqual(["o/a", "o/b"]); expect(calls).toBe(3); // 2 (timeout) → 1 + 1 }); }); diff --git a/src/lib/server/profile/augment.ts b/src/lib/server/profile/augment.ts index 083772e..8dd85e8 100644 --- a/src/lib/server/profile/augment.ts +++ b/src/lib/server/profile/augment.ts @@ -70,13 +70,14 @@ interface RepoCountsNode { } /** - * Expensive per-repo verification fields: a commit-graph walk - * (`history.totalCount`), two git-object reads (`.gitattributes`, - * `.github/workflows`), branch-protection rule detail, and the release-asset - * scan. Gathered in a second pass so a timeout here can't block the counts. + * Expensive per-repo verification fields: two git-object reads + * (`.gitattributes`, `.github/workflows`), branch-protection rule detail, and + * the release-asset scan. Gathered in a second pass so a timeout here can't + * block the counts. (Commit count is gathered separately via REST — see + * `commits.ts` — because GraphQL's `history.totalCount` walks the whole commit + * graph and times out at scale.) */ interface RepoDetailsNode { - defaultBranchRef: { target: { history: { totalCount: number } } | null } | null; /** Root `.gitattributes` on the default branch (`Blob`), or null if absent. */ gitattributes: { text: string | null } | null; /** `.github/workflows` dir on the default branch (`Tree`), or null if absent. */ @@ -91,7 +92,6 @@ interface RepoDetailsNode { /** The verification fields the details pass produces, merged onto the counts. */ export interface RepoDetails { nameWithOwner: string; - commitsCount: number; branchProtectionRulesUsingUnmigratedFeatures: number; usesLfs: boolean; workflowFileCount: number; @@ -122,18 +122,16 @@ const COUNTS_FRAGMENT = `fragment Counts on Repository { }`; /** - * Verification fragment — the expensive parts: the commit-graph walk - * (`history.totalCount`), two git-object reads (`.gitattributes`, - * `.github/workflows`), branch-protection rule detail, and (when `scanReleases`) - * the release-asset scan. Run as a second pass so a timeout here degrades a - * repo's verification rather than blocking its counts. + * Verification fragment — the expensive parts: two git-object reads + * (`.gitattributes`, `.github/workflows`), branch-protection rule detail, and + * (when `scanReleases`) the release-asset scan. Run as a second pass so a + * timeout here degrades a repo's verification rather than blocking its counts. */ function detailsFragment(scanReleases: boolean): string { const releases = scanReleases ? `releases(first: ${RELEASES_SCANNED}) { nodes { releaseAssets(first: ${ASSETS_SCANNED}) { nodes { size } } } }` : ""; return `fragment Details on Repository { - defaultBranchRef { target { ... on Commit { history(first: 1) { totalCount } } } } gitattributes: object(expression: "HEAD:.gitattributes") { ... on Blob { text } } workflows: object(expression: "HEAD:.github/workflows") { ... on Tree { entries { name } } } ${releases} @@ -230,12 +228,14 @@ function countWorkflowFiles(workflows: { entries: { name: string }[] } | null): function countsToSignals(repo: DiscoveredRepo, node: RepoCountsNode | null): RepoSignals { return { ...repo, - // Verification fields — defaults until the details pass merges them in. + // Verification fields — defaults until the details / REST passes merge them in. commitsCount: 0, branchProtectionRulesUsingUnmigratedFeatures: 0, usesLfs: false, workflowFileCount: 0, releaseAssetBytes: 0, + webhooksCount: 0, + hasCodeScanningAlerts: false, // Cheap counts. issuesCount: node?.issues.totalCount ?? 0, pullRequestsCount: node?.pullRequests.totalCount ?? 0, @@ -268,7 +268,6 @@ export function baseSignals(repo: DiscoveredRepo): RepoSignals { function toDetails(repo: DiscoveredRepo, node: RepoDetailsNode | null): RepoDetails { return { nameWithOwner: repo.nameWithOwner, - commitsCount: node?.defaultBranchRef?.target?.history.totalCount ?? 0, branchProtectionRulesUsingUnmigratedFeatures: node ? node.branchProtectionRules.nodes.filter(usesUnmigratedFeature).length : 0, diff --git a/src/lib/server/profile/commits.test.ts b/src/lib/server/profile/commits.test.ts new file mode 100644 index 0000000..f49a614 --- /dev/null +++ b/src/lib/server/profile/commits.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for the REST commit-count gatherer. The `rest` client is faked, so these + * exercise the real branch/empty guards and the delegation to + * `countByPagination` (the `Link`-header trick) with no network. + */ +import { describe, expect, test } from "bun:test"; +import type { GitHubClient } from "$lib/server/core/github"; +import { countRepoCommits } from "./commits"; +import type { DiscoveredRepo } from "./types"; + +function repo(over: Partial = {}): DiscoveredRepo { + return { + name: "widget", + nameWithOwner: "acme/widget", + visibility: "PRIVATE", + isArchived: false, + isFork: false, + isEmpty: false, + diskUsageKb: 100, + hasWiki: false, + hasIssues: true, + hasProjects: false, + hasDiscussions: false, + hasPages: false, + defaultBranch: "main", + pushedAt: null, + updatedAt: null, + ...over, + }; +} + +/** A `rest` double whose `request` returns a queued response and records args. */ +function mockRest(response: { headers?: Record; data?: unknown }) { + const calls: Array<{ route: string; params: Record }> = []; + const rest = { + request: async (route: string, params: Record) => { + calls.push({ route, params }); + return { headers: response.headers ?? {}, data: response.data ?? [{}] }; + }, + } as unknown as GitHubClient; + return { rest, calls }; +} + +describe("countRepoCommits", () => { + test("counts via the Link header's rel=last page number", async () => { + const { rest, calls } = mockRest({ + headers: { + link: '; rel="next", ; rel="last"', + }, + }); + + expect(await countRepoCommits(rest, repo())).toBe(4096); + // Hits the commits endpoint for the right repo, scoped to the default branch. + expect(calls[0]?.route).toBe("GET /repos/{owner}/{repo}/commits"); + expect(calls[0]?.params).toMatchObject({ + owner: "acme", + repo: "widget", + sha: "main", + per_page: 1, + }); + }); + + test("returns the item count when there's no rel=last link (0 or 1 commit)", async () => { + const { rest } = mockRest({ headers: {}, data: [{ sha: "abc" }] }); + expect(await countRepoCommits(rest, repo())).toBe(1); + }); + + test("scopes the count to the repo's own default branch", async () => { + const { rest, calls } = mockRest({ data: [{}] }); + await countRepoCommits(rest, repo({ defaultBranch: "develop" })); + expect(calls[0]?.params).toMatchObject({ sha: "develop" }); + }); + + test("returns 0 without a request for an empty repo", async () => { + const { rest, calls } = mockRest({ data: [{}] }); + expect(await countRepoCommits(rest, repo({ isEmpty: true }))).toBe(0); + expect(calls).toHaveLength(0); + }); + + test("returns 0 without a request when there is no default branch", async () => { + const { rest, calls } = mockRest({ data: [{}] }); + expect(await countRepoCommits(rest, repo({ defaultBranch: null }))).toBe(0); + expect(calls).toHaveLength(0); + }); + + test("degrades to 0 when the request throws (e.g. 409 on an empty repo)", async () => { + const rest = { + request: async () => { + throw Object.assign(new Error("Git Repository is empty."), { status: 409 }); + }, + } as unknown as GitHubClient; + expect(await countRepoCommits(rest, repo())).toBe(0); + }); +}); diff --git a/src/lib/server/profile/commits.ts b/src/lib/server/profile/commits.ts new file mode 100644 index 0000000..d26c2f8 --- /dev/null +++ b/src/lib/server/profile/commits.ts @@ -0,0 +1,43 @@ +/** + * Per-repo commit counts — gathered via REST, not GraphQL. + * + * A repo's commit count is its default branch's history length. GraphQL exposes + * it as `history.totalCount`, but that walks the entire commit graph and is the + * field most likely to trip GitHub's ~10s query timeout at org scale. The REST + * commits endpoint, by contrast, paginates — so requesting one commit per page + * and reading the `Link` header's `rel="last"` page number yields the exact + * count in a single cheap request (see `countByPagination`). + * + * The `rest` client is injected so this is unit-testable without a network. + */ +import { countByPagination, type GitHubClient } from "$lib/server/core/github"; +import type { DiscoveredRepo } from "./types"; + +/** + * Count commits on a repository's default branch via the REST `Link` header. + * + * Empty repos and repos without a default branch are 0 with no request. Any + * error (e.g. a 409 on an empty repo, or a permission issue) also degrades to 0 + * so one repo can't fail the crawl — commit count is scale decoration, not a + * blocker signal. + * + * @param rest Authenticated source REST client. + * @param r The discovered repo (provides `nameWithOwner` and `defaultBranch`). + * @returns The default-branch commit count, or 0 when unavailable. + */ +export async function countRepoCommits(rest: GitHubClient, r: DiscoveredRepo): Promise { + if (r.isEmpty || !r.defaultBranch) return 0; + const slash = r.nameWithOwner.indexOf("/"); + if (slash <= 0) return 0; + const owner = r.nameWithOwner.slice(0, slash); + const repo = r.nameWithOwner.slice(slash + 1); + try { + return await countByPagination(rest, "GET /repos/{owner}/{repo}/commits", { + owner, + repo, + sha: r.defaultBranch, + }); + } catch { + return 0; + } +} diff --git a/src/lib/server/profile/discover.test.ts b/src/lib/server/profile/discover.test.ts index ab62669..5bbded4 100644 --- a/src/lib/server/profile/discover.test.ts +++ b/src/lib/server/profile/discover.test.ts @@ -21,6 +21,7 @@ interface RestRepo { has_issues?: boolean; has_projects?: boolean; has_discussions?: boolean; + has_pages?: boolean; default_branch?: string; pushed_at?: string | null; updated_at?: string | null; @@ -40,6 +41,7 @@ function repo(name: string, over: Partial = {}): RestRepo { has_issues: true, has_projects: false, has_discussions: false, + has_pages: false, default_branch: "main", pushed_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-02T00:00:00Z", @@ -126,6 +128,7 @@ describe("discoverOrgRepos", () => { default_branch: undefined, has_wiki: true, has_discussions: true, + has_pages: true, pushed_at: null, }), ], @@ -145,6 +148,7 @@ describe("discoverOrgRepos", () => { hasIssues: true, hasProjects: false, hasDiscussions: true, + hasPages: true, // read free from the org listing's has_pages defaultBranch: null, pushedAt: null, updatedAt: "2026-01-02T00:00:00Z", diff --git a/src/lib/server/profile/discover.ts b/src/lib/server/profile/discover.ts index bf79e40..ddc2dc8 100644 --- a/src/lib/server/profile/discover.ts +++ b/src/lib/server/profile/discover.ts @@ -36,6 +36,7 @@ interface RestRepoSummary { has_issues?: boolean; has_projects?: boolean; has_discussions?: boolean; + has_pages?: boolean; default_branch?: string; pushed_at?: string | null; updated_at?: string | null; @@ -63,6 +64,7 @@ function toDiscoveredRepo(repo: RestRepoSummary): DiscoveredRepo { hasIssues: repo.has_issues ?? false, hasProjects: repo.has_projects ?? false, hasDiscussions: repo.has_discussions ?? false, + hasPages: repo.has_pages ?? false, defaultBranch: repo.default_branch ?? null, pushedAt: repo.pushed_at ?? null, updatedAt: repo.updated_at ?? null, diff --git a/src/lib/server/profile/estimate.test.ts b/src/lib/server/profile/estimate.test.ts index 68401aa..a8f3cea 100644 --- a/src/lib/server/profile/estimate.test.ts +++ b/src/lib/server/profile/estimate.test.ts @@ -21,6 +21,7 @@ function signals(diskUsageKb: number | null): RepoSignals { hasIssues: true, hasProjects: false, hasDiscussions: false, + hasPages: false, defaultBranch: "main", pushedAt: null, updatedAt: null, @@ -43,6 +44,8 @@ function signals(diskUsageKb: number | null): RepoSignals { usesLfs: false, releaseAssetBytes: 0, workflowFileCount: 0, + webhooksCount: 0, + hasCodeScanningAlerts: false, }; } diff --git a/src/lib/server/profile/insights.test.ts b/src/lib/server/profile/insights.test.ts index 7994aee..cd2291f 100644 --- a/src/lib/server/profile/insights.test.ts +++ b/src/lib/server/profile/insights.test.ts @@ -26,6 +26,7 @@ function signals(over: Partial = {}): RepoSignals { hasIssues: true, hasProjects: false, hasDiscussions: false, + hasPages: false, defaultBranch: "main", pushedAt: monthsAgo(1), updatedAt: monthsAgo(1), @@ -48,6 +49,8 @@ function signals(over: Partial = {}): RepoSignals { usesLfs: false, releaseAssetBytes: 0, workflowFileCount: 0, + webhooksCount: 0, + hasCodeScanningAlerts: false, ...over, }; } diff --git a/src/lib/server/profile/rest-signals.test.ts b/src/lib/server/profile/rest-signals.test.ts new file mode 100644 index 0000000..2b6ec5a --- /dev/null +++ b/src/lib/server/profile/rest-signals.test.ts @@ -0,0 +1,140 @@ +/** + * Tests for the per-repo REST signals gatherer. The `rest` client is faked, so + * these exercise the webhook count, the Pages 200/404 probe, the code-scanning + * presence check, and their independent degrade-to-default behavior, with no + * network. + */ +import { describe, expect, test } from "bun:test"; +import type { GitHubClient } from "$lib/server/core/github"; +import { gatherRepoRestSignals } from "./rest-signals"; +import type { DiscoveredRepo } from "./types"; + +function repo(over: Partial = {}): DiscoveredRepo { + return { + name: "widget", + nameWithOwner: "acme/widget", + visibility: "PRIVATE", + isArchived: false, + isFork: false, + isEmpty: false, + diskUsageKb: 100, + hasWiki: false, + hasIssues: true, + hasProjects: false, + hasDiscussions: false, + hasPages: false, + defaultBranch: "main", + pushedAt: null, + updatedAt: null, + ...over, + }; +} + +/** + * A `rest` double that routes each endpoint to a handler. A handler may return a + * response object or throw (to simulate 404/403). Missing handlers throw 404. + */ +function mockRest( + handlers: Record< + string, + () => { status?: number; headers?: Record; data?: unknown } + >, +) { + const rest = { + request: async (route: string) => { + const h = handlers[route]; + if (!h) throw Object.assign(new Error("Not Found"), { status: 404 }); + const res = h(); + return { status: res.status ?? 200, headers: res.headers ?? {}, data: res.data ?? [] }; + }, + } as unknown as GitHubClient; + return rest; +} + +describe("gatherRepoRestSignals", () => { + test("gathers webhook count and code-scanning together", async () => { + const rest = mockRest({ + "GET /repos/{owner}/{repo}/hooks": () => ({ + headers: { + link: '; rel="last"', + }, + data: [{}], + }), + "GET /repos/{owner}/{repo}/code-scanning/alerts": () => ({ data: [{ number: 1 }] }), + }); + + expect(await gatherRepoRestSignals(rest, repo())).toEqual({ + webhooksCount: 3, + hasCodeScanningAlerts: true, + }); + }); + + test("no code-scanning alerts → false", async () => { + const rest = mockRest({ + "GET /repos/{owner}/{repo}/hooks": () => ({ data: [{}] }), // 1 hook, no last link + "GET /repos/{owner}/{repo}/code-scanning/alerts": () => ({ data: [] }), + }); + + expect(await gatherRepoRestSignals(rest, repo())).toEqual({ + webhooksCount: 1, + hasCodeScanningAlerts: false, + }); + }); + + test("a 404 (code scanning not enabled) degrades to false", async () => { + // The hooks handler is present; code-scanning falls through to the default + // 404 thrower — the expected response when code scanning isn't set up. + const rest = mockRest({ + "GET /repos/{owner}/{repo}/hooks": () => ({ data: [{}] }), + }); + + expect(await gatherRepoRestSignals(rest, repo())).toEqual({ + webhooksCount: 1, + hasCodeScanningAlerts: false, + }); + }); + + test("each signal degrades independently (a 403 on one doesn't suppress the other)", async () => { + const rest = mockRest({ + "GET /repos/{owner}/{repo}/hooks": () => { + throw Object.assign(new Error("Forbidden"), { status: 403 }); + }, + "GET /repos/{owner}/{repo}/code-scanning/alerts": () => ({ data: [{ number: 1 }] }), + }); + + expect(await gatherRepoRestSignals(rest, repo())).toEqual({ + webhooksCount: 0, // 403 → 0 + hasCodeScanningAlerts: true, // still read + }); + }); + + test("a 403 on code scanning (GHAS off / no scope) degrades to false", async () => { + const rest = mockRest({ + "GET /repos/{owner}/{repo}/hooks": () => ({ data: [{}] }), + "GET /repos/{owner}/{repo}/code-scanning/alerts": () => { + throw Object.assign(new Error("Forbidden"), { status: 403 }); + }, + }); + + expect(await gatherRepoRestSignals(rest, repo())).toEqual({ + webhooksCount: 1, + hasCodeScanningAlerts: false, + }); + }); + + test("returns all defaults for a malformed nameWithOwner without any request", async () => { + let called = false; + const rest = { + request: async () => { + called = true; + return { data: [] }; + }, + } as unknown as GitHubClient; + + expect(await gatherRepoRestSignals(rest, repo({ nameWithOwner: "no-slash" }))).toEqual({ + webhooksCount: 0, + hasCodeScanningAlerts: false, + }); + expect(called).toBe(false); + }); +}); diff --git a/src/lib/server/profile/rest-signals.ts b/src/lib/server/profile/rest-signals.ts new file mode 100644 index 0000000..867d7a9 --- /dev/null +++ b/src/lib/server/profile/rest-signals.ts @@ -0,0 +1,84 @@ +/** + * Per-repo REST signals — the cheap presence/count checks GraphQL can't fold in. + * + * Two migration considerations are best read straight from REST: + * - webhooks — migrate but arrive disabled; their secrets aren't carried. + * - code scanning — alert history and states aren't migrated. + * + * (GitHub Pages is read for free from discovery's `has_pages`, so it needs no + * per-repo request here.) Each call is permission-sensitive and degrades to + * 0/false rather than failing the repo, so a sparse-scope crawl still completes. + * + * The `rest` client is injected so this is unit-testable without a network. + */ +import { countByPagination, type GitHubClient } from "$lib/server/core/github"; +import type { DiscoveredRepo } from "./types"; + +/** The REST-only signals merged onto a repo's profile. */ +export interface RepoRestSignals { + webhooksCount: number; + hasCodeScanningAlerts: boolean; +} + +/** Split `owner/name`; returns null when it isn't a well-formed pair. */ +function splitOwnerRepo(nameWithOwner: string): { owner: string; repo: string } | null { + const slash = nameWithOwner.indexOf("/"); + if (slash <= 0) return null; + return { owner: nameWithOwner.slice(0, slash), repo: nameWithOwner.slice(slash + 1) }; +} + +/** Webhook count via the `Link` header (0 when unreadable). */ +async function countWebhooks(rest: GitHubClient, owner: string, repo: string): Promise { + try { + return await countByPagination(rest, "GET /repos/{owner}/{repo}/hooks", { owner, repo }); + } catch { + return 0; + } +} + +/** + * Whether the repo has any code-scanning alert (so there's history to lose). + * + * The endpoint legitimately answers with a non-200 for most repos, which we map + * to "no scanning history": + * - 404 — code scanning isn't set up (no default setup, no analyses). + * - 403 — GitHub Advanced Security isn't enabled, or the token lacks the + * `security_events` scope. + * Both mean “nothing to lose here” for our purposes, so they degrade to false + * (these are expected responses, not crawl failures). + */ +async function hasCodeScanning(rest: GitHubClient, owner: string, repo: string): Promise { + try { + const res = await rest.request("GET /repos/{owner}/{repo}/code-scanning/alerts", { + owner, + repo, + per_page: 1, + }); + return Array.isArray(res.data) && res.data.length > 0; + } catch { + return false; + } +} + +/** + * Gather a repository's REST-only signals. The requests run concurrently; each + * degrades independently, so one inaccessible endpoint doesn't suppress the + * other. + * + * @param rest Authenticated source REST client. + * @param r The discovered repo (provides `nameWithOwner`). + * @returns Webhook count plus the code-scanning presence flag. + */ +export async function gatherRepoRestSignals( + rest: GitHubClient, + r: DiscoveredRepo, +): Promise { + const parts = splitOwnerRepo(r.nameWithOwner); + if (!parts) return { webhooksCount: 0, hasCodeScanningAlerts: false }; + const { owner, repo } = parts; + const [webhooksCount, hasCodeScanningAlerts] = await Promise.all([ + countWebhooks(rest, owner, repo), + hasCodeScanning(rest, owner, repo), + ]); + return { webhooksCount, hasCodeScanningAlerts }; +} diff --git a/src/lib/server/profile/runner.test.ts b/src/lib/server/profile/runner.test.ts index fcfb76f..35463f3 100644 --- a/src/lib/server/profile/runner.test.ts +++ b/src/lib/server/profile/runner.test.ts @@ -8,7 +8,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { initStore } from "$lib/server/core/db"; import { DOMAIN_STORES } from "$lib/server/registry"; import type { RepoDetails } from "./augment"; -import { type ProfileRunnerDeps, runProfile } from "./runner"; +import { type ProfileClients, type ProfileRunnerDeps, runProfile } from "./runner"; import { getProfileRun, getRunRepoProfiles } from "./store"; import { type DiscoveredRepo, @@ -19,7 +19,17 @@ import { ZERO_ORG_RESOURCES, } from "./types"; -const clients = {} as never; // the fakes ignore it +// The fakes ignore gql/rest; getApiCalls is called by the runner at completion. +const clients = { getApiCalls: () => 0 } as unknown as ProfileClients; + +/** Pass-3 REST stubs for inline deps that reach the per-repo REST pass but don't + * care about commits / webhooks / code scanning. Keeps tests off the + * real network functions (which would hit the fake `rest` client). */ +const noCommits: ProfileRunnerDeps["countCommits"] = async () => 0; +const noRestSignals: ProfileRunnerDeps["gatherRestSignals"] = async () => ({ + webhooksCount: 0, + hasCodeScanningAlerts: false, +}); function discovered(name: string, over: Partial = {}): DiscoveredRepo { return { @@ -34,6 +44,7 @@ function discovered(name: string, over: Partial = {}): Discovere hasIssues: true, hasProjects: false, hasDiscussions: false, + hasPages: false, defaultBranch: "main", pushedAt: null, updatedAt: null, @@ -58,6 +69,9 @@ function signalsFor(repo: DiscoveredRepo, over: Partial = {}): Repo usesLfs: false, releaseAssetBytes: 0, workflowFileCount: 0, + webhooksCount: 0, + hasPages: false, + hasCodeScanningAlerts: false, issuesCount: 0, pullRequestsCount: 0, branchesCount: 0, @@ -84,7 +98,6 @@ function detailsFor(repo: DiscoveredRepo, over: Partial = {}): Repo const s = signalsFor(repo, over); return { nameWithOwner: repo.nameWithOwner, - commitsCount: s.commitsCount, branchProtectionRulesUsingUnmigratedFeatures: s.branchProtectionRulesUsingUnmigratedFeatures, usesLfs: s.usesLfs, workflowFileCount: s.workflowFileCount, @@ -103,6 +116,11 @@ function deps( discover: async (): Promise => ({ org: "acme", total: repos.length, repos }), augmentCounts: async (_gql, c) => c.map((r) => countsSignals(r, augmentOver[r.name] ?? {})), augmentDetails: async (_gql, c) => c.map((r) => detailsFor(r, augmentOver[r.name] ?? {})), + countCommits: async (_rest, r) => augmentOver[r.name]?.commitsCount ?? 0, + gatherRestSignals: async (_rest, r) => ({ + webhooksCount: augmentOver[r.name]?.webhooksCount ?? 0, + hasCodeScanningAlerts: augmentOver[r.name]?.hasCodeScanningAlerts ?? false, + }), getOrgRulesetCount: async () => rulesetCount, getOrgResources: async () => ({ ...ZERO_ORG_RESOURCES, ...orgResources }), }; @@ -170,6 +188,8 @@ describe("runProfile", () => { }, augmentCounts: async (_gql, c) => c.map((r) => countsSignals(r)), augmentDetails: async (_gql, c) => c.map((r) => detailsFor(r)), + countCommits: noCommits, + gatherRestSignals: noRestSignals, getOrgRulesetCount: async () => 0, getOrgResources: async () => ZERO_ORG_RESOURCES, }; @@ -267,6 +287,8 @@ describe("runProfile", () => { if (chunk.some((r) => r.name === "boom")) throw new Error("repo augmentation failed"); return chunk.map((r) => countsSignals(r)); }, + countCommits: noCommits, + gatherRestSignals: noRestSignals, }; const run = await runProfile( @@ -299,6 +321,8 @@ describe("runProfile", () => { if (chunk.some((r) => r.name === "r25")) throw new Error("a chunk failed"); return chunk.map((r) => countsSignals(r)); }, + countCommits: noCommits, + gatherRestSignals: noRestSignals, }; const run = await runProfile( @@ -333,6 +357,8 @@ describe("runProfile", () => { calls.push({ size: c.length, scanReleases: opts?.scanReleases }); return c.map((r) => detailsFor(r)); }, + countCommits: noCommits, + gatherRestSignals: noRestSignals, }; const run = await runProfile( @@ -350,6 +376,62 @@ describe("runProfile", () => { expect(full?.size).toBe(3); // release-bearing repos, batched together }); + test("merges per-repo commit counts (REST pass) onto the recorded signals", async () => { + const repos = [discovered("a"), discovered("b")]; + const commitCalls: string[] = []; + const commitDeps: Partial = { + discover: async () => ({ org: "acme", total: repos.length, repos }), + augmentCounts: async (_gql, c) => c.map((r) => countsSignals(r)), + augmentDetails: async (_gql, c) => c.map((r) => detailsFor(r)), + countCommits: async (_rest, r) => { + commitCalls.push(r.nameWithOwner); + return r.name === "a" ? 1200 : 42; + }, + gatherRestSignals: noRestSignals, + }; + + const run = await runProfile( + clients, + { id: "r", org: "acme", sourceApiUrl: "u" }, + undefined, + commitDeps, + ); + + expect(run.state).toBe("completed"); + expect(commitCalls.sort()).toEqual(["acme/a", "acme/b"]); // one call per repo + const byName = new Map(getRunRepoProfiles("r").map((p) => [p.nameWithOwner, p.signals])); + expect(byName.get("acme/a")?.commitsCount).toBe(1200); + expect(byName.get("acme/b")?.commitsCount).toBe(42); + }); + + test("keeps the run completed when a repo's commit count fails", async () => { + const repos = [discovered("ok"), discovered("boom")]; + const commitDeps: Partial = { + discover: async () => ({ org: "acme", total: repos.length, repos }), + augmentCounts: async (_gql, c) => c.map((r) => countsSignals(r)), + augmentDetails: async (_gql, c) => c.map((r) => detailsFor(r)), + countCommits: async (_rest, r) => { + if (r.name === "boom") throw new Error("commit count blew up"); + return 7; + }, + gatherRestSignals: noRestSignals, + }; + + const run = await runProfile( + clients, + { id: "r", org: "acme", sourceApiUrl: "u" }, + undefined, + commitDeps, + ); + + // A commit-count failure is best-effort: the run still completes and both + // repos are listed; the failing repo just keeps commitsCount 0. + expect(run.state).toBe("completed"); + const byName = new Map(getRunRepoProfiles("r").map((p) => [p.nameWithOwner, p.signals])); + expect(byName.get("acme/ok")?.commitsCount).toBe(7); + expect(byName.get("acme/boom")?.commitsCount).toBe(0); + }); + test("persists the run before crawling (a created run exists by completion)", async () => { await runProfile( clients, diff --git a/src/lib/server/profile/runner.ts b/src/lib/server/profile/runner.ts index 0019bea..4e0ad23 100644 --- a/src/lib/server/profile/runner.ts +++ b/src/lib/server/profile/runner.ts @@ -19,13 +19,16 @@ import type { graphql } from "@octokit/graphql"; import type { GitHubClient } from "$lib/server/core/github"; import { analyzeRepo } from "./analyze"; import { augmentRepoCounts, augmentRepoDetails, baseSignals } from "./augment"; +import { countRepoCommits } from "./commits"; import { discoverOrgRepos } from "./discover"; +import { gatherRepoRestSignals } from "./rest-signals"; import { completeProfileRun, createProfileRun, failProfileRun, getProfileRun, recordRepoProfile, + setProfileRunApiCalls, setProfileRunOrgResources, setProfileRunRulesets, setProfileRunTotal, @@ -39,12 +42,13 @@ import { } from "./types"; /** - * Repos per augment request. The crawl runs in two passes: + * Repos per augment request. The crawl runs in three passes: * - COUNTS: cheap `{ totalCount }`/scalar fields only — fills each repo's * counts fast and is very unlikely to hit GitHub's 10s timeout. - * - DETAILS: the expensive verification (commit-graph walk, two git-object - * reads, branch-protection detail, and — for release-bearing repos — the + * - DETAILS: the expensive verification (two git-object reads, + * branch-protection detail, and — for release-bearing repos — the * release-asset scan), so those batch smaller. + * - COMMITS: one cheap REST `Link`-header count per repo (no graph walk). * Kept at 10-15 repos/request; `augmentRepo*` further splits any chunk that * still times out, so these are safe ceilings. */ @@ -55,6 +59,10 @@ const DETAILS_CHUNK_LITE = 15; /** How many augment requests are in flight at once (cuts wall-clock ~Nx). */ const AUGMENT_CONCURRENCY = 3; +/** How many per-repo commit-count requests are in flight at once. Each is a + * single cheap REST call, so this runs wider than the GraphQL passes. */ +const COMMIT_CONCURRENCY = 8; + /** Split an array into fixed-size chunks (the last may be smaller). */ function chunk(items: T[], size: number): T[][] { const out: T[][] = []; @@ -62,6 +70,18 @@ function chunk(items: T[], size: number): T[][] { return out; } +/** + * A coarse "scan this first" weight from a repo's size vectors. The expensive + * per-repo passes process repos heaviest-first, so if a run is cut short (rate + * limit, timeout) the most significant repos — the ones most likely to hit a + * migration limit — are the ones already covered. Issues and PRs are the + * metadata-volume drivers; disk usage (MiB) the Git-size driver; releases hint + * at large assets. The exact weighting doesn't matter, only the ordering. + */ +function riskScore(s: RepoSignals): number { + return s.issuesCount + s.pullRequestsCount + (s.diskUsageKb ?? 0) / 1024 + s.releasesCount * 10; +} + /** * Produce a concise, actionable failure reason from a crawl error. Octokit * surfaces HTTP failures with a numeric `status` (e.g. 502 on a GraphQL @@ -88,10 +108,13 @@ function describeError(err: unknown): string { } /** Source clients the runner needs: REST for discovery (the reliable repo - * listing), GraphQL for the batched augment passes (indexed counts + details). */ + * listing), GraphQL for the batched augment passes (indexed counts + details), + * and a tally of every API request made (for cost/rate-limit visibility). */ export interface ProfileClients { gql: typeof graphql; rest: GitHubClient; + /** Total GitHub API requests made so far across this client pair. */ + getApiCalls: () => number; } /** Injectable crawl primitives (defaults hit the real GraphQL helpers). */ @@ -101,6 +124,10 @@ export interface ProfileRunnerDeps { augmentCounts: typeof augmentRepoCounts; /** Pass 2: expensive per-repo verification details. */ augmentDetails: typeof augmentRepoDetails; + /** Pass 3: per-repo commit count (cheap REST `Link`-header count). */ + countCommits: typeof countRepoCommits; + /** Pass 3: per-repo REST signals (webhooks, Pages, code scanning). */ + gatherRestSignals: typeof gatherRepoRestSignals; /** Count the org's rulesets. Default is a no-op (0); the service binds the * real REST-backed implementation, which needs a client this module lacks. */ getOrgRulesetCount: (org: string) => Promise; @@ -113,6 +140,8 @@ const DEFAULT_DEPS: ProfileRunnerDeps = { discover: discoverOrgRepos, augmentCounts: augmentRepoCounts, augmentDetails: augmentRepoDetails, + countCommits: countRepoCommits, + gatherRestSignals: gatherRepoRestSignals, getOrgRulesetCount: async () => 0, getOrgResources: async () => ZERO_ORG_RESOURCES, }; @@ -186,7 +215,12 @@ export async function runProfile( // whole run. let profiled = 0; { - const chunks = chunk(discovery.repos, COUNTS_CHUNK); + // Before counts land, disk usage (from REST discovery) is the only size + // vector — chunk biggest-first so heavy repos are counted earliest. + const ordered = [...discovery.repos].sort( + (a, b) => (b.diskUsageKb ?? 0) - (a.diskUsageKb ?? 0), + ); + const chunks = chunk(ordered, COUNTS_CHUNK); let next = 0; const worker = async (): Promise => { while (next < chunks.length) { @@ -229,10 +263,19 @@ export async function runProfile( // merges onto the already-recorded counts and re-records. This pass is // NON-fatal: the counts are already persisted, so a details failure enriches // less but never fails the run. - const releasesOf = (nameWithOwner: string) => - signalsByRepo.get(nameWithOwner)?.releasesCount ?? 0; - const withReleases = discovery.repos.filter((r) => releasesOf(r.nameWithOwner) > 0); - const withoutReleases = discovery.repos.filter((r) => releasesOf(r.nameWithOwner) === 0); + // + // Now that counts have landed, order the per-repo passes heaviest-first by + // the size-vector risk score, so a run cut short still covered the riskiest + // repos. Partition that ordered list by releases for the scan/no-scan batches. + const signalsOf = (nameWithOwner: string) => signalsByRepo.get(nameWithOwner); + const byRisk = [...discovery.repos].sort( + (a, b) => + riskScore(signalsOf(b.nameWithOwner) ?? baseSignals(b)) - + riskScore(signalsOf(a.nameWithOwner) ?? baseSignals(a)), + ); + const releasesOf = (nameWithOwner: string) => signalsOf(nameWithOwner)?.releasesCount ?? 0; + const withReleases = byRisk.filter((r) => releasesOf(r.nameWithOwner) > 0); + const withoutReleases = byRisk.filter((r) => releasesOf(r.nameWithOwner) === 0); const tasks: Array<{ repos: typeof discovery.repos; scanReleases: boolean }> = [ ...chunk(withoutReleases, DETAILS_CHUNK_LITE).map((repos) => ({ repos, @@ -255,7 +298,6 @@ export async function runProfile( if (!base) continue; const merged: RepoSignals = { ...base, - commitsCount: det.commitsCount, branchProtectionRulesUsingUnmigratedFeatures: det.branchProtectionRulesUsingUnmigratedFeatures, usesLfs: det.usesLfs, @@ -280,6 +322,50 @@ export async function runProfile( ); } + // ── Pass 3: per-repo REST signals (best-effort) ──────────────────────── + // Commit count (the `rel="last"` Link-header trick — no commit-graph walk) + // plus webhooks / Pages / code-scanning presence, gathered per repo in one + // worker. Runs riskiest-first and wider than the GraphQL passes since each + // call is tiny. Non-fatal: a repo that can't be read keeps its defaults. + { + let next = 0; + const worker = async (): Promise => { + while (next < byRisk.length) { + const r = byRisk[next++]; + if (!r) break; + const base = signalsByRepo.get(r.nameWithOwner); + if (!base) continue; + try { + const [commitsCount, rest] = await Promise.all([ + d.countCommits(clients.rest, r), + d.gatherRestSignals(clients.rest, r), + ]); + const merged: RepoSignals = { + ...base, + commitsCount, + webhooksCount: rest.webhooksCount, + hasCodeScanningAlerts: rest.hasCodeScanningAlerts, + }; + signalsByRepo.set(r.nameWithOwner, merged); + recordRepoProfile(input.id, merged, analyzeRepo(merged)); + } catch (err) { + console.error( + `[profile] run ${input.id} REST signals failed for ${r.nameWithOwner}:`, + err, + ); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(COMMIT_CONCURRENCY, byRisk.length) }, () => worker()), + ); + if (byRisk.length > 0) { + onProgress?.({ runId: input.id, profiled, total: discovery.total, repo: "" }); + } + } + + // Persist the run's total API cost before marking it complete. + setProfileRunApiCalls(input.id, clients.getApiCalls()); completeProfileRun(input.id); const completed = getProfileRun(input.id); console.log( diff --git a/src/lib/server/profile/schema.ts b/src/lib/server/profile/schema.ts index 24fb9b3..660c8d9 100644 --- a/src/lib/server/profile/schema.ts +++ b/src/lib/server/profile/schema.ts @@ -23,6 +23,7 @@ const PROFILE_DDL = ` warnings INTEGER NOT NULL DEFAULT 0, org_ruleset_count INTEGER NOT NULL DEFAULT 0, org_resources TEXT NOT NULL DEFAULT '{}', + api_calls INTEGER NOT NULL DEFAULT 0, started_at TEXT NOT NULL, completed_at TEXT, failure_reason TEXT @@ -77,6 +78,7 @@ export const profileStore: DomainStore = { // Columns added after the table's first release — upgrade older databases. addColumnIfMissing(db, "profile_runs", "org_ruleset_count", "INTEGER NOT NULL DEFAULT 0"); addColumnIfMissing(db, "profile_runs", "org_resources", "TEXT NOT NULL DEFAULT '{}'"); + addColumnIfMissing(db, "profile_runs", "api_calls", "INTEGER NOT NULL DEFAULT 0"); }, onInit: recoverInterruptedProfiles, }; diff --git a/src/lib/server/profile/service.test.ts b/src/lib/server/profile/service.test.ts index f16ed23..a5f6ad4 100644 --- a/src/lib/server/profile/service.test.ts +++ b/src/lib/server/profile/service.test.ts @@ -28,6 +28,7 @@ function discovered(name: string): DiscoveredRepo { hasIssues: true, hasProjects: false, hasDiscussions: false, + hasPages: false, defaultBranch: "main", pushedAt: null, updatedAt: null, @@ -51,6 +52,9 @@ function signalsFor(repo: DiscoveredRepo, over: Partial = {}): Repo usesLfs: false, releaseAssetBytes: 0, workflowFileCount: 0, + webhooksCount: 0, + hasPages: false, + hasCodeScanningAlerts: false, issuesCount: 0, pullRequestsCount: 0, branchesCount: 0, @@ -65,7 +69,6 @@ function detailsFor(repo: DiscoveredRepo, over: Partial = {}): Repo const s = signalsFor(repo, over); return { nameWithOwner: repo.nameWithOwner, - commitsCount: s.commitsCount, branchProtectionRulesUsingUnmigratedFeatures: s.branchProtectionRulesUsingUnmigratedFeatures, usesLfs: s.usesLfs, workflowFileCount: s.workflowFileCount, @@ -91,6 +94,7 @@ function serviceDeps( gql: {} as never, rest: {} as never, sourceApiUrl: "https://ghes.example.com/api/v3", + getApiCalls: () => 0, }; }, run: (clients, input, onProgress) => { @@ -105,6 +109,11 @@ function serviceDeps( chunk.map((r) => signalsFor(r, augmentOver[r.name] ?? {})), augmentDetails: async (_gql, chunk) => chunk.map((r) => detailsFor(r, augmentOver[r.name] ?? {})), + countCommits: async (_rest, r) => augmentOver[r.name]?.commitsCount ?? 0, + gatherRestSignals: async (_rest, r) => ({ + webhooksCount: augmentOver[r.name]?.webhooksCount ?? 0, + hasCodeScanningAlerts: augmentOver[r.name]?.hasCodeScanningAlerts ?? false, + }), }); return state.runPromise; }, diff --git a/src/lib/server/profile/service.ts b/src/lib/server/profile/service.ts index 863dde4..6a05664 100644 --- a/src/lib/server/profile/service.ts +++ b/src/lib/server/profile/service.ts @@ -102,12 +102,12 @@ function computeScale(repos: RepoProfileView[]): MigrationScale { * 400 before the crawl starts). */ export function startOrgProfile(org: string, deps: ProfileServiceDeps = DEFAULT_DEPS): ProfileRun { - const { gql, rest, sourceApiUrl } = deps.buildSourceClients(); + const { gql, rest, sourceApiUrl, getApiCalls } = deps.buildSourceClients(); const id = deps.newId(); deps .run( - { gql, rest }, + { gql, rest, getApiCalls }, { id, org, sourceApiUrl }, (p) => publishProfileEvent(id, { diff --git a/src/lib/server/profile/store.test.ts b/src/lib/server/profile/store.test.ts index a310784..5116057 100644 --- a/src/lib/server/profile/store.test.ts +++ b/src/lib/server/profile/store.test.ts @@ -18,6 +18,7 @@ import { getRunRepoProfiles, listProfileRuns, recordRepoProfile, + setProfileRunApiCalls, setProfileRunOrgResources, setProfileRunRulesets, setProfileRunTotal, @@ -43,6 +44,7 @@ function signals(over: Partial = {}): RepoSignals { hasIssues: true, hasProjects: false, hasDiscussions: false, + hasPages: false, defaultBranch: "main", pushedAt: null, updatedAt: null, @@ -65,6 +67,8 @@ function signals(over: Partial = {}): RepoSignals { usesLfs: false, releaseAssetBytes: 0, workflowFileCount: 0, + webhooksCount: 0, + hasCodeScanningAlerts: false, ...over, }; } @@ -149,6 +153,15 @@ describe("setProfileRunRulesets", () => { }); }); +describe("setProfileRunApiCalls", () => { + test("records the run's API-call total (default 0)", () => { + createProfileRun({ id: "r", sourceApiUrl: "u", org: "acme" }); + expect(getProfileRun("r")?.apiCalls).toBe(0); + setProfileRunApiCalls("r", 1234); + expect(getProfileRun("r")?.apiCalls).toBe(1234); + }); +}); + describe("setProfileRunOrgResources", () => { test("defaults to all-zero resources before gathering", () => { createProfileRun({ id: "r", sourceApiUrl: "u", org: "acme" }); diff --git a/src/lib/server/profile/store.ts b/src/lib/server/profile/store.ts index 8af19de..820b162 100644 --- a/src/lib/server/profile/store.ts +++ b/src/lib/server/profile/store.ts @@ -33,6 +33,7 @@ interface ProfileRunRow { warnings: number; org_ruleset_count: number; org_resources: string; + api_calls: number; started_at: string; completed_at: string | null; failure_reason: string | null; @@ -49,7 +50,7 @@ interface ProfileRepoRow { } const RUN_COLS = - "id, source_api_url, org, state, total_repos, profiled_repos, blockers, warnings, org_ruleset_count, org_resources, started_at, completed_at, failure_reason"; + "id, source_api_url, org, state, total_repos, profiled_repos, blockers, warnings, org_ruleset_count, org_resources, api_calls, started_at, completed_at, failure_reason"; /** Parse JSON from a DB column, falling back to a default on malformed data. */ function safeParse(json: string, fallback: T): T { @@ -75,6 +76,7 @@ function rowToRun(row: ProfileRunRow): ProfileRun { ...ZERO_ORG_RESOURCES, ...safeParse>(row.org_resources, {}), }, + apiCalls: row.api_calls, startedAt: row.started_at, completedAt: row.completed_at, failureReason: row.failure_reason, @@ -132,6 +134,13 @@ export function setProfileRunOrgResources(runId: string, resources: OrgResources .run({ $json: JSON.stringify(resources), $id: runId }); } +/** Record the total API requests the crawl made (persisted once it settles). */ +export function setProfileRunApiCalls(runId: string, count: number): void { + getDb() + .prepare(`UPDATE profile_runs SET api_calls = $count WHERE id = $id`) + .run({ $count: count, $id: runId }); +} + /** * Upsert one repository's profile into a run. Idempotent on * `(run_id, name_with_owner)` so a resumed crawl can safely re-record a repo. diff --git a/src/lib/server/profile/summary.test.ts b/src/lib/server/profile/summary.test.ts index 8a94b85..fcd9d7f 100644 --- a/src/lib/server/profile/summary.test.ts +++ b/src/lib/server/profile/summary.test.ts @@ -68,10 +68,12 @@ describe("buildPreparationSummary", () => { test("lists not-yet-crawled considerations, excluding detected ones", () => { const { notYetCrawled } = buildPreparationSummary([]); const ids = notYetCrawled.map((c) => c.considerationId); - expect(ids).toContain("webhooks"); // signal not gathered yet + expect(ids).toContain("commit-size-limit"); // needs git-sizer — not gathered yet expect(ids).not.toContain("discussions"); // detected expect(ids).not.toContain("packages"); // detected expect(ids).not.toContain("git-lfs"); // detected + expect(ids).not.toContain("webhooks"); // detected (REST signals pass) + expect(ids).not.toContain("actions-secrets"); // org-level evaluated }); test("ignores a stale considerationId that is no longer in the registry", () => { diff --git a/src/lib/server/profile/summary.ts b/src/lib/server/profile/summary.ts index 1e8a5aa..a154808 100644 --- a/src/lib/server/profile/summary.ts +++ b/src/lib/server/profile/summary.ts @@ -16,7 +16,7 @@ import { type ConsiderationSeverity, MIGRATION_CONSIDERATIONS, } from "$lib/profile/consideration-registry"; -import { DETECTED_CONSIDERATION_IDS } from "./analyze"; +import { DETECTED_CONSIDERATION_IDS, ORG_EVALUATED_CONSIDERATION_IDS } from "./analyze"; import type { StoredRepoProfile } from "./types"; /** One consideration rolled up across the org, with how many repos it touches. */ @@ -99,9 +99,12 @@ export function buildPreparationSummary(repos: StoredRepoProfile[]): Preparation a.label.localeCompare(b.label), ); - const detected = new Set(DETECTED_CONSIDERATION_IDS); + // "Crawled" = a per-repo detector evaluated it, OR it's an org-level + // consideration gathered once per run (secrets, runners, custom properties). + // Neither belongs in the not-yet-evaluated list. + const evaluated = new Set([...DETECTED_CONSIDERATION_IDS, ...ORG_EVALUATED_CONSIDERATION_IDS]); const notYetCrawled: UncrawledConsideration[] = MIGRATION_CONSIDERATIONS.filter( - (c) => !detected.has(c.id), + (c) => !evaluated.has(c.id), ).map((c) => ({ considerationId: c.id, label: c.label, severity: c.severity })); return { items, notYetCrawled, blockerRepos, warnRepos, cleanRepos }; diff --git a/src/lib/server/profile/types.ts b/src/lib/server/profile/types.ts index f553b6a..eb326c3 100644 --- a/src/lib/server/profile/types.ts +++ b/src/lib/server/profile/types.ts @@ -37,6 +37,11 @@ export interface DiscoveredRepo { hasIssues: boolean; hasProjects: boolean; hasDiscussions: boolean; + /** + * Whether GitHub Pages is enabled (`has_pages` from the org listing — free, no + * per-repo call). Pages settings migrate but typically need reconfiguring. + */ + hasPages: boolean; /** Default branch name, or null for an empty repo with no commits. */ defaultBranch: string | null; /** ISO timestamp of the last push, or null if never pushed. */ @@ -125,6 +130,16 @@ export interface RepoSignals extends DiscoveredRepo { * means there's run history that will be lost. */ workflowFileCount: number; + // ── Per-repo REST signals (gathered in the REST signals pass) ───────────── + // Cheap single-count / presence checks via REST (the `Link`-header trick or a + // 200/404 probe). Permission-sensitive endpoints (hooks, code scanning) + // degrade to 0/false for a read-only token rather than failing the repo. + /** Webhooks (`GET …/hooks`); migrate but arrive disabled, and their secrets + * are not migrated. Needs `admin:repo_hook`/`repo`; 0 when unreadable. */ + webhooksCount: number; + /** Whether the repo has code-scanning alerts (`GET …/code-scanning/alerts`). + * Scanning history is not migrated. False when scanning is off or unreadable. */ + hasCodeScanningAlerts: boolean; // ── Content-volume counts (migration scale) ────────────────────────────── // Indexed GraphQL `totalCount`s gathered in the cheap counts pass (they live // here, not on the REST discovery spine, because REST doesn't expose the @@ -201,6 +216,9 @@ export interface ProfileRun { orgRulesetCount: number; /** Organization-level resources (secrets, runners, …) gathered once per run. */ orgResources: OrgResources; + /** Total GitHub API requests the crawl made (REST + GraphQL, incl. retries and + * each pagination page). Persisted at completion for cost/rate-limit visibility. */ + apiCalls: number; startedAt: string; completedAt: string | null; failureReason: string | null; diff --git a/src/routes/profile/[id]/+page.svelte b/src/routes/profile/[id]/+page.svelte index f7f978c..df1e6b6 100644 --- a/src/routes/profile/[id]/+page.svelte +++ b/src/routes/profile/[id]/+page.svelte @@ -1,15 +1,24 @@