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 @@