diff --git a/migrations/0037_badge_enabled.sql b/migrations/0037_badge_enabled.sql new file mode 100644 index 0000000000..ba3227efb0 --- /dev/null +++ b/migrations/0037_badge_enabled.sql @@ -0,0 +1,3 @@ +-- #541: opt-in flag for the public README status badge. Default 0 (off) — the unauthenticated badge +-- endpoint only serves whitelisted metrics for installed repos that have explicitly opted in. +ALTER TABLE repository_settings ADD COLUMN badge_enabled INTEGER NOT NULL DEFAULT 0; diff --git a/src/api/badge.ts b/src/api/badge.ts new file mode 100644 index 0000000000..77c1273592 --- /dev/null +++ b/src/api/badge.ts @@ -0,0 +1,110 @@ +import type { PublicRepoQuality, QueueHealthLevel } from "../services/public-repo-quality"; + +// Self-rendered README status badge (#541). Renders ONLY the public-safe whitelisted metrics from +// `PublicRepoQuality` — no external badge service, no contributor/reward/trust data. All text is XML-escaped +// before it reaches the SVG so the unauthenticated, embeddable surface cannot be turned into an injection +// vector even if upstream values ever change shape. + +const LABEL = "gittensory"; + +const QUEUE_COLORS: Record = { + low: "#3fb950", + medium: "#d29922", + high: "#db6d28", + critical: "#f85149", +}; + +const LOW_REAL_CONTRIBUTION_PCT = 50; +const UNAVAILABLE_COLOR = "#9e9e9e"; + +export type ShieldsBadge = { + schemaVersion: 1; + label: string; + message: string; + color: string; + cacheSeconds: number; +}; + +export function buildBadgeMessage(quality: PublicRepoQuality): string { + const real = quality.realContributionPct === null ? "real n/a" : `${quality.realContributionPct}% real`; + const merge = + quality.medianTimeToMergeHours === null ? "merge n/a" : `merge ${formatDuration(quality.medianTimeToMergeHours)}`; + return `${real} · ${merge} · queue ${quality.queueHealthLevel}`; +} + +export function buildBadgeColor(quality: PublicRepoQuality): string { + // Color tracks queue health, but a low real-contribution share dominates the signal. + if (quality.realContributionPct !== null && quality.realContributionPct < LOW_REAL_CONTRIBUTION_PCT) { + return QUEUE_COLORS.high; + } + return QUEUE_COLORS[quality.queueHealthLevel]; +} + +export function buildShieldsBadge(quality: PublicRepoQuality, cacheSeconds: number): ShieldsBadge { + return { + schemaVersion: 1, + label: LABEL, + message: buildBadgeMessage(quality), + color: buildBadgeColor(quality), + cacheSeconds, + }; +} + +export function renderBadgeSvg(quality: PublicRepoQuality): string { + return renderFlatBadge(LABEL, buildBadgeMessage(quality), buildBadgeColor(quality)); +} + +export function renderUnavailableBadgeSvg(): string { + return renderFlatBadge(LABEL, "unavailable", UNAVAILABLE_COLOR); +} + +function formatDuration(hours: number): string { + if (hours < 1) return "<1h"; + if (hours < 48) return `${Math.round(hours)}h`; + return `${Math.round(hours / 24)}d`; +} + +// Minimal flat ("shields"-style) badge. Widths are approximated from character count; exactness is not +// required for a README badge and keeps the renderer dependency-free. +function renderFlatBadge(label: string, message: string, color: string): string { + const labelText = escapeXml(label); + const messageText = escapeXml(message); + const labelWidth = textWidth(label); + const messageWidth = textWidth(message); + const totalWidth = labelWidth + messageWidth; + const labelMid = labelWidth / 2; + const messageMid = labelWidth + messageWidth / 2; + return [ + ``, + `${labelText}: ${messageText}`, + ``, + ``, + ``, + ``, + `${labelText}`, + `${messageText}`, + ``, + ].join(""); +} + +function textWidth(text: string): number { + // ~6.5px per character + 10px horizontal padding, clamped to a sane minimum. + return Math.max(40, Math.round(text.length * 6.5) + 10); +} + +export function escapeXml(value: string): string { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} diff --git a/src/api/routes.ts b/src/api/routes.ts index eb0f455581..b7d19abe75 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -158,6 +158,8 @@ import { import { buildOperatorDashboardPayload } from "../services/operator-dashboard"; import { buildSelfDogfoodRegistrationPack, resolveSelfDogfoodRepoFullName } from "../services/self-dogfood-registration-pack"; import { buildSubnetInterfaceDescriptor } from "../services/subnet-interface"; +import { buildPublicRepoQuality, type PublicRepoQuality } from "../services/public-repo-quality"; +import { buildShieldsBadge, renderBadgeSvg, renderUnavailableBadgeSvg } from "./badge"; import { buildWeeklyValueReport, formatWeeklyValueReportMarkdown, @@ -246,6 +248,18 @@ import { errorMessage, nowIso } from "../utils/json"; type AppBindings = { Bindings: Env }; type AppContext = Context; +// Resolves the public README badge metrics for a repo, enforcing the two gates in one place: the repo must +// be installed AND have opted in via `badgeEnabled`. Returns null (→ a benign "unavailable" badge) for any +// repo that is unknown, uninstalled, or has not opted in — so no metrics are ever served otherwise. +async function loadPublicRepoBadge(env: Env, owner: string, repo: string): Promise { + const repository = await getRepository(env, `${owner}/${repo}`); + if (!repository || !repository.isInstalled) return null; + const settings = await getRepositorySettings(env, repository.fullName); + if (!settings.badgeEnabled) return null; + const pullRequests = await listPullRequests(env, repository.fullName); + return buildPublicRepoQuality(pullRequests); +} + async function recordRouteProductUsage( c: AppContext, event: { @@ -567,6 +581,7 @@ const repositorySettingsSchema = z.object({ requireLinkedIssue: z.boolean().default(false), backfillEnabled: z.boolean().default(true), privateTrustEnabled: z.boolean().default(true), + badgeEnabled: z.boolean().default(false), commandAuthorization: z .object({ default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4).optional(), @@ -736,6 +751,30 @@ export function createApp() { } }); + // Public-safe README status badge (#541). Unauthenticated and embeddable: it serves ONLY whitelisted, + // repo-level metrics, and ONLY for installed repos that opted in via the `badgeEnabled` setting. Excluded + // from requiresApiToken above; aggressively cached + stale-while-revalidate like the public stats route. + app.get("/v1/public/repos/:owner/:repo/badge.svg", async (c) => { + const quality = await loadPublicRepoBadge(c.env, c.req.param("owner"), c.req.param("repo")); + c.header("Content-Type", "image/svg+xml; charset=utf-8"); + if (!quality) { + c.header("Cache-Control", "public, max-age=300"); + return c.body(renderUnavailableBadgeSvg(), 404); + } + c.header("Cache-Control", "public, max-age=600, stale-while-revalidate=86400"); + return c.body(renderBadgeSvg(quality)); + }); + + app.get("/v1/public/repos/:owner/:repo/badge.json", async (c) => { + const quality = await loadPublicRepoBadge(c.env, c.req.param("owner"), c.req.param("repo")); + if (!quality) { + c.header("Cache-Control", "public, max-age=300"); + return c.json({ schemaVersion: 1, label: "gittensory", message: "unavailable", color: "#9e9e9e", cacheSeconds: 300 }, 404); + } + c.header("Cache-Control", "public, max-age=600, stale-while-revalidate=86400"); + return c.json(buildShieldsBadge(quality, 600)); + }); + app.get("/v1/auth/github/start", async (c) => { try { const start = await startGitHubWebOAuth(c.env, c.req.url, c.req.query("returnTo")); @@ -2882,6 +2921,7 @@ export function createApp() { requireLinkedIssue: parsed.data.requireLinkedIssue, backfillEnabled: parsed.data.backfillEnabled, privateTrustEnabled: parsed.data.privateTrustEnabled, + badgeEnabled: parsed.data.badgeEnabled, commandAuthorization: normalizeCommandAuthorizationPolicy(parsed.data.commandAuthorization).policy, }), ); @@ -4453,6 +4493,7 @@ function requiresApiToken(path: string): boolean { if (path === "/health") return false; if (path === "/v1/mcp/compatibility") return false; if (/^\/v1\/public\/github\/repos\/[^/]+\/[^/]+\/stats$/.test(path)) return false; + if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/badge\.(svg|json)$/.test(path)) return false; if (path === "/v1/public/subnet-interface") return false; if (path === "/openapi.json") return false; if (path === "/mcp") return false; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 04567a9b08..91e8bbac77 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -415,6 +415,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise requireLinkedIssue: false, backfillEnabled: true, privateTrustEnabled: true, + badgeEnabled: false, commandAuthorization: normalizeCommandAuthorizationPolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY).policy, }; } @@ -446,6 +447,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise requireLinkedIssue: row.requireLinkedIssue, backfillEnabled: row.backfillEnabled, privateTrustEnabled: row.privateTrustEnabled, + badgeEnabled: row.badgeEnabled, commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson), createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -481,6 +483,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), diff --git a/src/services/public-repo-quality.ts b/src/services/public-repo-quality.ts new file mode 100644 index 0000000000..83ebb207a0 --- /dev/null +++ b/src/services/public-repo-quality.ts @@ -0,0 +1,78 @@ +import type { PullRequestRecord } from "../types"; + +// Public-safe repository quality metrics for the unauthenticated README badge (#541). +// +// HARD whitelist: this module derives ONLY three coarse, repo-level, public-safe metrics from cached +// pull-request records — median time-to-merge, the share of non-slop merged contributions, and a coarse +// queue-health level. It never reads or exposes contributor-level data, reward/trust values, or private +// scoreability context. Pure and deterministic (clock injected) so the badge surface stays auditable. + +export type QueueHealthLevel = "low" | "medium" | "high" | "critical"; + +export type PublicRepoQuality = { + /** Median hours from PR open to merge across known merged PRs. `null` when none are known. */ + medianTimeToMergeHours: number | null; + /** Share (0-100) of *assessed* merged PRs whose slop band is clean/low. `null` when none assessed. */ + realContributionPct: number | null; + queueHealthLevel: QueueHealthLevel; + /** Counts only — included for transparency, never contributor-level detail. */ + mergedSampleSize: number; + assessedSampleSize: number; +}; + +const STALE_OPEN_PR_DAYS = 14; +const MS_PER_HOUR = 3_600_000; +const MS_PER_DAY = 86_400_000; +const NON_SLOP_BANDS: ReadonlySet = new Set(["clean", "low"]); + +export function buildPublicRepoQuality(pullRequests: PullRequestRecord[], now: number = Date.now()): PublicRepoQuality { + const merged = pullRequests.filter(isMergedPullRequest); + const mergeDurations = merged + .map(mergeDurationHours) + .filter((hours): hours is number => hours !== null); + const assessed = merged.filter((pr) => typeof pr.slopBand === "string" && pr.slopBand.trim().length > 0); + const nonSlop = assessed.filter((pr) => NON_SLOP_BANDS.has((pr.slopBand as string).toLowerCase())); + + return { + medianTimeToMergeHours: mergeDurations.length > 0 ? Math.round(median(mergeDurations)) : null, + realContributionPct: assessed.length > 0 ? Math.round((nonSlop.length / assessed.length) * 100) : null, + queueHealthLevel: resolveQueueHealthLevel(pullRequests, now), + mergedSampleSize: merged.length, + assessedSampleSize: assessed.length, + }; +} + +function isMergedPullRequest(pr: PullRequestRecord): boolean { + return Boolean(pr.mergedAt) || pr.state.toLowerCase() === "merged"; +} + +function mergeDurationHours(pr: PullRequestRecord): number | null { + if (!pr.mergedAt || !pr.createdAt) return null; + const merged = Date.parse(pr.mergedAt); + const created = Date.parse(pr.createdAt); + if (!Number.isFinite(merged) || !Number.isFinite(created) || merged < created) return null; + return (merged - created) / MS_PER_HOUR; +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) return ((sorted[mid - 1] as number) + (sorted[mid] as number)) / 2; + return sorted[mid] as number; +} + +// Coarse, public-safe queue level derived only from open-PR volume and staleness — deliberately simpler +// than the internal QueueHealth signal so no private-derived value reaches this unauthenticated surface. +function resolveQueueHealthLevel(pullRequests: PullRequestRecord[], now: number): QueueHealthLevel { + const open = pullRequests.filter((pr) => pr.state.toLowerCase() === "open"); + const openCount = open.length; + const staleCount = open.filter((pr) => { + const stamp = Date.parse(pr.updatedAt ?? pr.createdAt ?? ""); + return Number.isFinite(stamp) && (now - stamp) / MS_PER_DAY >= STALE_OPEN_PR_DAYS; + }).length; + + if (openCount >= 50 || staleCount >= 20) return "critical"; + if (openCount >= 20 || staleCount >= 8) return "high"; + if (openCount >= 5 || staleCount >= 2) return "medium"; + return "low"; +} diff --git a/src/types.ts b/src/types.ts index f16a5d1ca6..c10efbd031 100644 --- a/src/types.ts +++ b/src/types.ts @@ -444,6 +444,9 @@ export type RepositorySettings = { requireLinkedIssue: boolean; backfillEnabled: boolean; privateTrustEnabled: boolean; + /** Opt-in for the public, unauthenticated README status badge (#541). Always populated by the DB layer + * (default false); optional so existing settings fixtures/callers need not be touched. */ + badgeEnabled?: boolean | undefined; commandAuthorization?: RepositoryCommandAuthorizationPolicy | undefined; createdAt?: string | null | undefined; updatedAt?: string | null | undefined; diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index afaf6672b6..1ad9fc2363 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -193,6 +193,62 @@ describe("api routes", () => { await expect(unavailable.json()).resolves.toMatchObject({ error: "github_repo_stats_unavailable" }); }); + it("serves the public README badge only for installed, opted-in repos (#541)", async () => { + const app = createApp(); + const env = createTestEnv(); + + // Installed + opted in, with assessed merged PRs. + await upsertRepositoryFromGitHub(env, { name: "badged", full_name: "acme/badged", private: false, owner: { login: "acme" }, default_branch: "main" }, 555); + await upsertRepositorySettings(env, { repoFullName: "acme/badged", badgeEnabled: true }); + await upsertPullRequestFromGitHub(env, "acme/badged", { number: 1, title: "Feature", state: "merged", created_at: "2026-06-01T00:00:00Z", merged_at: "2026-06-01T04:00:00Z", labels: [] }); + await upsertPullRequestFromGitHub(env, "acme/badged", { number: 2, title: "Slop", state: "merged", created_at: "2026-06-02T00:00:00Z", merged_at: "2026-06-02T06:00:00Z", labels: [] }); + await updatePullRequestSlopAssessment(env, "acme/badged", 1, { slopRisk: 0, slopBand: "clean" }); + await updatePullRequestSlopAssessment(env, "acme/badged", 2, { slopRisk: 80, slopBand: "high" }); + + const svg = await app.request("/v1/public/repos/acme/badged/badge.svg", {}, env); + expect(svg.status).toBe(200); + expect(svg.headers.get("content-type")).toContain("image/svg+xml"); + expect(svg.headers.get("cache-control")).toContain("stale-while-revalidate"); + const svgBody = await svg.text(); + expect(svgBody.startsWith(" { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request( + "/v1/internal/repos/acme/badged/settings", + { method: "POST", headers: { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ badgeEnabled: true }) }, + env, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ repoFullName: "acme/badged", badgeEnabled: true }); + }); + it("rejects invalid public GitHub repo stats paths before calling GitHub", async () => { const app = createApp(); const env = createTestEnv(); diff --git a/test/unit/badge.test.ts b/test/unit/badge.test.ts new file mode 100644 index 0000000000..2f3eb9d253 --- /dev/null +++ b/test/unit/badge.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + buildBadgeColor, + buildBadgeMessage, + buildShieldsBadge, + escapeXml, + renderBadgeSvg, + renderUnavailableBadgeSvg, +} from "../../src/api/badge"; +import type { PublicRepoQuality } from "../../src/services/public-repo-quality"; + +function quality(overrides: Partial = {}): PublicRepoQuality { + return { + medianTimeToMergeHours: 30, + realContributionPct: 92, + queueHealthLevel: "low", + mergedSampleSize: 10, + assessedSampleSize: 8, + ...overrides, + }; +} + +describe("buildBadgeMessage", () => { + it("summarizes the whitelisted metrics", () => { + expect(buildBadgeMessage(quality())).toBe("92% real · merge 30h · queue low"); + }); + + it("renders n/a for missing metrics and formats duration by magnitude", () => { + expect(buildBadgeMessage(quality({ realContributionPct: null, medianTimeToMergeHours: null }))).toBe( + "real n/a · merge n/a · queue low", + ); + expect(buildBadgeMessage(quality({ medianTimeToMergeHours: 0 }))).toContain("merge <1h"); + expect(buildBadgeMessage(quality({ medianTimeToMergeHours: 72 }))).toContain("merge 3d"); + }); +}); + +describe("buildBadgeColor", () => { + it("tracks queue health when contribution quality is healthy", () => { + expect(buildBadgeColor(quality({ queueHealthLevel: "low" }))).toBe("#3fb950"); + expect(buildBadgeColor(quality({ queueHealthLevel: "medium" }))).toBe("#d29922"); + expect(buildBadgeColor(quality({ queueHealthLevel: "critical" }))).toBe("#f85149"); + }); + + it("downgrades the color when the real-contribution share is low", () => { + expect(buildBadgeColor(quality({ queueHealthLevel: "low", realContributionPct: 40 }))).toBe("#db6d28"); + }); + + it("uses queue color when the contribution share is unknown", () => { + expect(buildBadgeColor(quality({ queueHealthLevel: "low", realContributionPct: null }))).toBe("#3fb950"); + }); +}); + +describe("buildShieldsBadge", () => { + it("emits a shields endpoint payload", () => { + expect(buildShieldsBadge(quality(), 600)).toEqual({ + schemaVersion: 1, + label: "gittensory", + message: "92% real · merge 30h · queue low", + color: "#3fb950", + cacheSeconds: 600, + }); + }); +}); + +describe("renderBadgeSvg", () => { + it("renders a valid SVG carrying the label and message", () => { + const svg = renderBadgeSvg(quality()); + expect(svg.startsWith(" { + const svg = renderUnavailableBadgeSvg(); + expect(svg).toContain("unavailable"); + expect(svg.startsWith(" { + it("escapes all XML-significant characters", () => { + expect(escapeXml("&<>\"'")).toBe("&<>"'"); + expect(escapeXml("safe text 92%")).toBe("safe text 92%"); + }); +}); diff --git a/test/unit/public-repo-quality.test.ts b/test/unit/public-repo-quality.test.ts new file mode 100644 index 0000000000..1cd76bf7a9 --- /dev/null +++ b/test/unit/public-repo-quality.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { buildPublicRepoQuality } from "../../src/services/public-repo-quality"; +import type { PullRequestRecord } from "../../src/types"; + +const NOW = Date.parse("2026-06-15T00:00:00.000Z"); + +function pr(overrides: Partial = {}): PullRequestRecord { + return { + repoFullName: "acme/widgets", + number: 1, + title: "PR", + state: "merged", + labels: [], + linkedIssues: [], + ...overrides, + }; +} + +function merged(createdAt: string, mergedAt: string, slopBand?: string): PullRequestRecord { + return pr({ state: "merged", createdAt, mergedAt, ...(slopBand ? { slopBand } : {}) }); +} + +describe("buildPublicRepoQuality", () => { + it("returns public-safe defaults for an empty repo", () => { + expect(buildPublicRepoQuality([], NOW)).toEqual({ + medianTimeToMergeHours: null, + realContributionPct: null, + queueHealthLevel: "low", + mergedSampleSize: 0, + assessedSampleSize: 0, + }); + }); + + it("computes the odd-count median time-to-merge in whole hours", () => { + const quality = buildPublicRepoQuality( + [ + merged("2026-06-01T00:00:00Z", "2026-06-01T02:00:00Z"), // 2h + merged("2026-06-01T00:00:00Z", "2026-06-01T06:00:00Z"), // 6h + merged("2026-06-01T00:00:00Z", "2026-06-01T10:00:00Z"), // 10h + ], + NOW, + ); + expect(quality.medianTimeToMergeHours).toBe(6); + expect(quality.mergedSampleSize).toBe(3); + }); + + it("averages the two middle values for an even-count median", () => { + expect( + buildPublicRepoQuality( + [merged("2026-06-01T00:00:00Z", "2026-06-01T02:00:00Z"), merged("2026-06-01T00:00:00Z", "2026-06-01T08:00:00Z")], + NOW, + ).medianTimeToMergeHours, + ).toBe(5); + }); + + it("excludes merges with missing or impossible timestamps from the median", () => { + const quality = buildPublicRepoQuality( + [ + merged("2026-06-01T00:00:00Z", "2026-06-01T04:00:00Z"), // 4h, valid + pr({ state: "merged", mergedAt: "2026-06-02T00:00:00Z" }), // no createdAt + pr({ state: "merged", createdAt: "2026-06-03T05:00:00Z", mergedAt: "2026-06-03T00:00:00Z" }), // merged < created + ], + NOW, + ); + expect(quality.medianTimeToMergeHours).toBe(4); + expect(quality.mergedSampleSize).toBe(3); + }); + + it("treats state=merged without mergedAt as merged for sample size", () => { + expect(buildPublicRepoQuality([pr({ state: "MERGED" })], NOW).mergedSampleSize).toBe(1); + }); + + it("computes the non-slop contribution share only over assessed merges", () => { + const quality = buildPublicRepoQuality( + [ + merged("2026-06-01T00:00:00Z", "2026-06-01T01:00:00Z", "clean"), + merged("2026-06-01T00:00:00Z", "2026-06-01T01:00:00Z", "LOW"), // case-insensitive non-slop + merged("2026-06-01T00:00:00Z", "2026-06-01T01:00:00Z", "high"), // slop + merged("2026-06-01T00:00:00Z", "2026-06-01T01:00:00Z"), // not assessed → excluded + ], + NOW, + ); + expect(quality.assessedSampleSize).toBe(3); + expect(quality.realContributionPct).toBe(67); // 2 of 3 assessed are non-slop + }); + + it("returns null real-contribution share when no merge is assessed", () => { + expect(buildPublicRepoQuality([merged("2026-06-01T00:00:00Z", "2026-06-01T01:00:00Z")], NOW).realContributionPct).toBeNull(); + }); + + it("classifies queue health by open volume and staleness", () => { + const openFresh = (count: number) => + Array.from({ length: count }, (_, i) => pr({ number: i + 1, state: "open", updatedAt: "2026-06-14T00:00:00Z" })); + const openStale = (count: number) => + Array.from({ length: count }, (_, i) => pr({ number: i + 100, state: "open", updatedAt: "2026-05-01T00:00:00Z" })); + + expect(buildPublicRepoQuality(openFresh(3), NOW).queueHealthLevel).toBe("low"); + expect(buildPublicRepoQuality(openFresh(6), NOW).queueHealthLevel).toBe("medium"); + expect(buildPublicRepoQuality(openStale(2), NOW).queueHealthLevel).toBe("medium"); // staleness path + expect(buildPublicRepoQuality(openFresh(20), NOW).queueHealthLevel).toBe("high"); + expect(buildPublicRepoQuality(openStale(8), NOW).queueHealthLevel).toBe("high"); + expect(buildPublicRepoQuality(openFresh(50), NOW).queueHealthLevel).toBe("critical"); + expect(buildPublicRepoQuality(openStale(20), NOW).queueHealthLevel).toBe("critical"); + }); + + it("falls back to createdAt, then ignores, when open PRs lack updatedAt", () => { + const staleByCreated = pr({ number: 1, state: "open", createdAt: "2026-05-01T00:00:00Z" }); // no updatedAt → use createdAt → stale + const alsoStale = pr({ number: 2, state: "open", createdAt: "2026-05-01T00:00:00Z" }); + const noTimestamps = pr({ number: 3, state: "open" }); // neither field → not counted as stale + expect(buildPublicRepoQuality([staleByCreated, alsoStale, noTimestamps], NOW).queueHealthLevel).toBe("medium"); + }); + + it("never exposes contributor-level or private terms", () => { + const quality = buildPublicRepoQuality([merged("2026-06-01T00:00:00Z", "2026-06-01T01:00:00Z", "clean")], NOW); + expect(JSON.stringify(quality)).not.toMatch(/wallet|hotkey|trust|reward|login|author|scoreability/i); + }); +});