From 710e6a533599d1ccf66c799d7c8f2930dcafb022 Mon Sep 17 00:00:00 2001 From: Casey Vega Date: Mon, 15 Jun 2026 16:45:40 -0700 Subject: [PATCH 1/5] feat(profile): map the entirety of non-migrated data Make the consideration registry the complete map of what a migration loses, so the Profile surfaces all of it up front rather than only the items we can currently detect. - Add a `scope` field (repo|org|migration) to each consideration. - Add 13 missing considerations: teams, GitHub Apps, repo collaborators, tag protection, plus nine whole-migration losses (mannequins, audit logs, commit status checks, comment edit history, sub-issues, cross-repo references, @mention re-linking, secret-scanning states, activity feed). - Summary now reports migration-scope facts as `alwaysLost` and excludes them from `notYetCrawled` (they're known losses, not coverage gaps). - Profile detail page renders an 'Always lost in every migration' panel. - Bump docs-verified stamp to 2026-06-15. --- .../profile/consideration-registry.test.ts | 15 ++ src/lib/profile/consideration-registry.ts | 190 +++++++++++++++++- src/lib/server/profile/summary.test.ts | 14 ++ src/lib/server/profile/summary.ts | 23 ++- src/routes/profile/[id]/+page.svelte | 24 +++ 5 files changed, 262 insertions(+), 4 deletions(-) diff --git a/src/lib/profile/consideration-registry.test.ts b/src/lib/profile/consideration-registry.test.ts index dd24058..8dad1e7 100644 --- a/src/lib/profile/consideration-registry.test.ts +++ b/src/lib/profile/consideration-registry.test.ts @@ -54,6 +54,21 @@ describe("consideration registry integrity", () => { } }); + test("scope, when set, is one of the allowed values", () => { + const scopes = ["repo", "org", "migration"]; + for (const g of MIGRATION_CONSIDERATIONS) { + if (g.scope !== undefined) expect(scopes, g.id).toContain(g.scope); + } + }); + + test("migration-scope considerations are accepted losses that route nowhere", () => { + for (const g of MIGRATION_CONSIDERATIONS.filter((x) => x.scope === "migration")) { + expect(g.kind, g.id).toBe("accepted-loss"); + expect(g.routesTo, g.id).toBeNull(); + expect(g.detector, g.id).toBe("always"); + } + }); + test("accepted-loss considerations are informational and route nowhere", () => { for (const g of MIGRATION_CONSIDERATIONS.filter((x) => x.kind === "accepted-loss")) { expect(g.severity, g.id).toBe("info"); diff --git a/src/lib/profile/consideration-registry.ts b/src/lib/profile/consideration-registry.ts index 6f3fedd..7982f41 100644 --- a/src/lib/profile/consideration-registry.ts +++ b/src/lib/profile/consideration-registry.ts @@ -20,7 +20,17 @@ export const MIGRATION_DOC_URL = "https://docs.github.com/en/migrations/using-github-enterprise-importer/migrating-between-github-products/about-migrations-between-github-products"; /** Date the registry was last reconciled with the migration docs (YYYY-MM-DD). */ -export const MIGRATION_DOCS_VERIFIED = "2026-06-13"; +export const MIGRATION_DOCS_VERIFIED = "2026-06-15"; + +/** + * Where a consideration's data lives — which determines how the Profiler + * evaluates and surfaces it: + * - `repo` — per repository; detected in the per-repo crawl and shown in the matrix. + * - `org` — once per organization (secrets, runners, teams…); shown in the org panel. + * - `migration` — a whole-migration fact that always applies (mannequins, audit logs…); + * there's nothing to count, so it's listed as an always-present loss. + */ +type ConsiderationScope = "repo" | "org" | "migration"; /** * What kind of remediation a consideration needs: @@ -50,6 +60,11 @@ export interface Consideration { label: string; kind: ConsiderationKind; severity: ConsiderationSeverity; + /** + * Where the data lives. Defaults to `repo` (the common case) when omitted, so + * only org- and migration-scoped entries need to state it. + */ + scope?: ConsiderationScope; /** * Identifier of the profiler check that detects this consideration. The * detection function itself lives server-side and keys off this string. @@ -130,6 +145,7 @@ export const MIGRATION_CONSIDERATIONS: readonly Consideration[] = [ label: "Actions secrets & variables", kind: "recreate", severity: "warn", + scope: "org", detector: "actions-secrets-count", confidence: "exact", routesTo: "Re-create secrets/variables on the target", @@ -153,6 +169,7 @@ export const MIGRATION_CONSIDERATIONS: readonly Consideration[] = [ label: "Self-hosted runners", kind: "recreate", severity: "warn", + scope: "org", detector: "actions-runners-count", confidence: "exact", routesTo: "Re-register runners on the target", @@ -164,6 +181,7 @@ export const MIGRATION_CONSIDERATIONS: readonly Consideration[] = [ label: "Dependabot secrets", kind: "recreate", severity: "warn", + scope: "org", detector: "dependabot-secrets-count", confidence: "exact", routesTo: "Re-create Dependabot secrets", @@ -175,12 +193,51 @@ export const MIGRATION_CONSIDERATIONS: readonly Consideration[] = [ label: "Codespaces secrets", kind: "recreate", severity: "warn", + scope: "org", detector: "codespaces-secrets-count", confidence: "exact", routesTo: "Re-create Codespaces secrets", summary: "Codespaces secrets are not migrated.", docAnchor: NOT_MIGRATED, }, + { + id: "teams", + label: "Teams & team membership", + kind: "recreate", + severity: "warn", + scope: "org", + detector: "org-teams-count", + confidence: "exact", + routesTo: "Recreate teams and re-add members on the target", + summary: + "On a direct repository migration teams are not migrated; even on an organization migration, team membership is not carried over and members must be re-added.", + docAnchor: NOT_MIGRATED, + }, + { + id: "github-apps", + label: "GitHub Apps & installations", + kind: "recreate", + severity: "warn", + scope: "org", + detector: "org-app-installations", + confidence: "exact", + routesTo: "Reinstall and reconfigure GitHub Apps on the target", + summary: + "GitHub Apps and their installations are not migrated; each must be reinstalled and reconfigured on the destination.", + docAnchor: NOT_MIGRATED, + }, + { + id: "repo-collaborators", + label: "Repository access (collaborators & teams)", + kind: "recreate", + severity: "warn", + detector: "repo-collaborators-count", + confidence: "exact", + routesTo: "Re-grant collaborator and team access on the target", + summary: + "Direct user access and team access to a repository are not migrated — collaborators and their permissions must be re-granted after migrating.", + docAnchor: NOT_MIGRATED, + }, // ── Reconfigure: migrates but needs a target-side pass ────────────────────── { @@ -246,12 +303,25 @@ export const MIGRATION_CONSIDERATIONS: readonly Consideration[] = [ label: "Custom properties", kind: "reconfigure", severity: "info", + scope: "org", detector: "custom-properties-api", confidence: "exact", routesTo: "Re-apply custom properties", summary: "Repository custom properties are not migrated.", docAnchor: NOT_MIGRATED, }, + { + id: "tag-protection", + label: "Tag protection rules", + kind: "reconfigure", + severity: "info", + detector: "tag-protection-rules", + confidence: "exact", + routesTo: "Recreate tag protection (or move it to rulesets)", + summary: + "Tag protection rules are not migrated; recreate them on the target — GitHub now recommends expressing them as rulesets.", + docAnchor: NOT_MIGRATED, + }, // ── Blockers: size/policy limits that can fail the migration ──────────────── { @@ -370,4 +440,122 @@ export const MIGRATION_CONSIDERATIONS: readonly Consideration[] = [ summary: "Fork relationships between repositories are not preserved.", docAnchor: NOT_MIGRATED, }, + + // ── Always lost: whole-migration facts that apply to every run ────────────── + // Nothing to count per repo — these are intrinsic to how the Importer works, + // so they're surfaced as always-present losses rather than detected signals. + { + id: "mannequins", + label: "User attribution (mannequins)", + kind: "accepted-loss", + severity: "info", + scope: "migration", + detector: "always", + confidence: "exact", + routesTo: null, + summary: + "Imported issues, PRs, and comments are attributed to placeholder “mannequin” users until an admin reclaims each one to a real account on the target.", + docAnchor: LIMITS, + }, + { + id: "audit-log", + label: "Audit logs", + kind: "accepted-loss", + severity: "info", + scope: "migration", + detector: "always", + confidence: "exact", + routesTo: null, + summary: "Audit log history is not migrated.", + docAnchor: NOT_MIGRATED, + }, + { + id: "commit-status-checks", + label: "Commit status checks", + kind: "accepted-loss", + severity: "info", + scope: "migration", + detector: "always", + confidence: "exact", + routesTo: null, + summary: + "Historical commit status checks — the pass/fail marks CI posts on commits — are not migrated.", + docAnchor: NOT_MIGRATED, + }, + { + id: "comment-edit-history", + label: "Comment edit history", + kind: "accepted-loss", + severity: "info", + scope: "migration", + detector: "always", + confidence: "exact", + routesTo: null, + summary: + "Only the latest version of each issue and pull request comment migrates — prior edit history is not carried over.", + docAnchor: NOT_MIGRATED, + }, + { + id: "sub-issues", + label: "Sub-issue relationships", + kind: "accepted-loss", + severity: "info", + scope: "migration", + detector: "always", + confidence: "exact", + routesTo: null, + summary: "Parent/child sub-issue relationships are not preserved by the migration.", + docAnchor: NOT_MIGRATED, + }, + { + id: "cross-repo-references", + label: "Cross-repository references", + kind: "accepted-loss", + severity: "info", + scope: "migration", + detector: "always", + confidence: "exact", + routesTo: null, + summary: + "Autolinked references between pull requests and issues in different repositories are not migrated and will not resolve.", + docAnchor: NOT_MIGRATED, + }, + { + id: "mention-relinking", + label: "@mention re-linking", + kind: "accepted-loss", + severity: "info", + scope: "migration", + detector: "always", + confidence: "exact", + routesTo: null, + summary: + "Mentions of users, teams, and organizations keep their original text but are not re-linked to accounts on the target.", + docAnchor: NOT_MIGRATED, + }, + { + id: "secret-scanning-states", + label: "Secret-scanning remediation states", + kind: "accepted-loss", + severity: "info", + scope: "migration", + detector: "always", + confidence: "exact", + routesTo: null, + summary: + "Remediation states of secret-scanning results (resolved, false-positive, …) are not migrated.", + docAnchor: NOT_MIGRATED, + }, + { + id: "activity-feed", + label: "Repository activity feed", + kind: "accepted-loss", + severity: "info", + scope: "migration", + detector: "always", + confidence: "exact", + routesTo: null, + summary: "The repository activity feed is not migrated.", + docAnchor: NOT_MIGRATED, + }, ]; diff --git a/src/lib/server/profile/summary.test.ts b/src/lib/server/profile/summary.test.ts index fcd9d7f..ab2d638 100644 --- a/src/lib/server/profile/summary.test.ts +++ b/src/lib/server/profile/summary.test.ts @@ -69,11 +69,25 @@ describe("buildPreparationSummary", () => { const { notYetCrawled } = buildPreparationSummary([]); const ids = notYetCrawled.map((c) => c.considerationId); expect(ids).toContain("commit-size-limit"); // needs git-sizer — not gathered yet + expect(ids).toContain("teams"); // org-scope, recognized but 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 + expect(ids).not.toContain("mannequins"); // migration-scope — reported as always-lost + }); + + test("reports whole-migration losses as always-lost, not as coverage gaps", () => { + const { alwaysLost, notYetCrawled } = buildPreparationSummary([]); + const lostIds = alwaysLost.map((c) => c.considerationId); + expect(lostIds).toContain("mannequins"); + expect(lostIds).toContain("audit-log"); + // Every always-lost entry carries a human summary for the UI. + expect(alwaysLost.every((c) => c.summary.length > 0)).toBe(true); + // No overlap between the two lists. + const crawlIds = new Set(notYetCrawled.map((c) => c.considerationId)); + expect(lostIds.some((id) => crawlIds.has(id))).toBe(false); }); 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 a154808..3d7beda 100644 --- a/src/lib/server/profile/summary.ts +++ b/src/lib/server/profile/summary.ts @@ -38,12 +38,21 @@ interface UncrawledConsideration { severity: ConsiderationSeverity; } +/** A whole-migration loss that always applies (mannequins, audit logs, …). */ +interface AlwaysLostConsideration { + considerationId: string; + label: string; + summary: string; +} + /** Org-level preparation rollup across a run's repos. */ export interface PreparationSummary { /** Applying considerations, most-actionable first (blocker→warn→info, then reach). */ items: PreparationItem[]; /** Considerations not yet detectable (signal not gathered) — honesty about coverage. */ notYetCrawled: UncrawledConsideration[]; + /** Whole-migration losses that always apply, regardless of repo. */ + alwaysLost: AlwaysLostConsideration[]; /** Repos with at least one blocker-severity consideration. */ blockerRepos: number; /** Repos with at least one warn-severity consideration. */ @@ -99,13 +108,21 @@ export function buildPreparationSummary(repos: StoredRepoProfile[]): Preparation a.label.localeCompare(b.label), ); + // Whole-migration losses (scope "migration") always apply — list them as such, + // separate from per-repo / org coverage. + const alwaysLost: AlwaysLostConsideration[] = MIGRATION_CONSIDERATIONS.filter( + (c) => c.scope === "migration", + ).map((c) => ({ considerationId: c.id, label: c.label, summary: c.summary })); + // "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. + // Migration-scope facts are reported via `alwaysLost`, not here. Whatever's + // left — a repo/org consideration we recognize but haven't gathered yet — is + // honestly listed as not-yet-evaluated. const evaluated = new Set([...DETECTED_CONSIDERATION_IDS, ...ORG_EVALUATED_CONSIDERATION_IDS]); const notYetCrawled: UncrawledConsideration[] = MIGRATION_CONSIDERATIONS.filter( - (c) => !evaluated.has(c.id), + (c) => c.scope !== "migration" && !evaluated.has(c.id), ).map((c) => ({ considerationId: c.id, label: c.label, severity: c.severity })); - return { items, notYetCrawled, blockerRepos, warnRepos, cleanRepos }; + return { items, notYetCrawled, alwaysLost, blockerRepos, warnRepos, cleanRepos }; } diff --git a/src/routes/profile/[id]/+page.svelte b/src/routes/profile/[id]/+page.svelte index df1e6b6..40db095 100644 --- a/src/routes/profile/[id]/+page.svelte +++ b/src/routes/profile/[id]/+page.svelte @@ -485,6 +485,30 @@ {/if} + + {#if summary.alwaysLost.length > 0} +
+

+ + Always lost in every migration ({summary.alwaysLost.length}) +

+

+ Not tied to any repo — the Importer never carries these over, so plan to recreate or accept them. +

+ +
+ {/if} + {#if summary.notYetCrawled.length > 0}
From 8b93dccbf9ef42f06d9b192ed54114d5448b4740 Mon Sep 17 00:00:00 2001 From: Casey Vega Date: Mon, 15 Jun 2026 16:52:05 -0700 Subject: [PATCH 2/5] feat(profile): gather org teams and GitHub App installs Phase 2 of the org scan now counts two more org-scoped resources that do not migrate, so the teams and github-apps considerations light up with real data instead of sitting in 'not yet evaluated'. - org-resources.ts: count teams via the Link-header pagination primitive and GitHub App installations via installations.total_count, both best-effort (degrade to 0 on missing scope / older GHES). - OrgResources gains teams + appInstallations (and zero defaults). - Mark teams and github-apps as org-evaluated so the summary stops listing them as coverage gaps. - Profile page shows Teams and GitHub Apps org-resource tiles. --- src/lib/server/profile/analyze.ts | 2 + src/lib/server/profile/org-resources.test.ts | 45 +++++++++++++++----- src/lib/server/profile/org-resources.ts | 27 +++++++++--- src/lib/server/profile/store.test.ts | 6 +++ src/lib/server/profile/summary.test.ts | 3 +- src/lib/server/profile/types.ts | 6 +++ src/routes/profile/[id]/+page.svelte | 4 +- 7 files changed, 75 insertions(+), 18 deletions(-) diff --git a/src/lib/server/profile/analyze.ts b/src/lib/server/profile/analyze.ts index 87096c2..e2bd725 100644 --- a/src/lib/server/profile/analyze.ts +++ b/src/lib/server/profile/analyze.ts @@ -144,6 +144,8 @@ export const ORG_EVALUATED_CONSIDERATION_IDS: readonly string[] = [ "dependabot-secrets", "codespaces-secrets", "custom-properties", + "teams", + "github-apps", ]; function summarize(findings: ConsiderationFinding[]): ProfileSummary { diff --git a/src/lib/server/profile/org-resources.test.ts b/src/lib/server/profile/org-resources.test.ts index 711baa2..abd5677 100644 --- a/src/lib/server/profile/org-resources.test.ts +++ b/src/lib/server/profile/org-resources.test.ts @@ -9,13 +9,19 @@ import { getOrgResources } from "./org-resources"; /** * A REST double whose `request(route, …)` returns a per-route response. Routes - * not in the map throw (simulating an unavailable/unauthorized endpoint). + * not in the map throw (simulating an unavailable/unauthorized endpoint). A + * route present in `linkByRoute` also returns a `Link` header, so the teams + * pagination count can be exercised. */ -function restWith(byRoute: Record): GitHubClient { +function restWith( + byRoute: Record, + linkByRoute: Record = {}, +): GitHubClient { return { request: async (route: string) => { if (!(route in byRoute)) throw new Error(`404 ${route}`); - return { data: byRoute[route] }; + const headers = route in linkByRoute ? { link: linkByRoute[route] } : {}; + return { data: byRoute[route], headers }; }, } as unknown as GitHubClient; } @@ -27,18 +33,26 @@ const ROUTES = { codespaces: "GET /orgs/{org}/codespaces/secrets", runners: "GET /orgs/{org}/actions/runners", schema: "GET /orgs/{org}/properties/schema", + teams: "GET /orgs/{org}/teams", + installations: "GET /orgs/{org}/installations", }; describe("getOrgResources", () => { test("reads total_count from each list endpoint and array length from schema", async () => { - const rest = restWith({ - [ROUTES.secrets]: { total_count: 3, secrets: [] }, - [ROUTES.variables]: { total_count: 1, variables: [] }, - [ROUTES.dependabot]: { total_count: 2, secrets: [] }, - [ROUTES.codespaces]: { total_count: 4, secrets: [] }, - [ROUTES.runners]: { total_count: 6, runners: [] }, - [ROUTES.schema]: [{ property_name: "team" }, { property_name: "tier" }], - }); + const rest = restWith( + { + [ROUTES.secrets]: { total_count: 3, secrets: [] }, + [ROUTES.variables]: { total_count: 1, variables: [] }, + [ROUTES.dependabot]: { total_count: 2, secrets: [] }, + [ROUTES.codespaces]: { total_count: 4, secrets: [] }, + [ROUTES.runners]: { total_count: 6, runners: [] }, + [ROUTES.schema]: [{ property_name: "team" }, { property_name: "tier" }], + [ROUTES.teams]: [{ slug: "core" }], + [ROUTES.installations]: { total_count: 5, installations: [] }, + }, + // Teams paginate: the Link header's rel="last" page is the count. + { [ROUTES.teams]: '; rel="last"' }, + ); expect(await getOrgResources(rest, "acme")).toEqual({ actionsSecrets: 3, @@ -47,9 +61,16 @@ describe("getOrgResources", () => { codespacesSecrets: 4, selfHostedRunners: 6, customProperties: 2, + teams: 8, + appInstallations: 5, }); }); + test("counts a single page of teams (no Link header) by array length", async () => { + const rest = restWith({ [ROUTES.teams]: [{ slug: "a" }, { slug: "b" }] }); + expect((await getOrgResources(rest, "acme")).teams).toBe(2); + }); + test("degrades each unavailable endpoint to 0 independently", async () => { // Only runners is available; everything else throws → 0. const rest = restWith({ [ROUTES.runners]: { total_count: 9, runners: [] } }); @@ -61,6 +82,8 @@ describe("getOrgResources", () => { codespacesSecrets: 0, selfHostedRunners: 9, customProperties: 0, + teams: 0, + appInstallations: 0, }); }); diff --git a/src/lib/server/profile/org-resources.ts b/src/lib/server/profile/org-resources.ts index ef54b4e..0420fb4 100644 --- a/src/lib/server/profile/org-resources.ts +++ b/src/lib/server/profile/org-resources.ts @@ -1,14 +1,15 @@ /** * Organization-level resources — gathered via REST, once per run. These are * org-scoped (not per-repo): Actions/Dependabot/Codespaces secrets, Actions - * variables, self-hosted runners, and custom-property definitions. None are - * migrated, and all must be recreated on the target. + * variables, self-hosted runners, custom-property definitions, teams, and + * installed GitHub Apps. None are migrated, and all must be recreated on the + * target. * * Every call is best-effort and independent: a missing scope, an endpoint * unavailable on an older GHES, or a network error degrades that one count to - * 0 rather than failing the crawl. The six calls run concurrently. + * 0 rather than failing the crawl. The calls run concurrently. */ -import type { GitHubClient } from "$lib/server/core/github"; +import { countByPagination, type GitHubClient } from "$lib/server/core/github"; import { type OrgResources, ZERO_ORG_RESOURCES } from "./types"; /** Read `total_count` from a list endpoint's response (0 on any error). */ @@ -35,6 +36,15 @@ async function arrayLength(call: () => Promise<{ data: unknown }>): Promise { + try { + return await countByPagination(rest, route, { org }); + } catch { + return 0; + } +} + /** * Gather an organization's migration-relevant resource counts in one concurrent * batch. Counts only — secret *values* are never exposed by these endpoints. @@ -45,7 +55,8 @@ async function arrayLength(call: () => Promise<{ data: unknown }>): Promise { // `per_page: 1` keeps payloads tiny — only `total_count` is read from the list - // endpoints; the schema endpoint returns a bare array, so length is the count. + // endpoints; the schema endpoint returns a bare array, so length is the count; + // teams have no `total_count`, so they're counted via the Link header. const [ actionsSecrets, actionsVariables, @@ -53,6 +64,8 @@ export async function getOrgResources(rest: GitHubClient, org: string): Promise< codespacesSecrets, selfHostedRunners, customProperties, + teams, + appInstallations, ] = await Promise.all([ totalCount(() => rest.request("GET /orgs/{org}/actions/secrets", { org, per_page: 1 })), totalCount(() => rest.request("GET /orgs/{org}/actions/variables", { org, per_page: 1 })), @@ -60,6 +73,8 @@ export async function getOrgResources(rest: GitHubClient, org: string): Promise< totalCount(() => rest.request("GET /orgs/{org}/codespaces/secrets", { org, per_page: 1 })), totalCount(() => rest.request("GET /orgs/{org}/actions/runners", { org, per_page: 1 })), arrayLength(() => rest.request("GET /orgs/{org}/properties/schema", { org })), + paginatedCount(rest, "GET /orgs/{org}/teams", org), + totalCount(() => rest.request("GET /orgs/{org}/installations", { org, per_page: 1 })), ]); return { @@ -70,5 +85,7 @@ export async function getOrgResources(rest: GitHubClient, org: string): Promise< codespacesSecrets, selfHostedRunners, customProperties, + teams, + appInstallations, }; } diff --git a/src/lib/server/profile/store.test.ts b/src/lib/server/profile/store.test.ts index 5116057..c54219b 100644 --- a/src/lib/server/profile/store.test.ts +++ b/src/lib/server/profile/store.test.ts @@ -172,6 +172,8 @@ describe("setProfileRunOrgResources", () => { codespacesSecrets: 0, selfHostedRunners: 0, customProperties: 0, + teams: 0, + appInstallations: 0, }); }); @@ -184,12 +186,16 @@ describe("setProfileRunOrgResources", () => { codespacesSecrets: 0, selfHostedRunners: 4, customProperties: 5, + teams: 7, + appInstallations: 6, }); expect(getProfileRun("r")?.orgResources).toMatchObject({ actionsSecrets: 3, dependabotSecrets: 2, selfHostedRunners: 4, customProperties: 5, + teams: 7, + appInstallations: 6, }); }); }); diff --git a/src/lib/server/profile/summary.test.ts b/src/lib/server/profile/summary.test.ts index ab2d638..f14ad1d 100644 --- a/src/lib/server/profile/summary.test.ts +++ b/src/lib/server/profile/summary.test.ts @@ -69,12 +69,13 @@ describe("buildPreparationSummary", () => { const { notYetCrawled } = buildPreparationSummary([]); const ids = notYetCrawled.map((c) => c.considerationId); expect(ids).toContain("commit-size-limit"); // needs git-sizer — not gathered yet - expect(ids).toContain("teams"); // org-scope, recognized but not gathered yet + expect(ids).toContain("repo-collaborators"); // recognized but 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 + expect(ids).not.toContain("teams"); // org-level evaluated expect(ids).not.toContain("mannequins"); // migration-scope — reported as always-lost }); diff --git a/src/lib/server/profile/types.ts b/src/lib/server/profile/types.ts index eb326c3..7d53114 100644 --- a/src/lib/server/profile/types.ts +++ b/src/lib/server/profile/types.ts @@ -183,6 +183,10 @@ export interface OrgResources { selfHostedRunners: number; /** Org custom-property definitions (`/orgs/{org}/properties/schema`). */ customProperties: number; + /** Org teams (`/orgs/{org}/teams`) — neither teams nor membership migrate. */ + teams: number; + /** Installed GitHub Apps (`/orgs/{org}/installations`) — not migrated. */ + appInstallations: number; } /** All-zero org resources — the default before gathering (and on total failure). */ @@ -193,6 +197,8 @@ export const ZERO_ORG_RESOURCES: OrgResources = { codespacesSecrets: 0, selfHostedRunners: 0, customProperties: 0, + teams: 0, + appInstallations: 0, }; /** diff --git a/src/routes/profile/[id]/+page.svelte b/src/routes/profile/[id]/+page.svelte index 40db095..5328701 100644 --- a/src/routes/profile/[id]/+page.svelte +++ b/src/routes/profile/[id]/+page.svelte @@ -83,7 +83,9 @@ { label: 'Dependabot secrets', value: run.orgResources.dependabotSecrets, icon: 'dependabot' }, { label: 'Codespaces secrets', value: run.orgResources.codespacesSecrets, icon: 'codespaces' }, { label: 'Self-hosted runners', value: run.orgResources.selfHostedRunners, icon: 'server' }, - { label: 'Custom properties', value: run.orgResources.customProperties, icon: 'list-unordered' } + { label: 'Custom properties', value: run.orgResources.customProperties, icon: 'list-unordered' }, + { label: 'Teams', value: run.orgResources.teams, icon: 'people' }, + { label: 'GitHub Apps', value: run.orgResources.appInstallations, icon: 'apps' } ] satisfies Array<{ label: string; value: number; From 51deb46001f592215b3b3e350a751298160bca5c Mon Sep 17 00:00:00 2001 From: Casey Vega Date: Mon, 15 Jun 2026 17:01:39 -0700 Subject: [PATCH 3/5] feat(profile): detect repo collaborators and tag protection Phase 6 of the per-repo REST signals pass now reads two more repo-scoped considerations that do not migrate, lighting them up with real counts. - rest-signals.ts: count direct collaborators (affiliation=direct, the access a migration does not carry) via the Link-header pagination primitive, and tag protection rules from /tags/protection. Both best-effort, degrading to 0 on 403/404. - RepoSignals gains collaboratorsCount + tagProtectionCount; analyze adds the repo-collaborators and tag-protection detectors. - Profile repo rows show Collaborators and Tag protection count tiles. --- src/lib/server/profile/analyze.test.ts | 10 +++ src/lib/server/profile/analyze.ts | 8 +++ src/lib/server/profile/augment.ts | 2 + src/lib/server/profile/estimate.test.ts | 2 + src/lib/server/profile/insights.test.ts | 2 + src/lib/server/profile/rest-signals.test.ts | 52 ++++++++++++++-- src/lib/server/profile/rest-signals.ts | 68 ++++++++++++++++++--- src/lib/server/profile/runner.test.ts | 6 ++ src/lib/server/profile/runner.ts | 9 ++- src/lib/server/profile/service.test.ts | 4 ++ src/lib/server/profile/store.test.ts | 2 + src/lib/server/profile/summary.test.ts | 3 +- src/lib/server/profile/types.ts | 6 ++ src/routes/profile/[id]/+page.svelte | 2 + 14 files changed, 161 insertions(+), 15 deletions(-) diff --git a/src/lib/server/profile/analyze.test.ts b/src/lib/server/profile/analyze.test.ts index cac2bce..619cf0e 100644 --- a/src/lib/server/profile/analyze.test.ts +++ b/src/lib/server/profile/analyze.test.ts @@ -48,6 +48,8 @@ function cleanSignals(over: Partial = {}): RepoSignals { workflowFileCount: 0, webhooksCount: 0, hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, ...over, }; } @@ -101,6 +103,8 @@ describe("analyzeRepo", () => { webhooksCount: 2, hasPages: true, hasCodeScanningAlerts: true, + collaboratorsCount: 5, + tagProtectionCount: 2, }), ); @@ -127,6 +131,12 @@ describe("analyzeRepo", () => { expect(finding(profile, "webhooks")?.evidence).toBe("2 webhooks"); expect(finding(profile, "pages")?.status).toBe("applies"); expect(finding(profile, "code-scanning-history")?.status).toBe("applies"); + expect(finding(profile, "repo-collaborators")?.evidence).toBe( + "5 direct collaborators (access not migrated)", + ); + expect(finding(profile, "tag-protection")?.evidence).toBe( + "2 tag protection rules (recreate as rulesets)", + ); }); test("rolls up applying considerations by severity", () => { diff --git a/src/lib/server/profile/analyze.ts b/src/lib/server/profile/analyze.ts index e2bd725..f314af4 100644 --- a/src/lib/server/profile/analyze.ts +++ b/src/lib/server/profile/analyze.ts @@ -100,7 +100,15 @@ const DETECTORS: Record = { ? `${count(s.branchProtectionRulesUsingUnmigratedFeatures, "rule")} using unmigrated features` : null, rulesets: (s) => (s.rulesetCount > 0 ? count(s.rulesetCount, "ruleset") : null), + "tag-protection": (s) => + s.tagProtectionCount > 0 + ? `${count(s.tagProtectionCount, "tag protection rule")} (recreate as rulesets)` + : null, webhooks: (s) => (s.webhooksCount > 0 ? count(s.webhooksCount, "webhook") : null), + "repo-collaborators": (s) => + s.collaboratorsCount > 0 + ? `${count(s.collaboratorsCount, "direct collaborator")} (access not migrated)` + : 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, diff --git a/src/lib/server/profile/augment.ts b/src/lib/server/profile/augment.ts index 8dd85e8..c04ec60 100644 --- a/src/lib/server/profile/augment.ts +++ b/src/lib/server/profile/augment.ts @@ -236,6 +236,8 @@ function countsToSignals(repo: DiscoveredRepo, node: RepoCountsNode | null): Rep releaseAssetBytes: 0, webhooksCount: 0, hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, // Cheap counts. issuesCount: node?.issues.totalCount ?? 0, pullRequestsCount: node?.pullRequests.totalCount ?? 0, diff --git a/src/lib/server/profile/estimate.test.ts b/src/lib/server/profile/estimate.test.ts index a8f3cea..984c5fe 100644 --- a/src/lib/server/profile/estimate.test.ts +++ b/src/lib/server/profile/estimate.test.ts @@ -46,6 +46,8 @@ function signals(diskUsageKb: number | null): RepoSignals { workflowFileCount: 0, webhooksCount: 0, hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, }; } diff --git a/src/lib/server/profile/insights.test.ts b/src/lib/server/profile/insights.test.ts index cd2291f..9626257 100644 --- a/src/lib/server/profile/insights.test.ts +++ b/src/lib/server/profile/insights.test.ts @@ -51,6 +51,8 @@ function signals(over: Partial = {}): RepoSignals { workflowFileCount: 0, webhooksCount: 0, hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, ...over, }; } diff --git a/src/lib/server/profile/rest-signals.test.ts b/src/lib/server/profile/rest-signals.test.ts index 2b6ec5a..c1d4b30 100644 --- a/src/lib/server/profile/rest-signals.test.ts +++ b/src/lib/server/profile/rest-signals.test.ts @@ -1,8 +1,8 @@ /** * 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. + * these exercise the webhook count, the code-scanning presence check, the direct + * collaborator count, the tag-protection count, 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"; @@ -52,7 +52,7 @@ function mockRest( } describe("gatherRepoRestSignals", () => { - test("gathers webhook count and code-scanning together", async () => { + test("gathers all four signals together", async () => { const rest = mockRest({ "GET /repos/{owner}/{repo}/hooks": () => ({ headers: { @@ -61,11 +61,20 @@ describe("gatherRepoRestSignals", () => { data: [{}], }), "GET /repos/{owner}/{repo}/code-scanning/alerts": () => ({ data: [{ number: 1 }] }), + "GET /repos/{owner}/{repo}/collaborators": () => ({ + headers: { + link: '; rel="last"', + }, + data: [{}], + }), + "GET /repos/{owner}/{repo}/tags/protection": () => ({ data: [{ id: 1 }, { id: 2 }] }), }); expect(await gatherRepoRestSignals(rest, repo())).toEqual({ webhooksCount: 3, hasCodeScanningAlerts: true, + collaboratorsCount: 4, + tagProtectionCount: 2, }); }); @@ -78,6 +87,8 @@ describe("gatherRepoRestSignals", () => { expect(await gatherRepoRestSignals(rest, repo())).toEqual({ webhooksCount: 1, hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, }); }); @@ -91,6 +102,8 @@ describe("gatherRepoRestSignals", () => { expect(await gatherRepoRestSignals(rest, repo())).toEqual({ webhooksCount: 1, hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, }); }); @@ -105,6 +118,8 @@ describe("gatherRepoRestSignals", () => { expect(await gatherRepoRestSignals(rest, repo())).toEqual({ webhooksCount: 0, // 403 → 0 hasCodeScanningAlerts: true, // still read + collaboratorsCount: 0, + tagProtectionCount: 0, }); }); @@ -119,9 +134,36 @@ describe("gatherRepoRestSignals", () => { expect(await gatherRepoRestSignals(rest, repo())).toEqual({ webhooksCount: 1, hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, + }); + }); + + test("counts a single page of collaborators by length and reads tag protection", async () => { + const rest = mockRest({ + "GET /repos/{owner}/{repo}/collaborators": () => ({ data: [{}, {}, {}] }), // 3, no last link + "GET /repos/{owner}/{repo}/tags/protection": () => ({ data: [{ id: 1 }] }), + }); + + expect(await gatherRepoRestSignals(rest, repo())).toEqual({ + webhooksCount: 0, + hasCodeScanningAlerts: false, + collaboratorsCount: 3, + tagProtectionCount: 1, }); }); + test("tag protection 404 (none, or expressed as rulesets) degrades to 0", async () => { + const rest = mockRest({ + "GET /repos/{owner}/{repo}/collaborators": () => ({ data: [{}] }), + // tags/protection falls through to the default 404 thrower. + }); + + const res = await gatherRepoRestSignals(rest, repo()); + expect(res.tagProtectionCount).toBe(0); + expect(res.collaboratorsCount).toBe(1); + }); + test("returns all defaults for a malformed nameWithOwner without any request", async () => { let called = false; const rest = { @@ -134,6 +176,8 @@ describe("gatherRepoRestSignals", () => { expect(await gatherRepoRestSignals(rest, repo({ nameWithOwner: "no-slash" }))).toEqual({ webhooksCount: 0, hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, }); expect(called).toBe(false); }); diff --git a/src/lib/server/profile/rest-signals.ts b/src/lib/server/profile/rest-signals.ts index 867d7a9..fe1aa21 100644 --- a/src/lib/server/profile/rest-signals.ts +++ b/src/lib/server/profile/rest-signals.ts @@ -1,9 +1,11 @@ /** * Per-repo REST signals — the cheap presence/count checks GraphQL can't fold in. * - * Two migration considerations are best read straight from REST: + * Four 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. + * - collaborators — direct per-repo user/team access isn't migrated. + * - tag protection — tag protection rules 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 @@ -18,6 +20,8 @@ import type { DiscoveredRepo } from "./types"; export interface RepoRestSignals { webhooksCount: number; hasCodeScanningAlerts: boolean; + collaboratorsCount: number; + tagProtectionCount: number; } /** Split `owner/name`; returns null when it isn't a well-formed pair. */ @@ -60,6 +64,46 @@ async function hasCodeScanning(rest: GitHubClient, owner: string, repo: string): } } +/** + * Count direct collaborators (`affiliation=direct`) — users granted access to + * this repo specifically, excluding access inherited from org membership. This + * is the per-repo access that a migration does not carry over. 0 when unreadable + * (needs the `repo`/`read:org` reach a read-only token may lack). + */ +async function countDirectCollaborators( + rest: GitHubClient, + owner: string, + repo: string, +): Promise { + try { + return await countByPagination(rest, "GET /repos/{owner}/{repo}/collaborators", { + owner, + repo, + affiliation: "direct", + }); + } catch { + return 0; + } +} + +/** + * Count tag protection rules. The endpoint returns a bare array of rules, so the + * length is the count. 404/empty (no rules, or expressed as rulesets instead) + * and permission errors degrade to 0. + */ +async function countTagProtection( + rest: GitHubClient, + owner: string, + repo: string, +): Promise { + try { + const res = await rest.request("GET /repos/{owner}/{repo}/tags/protection", { owner, repo }); + return Array.isArray(res.data) ? res.data.length : 0; + } catch { + return 0; + } +} + /** * Gather a repository's REST-only signals. The requests run concurrently; each * degrades independently, so one inaccessible endpoint doesn't suppress the @@ -74,11 +118,21 @@ export async function gatherRepoRestSignals( r: DiscoveredRepo, ): Promise { const parts = splitOwnerRepo(r.nameWithOwner); - if (!parts) return { webhooksCount: 0, hasCodeScanningAlerts: false }; + if (!parts) { + return { + webhooksCount: 0, + hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, + }; + } const { owner, repo } = parts; - const [webhooksCount, hasCodeScanningAlerts] = await Promise.all([ - countWebhooks(rest, owner, repo), - hasCodeScanning(rest, owner, repo), - ]); - return { webhooksCount, hasCodeScanningAlerts }; + const [webhooksCount, hasCodeScanningAlerts, collaboratorsCount, tagProtectionCount] = + await Promise.all([ + countWebhooks(rest, owner, repo), + hasCodeScanning(rest, owner, repo), + countDirectCollaborators(rest, owner, repo), + countTagProtection(rest, owner, repo), + ]); + return { webhooksCount, hasCodeScanningAlerts, collaboratorsCount, tagProtectionCount }; } diff --git a/src/lib/server/profile/runner.test.ts b/src/lib/server/profile/runner.test.ts index 35463f3..8f5b91d 100644 --- a/src/lib/server/profile/runner.test.ts +++ b/src/lib/server/profile/runner.test.ts @@ -29,6 +29,8 @@ const noCommits: ProfileRunnerDeps["countCommits"] = async () => 0; const noRestSignals: ProfileRunnerDeps["gatherRestSignals"] = async () => ({ webhooksCount: 0, hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, }); function discovered(name: string, over: Partial = {}): DiscoveredRepo { @@ -72,6 +74,8 @@ function signalsFor(repo: DiscoveredRepo, over: Partial = {}): Repo webhooksCount: 0, hasPages: false, hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, issuesCount: 0, pullRequestsCount: 0, branchesCount: 0, @@ -120,6 +124,8 @@ function deps( gatherRestSignals: async (_rest, r) => ({ webhooksCount: augmentOver[r.name]?.webhooksCount ?? 0, hasCodeScanningAlerts: augmentOver[r.name]?.hasCodeScanningAlerts ?? false, + collaboratorsCount: augmentOver[r.name]?.collaboratorsCount ?? 0, + tagProtectionCount: augmentOver[r.name]?.tagProtectionCount ?? 0, }), getOrgRulesetCount: async () => rulesetCount, getOrgResources: async () => ({ ...ZERO_ORG_RESOURCES, ...orgResources }), diff --git a/src/lib/server/profile/runner.ts b/src/lib/server/profile/runner.ts index 4e0ad23..605640d 100644 --- a/src/lib/server/profile/runner.ts +++ b/src/lib/server/profile/runner.ts @@ -324,9 +324,10 @@ 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. + // plus webhooks / code-scanning / direct-collaborator / tag-protection + // 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 => { @@ -345,6 +346,8 @@ export async function runProfile( commitsCount, webhooksCount: rest.webhooksCount, hasCodeScanningAlerts: rest.hasCodeScanningAlerts, + collaboratorsCount: rest.collaboratorsCount, + tagProtectionCount: rest.tagProtectionCount, }; signalsByRepo.set(r.nameWithOwner, merged); recordRepoProfile(input.id, merged, analyzeRepo(merged)); diff --git a/src/lib/server/profile/service.test.ts b/src/lib/server/profile/service.test.ts index a5f6ad4..7e1ee1f 100644 --- a/src/lib/server/profile/service.test.ts +++ b/src/lib/server/profile/service.test.ts @@ -55,6 +55,8 @@ function signalsFor(repo: DiscoveredRepo, over: Partial = {}): Repo webhooksCount: 0, hasPages: false, hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, issuesCount: 0, pullRequestsCount: 0, branchesCount: 0, @@ -113,6 +115,8 @@ function serviceDeps( gatherRestSignals: async (_rest, r) => ({ webhooksCount: augmentOver[r.name]?.webhooksCount ?? 0, hasCodeScanningAlerts: augmentOver[r.name]?.hasCodeScanningAlerts ?? false, + collaboratorsCount: augmentOver[r.name]?.collaboratorsCount ?? 0, + tagProtectionCount: augmentOver[r.name]?.tagProtectionCount ?? 0, }), }); return state.runPromise; diff --git a/src/lib/server/profile/store.test.ts b/src/lib/server/profile/store.test.ts index c54219b..c38d7c3 100644 --- a/src/lib/server/profile/store.test.ts +++ b/src/lib/server/profile/store.test.ts @@ -69,6 +69,8 @@ function signals(over: Partial = {}): RepoSignals { workflowFileCount: 0, webhooksCount: 0, hasCodeScanningAlerts: false, + collaboratorsCount: 0, + tagProtectionCount: 0, ...over, }; } diff --git a/src/lib/server/profile/summary.test.ts b/src/lib/server/profile/summary.test.ts index f14ad1d..ee9f40f 100644 --- a/src/lib/server/profile/summary.test.ts +++ b/src/lib/server/profile/summary.test.ts @@ -69,11 +69,12 @@ describe("buildPreparationSummary", () => { const { notYetCrawled } = buildPreparationSummary([]); const ids = notYetCrawled.map((c) => c.considerationId); expect(ids).toContain("commit-size-limit"); // needs git-sizer — not gathered yet - expect(ids).toContain("repo-collaborators"); // recognized but 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("repo-collaborators"); // detected (REST signals pass) + expect(ids).not.toContain("tag-protection"); // detected (REST signals pass) expect(ids).not.toContain("actions-secrets"); // org-level evaluated expect(ids).not.toContain("teams"); // org-level evaluated expect(ids).not.toContain("mannequins"); // migration-scope — reported as always-lost diff --git a/src/lib/server/profile/types.ts b/src/lib/server/profile/types.ts index 7d53114..09f9046 100644 --- a/src/lib/server/profile/types.ts +++ b/src/lib/server/profile/types.ts @@ -140,6 +140,12 @@ export interface RepoSignals extends DiscoveredRepo { /** 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; + /** Direct collaborators (`GET …/collaborators?affiliation=direct`); per-repo + * user/team access is not migrated and must be re-granted. 0 when unreadable. */ + collaboratorsCount: number; + /** Tag protection rules (`GET …/tags/protection`); not migrated. 0 when none + * or unreadable. GitHub now recommends expressing these as rulesets. */ + tagProtectionCount: number; // ── 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 diff --git a/src/routes/profile/[id]/+page.svelte b/src/routes/profile/[id]/+page.svelte index 5328701..0bd9c3c 100644 --- a/src/routes/profile/[id]/+page.svelte +++ b/src/routes/profile/[id]/+page.svelte @@ -195,7 +195,9 @@ { label: 'Forks', value: s.forkCount.toLocaleString(), icon: 'repo-forked' }, { label: 'Protection rules', value: s.branchProtectionRuleCount.toLocaleString(), icon: 'shield' }, { label: 'Rulesets', value: s.rulesetCount.toLocaleString(), icon: 'law' }, + { label: 'Tag protection', value: s.tagProtectionCount.toLocaleString(), icon: 'shield-lock' }, { label: 'Webhooks', value: s.webhooksCount.toLocaleString(), icon: 'webhook' }, + { label: 'Collaborators', value: s.collaboratorsCount.toLocaleString(), icon: 'people' }, { label: 'Pages', value: s.hasPages ? 'Yes' : '—', icon: 'browser' }, { label: 'Code scanning', value: s.hasCodeScanningAlerts ? 'Yes' : '—', icon: 'codescan' }, { label: 'Size', value: formatRepoSize(s.diskUsageKb), icon: 'database' } From bcc0b7e03e3bf63710713ab6ceff6e1a78f5d3d5 Mon Sep 17 00:00:00 2001 From: Casey Vega Date: Mon, 15 Jun 2026 17:13:00 -0700 Subject: [PATCH 4/5] feat(profile): label the live crawl phase in progress UI The runner now tags every progress nudge with its crawl phase, so a watching detail page shows what the run is doing right now instead of just a percentage. - Add ProfilePhase (discovering | organization | counting | details | signals) and carry it on ProfileProgress and the SSE progress event. - Emit a phase nudge at each pass boundary so the label transitions crisply (discover -> org scan -> counts -> details -> repo signals). - Profile page renders the phase label with a spinner under the progress bar while the run is in progress. --- src/lib/server/profile/events.test.ts | 11 ++++- src/lib/server/profile/events.ts | 4 +- src/lib/server/profile/runner.test.ts | 14 +++++-- src/lib/server/profile/runner.ts | 58 ++++++++++++++++++++++++-- src/lib/server/profile/service.test.ts | 4 +- src/lib/server/profile/service.ts | 1 + src/lib/server/profile/types.ts | 13 ++++++ src/routes/profile/[id]/+page.svelte | 26 +++++++++++- 8 files changed, 115 insertions(+), 16 deletions(-) diff --git a/src/lib/server/profile/events.test.ts b/src/lib/server/profile/events.test.ts index 22c11df..56cedf1 100644 --- a/src/lib/server/profile/events.test.ts +++ b/src/lib/server/profile/events.test.ts @@ -26,7 +26,14 @@ function parse(frame: string): ProfileSseEvent { } const progress = (over: Partial<{ profiled: number; total: number; repo: string }> = {}) => - ({ type: "progress", profiled: 1, total: 3, repo: "acme/widget", ...over }) as const; + ({ + type: "progress", + profiled: 1, + total: 3, + repo: "acme/widget", + phase: "counting", + ...over, + }) as const; describe("subscribeProfile / publishProfileEvent", () => { test("delivers published events to a subscriber as SSE frames", () => { @@ -37,7 +44,7 @@ describe("subscribeProfile / publishProfileEvent", () => { expect(sink).toHaveLength(1); expect(sink[0]).toBe( - 'data: {"type":"progress","profiled":1,"total":3,"repo":"acme/widget"}\n\n', + 'data: {"type":"progress","profiled":1,"total":3,"repo":"acme/widget","phase":"counting"}\n\n', ); expect(parse(sink[0] as string)).toEqual(progress()); }); diff --git a/src/lib/server/profile/events.ts b/src/lib/server/profile/events.ts index 4800ac0..dfa346e 100644 --- a/src/lib/server/profile/events.ts +++ b/src/lib/server/profile/events.ts @@ -7,11 +7,11 @@ * replay here. The bus just nudges connected clients (keyed by run id) to * refetch, and signals a terminal `done` so they can stop streaming. */ -import type { ProfileRunState } from "./types"; +import type { ProfilePhase, ProfileRunState } from "./types"; /** An event pushed to a run's subscribers. */ export type ProfileSseEvent = - | { type: "progress"; profiled: number; total: number; repo: string } + | { type: "progress"; profiled: number; total: number; repo: string; phase: ProfilePhase } | { type: "done"; state: ProfileRunState }; /** Live stream controllers, keyed by run id. */ diff --git a/src/lib/server/profile/runner.test.ts b/src/lib/server/profile/runner.test.ts index 8f5b91d..3f27bd0 100644 --- a/src/lib/server/profile/runner.test.ts +++ b/src/lib/server/profile/runner.test.ts @@ -209,7 +209,13 @@ describe("runProfile", () => { // The first nudge is the discovery one (profiled 0), before any per-repo // progress, and the run total is set from it. - expect(progress[0]).toEqual({ runId: "disc", profiled: 0, total: 2, repo: "" }); + expect(progress[0]).toEqual({ + runId: "disc", + profiled: 0, + total: 2, + repo: "", + phase: "discovering", + }); expect(getProfileRun("disc")?.totalRepos).toBe(2); }); @@ -248,9 +254,9 @@ describe("runProfile", () => { // Pass 1 emits one per-repo frame as each repo's counts are recorded. (The // details pass adds repo-less nudges, filtered out here.) expect(progress.filter((p) => p.repo !== "")).toEqual([ - { runId: "r", profiled: 1, total: 3, repo: "acme/a" }, - { runId: "r", profiled: 2, total: 3, repo: "acme/b" }, - { runId: "r", profiled: 3, total: 3, repo: "acme/c" }, + { runId: "r", profiled: 1, total: 3, repo: "acme/a", phase: "counting" }, + { runId: "r", profiled: 2, total: 3, repo: "acme/b", phase: "counting" }, + { runId: "r", profiled: 3, total: 3, repo: "acme/c", phase: "counting" }, ]); }); diff --git a/src/lib/server/profile/runner.ts b/src/lib/server/profile/runner.ts index 605640d..483c4a2 100644 --- a/src/lib/server/profile/runner.ts +++ b/src/lib/server/profile/runner.ts @@ -172,7 +172,13 @@ export async function runProfile( // live watcher with it); the exact total lands once discovery completes. const discovery = await d.discover(clients.rest, input.org, (p) => { setProfileRunTotal(input.id, p.total); - onProgress?.({ runId: input.id, profiled: 0, total: p.total, repo: "" }); + onProgress?.({ + runId: input.id, + profiled: 0, + total: p.total, + repo: "", + phase: "discovering", + }); }); setProfileRunTotal(input.id, discovery.total); console.log( @@ -181,6 +187,13 @@ export async function runProfile( // Org-level signals are gathered once per run (best-effort, never fatal) — // they're org-scoped, not per-repo. Run the two REST passes concurrently. + onProgress?.({ + runId: input.id, + profiled: 0, + total: discovery.total, + repo: "", + phase: "organization", + }); const [rulesetCount, orgResources] = await Promise.all([ d.getOrgRulesetCount(input.org), d.getOrgResources(input.org), @@ -203,7 +216,13 @@ export async function runProfile( } if (discovery.repos.length > 0) { // Nudge any live watcher to render the freshly-listed repos. - onProgress?.({ runId: input.id, profiled: 0, total: discovery.total, repo: "" }); + onProgress?.({ + runId: input.id, + profiled: 0, + total: discovery.total, + repo: "", + phase: "counting", + }); } // ── Pass 1: cheap counts (best-effort) ──────────────────────────────── @@ -237,6 +256,7 @@ export async function runProfile( profiled, total: discovery.total, repo: signals.nameWithOwner, + phase: "counting", }); } } catch (err) { @@ -283,6 +303,15 @@ export async function runProfile( })), ...chunk(withReleases, DETAILS_CHUNK_FULL).map((repos) => ({ repos, scanReleases: true })), ]; + if (tasks.length > 0) { + onProgress?.({ + runId: input.id, + profiled, + total: discovery.total, + repo: "", + phase: "details", + }); + } { let next = 0; const worker = async (): Promise => { @@ -308,7 +337,13 @@ export async function runProfile( recordRepoProfile(input.id, merged, analyzeRepo(merged)); } // Nudge any live watcher to refetch the now-enriched data. - onProgress?.({ runId: input.id, profiled, total: discovery.total, repo: "" }); + onProgress?.({ + runId: input.id, + profiled, + total: discovery.total, + repo: "", + phase: "details", + }); } catch (err) { console.error( `[profile] run ${input.id} details chunk failed — ${task.repos.length} repo(s), scanReleases=${task.scanReleases} (counts kept):`, @@ -328,6 +363,15 @@ export async function runProfile( // 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. + if (byRisk.length > 0) { + onProgress?.({ + runId: input.id, + profiled, + total: discovery.total, + repo: "", + phase: "signals", + }); + } { let next = 0; const worker = async (): Promise => { @@ -363,7 +407,13 @@ export async function runProfile( Array.from({ length: Math.min(COMMIT_CONCURRENCY, byRisk.length) }, () => worker()), ); if (byRisk.length > 0) { - onProgress?.({ runId: input.id, profiled, total: discovery.total, repo: "" }); + onProgress?.({ + runId: input.id, + profiled, + total: discovery.total, + repo: "", + phase: "signals", + }); } } diff --git a/src/lib/server/profile/service.test.ts b/src/lib/server/profile/service.test.ts index 7e1ee1f..f2108d7 100644 --- a/src/lib/server/profile/service.test.ts +++ b/src/lib/server/profile/service.test.ts @@ -202,8 +202,8 @@ describe("startOrgProfile", () => { // refetch nudges, filtered out here. const progress = events.filter((e) => e.type === "progress" && e.repo !== ""); expect(progress).toEqual([ - { type: "progress", profiled: 1, total: 2, repo: "acme/a" }, - { type: "progress", profiled: 2, total: 2, repo: "acme/b" }, + { type: "progress", profiled: 1, total: 2, repo: "acme/a", phase: "counting" }, + { type: "progress", profiled: 2, total: 2, repo: "acme/b", phase: "counting" }, ]); expect(events.at(-1)).toEqual({ type: "done", state: "completed" }); }); diff --git a/src/lib/server/profile/service.ts b/src/lib/server/profile/service.ts index 6a05664..4928aed 100644 --- a/src/lib/server/profile/service.ts +++ b/src/lib/server/profile/service.ts @@ -115,6 +115,7 @@ export function startOrgProfile(org: string, deps: ProfileServiceDeps = DEFAULT_ profiled: p.profiled, total: p.total, repo: p.repo, + phase: p.phase, }), { getOrgRulesetCount: (target) => getOrgRulesetCount(rest, target), diff --git a/src/lib/server/profile/types.ts b/src/lib/server/profile/types.ts index 09f9046..2c3f35c 100644 --- a/src/lib/server/profile/types.ts +++ b/src/lib/server/profile/types.ts @@ -262,4 +262,17 @@ export interface ProfileProgress { total: number; /** The repository just profiled (`owner/name`). */ repo: string; + /** Which crawl phase emitted this nudge (drives the live phase label). */ + phase: ProfilePhase; } + +/** + * The crawl's coarse phases, in order. Each emits progress nudges so the live + * UI can show what the run is doing right now: + * - `discovering` — listing the org's repositories (REST). + * - `organization` — gathering org-level resources (rulesets, secrets, teams…). + * - `counting` — the cheap per-repo `totalCount` pass. + * - `details` — the expensive per-repo pass (LFS, workflows, release assets). + * - `signals` — the per-repo REST pass (commits, webhooks, collaborators…). + */ +export type ProfilePhase = "discovering" | "organization" | "counting" | "details" | "signals"; diff --git a/src/routes/profile/[id]/+page.svelte b/src/routes/profile/[id]/+page.svelte index 0bd9c3c..49a31b7 100644 --- a/src/routes/profile/[id]/+page.svelte +++ b/src/routes/profile/[id]/+page.svelte @@ -225,6 +225,18 @@ const pct = $derived(run.totalRepos > 0 ? Math.round((run.profiledRepos / run.totalRepos) * 100) : 0); + // Human label for the crawl phase the run is currently in, streamed over SSE. + // Keyed by the runner's ProfilePhase; unknown/absent → no caption. + const PHASE_LABELS: Record = { + discovering: 'Discovering repositories…', + organization: 'Scanning organization resources…', + counting: 'Counting issues, PRs & branches…', + details: 'Scanning details — LFS, workflows, releases…', + signals: 'Checking commits, webhooks & collaborators…' + }; + // The latest phase from the SSE stream (null until the first nudge / after done). + let livePhase = $state(null); + async function refresh() { try { const res = await fetch(`/api/profile/${data.run.id}`); @@ -243,14 +255,18 @@ const conn = createReconnectingEventSource({ url: () => `/api/profile/${id}/events`, onMessage: (e, controls) => { - let msg: { type?: string }; + let msg: { type?: string; phase?: string }; try { msg = JSON.parse(e.data); } catch { return; } + if (msg.type === 'progress' && msg.phase) livePhase = msg.phase; refresh(); - if (msg.type === 'done') controls.destroy(); + if (msg.type === 'done') { + livePhase = null; + controls.destroy(); + } } }); return () => conn.destroy(); @@ -309,6 +325,12 @@
+ {#if livePhase && PHASE_LABELS[livePhase]} +
+ + {PHASE_LABELS[livePhase]} +
+ {/if} {/if}
From 54787edae1e6f77b03ce19c5b014f99e9624ba99 Mon Sep 17 00:00:00 2001 From: Casey Vega Date: Mon, 15 Jun 2026 17:25:14 -0700 Subject: [PATCH 5/5] style(profile): redesign the always-lost panel as a card grid The bulleted two-column list left ragged whitespace because rows aligned to the tallest item. Render each loss as a bordered card in a responsive grid (matching the org-resource and scale tiles), so items have even heights per row and the panel reads as intentional. --- src/routes/profile/[id]/+page.svelte | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/routes/profile/[id]/+page.svelte b/src/routes/profile/[id]/+page.svelte index 49a31b7..784b772 100644 --- a/src/routes/profile/[id]/+page.svelte +++ b/src/routes/profile/[id]/+page.svelte @@ -513,25 +513,23 @@ {#if summary.alwaysLost.length > 0} -
-

+
+
- Always lost in every migration ({summary.alwaysLost.length}) -

-

+ Always lost in every migration + {summary.alwaysLost.length} +

+

Not tied to any repo — the Importer never carries these over, so plan to recreate or accept them.

-
    +
    {#each summary.alwaysLost as c (c.considerationId)} -
  • - - - {c.label} - — {c.summary} - -
  • +
    +
    {c.label}
    +

    {c.summary}

    +
    {/each} -
+
{/if}