Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/lib/components/AuthPill.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,6 @@

<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium leading-4 transition-colors {pillClass}">
<Octicon name={isApp ? 'shield-lock' : 'key'} size={12} class={shouldPulse ? 'animate-pulse' : ''} />
{label}: {isApp ? 'App' : 'PAT'}
<span class={rateClass}>{rateText}</span>
<span>{label} <span class="opacity-70">·</span> {isApp ? 'App' : 'PAT'}</span>
<span class="border-l border-current/20 pl-1.5 tabular-nums {rateClass}">{rateText}</span>
</span>
16 changes: 15 additions & 1 deletion src/lib/server/core/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

/**
Expand Down
55 changes: 55 additions & 0 deletions src/lib/server/core/github.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, spyOn, test } from "bun:test";
import {
countByPagination,
doesOrgExist,
doesRepoExist,
isGhecSource,
Expand Down Expand Up @@ -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<string, string>; data?: unknown }) {
const calls: Array<{ route: string; params: Record<string, unknown> }> = [];
const c = {
request: async (route: string, params: Record<string, unknown>) => {
calls.push({ route, params });
return { headers: response.headers ?? {}, data: response.data ?? [] };
},
} as unknown as Parameters<typeof countByPagination>[0];
return { c, calls };
}

test("reads the rel=last page number as the count", async () => {
const { c } = client({
headers: {
link: '<https://api.github.com/x?per_page=1&page=2>; rel="next", <https://api.github.com/x?per_page=1&page=987>; 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: '<https://api.github.com/x?page=55&per_page=1>; 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: '<https://api.github.com/x?per_page=1&page=2>; 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);
});
});
40 changes: 40 additions & 0 deletions src/lib/server/core/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof RetryOctokit>,
route: string,
params: Record<string, string | number>,
): Promise<number> {
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 `<url>; 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".
Expand Down
13 changes: 11 additions & 2 deletions src/lib/server/profile/analyze.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function cleanSignals(over: Partial<RepoSignals> = {}): RepoSignals {
hasIssues: true,
hasProjects: false,
hasDiscussions: false,
hasPages: false,
defaultBranch: "main",
pushedAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
Expand All @@ -45,6 +46,8 @@ function cleanSignals(over: Partial<RepoSignals> = {}): RepoSignals {
usesLfs: false,
releaseAssetBytes: 0,
workflowFileCount: 0,
webhooksCount: 0,
hasCodeScanningAlerts: false,
...over,
};
}
Expand Down Expand Up @@ -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");
});

Expand All @@ -95,6 +98,9 @@ describe("analyzeRepo", () => {
usesLfs: true,
workflowFileCount: 4,
rulesetCount: 3,
webhooksCount: 2,
hasPages: true,
hasCodeScanningAlerts: true,
}),
);

Expand All @@ -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", () => {
Expand Down
18 changes: 18 additions & 0 deletions src/lib/server/profile/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ const DETECTORS: Record<string, Detector> = {
? `${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")}`
Expand Down Expand Up @@ -128,6 +132,20 @@ const DETECTORS: Record<string, Detector> = {
/** 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,
Expand Down
32 changes: 10 additions & 22 deletions src/lib/server/profile/augment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function discovered(over: Partial<DiscoveredRepo> = {}): DiscoveredRepo {
hasIssues: true,
hasProjects: false,
hasDiscussions: true,
hasPages: false,
defaultBranch: "main",
pushedAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
Expand Down Expand Up @@ -92,20 +93,15 @@ 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). */
workflowFiles?: string[];
/** Asset byte sizes; placed in a single scanned release node (omit = not scanned). */
releaseAssetSizes?: number[];
rules?: ReturnType<typeof rule>[];
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 })) }
Expand Down Expand Up @@ -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" }),
Expand Down Expand Up @@ -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 () => {
Expand All @@ -398,15 +385,16 @@ 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 = [
discovered({ name: "a", nameWithOwner: "o/a" }),
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
});
});
27 changes: 13 additions & 14 deletions src/lib/server/profile/augment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading