From 99b823757c34092a4661e9db36d7171c11115f3e Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Mon, 6 Jul 2026 17:22:55 +0200 Subject: [PATCH 1/5] feat(site): add public per-repo review-quality metrics (#2568) Ship an opt-in public quality page and API that reuse gate-precision and outcome telemetry with public-safe aggregates only. Co-authored-by: Cursor --- .gittensory.yml.example | 3 + apps/gittensory-ui/public/openapi.json | 205 +++++++++++++ .../site/app-panels/maintainer-settings.tsx | 3 + apps/gittensory-ui/src/routeTree.gen.ts | 21 ++ apps/gittensory-ui/src/routes/docs.tuning.tsx | 3 +- .../src/routes/repos.$owner.$repo.quality.tsx | 179 +++++++++++ config/examples/gittensory.full.yml | 3 + migrations/0115_public_quality_metrics.sql | 2 + src/api/routes.ts | 31 ++ src/db/repositories.ts | 5 + src/db/schema.ts | 1 + src/openapi/schemas.ts | 45 +++ src/openapi/spec.ts | 18 +- src/services/public-quality-metrics.ts | 277 ++++++++++++++++++ src/signals/focus-manifest.ts | 3 +- src/signals/settings-preview.ts | 2 + src/types.ts | 3 + test/integration/api.test.ts | 39 +++ test/unit/focus-manifest.test.ts | 13 + test/unit/public-quality-metrics.test.ts | 191 ++++++++++++ 20 files changed, 1044 insertions(+), 3 deletions(-) create mode 100644 apps/gittensory-ui/src/routes/repos.$owner.$repo.quality.tsx create mode 100644 migrations/0115_public_quality_metrics.sql create mode 100644 src/services/public-quality-metrics.ts create mode 100644 test/unit/public-quality-metrics.test.ts diff --git a/.gittensory.yml.example b/.gittensory.yml.example index a2214e827b..8bb1d66a37 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -596,6 +596,9 @@ settings: # Render a README status badge for the repo. Bool. Default: false. badgeEnabled: false + # Publish a public per-repo review-quality page. Bool. Default: false. + publicQualityMetrics: false + # Per-repo kill-switch: when true, the agent does nothing on this repo. # Bool. Default: false. agentPaused: false diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index d0e3b1d609..7ab7445463 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -9241,6 +9241,9 @@ "updatedAt": { "type": "string", "nullable": true + }, + "publicQualityMetrics": { + "type": "boolean" } }, "required": [ @@ -9959,6 +9962,9 @@ "defaultAllowed", "commandOverrides" ] + }, + "publicQualityMetrics": { + "type": "boolean" } }, "required": [ @@ -9987,6 +9993,7 @@ "includeMaintainerAuthors", "requireLinkedIssue", "badgeEnabled", + "publicQualityMetrics", "aiReviewMode", "aiReviewByok", "aiReviewProvider", @@ -13416,6 +13423,164 @@ "maintainerNextSteps", "privateSummary" ] + }, + "PublicQualityMetrics": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "generatedAt": { + "type": "string" + }, + "gate": { + "type": "object", + "properties": { + "blocked": { + "type": "number" + }, + "blockedThenMerged": { + "type": "number" + }, + "falsePositiveRate": { + "type": "number", + "nullable": true + }, + "precisionPct": { + "type": "number", + "nullable": true + }, + "topGateTypes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "gateType": { + "type": "string" + }, + "blocked": { + "type": "number" + }, + "blockedThenMerged": { + "type": "number" + }, + "falsePositiveRate": { + "type": "number", + "nullable": true + }, + "precisionPct": { + "type": "number", + "nullable": true + } + }, + "required": [ + "gateType", + "blocked", + "blockedThenMerged", + "falsePositiveRate", + "precisionPct" + ] + } + } + }, + "required": [ + "blocked", + "blockedThenMerged", + "falsePositiveRate", + "precisionPct", + "topGateTypes" + ] + }, + "outcomes": { + "type": "object", + "properties": { + "merged": { + "type": "number" + }, + "closed": { + "type": "number" + }, + "mergeRatioPct": { + "type": "number", + "nullable": true + } + }, + "required": [ + "merged", + "closed", + "mergeRatioPct" + ] + }, + "slop": { + "type": "object", + "properties": { + "totalResolved": { + "type": "number" + }, + "overallMergeRate": { + "type": "number", + "nullable": true + }, + "discriminates": { + "type": "boolean", + "nullable": true + } + }, + "required": [ + "totalResolved", + "overallMergeRate", + "discriminates" + ] + }, + "trend": { + "type": "array", + "items": { + "type": "object", + "properties": { + "weekStart": { + "type": "string" + }, + "gateBlocked": { + "type": "number" + }, + "gateBlockedThenMerged": { + "type": "number" + }, + "gateFalsePositiveRate": { + "type": "number", + "nullable": true + }, + "outcomesMerged": { + "type": "number" + }, + "outcomesClosed": { + "type": "number" + }, + "mergeRatioPct": { + "type": "number", + "nullable": true + } + }, + "required": [ + "weekStart", + "gateBlocked", + "gateBlockedThenMerged", + "gateFalsePositiveRate", + "outcomesMerged", + "outcomesClosed", + "mergeRatioPct" + ] + } + } + }, + "required": [ + "repoFullName", + "generatedAt", + "gate", + "outcomes", + "slop", + "trend" + ] } }, "parameters": {}, @@ -16517,6 +16682,46 @@ } ] } + }, + "/v1/public/repos/{owner}/{repo}/quality": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Public per-repo review-quality metrics: gate false-positive rates, merge-vs-close ratio, and weekly trend. Aggregate counts only; opt-in via publicQualityMetrics.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicQualityMetrics" + } + } + } + }, + "404": { + "description": "Repo is unknown/private/uninstalled or has not opted in" + }, + "503": { + "description": "Public quality metrics are temporarily unavailable" + } + } + } } }, "servers": [ diff --git a/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx b/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx index 1919327003..1fadbd9fed 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx @@ -39,6 +39,7 @@ type MaintainerSettings = { includeMaintainerAuthors: boolean; requireLinkedIssue: boolean; badgeEnabled: boolean; + publicQualityMetrics: boolean; commandAuthorization: CommandAuthorization; autonomy: Partial>; autoMaintain: { requireApprovals: number; mergeMethod: AutoMergeMethod }; @@ -107,6 +108,7 @@ const EDITABLE_KEYS: Array = [ "includeMaintainerAuthors", "requireLinkedIssue", "badgeEnabled", + "publicQualityMetrics", "commandAuthorization", "autonomy", "autoMaintain", @@ -265,6 +267,7 @@ const SURFACE_FIELDS: FieldDef[] = [ { key: "includeMaintainerAuthors", label: "Include maintainer-authored PRs", kind: "toggle" }, { key: "requireLinkedIssue", label: "Require a linked issue", kind: "toggle" }, { key: "badgeEnabled", label: "Repo badge", kind: "toggle" }, + { key: "publicQualityMetrics", label: "Public quality page", kind: "toggle" }, ]; function repoApiBase(repoFullName: string): string | null { diff --git a/apps/gittensory-ui/src/routeTree.gen.ts b/apps/gittensory-ui/src/routeTree.gen.ts index 5b1a6cef2e..7173d5a8c6 100644 --- a/apps/gittensory-ui/src/routeTree.gen.ts +++ b/apps/gittensory-ui/src/routeTree.gen.ts @@ -66,6 +66,7 @@ import { Route as AppCommandsRouteImport } from './routes/app.commands' import { Route as AppAuditRouteImport } from './routes/app.audit' import { Route as AppAnalyticsRouteImport } from './routes/app.analytics' import { Route as ApiOpRouteImport } from './routes/api.$op' +import { Route as ReposOwnerRepoQualityRouteImport } from './routes/repos.$owner.$repo.quality' const RoadmapRoute = RoadmapRouteImport.update({ id: '/roadmap', @@ -363,6 +364,11 @@ const ApiOpRoute = ApiOpRouteImport.update({ path: '/$op', getParentRoute: () => ApiRoute, } as any) +const ReposOwnerRepoQualityRoute = ReposOwnerRepoQualityRouteImport.update({ + id: '/repos/$owner/$repo/quality', + path: '/repos/$owner/$repo/quality', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -422,6 +428,7 @@ export interface FileRoutesByFullPath { '/api/': typeof ApiIndexRoute '/app/': typeof AppIndexRoute '/docs/': typeof DocsIndexRoute + '/repos/$owner/$repo/quality': typeof ReposOwnerRepoQualityRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -478,6 +485,7 @@ export interface FileRoutesByTo { '/api': typeof ApiIndexRoute '/app': typeof AppIndexRoute '/docs': typeof DocsIndexRoute + '/repos/$owner/$repo/quality': typeof ReposOwnerRepoQualityRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -538,6 +546,7 @@ export interface FileRoutesById { '/api/': typeof ApiIndexRoute '/app/': typeof AppIndexRoute '/docs/': typeof DocsIndexRoute + '/repos/$owner/$repo/quality': typeof ReposOwnerRepoQualityRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -599,6 +608,7 @@ export interface FileRouteTypes { | '/api/' | '/app/' | '/docs/' + | '/repos/$owner/$repo/quality' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -655,6 +665,7 @@ export interface FileRouteTypes { | '/api' | '/app' | '/docs' + | '/repos/$owner/$repo/quality' id: | '__root__' | '/' @@ -714,6 +725,7 @@ export interface FileRouteTypes { | '/api/' | '/app/' | '/docs/' + | '/repos/$owner/$repo/quality' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -727,6 +739,7 @@ export interface RootRouteChildren { MaintainersRoute: typeof MaintainersRoute MinersRoute: typeof MinersRoute RoadmapRoute: typeof RoadmapRoute + ReposOwnerRepoQualityRoute: typeof ReposOwnerRepoQualityRoute } declare module '@tanstack/react-router' { @@ -1130,6 +1143,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOpRouteImport parentRoute: typeof ApiRoute } + '/repos/$owner/$repo/quality': { + id: '/repos/$owner/$repo/quality' + path: '/repos/$owner/$repo/quality' + fullPath: '/repos/$owner/$repo/quality' + preLoaderRoute: typeof ReposOwnerRepoQualityRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -1262,6 +1282,7 @@ const rootRouteChildren: RootRouteChildren = { MaintainersRoute: MaintainersRoute, MinersRoute: MinersRoute, RoadmapRoute: RoadmapRoute, + ReposOwnerRepoQualityRoute: ReposOwnerRepoQualityRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/gittensory-ui/src/routes/docs.tuning.tsx b/apps/gittensory-ui/src/routes/docs.tuning.tsx index de0d053333..13a63fb8a4 100644 --- a/apps/gittensory-ui/src/routes/docs.tuning.tsx +++ b/apps/gittensory-ui/src/routes/docs.tuning.tsx @@ -463,7 +463,8 @@ function Tuning() { includeMaintainerAuthors (default false),{" "} requireLinkedIssue (default false), backfillEnabled{" "} (default true), privateTrustEnabled (default true), - and badgeEnabled (README status badge, default false). + and badgeEnabled (README status badge, default false), + and publicQualityMetrics (public review-quality page, default false).
  • agentPaused (per-repo kill-switch, default false) and{" "} diff --git a/apps/gittensory-ui/src/routes/repos.$owner.$repo.quality.tsx b/apps/gittensory-ui/src/routes/repos.$owner.$repo.quality.tsx new file mode 100644 index 0000000000..058cd7c559 --- /dev/null +++ b/apps/gittensory-ui/src/routes/repos.$owner.$repo.quality.tsx @@ -0,0 +1,179 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; + +import { getApiOrigin } from "@/lib/api/origin"; +import { apiFetch } from "@/lib/api/request"; +import { Card, Section, SectionTitle } from "@/components/site/primitives"; + +export const Route = createFileRoute("/repos/$owner/$repo/quality")({ + head: ({ params }) => ({ + meta: [ + { title: `${params.owner}/${params.repo} review quality — Gittensory` }, + { + name: "description", + content: "Public, opt-in review-quality metrics for a repository: gate precision, merge outcomes, and weekly trend.", + }, + { property: "og:title", content: `${params.owner}/${params.repo} review quality` }, + { property: "og:url", content: `/repos/${params.owner}/${params.repo}/quality` }, + ], + links: [{ rel: "canonical", href: `/repos/${params.owner}/${params.repo}/quality` }], + }), + component: PublicRepoQualityPage, +}); + +type PublicQualityMetrics = { + repoFullName: string; + generatedAt: string; + gate: { + blocked: number; + blockedThenMerged: number; + falsePositiveRate: number | null; + precisionPct: number | null; + topGateTypes: Array<{ + gateType: string; + blocked: number; + blockedThenMerged: number; + falsePositiveRate: number | null; + precisionPct: number | null; + }>; + }; + outcomes: { merged: number; closed: number; mergeRatioPct: number | null }; + slop: { totalResolved: number; overallMergeRate: number | null; discriminates: boolean | null }; + trend: Array<{ + weekStart: string; + gateBlocked: number; + gateBlockedThenMerged: number; + gateFalsePositiveRate: number | null; + outcomesMerged: number; + outcomesClosed: number; + mergeRatioPct: number | null; + }>; +}; + +const pctFmt = new Intl.NumberFormat("en", { maximumFractionDigits: 1 }); + +function PublicRepoQualityPage() { + const { owner, repo } = Route.useParams(); + const { data, isLoading, isError } = useQuery({ + queryKey: ["public-quality", owner, repo], + queryFn: async () => { + const result = await apiFetch( + `${getApiOrigin()}/v1/public/repos/${owner}/${repo}/quality`, + { label: "Public quality metrics", timeoutMs: 8000, silentStatus: true }, + ); + if (!result.ok || !result.data) return null; + return result.data; + }, + staleTime: 60_000, + }); + + if (isLoading) { + return ( +
    +

    Loading review-quality metrics…

    +
    + ); + } + + if (!data || isError) { + return ( +
    + +

    + This repository has not opted in to public review-quality metrics, or the metrics are temporarily unavailable. +

    +
    + ); + } + + return ( +
    +
    +
    Public review quality
    +

    {data.repoFullName}

    +

    + Aggregate counts only — no raw trust scores, rewards, or contributor rankings. Updated {new Date(data.generatedAt).toLocaleString()}. +

    + +
    + +
    Gate precision
    +
    + {data.gate.precisionPct != null ? `${pctFmt.format(data.gate.precisionPct)}%` : "—"} +
    +

    + {data.gate.blocked} blocks, {data.gate.blockedThenMerged} later merged +

    +
    + +
    Merge ratio
    +
    + {data.outcomes.mergeRatioPct != null ? `${pctFmt.format(data.outcomes.mergeRatioPct)}%` : "—"} +
    +

    + {data.outcomes.merged} merged / {data.outcomes.closed} closed +

    +
    + +
    Slop calibration
    +
    + {data.slop.overallMergeRate != null ? `${pctFmt.format(data.slop.overallMergeRate)}% merge` : "—"} +
    +

    + {data.slop.totalResolved} resolved PRs + {data.slop.discriminates === true ? " · discriminating" : data.slop.discriminates === false ? " · recalibrate" : ""} +

    +
    +
    + + {data.gate.topGateTypes.length > 0 ? ( +
    +

    Top gate types

    +
      + {data.gate.topGateTypes.map((row) => ( +
    • + {row.gateType} + + {" "} + — {row.blocked} blocks, {row.blockedThenMerged} merged anyway + {row.precisionPct != null ? ` (${pctFmt.format(row.precisionPct)}% precision)` : ""} + +
    • + ))} +
    +
    + ) : null} + +
    +

    Weekly trend

    +
    + + + + + + + + + + + {data.trend.map((row) => ( + + + + + + + ))} + +
    WeekGate FP rateMerge ratioBlocks
    {row.weekStart} + {row.gateFalsePositiveRate != null ? `${pctFmt.format(row.gateFalsePositiveRate * 100)}%` : "—"} + + {row.mergeRatioPct != null ? `${pctFmt.format(row.mergeRatioPct)}%` : "—"} + {row.gateBlocked}
    +
    +
    +
    +
    + ); +} diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index 7bfcc48293..657c0f3970 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -609,6 +609,9 @@ settings: # Render a README status badge for the repo. Bool. Default: false. badgeEnabled: false + # Publish a public per-repo review-quality page. Bool. Default: false. + publicQualityMetrics: false + # Per-repo kill-switch: when true, the agent does nothing on this repo. # Bool. Default: false. agentPaused: false diff --git a/migrations/0115_public_quality_metrics.sql b/migrations/0115_public_quality_metrics.sql new file mode 100644 index 0000000000..7ffd7c1b56 --- /dev/null +++ b/migrations/0115_public_quality_metrics.sql @@ -0,0 +1,2 @@ +-- Opt-in public per-repo review-quality metrics (#2568). Default OFF — no public exposure until a maintainer enables it. +ALTER TABLE repository_settings ADD COLUMN public_quality_metrics INTEGER NOT NULL DEFAULT 0; diff --git a/src/api/routes.ts b/src/api/routes.ts index fa8d9cdb59..1344a4bfda 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -188,6 +188,7 @@ 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 { loadPublicQualityMetrics } from "../services/public-quality-metrics"; import { buildShieldsBadge, renderBadgeSvg, renderUnavailableBadgeSvg } from "./badge"; import { buildWeeklyValueReport, @@ -308,6 +309,16 @@ async function loadPublicRepoBadge(env: Env, owner: string, repo: string): Promi return buildPublicRepoQuality(pullRequests); } +// Resolves the public per-repo review-quality metrics (#2568), enforcing the same public-safety gates as the +// README badge: public, installed, and opted in via `publicQualityMetrics`. Returns null otherwise. +async function loadPublicRepoQualityMetrics(env: Env, owner: string, repo: string) { + const repository = await getRepository(env, `${owner}/${repo}`); + if (!repository || repository.isPrivate || !repository.isInstalled) return null; + const settings = await getRepositorySettings(env, repository.fullName); + if (!settings.publicQualityMetrics) return null; + return loadPublicQualityMetrics(env, repository.fullName); +} + async function recordRouteProductUsage( c: AppContext, event: { @@ -668,6 +679,7 @@ const repositorySettingsSchema = z.object({ backfillEnabled: z.boolean().default(true), privateTrustEnabled: z.boolean().default(true), badgeEnabled: z.boolean().default(false), + publicQualityMetrics: z.boolean().default(false), commandAuthorization: z .object({ default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4).optional(), @@ -717,6 +729,7 @@ const maintainerSettingsSchema = z closeOwnerAuthors: z.boolean(), requireLinkedIssue: z.boolean(), badgeEnabled: z.boolean(), + publicQualityMetrics: z.boolean(), agentPaused: z.boolean(), agentDryRun: z.boolean(), requireFreshRebaseWindowMinutes: z.number().int().positive().nullable(), @@ -965,6 +978,22 @@ export function createApp() { return c.json(buildShieldsBadge(quality, 600)); }); + // Public per-repo review-quality metrics (#2568). Unauthenticated; aggregate counts/rates only; opt-in via + // `publicQualityMetrics`. 404 when the repo is unknown/private/uninstalled or has not opted in. + app.get("/v1/public/repos/:owner/:repo/quality", async (c) => { + try { + const metrics = await loadPublicRepoQualityMetrics(c.env, c.req.param("owner"), c.req.param("repo")); + if (!metrics) { + c.header("Cache-Control", "public, max-age=300"); + return c.json({ error: "not_found" }, 404); + } + c.header("Cache-Control", "public, max-age=300, stale-while-revalidate=3600"); + return c.json(metrics); + } catch { + return c.json({ error: "public_quality_metrics_unavailable" }, 503); + } + }); + // Visual before/after screenshot endpoint (visual-capture port). PUBLIC + UNAUTHENTICATED by design: it // lives OUTSIDE the /v1/ prefix, so requiresApiToken (which only gates path.startsWith('/v1/')) never // touches it — GitHub's camo image proxy must fetch it without a bearer token. The handler itself enforces @@ -3765,6 +3794,7 @@ export function createApp() { backfillEnabled: parsed.data.backfillEnabled, privateTrustEnabled: parsed.data.privateTrustEnabled, badgeEnabled: parsed.data.badgeEnabled, + publicQualityMetrics: parsed.data.publicQualityMetrics, commandAuthorization: normalizeCommandAuthorizationPolicy(parsed.data.commandAuthorization).policy, contributorBlacklist: normalizeContributorBlacklist(parsed.data.contributorBlacklist).entries, }), @@ -5431,6 +5461,7 @@ function requiresApiToken(path: string): boolean { 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 (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/quality$/.test(path)) return false; if (path === "/v1/public/subnet-interface") return false; if (path === "/v1/public/stats") return false; if (path === "/openapi.json") return false; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 21cd7d32b3..cc619c2afb 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -532,6 +532,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise backfillEnabled: true, privateTrustEnabled: true, badgeEnabled: false, + publicQualityMetrics: false, agentPaused: false, agentDryRun: false, commandAuthorization: normalizeCommandAuthorizationPolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY).policy, @@ -607,6 +608,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise backfillEnabled: row.backfillEnabled, privateTrustEnabled: row.privateTrustEnabled, badgeEnabled: row.badgeEnabled, + publicQualityMetrics: row.publicQualityMetrics, agentPaused: row.agentPaused, agentDryRun: row.agentDryRun, commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson), @@ -724,6 +726,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial= weeks) return null; + return weekOffset; +} + +type TrendBucket = { + gateBlocked: number; + gateBlockedThenMerged: number; + outcomesMerged: number; + outcomesClosed: number; +}; + +function emptyBucket(): TrendBucket { + return { gateBlocked: 0, gateBlockedThenMerged: 0, outcomesMerged: 0, outcomesClosed: 0 }; +} + +/** Weekly gate false-positive and merge-vs-close trend over trailing `weeks` (default 8). Pure. */ +export function buildPublicQualityTrend( + gateOutcomes: GateOutcomeRecord[], + pullRequests: PullRequestRecord[], + nowMs: number, + weeks: number = PUBLIC_QUALITY_TREND_WEEKS, +): PublicQualityTrendWeek[] { + const prByNumber = new Map(pullRequests.map((pr) => [pr.number, pr])); + const currentStartMs = Date.parse(isoWeekStart(nowMs)); + const oldestStartMs = currentStartMs - (weeks - 1) * MS_PER_WEEK; + const buckets = Array.from({ length: weeks }, () => emptyBucket()); + + for (const outcome of gateOutcomes) { + const stamp = parseStamp(outcome.blockedAt ?? outcome.updatedAt); + if (stamp == null) continue; + const idx = weekBucketIndex(currentStartMs, stamp, weeks); + if (idx == null) continue; + const bucket = buckets[idx] as TrendBucket; + bucket.gateBlocked += 1; + const pr = prByNumber.get(outcome.pullNumber); + if (pr && terminalOutcome(pr) === "merged") bucket.gateBlockedThenMerged += 1; + } + + for (const pr of pullRequests) { + const terminal = terminalOutcome(pr); + if (!terminal) continue; + const stamp = parseStamp(terminal === "merged" ? pr.mergedAt : pr.updatedAt ?? pr.createdAt); + if (stamp == null) continue; + const idx = weekBucketIndex(currentStartMs, stamp, weeks); + if (idx == null) continue; + const bucket = buckets[idx] as TrendBucket; + if (terminal === "merged") bucket.outcomesMerged += 1; + else bucket.outcomesClosed += 1; + } + + return buckets.map((bucket, offset) => { + const weekStart = isoWeekStart(oldestStartMs + offset * MS_PER_WEEK); + return { + weekStart, + gateBlocked: bucket.gateBlocked, + gateBlockedThenMerged: bucket.gateBlockedThenMerged, + gateFalsePositiveRate: + bucket.gateBlocked >= MIN_GATE_TREND_SAMPLE + ? roundRate(bucket.gateBlockedThenMerged / bucket.gateBlocked) + : null, + outcomesMerged: bucket.outcomesMerged, + outcomesClosed: bucket.outcomesClosed, + mergeRatioPct: mergeRatioPct(bucket.outcomesMerged, bucket.outcomesClosed), + }; + }); +} + +function topPublicGateTypes(gatePrecision: GatePrecisionReport): PublicQualityGateTypeRow[] { + return gatePrecision.perGateType + .filter((row) => row.blocked > 0) + .slice(0, 5) + .map((row) => ({ + gateType: row.gateType, + blocked: row.blocked, + blockedThenMerged: row.blockedThenMerged, + falsePositiveRate: row.falsePositiveRate, + precisionPct: gatePrecisionPct(row.falsePositiveRate), + })); +} + +/** Assemble the public-safe per-repo quality payload from existing telemetry. Pure. */ +export function buildPublicQualityMetrics(args: { + repoFullName: string; + generatedAt: string; + gatePrecision: GatePrecisionReport; + slopCalibration: SlopOutcomeCalibration; + gateOutcomes: GateOutcomeRecord[]; + pullRequests: PullRequestRecord[]; + nowMs?: number; +}): PublicQualityMetricsPayload { + const nowMs = args.nowMs ?? Date.now(); + let merged = 0; + let closed = 0; + for (const pr of args.pullRequests) { + const terminal = terminalOutcome(pr); + if (terminal === "merged") merged += 1; + else if (terminal === "closed") closed += 1; + } + + const falsePositiveRate = args.gatePrecision.overall.falsePositiveRate; + return { + repoFullName: args.repoFullName, + generatedAt: args.generatedAt, + gate: { + blocked: args.gatePrecision.overall.blocked, + blockedThenMerged: args.gatePrecision.overall.blockedThenMerged, + falsePositiveRate, + precisionPct: gatePrecisionPct(falsePositiveRate), + topGateTypes: topPublicGateTypes(args.gatePrecision), + }, + outcomes: { + merged, + closed, + mergeRatioPct: mergeRatioPct(merged, closed), + }, + slop: { + totalResolved: args.slopCalibration.totalResolved, + overallMergeRate: + args.slopCalibration.overallMergeRate != null + ? roundPct(args.slopCalibration.overallMergeRate) + : null, + discriminates: args.slopCalibration.discriminates, + }, + trend: buildPublicQualityTrend(args.gateOutcomes, args.pullRequests, nowMs), + }; +} + +/** Load a repo's gate/outcome telemetry and assemble the public quality payload. */ +export async function loadPublicQualityMetrics(env: Env, repoFullName: string): Promise { + const [gatePrecision, calibration, pullRequests, gateOutcomes] = await Promise.all([ + loadGatePrecisionReport(env, repoFullName), + buildRepoOutcomeCalibration(env, repoFullName), + listPullRequests(env, repoFullName), + listGateOutcomes(env, { repoFullName }), + ]); + return buildPublicQualityMetrics({ + repoFullName, + generatedAt: nowIso(), + gatePrecision, + slopCalibration: calibration.slop, + gateOutcomes, + pullRequests, + }); +} + +/** Convenience for tests: build from raw gate rows + PRs without I/O. */ +export function buildPublicQualityMetricsFromRecords( + repoFullName: string, + gateOutcomes: GateOutcomeRecord[], + pullRequests: PullRequestRecord[], + generatedAt: string, + nowMs: number, +): PublicQualityMetricsPayload { + const gatePrecision: GatePrecisionReport = { + repoFullName, + generatedAt, + windowDays: null, + ...buildGatePrecisionReport(gateOutcomes, pullRequests, { repoFullName }), + }; + return buildPublicQualityMetrics({ + repoFullName, + generatedAt, + gatePrecision, + slopCalibration: buildSlopOutcomeCalibration(pullRequests), + gateOutcomes, + pullRequests, + nowMs, + }); +} diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 99f50b1c21..d8694de1e8 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -230,6 +230,7 @@ export type FocusManifestSettings = Partial< | "autoLabelEnabled" | "typeLabelsEnabled" | "badgeEnabled" + | "publicQualityMetrics" | "gittensorLabel" | "createMissingLabel" | "publicSurface" @@ -1451,7 +1452,7 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) } const publicSurface = normalizeOptionalEnum(r.publicSurface, "settings.publicSurface", ["off", "comment_and_label", "comment_only", "label_only"] as const, warnings); if (publicSurface !== null) out.publicSurface = publicSurface; - for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) { + for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) { const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); if (flag !== null) out[key] = flag; } diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 052f9cd75b..f4acbafb7f 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -209,6 +209,7 @@ export type RepoSettingsPreview = { includeMaintainerAuthors: boolean; requireLinkedIssue: boolean; badgeEnabled: boolean; + publicQualityMetrics: boolean; aiReviewMode: RepositorySettings["aiReviewMode"]; aiReviewByok: boolean; aiReviewProvider: string | null; @@ -336,6 +337,7 @@ export function buildRepoSettingsPreview(args: { includeMaintainerAuthors: settings.includeMaintainerAuthors, requireLinkedIssue: settings.requireLinkedIssue, badgeEnabled: settings.badgeEnabled ?? false, + publicQualityMetrics: settings.publicQualityMetrics ?? false, aiReviewMode: settings.aiReviewMode, aiReviewByok: settings.aiReviewByok, aiReviewProvider: settings.aiReviewProvider ?? null, diff --git a/src/types.ts b/src/types.ts index 96d2410695..33d4242cad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -806,6 +806,9 @@ export type RepositorySettings = { /** 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; + /** Opt-in for the public per-repo review-quality page (#2568). Always populated by the DB layer + * (default false); optional so existing settings fixtures/callers need not be touched. */ + publicQualityMetrics?: boolean | undefined; commandAuthorization?: RepositoryCommandAuthorizationPolicy | undefined; /** Per-repo contributor blacklist (#1425, anti-abuse): banned GitHub logins whose PRs/issues the engine * deterministically closes ahead of merit review. Layered the same as other settings (`.gittensory.yml` > diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index a001d711bb..cddb452f76 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -30,6 +30,7 @@ import { persistScoringModelSnapshot, upsertRepositoryFromGitHub, upsertRepositorySettings, + recordGateBlockOutcome, createAgentRun, replaceAgentActions, upsertAgentRecommendationOutcome, @@ -257,6 +258,44 @@ describe("api routes", () => { await expect(unknown.json()).resolves.toMatchObject({ message: "unavailable" }); }); + it("serves public per-repo review-quality metrics only for installed, opted-in repos (#2568)", async () => { + const app = createApp(); + const env = createTestEnv(); + + await upsertRepositoryFromGitHub(env, { name: "quality", full_name: "acme/quality", private: false, owner: { login: "acme" }, default_branch: "main" }, 560); + await upsertRepositorySettings(env, { repoFullName: "acme/quality", publicQualityMetrics: true }); + await upsertPullRequestFromGitHub(env, "acme/quality", { number: 1, title: "Merged", state: "merged", created_at: "2026-06-01T00:00:00Z", merged_at: "2026-06-02T00:00:00Z", labels: [] }); + await upsertPullRequestFromGitHub(env, "acme/quality", { number: 2, title: "Merged too", state: "merged", created_at: "2026-06-01T01:00:00Z", merged_at: "2026-06-02T01:00:00Z", labels: [] }); + await upsertPullRequestFromGitHub(env, "acme/quality", { number: 3, title: "Closed", state: "closed", created_at: "2026-06-03T00:00:00Z", labels: [] }); + await upsertPullRequestFromGitHub(env, "acme/quality", { number: 4, title: "Closed 2", state: "closed", created_at: "2026-06-03T01:00:00Z", labels: [] }); + await upsertPullRequestFromGitHub(env, "acme/quality", { number: 5, title: "Closed 3", state: "closed", created_at: "2026-06-03T02:00:00Z", labels: [] }); + await updatePullRequestSlopAssessment(env, "acme/quality", 1, { slopRisk: 0, slopBand: "clean" }); + for (let i = 1; i <= 5; i += 1) { + await recordGateBlockOutcome(env, { + repoFullName: "acme/quality", + pullNumber: i, + blockerCodes: ["missing_linked_issue"], + }); + } + + const ok = await app.request("/v1/public/repos/acme/quality/quality", {}, env); + expect(ok.status).toBe(200); + expect(ok.headers.get("cache-control")).toContain("stale-while-revalidate"); + const body = (await ok.json()) as Record; + expect(body).toMatchObject({ + repoFullName: "acme/quality", + gate: { blocked: 5, blockedThenMerged: 2, falsePositiveRate: 0.4, precisionPct: 60 }, + outcomes: { merged: 2, closed: 3, mergeRatioPct: 40 }, + }); + expect(JSON.stringify(body)).not.toMatch(FORBIDDEN_PUBLIC_REPORT_TERMS); + expect(body.trend).toHaveLength(8); + + await upsertRepositoryFromGitHub(env, { name: "quality-off", full_name: "acme/quality-off", private: false, owner: { login: "acme" }, default_branch: "main" }, 561); + const notOptedIn = await app.request("/v1/public/repos/acme/quality-off/quality", {}, env); + expect(notOptedIn.status).toBe(404); + await expect(notOptedIn.json()).resolves.toMatchObject({ error: "not_found" }); + }); + it("persists the badgeEnabled opt-in through the settings write endpoint (#541)", async () => { const app = createApp(); const env = createTestEnv(); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index db1f9e8714..fa2f961ef5 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -294,6 +294,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => { autoLabelEnabled: "autoLabelEnabled:", typeLabelsEnabled: "typeLabelsEnabled:", badgeEnabled: "badgeEnabled:", + publicQualityMetrics: "publicQualityMetrics:", gittensorLabel: "gittensorLabel:", createMissingLabel: "createMissingLabel:", publicSurface: "publicSurface:", @@ -2323,6 +2324,18 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(eff.badgeEnabled).toBe(true); // settings: override wins over the DB-stored value }); + it("wires settings.publicQualityMetrics into the manifest parser and lets it override the DB value (#2568)", () => { + const parsedTrue = parseFocusManifest({ settings: { publicQualityMetrics: true } }); + expect(parsedTrue.settings.publicQualityMetrics).toBe(true); + expect(parsedTrue.warnings).toEqual([]); + const parsedFalse = parseFocusManifest({ settings: { publicQualityMetrics: false } }); + expect(parsedFalse.settings.publicQualityMetrics).toBe(false); + + const db = { publicQualityMetrics: false } as unknown as RepositorySettings; + const eff = resolveEffectiveSettings(db, parseFocusManifest({ settings: { publicQualityMetrics: true } })); + expect(eff.publicQualityMetrics).toBe(true); + }); + it("wires settings.typeLabelsEnabled into the manifest parser and lets a per-repo override win over a global default (#label-decoupling)", () => { const parsedTrue = parseFocusManifest({ settings: { typeLabelsEnabled: true } }); expect(parsedTrue.settings.typeLabelsEnabled).toBe(true); diff --git a/test/unit/public-quality-metrics.test.ts b/test/unit/public-quality-metrics.test.ts new file mode 100644 index 0000000000..6234a267dc --- /dev/null +++ b/test/unit/public-quality-metrics.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import type { GateOutcomeRecord, PullRequestRecord } from "../../src/types"; +import { + PUBLIC_QUALITY_TREND_WEEKS, + buildPublicQualityMetricsFromRecords, + buildPublicQualityTrend, + gatePrecisionPct, + isoWeekStart, + loadPublicQualityMetrics, + mergeRatioPct, +} from "../../src/services/public-quality-metrics"; +import { recordGateBlockOutcome, upsertPullRequestFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +const NOW = Date.parse("2026-06-22T12:00:00.000Z"); +const GENERATED = "2026-06-22T12:00:00.000Z"; + +function pr( + number: number, + outcome: "merged" | "closed" | "open", + opts: { mergedAt?: string; updatedAt?: string; slopBand?: string; slopRisk?: number } = {}, +): PullRequestRecord { + return { + repoFullName: "owner/repo", + number, + title: `PR ${number}`, + state: outcome === "open" ? "open" : "closed", + mergedAt: opts.mergedAt ?? (outcome === "merged" ? "2026-06-20T00:00:00.000Z" : null), + updatedAt: opts.updatedAt ?? "2026-06-20T00:00:00.000Z", + createdAt: "2026-06-01T00:00:00.000Z", + slopBand: opts.slopBand, + slopRisk: opts.slopRisk, + labels: [], + linkedIssues: [], + }; +} + +function block( + pullNumber: number, + code: string, + blockedAt: string, + overridden = false, +): GateOutcomeRecord { + return { repoFullName: "owner/repo", pullNumber, blockerCodes: [code], overridden, blockedAt }; +} + +describe("gatePrecisionPct", () => { + it("returns the precision complement when a false-positive rate is known", () => { + expect(gatePrecisionPct(0.2)).toBe(80); + expect(gatePrecisionPct(0)).toBe(100); + }); + it("returns null when the false-positive rate is unknown", () => { + expect(gatePrecisionPct(null)).toBeNull(); + }); +}); + +describe("mergeRatioPct", () => { + it("returns the merged share of terminal outcomes", () => { + expect(mergeRatioPct(3, 1)).toBe(75); + }); + it("returns null when nothing is decided", () => { + expect(mergeRatioPct(0, 0)).toBeNull(); + }); +}); + +describe("isoWeekStart", () => { + it("returns the UTC Monday for a mid-week timestamp", () => { + expect(isoWeekStart(Date.parse("2026-06-18T15:00:00.000Z"))).toBe("2026-06-15"); + }); + it("rolls Sunday back to the prior Monday", () => { + expect(isoWeekStart(Date.parse("2026-06-21T15:00:00.000Z"))).toBe("2026-06-15"); + }); +}); + +describe("buildPublicQualityTrend", () => { + it("buckets gate blocks and terminal outcomes into weekly public-safe counts", () => { + const currentMonday = isoWeekStart(NOW); + const priorMonday = isoWeekStart(NOW - 7 * 86_400_000); + const trend = buildPublicQualityTrend( + [ + block(1, "slop_risk", `${currentMonday}T10:00:00.000Z`), + block(2, "slop_risk", `${priorMonday}T10:00:00.000Z`), + block(3, "slop_risk", `${priorMonday}T11:00:00.000Z`), + block(4, "slop_risk", `${priorMonday}T12:00:00.000Z`), + { repoFullName: "owner/repo", pullNumber: 99, blockerCodes: ["x"], overridden: false }, + ], + [ + pr(1, "merged", { mergedAt: `${currentMonday}T12:00:00.000Z` }), + pr(2, "merged", { mergedAt: `${priorMonday}T13:00:00.000Z` }), + pr(3, "closed", { updatedAt: `${priorMonday}T14:00:00.000Z` }), + pr(4, "closed", { updatedAt: `${priorMonday}T15:00:00.000Z` }), + pr(5, "closed", { updatedAt: `${priorMonday}T16:00:00.000Z` }), + pr(6, "open"), + ], + NOW, + 2, + ); + expect(trend).toHaveLength(2); + expect(trend[0]?.weekStart).toBe(priorMonday); + expect(trend[0]).toMatchObject({ + gateBlocked: 3, + gateBlockedThenMerged: 1, + gateFalsePositiveRate: 0.333, + outcomesMerged: 1, + outcomesClosed: 3, + mergeRatioPct: 25, + }); + expect(trend[1]).toMatchObject({ + weekStart: currentMonday, + gateBlocked: 1, + gateBlockedThenMerged: 1, + gateFalsePositiveRate: null, + outcomesMerged: 1, + outcomesClosed: 0, + mergeRatioPct: 100, + }); + }); + + it("ignores events outside the trailing window and rows without timestamps", () => { + const trend = buildPublicQualityTrend( + [block(1, "x", "2020-01-01T00:00:00.000Z")], + [pr(1, "merged", { mergedAt: "2020-01-02T00:00:00.000Z" })], + NOW, + PUBLIC_QUALITY_TREND_WEEKS, + ); + expect(trend.every((row) => row.gateBlocked === 0 && row.outcomesMerged === 0)).toBe(true); + }); +}); + +describe("buildPublicQualityMetricsFromRecords", () => { + it("assembles gate precision, outcomes, slop calibration, and trend from existing telemetry", () => { + const blocks: GateOutcomeRecord[] = []; + const pullRequests: PullRequestRecord[] = []; + for (let i = 1; i <= 6; i += 1) { + blocks.push(block(i, "missing_linked_issue", "2026-06-10T00:00:00.000Z")); + pullRequests.push( + pr(i, i <= 2 ? "merged" : "closed", { + slopBand: "clean", + slopRisk: 0.1, + ...(i <= 2 ? { mergedAt: "2026-06-11T00:00:00.000Z" } : {}), + updatedAt: "2026-06-11T00:00:00.000Z", + }), + ); + } + const payload = buildPublicQualityMetricsFromRecords("owner/repo", blocks, pullRequests, GENERATED, NOW); + expect(payload.repoFullName).toBe("owner/repo"); + expect(payload.gate).toMatchObject({ + blocked: 6, + blockedThenMerged: 2, + falsePositiveRate: 0.333, + precisionPct: 66.7, + }); + expect(payload.gate.topGateTypes[0]).toMatchObject({ + gateType: "missing_linked_issue", + blocked: 6, + blockedThenMerged: 2, + precisionPct: 66.7, + }); + expect(payload.outcomes).toMatchObject({ merged: 2, closed: 4, mergeRatioPct: 33.3 }); + expect(payload.slop.totalResolved).toBe(6); + expect(payload.trend).toHaveLength(PUBLIC_QUALITY_TREND_WEEKS); + }); + + it("nulls slop merge rate when there is no resolved slop sample", () => { + const payload = buildPublicQualityMetricsFromRecords( + "owner/repo", + [], + [pr(1, "open")], + GENERATED, + NOW, + ); + expect(payload.slop).toMatchObject({ + totalResolved: 0, + overallMergeRate: null, + discriminates: null, + }); + expect(payload.outcomes.mergeRatioPct).toBeNull(); + }); +}); + +describe("loadPublicQualityMetrics (env loader)", () => { + it("loads gate/outcome telemetry and assembles the public-safe payload", async () => { + const env = createTestEnv(); + await recordGateBlockOutcome(env, { repoFullName: "owner/repo", pullNumber: 1, blockerCodes: ["slop_risk"] }); + await upsertPullRequestFromGitHub(env, "owner/repo", { number: 1, title: "merged", state: "closed", merged_at: "2026-06-01T00:00:00.000Z" }); + const payload = await loadPublicQualityMetrics(env, "owner/repo"); + expect(payload.repoFullName).toBe("owner/repo"); + expect(payload.gate.blocked).toBe(1); + expect(JSON.stringify(payload)).not.toMatch(/reward|payout|trust score|wallet|hotkey|login|actor/i); + }); +}); From 2fb668a7c86ee155a82356a8020f1a68201cd9bc Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Mon, 6 Jul 2026 17:45:42 +0200 Subject: [PATCH 2/5] test(site): reach 100% patch coverage for public quality metrics (#2568) Cover the route 503 path, loader guard branches, and invalid trend timestamps; fix UI lint by extracting the page component. Co-authored-by: Cursor --- .../site/public-repo-quality-page.tsx | 187 ++++++++++++++++++ .../src/routes/repos.$owner.$repo.quality.tsx | 166 +--------------- test/integration/api.test.ts | 29 +++ ...public-quality-metrics-route-error.test.ts | 24 +++ test/unit/public-quality-metrics.test.ts | 22 +++ 5 files changed, 268 insertions(+), 160 deletions(-) create mode 100644 apps/gittensory-ui/src/components/site/public-repo-quality-page.tsx create mode 100644 test/integration/public-quality-metrics-route-error.test.ts diff --git a/apps/gittensory-ui/src/components/site/public-repo-quality-page.tsx b/apps/gittensory-ui/src/components/site/public-repo-quality-page.tsx new file mode 100644 index 0000000000..a0637b29d6 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/public-repo-quality-page.tsx @@ -0,0 +1,187 @@ +import { useQuery } from "@tanstack/react-query"; + +import { getApiOrigin } from "@/lib/api/origin"; +import { apiFetch } from "@/lib/api/request"; +import { Card, Section, SectionTitle } from "@/components/site/primitives"; + +export type PublicQualityMetrics = { + repoFullName: string; + generatedAt: string; + gate: { + blocked: number; + blockedThenMerged: number; + falsePositiveRate: number | null; + precisionPct: number | null; + topGateTypes: Array<{ + gateType: string; + blocked: number; + blockedThenMerged: number; + falsePositiveRate: number | null; + precisionPct: number | null; + }>; + }; + outcomes: { merged: number; closed: number; mergeRatioPct: number | null }; + slop: { totalResolved: number; overallMergeRate: number | null; discriminates: boolean | null }; + trend: Array<{ + weekStart: string; + gateBlocked: number; + gateBlockedThenMerged: number; + gateFalsePositiveRate: number | null; + outcomesMerged: number; + outcomesClosed: number; + mergeRatioPct: number | null; + }>; +}; + +const pctFmt = new Intl.NumberFormat("en", { maximumFractionDigits: 1 }); + +async function fetchPublicQualityMetrics( + owner: string, + repo: string, +): Promise { + const result = await apiFetch( + `${getApiOrigin()}/v1/public/repos/${owner}/${repo}/quality`, + { + label: "Public quality metrics", + timeoutMs: 8000, + silentStatus: true, + }, + ); + if (!result.ok || !result.data) return null; + return result.data; +} + +function slopCalibrationHint(discriminates: boolean | null): string { + if (discriminates === true) return " · discriminating"; + if (discriminates === false) return " · recalibrate"; + return ""; +} + +export function PublicRepoQualityPage({ owner, repo }: { owner: string; repo: string }) { + const { data, isLoading, isError } = useQuery({ + queryKey: ["public-quality", owner, repo], + queryFn: () => fetchPublicQualityMetrics(owner, repo), + staleTime: 60_000, + }); + + if (isLoading) { + return ( +
    +

    Loading review-quality metrics…

    +
    + ); + } + + if (!data || isError) { + return ( +
    + +

    + This repository has not opted in to public review-quality metrics, or the metrics are + temporarily unavailable. +

    +
    + ); + } + + return ( +
    +
    +
    Public review quality
    +

    + {data.repoFullName} +

    +

    + Aggregate counts only — no raw trust scores, rewards, or contributor rankings. Updated{" "} + {new Date(data.generatedAt).toLocaleString()}. +

    + +
    + +
    Gate precision
    +
    + {data.gate.precisionPct != null ? `${pctFmt.format(data.gate.precisionPct)}%` : "—"} +
    +

    + {data.gate.blocked} blocks, {data.gate.blockedThenMerged} later merged +

    +
    + +
    Merge ratio
    +
    + {data.outcomes.mergeRatioPct != null + ? `${pctFmt.format(data.outcomes.mergeRatioPct)}%` + : "—"} +
    +

    + {data.outcomes.merged} merged / {data.outcomes.closed} closed +

    +
    + +
    Slop calibration
    +
    + {data.slop.overallMergeRate != null + ? `${pctFmt.format(data.slop.overallMergeRate)}% merge` + : "—"} +
    +

    + {data.slop.totalResolved} resolved PRs{slopCalibrationHint(data.slop.discriminates)} +

    +
    +
    + + {data.gate.topGateTypes.length > 0 ? ( +
    +

    Top gate types

    +
      + {data.gate.topGateTypes.map((row) => ( +
    • + {row.gateType} + + {" "} + — {row.blocked} blocks, {row.blockedThenMerged} merged anyway + {row.precisionPct != null + ? ` (${pctFmt.format(row.precisionPct)}% precision)` + : ""} + +
    • + ))} +
    +
    + ) : null} + +
    +

    Weekly trend

    +
    + + + + + + + + + + + {data.trend.map((row) => ( + + + + + + + ))} + +
    WeekGate FP rateMerge ratioBlocks
    {row.weekStart} + {row.gateFalsePositiveRate != null + ? `${pctFmt.format(row.gateFalsePositiveRate * 100)}%` + : "—"} + + {row.mergeRatioPct != null ? `${pctFmt.format(row.mergeRatioPct)}%` : "—"} + {row.gateBlocked}
    +
    +
    +
    +
    + ); +} diff --git a/apps/gittensory-ui/src/routes/repos.$owner.$repo.quality.tsx b/apps/gittensory-ui/src/routes/repos.$owner.$repo.quality.tsx index 058cd7c559..b39801f7b4 100644 --- a/apps/gittensory-ui/src/routes/repos.$owner.$repo.quality.tsx +++ b/apps/gittensory-ui/src/routes/repos.$owner.$repo.quality.tsx @@ -1,9 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useQuery } from "@tanstack/react-query"; -import { getApiOrigin } from "@/lib/api/origin"; -import { apiFetch } from "@/lib/api/request"; -import { Card, Section, SectionTitle } from "@/components/site/primitives"; +import { PublicRepoQualityPage } from "@/components/site/public-repo-quality-page"; export const Route = createFileRoute("/repos/$owner/$repo/quality")({ head: ({ params }) => ({ @@ -11,169 +8,18 @@ export const Route = createFileRoute("/repos/$owner/$repo/quality")({ { title: `${params.owner}/${params.repo} review quality — Gittensory` }, { name: "description", - content: "Public, opt-in review-quality metrics for a repository: gate precision, merge outcomes, and weekly trend.", + content: + "Public, opt-in review-quality metrics for a repository: gate precision, merge outcomes, and weekly trend.", }, { property: "og:title", content: `${params.owner}/${params.repo} review quality` }, { property: "og:url", content: `/repos/${params.owner}/${params.repo}/quality` }, ], links: [{ rel: "canonical", href: `/repos/${params.owner}/${params.repo}/quality` }], }), - component: PublicRepoQualityPage, + component: RouteComponent, }); -type PublicQualityMetrics = { - repoFullName: string; - generatedAt: string; - gate: { - blocked: number; - blockedThenMerged: number; - falsePositiveRate: number | null; - precisionPct: number | null; - topGateTypes: Array<{ - gateType: string; - blocked: number; - blockedThenMerged: number; - falsePositiveRate: number | null; - precisionPct: number | null; - }>; - }; - outcomes: { merged: number; closed: number; mergeRatioPct: number | null }; - slop: { totalResolved: number; overallMergeRate: number | null; discriminates: boolean | null }; - trend: Array<{ - weekStart: string; - gateBlocked: number; - gateBlockedThenMerged: number; - gateFalsePositiveRate: number | null; - outcomesMerged: number; - outcomesClosed: number; - mergeRatioPct: number | null; - }>; -}; - -const pctFmt = new Intl.NumberFormat("en", { maximumFractionDigits: 1 }); - -function PublicRepoQualityPage() { +function RouteComponent() { const { owner, repo } = Route.useParams(); - const { data, isLoading, isError } = useQuery({ - queryKey: ["public-quality", owner, repo], - queryFn: async () => { - const result = await apiFetch( - `${getApiOrigin()}/v1/public/repos/${owner}/${repo}/quality`, - { label: "Public quality metrics", timeoutMs: 8000, silentStatus: true }, - ); - if (!result.ok || !result.data) return null; - return result.data; - }, - staleTime: 60_000, - }); - - if (isLoading) { - return ( -
    -

    Loading review-quality metrics…

    -
    - ); - } - - if (!data || isError) { - return ( -
    - -

    - This repository has not opted in to public review-quality metrics, or the metrics are temporarily unavailable. -

    -
    - ); - } - - return ( -
    -
    -
    Public review quality
    -

    {data.repoFullName}

    -

    - Aggregate counts only — no raw trust scores, rewards, or contributor rankings. Updated {new Date(data.generatedAt).toLocaleString()}. -

    - -
    - -
    Gate precision
    -
    - {data.gate.precisionPct != null ? `${pctFmt.format(data.gate.precisionPct)}%` : "—"} -
    -

    - {data.gate.blocked} blocks, {data.gate.blockedThenMerged} later merged -

    -
    - -
    Merge ratio
    -
    - {data.outcomes.mergeRatioPct != null ? `${pctFmt.format(data.outcomes.mergeRatioPct)}%` : "—"} -
    -

    - {data.outcomes.merged} merged / {data.outcomes.closed} closed -

    -
    - -
    Slop calibration
    -
    - {data.slop.overallMergeRate != null ? `${pctFmt.format(data.slop.overallMergeRate)}% merge` : "—"} -
    -

    - {data.slop.totalResolved} resolved PRs - {data.slop.discriminates === true ? " · discriminating" : data.slop.discriminates === false ? " · recalibrate" : ""} -

    -
    -
    - - {data.gate.topGateTypes.length > 0 ? ( -
    -

    Top gate types

    -
      - {data.gate.topGateTypes.map((row) => ( -
    • - {row.gateType} - - {" "} - — {row.blocked} blocks, {row.blockedThenMerged} merged anyway - {row.precisionPct != null ? ` (${pctFmt.format(row.precisionPct)}% precision)` : ""} - -
    • - ))} -
    -
    - ) : null} - -
    -

    Weekly trend

    -
    - - - - - - - - - - - {data.trend.map((row) => ( - - - - - - - ))} - -
    WeekGate FP rateMerge ratioBlocks
    {row.weekStart} - {row.gateFalsePositiveRate != null ? `${pctFmt.format(row.gateFalsePositiveRate * 100)}%` : "—"} - - {row.mergeRatioPct != null ? `${pctFmt.format(row.mergeRatioPct)}%` : "—"} - {row.gateBlocked}
    -
    -
    -
    -
    - ); + return ; } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index cddb452f76..33ec7dfd45 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -294,6 +294,35 @@ describe("api routes", () => { const notOptedIn = await app.request("/v1/public/repos/acme/quality-off/quality", {}, env); expect(notOptedIn.status).toBe(404); await expect(notOptedIn.json()).resolves.toMatchObject({ error: "not_found" }); + + // Private repos stay unavailable even when installed and explicitly opted in. + await upsertRepositoryFromGitHub(env, { name: "quality-private", full_name: "acme/quality-private", private: true, owner: { login: "acme" }, default_branch: "main" }, 562); + await upsertRepositorySettings(env, { repoFullName: "acme/quality-private", publicQualityMetrics: true }); + const privateRes = await app.request("/v1/public/repos/acme/quality-private/quality", {}, env); + expect(privateRes.status).toBe(404); + + // Opted in but NOT installed → unavailable. + await upsertRepositoryFromGitHub(env, { name: "quality-uninstalled", full_name: "acme/quality-uninstalled", private: false, owner: { login: "acme" }, default_branch: "main" }); + await upsertRepositorySettings(env, { repoFullName: "acme/quality-uninstalled", publicQualityMetrics: true }); + const notInstalled = await app.request("/v1/public/repos/acme/quality-uninstalled/quality", {}, env); + expect(notInstalled.status).toBe(404); + + // Unknown repo → unavailable. + const unknown = await app.request("/v1/public/repos/acme/missing-quality/quality", {}, env); + expect(unknown.status).toBe(404); + await expect(unknown.json()).resolves.toMatchObject({ error: "not_found" }); + }); + + it("persists the publicQualityMetrics opt-in through the settings write endpoint (#2568)", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request( + "/v1/internal/repos/acme/quality/settings", + { method: "POST", headers: { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ publicQualityMetrics: true }) }, + env, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ repoFullName: "acme/quality", publicQualityMetrics: true }); }); it("persists the badgeEnabled opt-in through the settings write endpoint (#541)", async () => { diff --git a/test/integration/public-quality-metrics-route-error.test.ts b/test/integration/public-quality-metrics-route-error.test.ts new file mode 100644 index 0000000000..7c5292ac55 --- /dev/null +++ b/test/integration/public-quality-metrics-route-error.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +// Force the loader to throw so the route's defensive 503 catch is exercised. +vi.mock("../../src/services/public-quality-metrics", () => ({ + loadPublicQualityMetrics: () => Promise.reject(new Error("quality boom")), +})); + +import { createApp } from "../../src/api/routes"; +import { createTestEnv } from "../helpers/d1"; +import { upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; + +describe("GET /v1/public/repos/:owner/:repo/quality — error path", () => { + it("returns 503 when quality metrics computation throws", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "quality", full_name: "acme/quality", private: false, owner: { login: "acme" }, default_branch: "main" }, 560); + await upsertRepositorySettings(env, { repoFullName: "acme/quality", publicQualityMetrics: true }); + + const res = await createApp().request("/v1/public/repos/acme/quality/quality", {}, env); + expect(res.status).toBe(503); + expect((await res.json()) as { error: string }).toEqual({ + error: "public_quality_metrics_unavailable", + }); + }); +}); diff --git a/test/unit/public-quality-metrics.test.ts b/test/unit/public-quality-metrics.test.ts index 6234a267dc..b31b76599b 100644 --- a/test/unit/public-quality-metrics.test.ts +++ b/test/unit/public-quality-metrics.test.ts @@ -125,6 +125,28 @@ describe("buildPublicQualityTrend", () => { ); expect(trend.every((row) => row.gateBlocked === 0 && row.outcomesMerged === 0)).toBe(true); }); + + it("skips trend rows with unparseable timestamps and invalid date strings", () => { + const currentMonday = isoWeekStart(NOW); + const trend = buildPublicQualityTrend( + [ + block(1, "x", "not-a-date"), + { repoFullName: "owner/repo", pullNumber: 2, blockerCodes: ["x"], overridden: false, blockedAt: `${currentMonday}T10:00:00.000Z` }, + ], + [ + pr(1, "closed", { updatedAt: "also-not-a-date", createdAt: "still-not-a-date" }), + pr(2, "merged", { mergedAt: `${currentMonday}T12:00:00.000Z` }), + ], + NOW, + 1, + ); + expect(trend[0]).toMatchObject({ + gateBlocked: 1, + gateBlockedThenMerged: 1, + outcomesMerged: 1, + outcomesClosed: 0, + }); + }); }); describe("buildPublicQualityMetricsFromRecords", () => { From a5a765e23c0a431dd4f16f8ed359845b4dda3435 Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Mon, 6 Jul 2026 17:47:22 +0200 Subject: [PATCH 3/5] test(site): cover remaining public-quality trend branches (#2568) Exercise closed-outcome createdAt fallback and the Monday week-start branch for patch coverage. Co-authored-by: Cursor --- test/unit/public-quality-metrics.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/test/unit/public-quality-metrics.test.ts b/test/unit/public-quality-metrics.test.ts index b31b76599b..8271708924 100644 --- a/test/unit/public-quality-metrics.test.ts +++ b/test/unit/public-quality-metrics.test.ts @@ -136,6 +136,16 @@ describe("buildPublicQualityTrend", () => { [ pr(1, "closed", { updatedAt: "also-not-a-date", createdAt: "still-not-a-date" }), pr(2, "merged", { mergedAt: `${currentMonday}T12:00:00.000Z` }), + { + repoFullName: "owner/repo", + number: 3, + title: "Closed via createdAt", + state: "closed", + mergedAt: null, + labels: [], + linkedIssues: [], + createdAt: `${currentMonday}T11:00:00.000Z`, + }, ], NOW, 1, @@ -144,9 +154,13 @@ describe("buildPublicQualityTrend", () => { gateBlocked: 1, gateBlockedThenMerged: 1, outcomesMerged: 1, - outcomesClosed: 0, + outcomesClosed: 1, }); }); + + it("uses the Monday UTC week-start path for non-Sunday timestamps", () => { + expect(isoWeekStart(Date.parse("2026-06-16T12:00:00.000Z"))).toBe("2026-06-16"); + }); }); describe("buildPublicQualityMetricsFromRecords", () => { From b921908bcdfd534a8983717bc2f7b8bec64a439c Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Mon, 6 Jul 2026 17:52:47 +0200 Subject: [PATCH 4/5] fix(test): add createdAt to public-quality test helper opts (#2568) Fixes the TypeScript error that broke validate-code on PR #3846. Co-authored-by: Cursor --- test/unit/public-quality-metrics.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/public-quality-metrics.test.ts b/test/unit/public-quality-metrics.test.ts index 8271708924..cf43b172f0 100644 --- a/test/unit/public-quality-metrics.test.ts +++ b/test/unit/public-quality-metrics.test.ts @@ -18,7 +18,7 @@ const GENERATED = "2026-06-22T12:00:00.000Z"; function pr( number: number, outcome: "merged" | "closed" | "open", - opts: { mergedAt?: string; updatedAt?: string; slopBand?: string; slopRisk?: number } = {}, + opts: { mergedAt?: string; updatedAt?: string; createdAt?: string; slopBand?: string; slopRisk?: number } = {}, ): PullRequestRecord { return { repoFullName: "owner/repo", @@ -27,7 +27,7 @@ function pr( state: outcome === "open" ? "open" : "closed", mergedAt: opts.mergedAt ?? (outcome === "merged" ? "2026-06-20T00:00:00.000Z" : null), updatedAt: opts.updatedAt ?? "2026-06-20T00:00:00.000Z", - createdAt: "2026-06-01T00:00:00.000Z", + createdAt: opts.createdAt ?? "2026-06-01T00:00:00.000Z", slopBand: opts.slopBand, slopRisk: opts.slopRisk, labels: [], @@ -159,7 +159,7 @@ describe("buildPublicQualityTrend", () => { }); it("uses the Monday UTC week-start path for non-Sunday timestamps", () => { - expect(isoWeekStart(Date.parse("2026-06-16T12:00:00.000Z"))).toBe("2026-06-16"); + expect(isoWeekStart(Date.parse("2026-06-16T12:00:00.000Z"))).toBe("2026-06-15"); }); }); From b0aabf2f63e514381be79260a13db5f3cdf49926 Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Mon, 6 Jul 2026 18:02:22 +0200 Subject: [PATCH 5/5] fix(ui): prettier formatting for publicQualityMetrics docs (#2568) Fixes validate-code UI lint failure on PR #3846. Co-authored-by: Cursor --- apps/gittensory-ui/src/routes/docs.tuning.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/gittensory-ui/src/routes/docs.tuning.tsx b/apps/gittensory-ui/src/routes/docs.tuning.tsx index 13a63fb8a4..86b5d47bf0 100644 --- a/apps/gittensory-ui/src/routes/docs.tuning.tsx +++ b/apps/gittensory-ui/src/routes/docs.tuning.tsx @@ -463,8 +463,9 @@ function Tuning() { includeMaintainerAuthors (default false),{" "} requireLinkedIssue (default false), backfillEnabled{" "} (default true), privateTrustEnabled (default true), - and badgeEnabled (README status badge, default false), - and publicQualityMetrics (public review-quality page, default false). + and badgeEnabled (README status badge, default false), and{" "} + publicQualityMetrics (public review-quality page, default false + ).
  • agentPaused (per-repo kill-switch, default false) and{" "}