diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index ecf9b9bfaa..01e8f74883 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -464,6 +464,37 @@ "accuracyPct" ] } + }, + "accuracyTrend": { + "type": "array", + "items": { + "type": "object", + "properties": { + "weekStart": { + "type": "string" + }, + "merged": { + "type": "number" + }, + "closed": { + "type": "number" + }, + "reversed": { + "type": "number" + }, + "accuracyPct": { + "type": "number", + "nullable": true + } + }, + "required": [ + "weekStart", + "merged", + "closed", + "reversed", + "accuracyPct" + ] + } } }, "required": [ @@ -471,7 +502,8 @@ "updatedAt", "totals", "weekly", - "byProject" + "byProject", + "accuracyTrend" ] }, "PublicQualityMetrics": { diff --git a/src/api/routes.ts b/src/api/routes.ts index ba3d0e72c8..8adf62def9 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -258,6 +258,7 @@ import { computeParityReadiness, isParityAuditEnabled } from "../review/parity-w import { computePredictedGateAgreement } from "../review/predicted-gate-agreement"; import { isRagEnabled } from "../review/rag-wire"; import { getPublicStats, isPublicStatsEnabled } from "../review/public-stats"; +import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend"; import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard"; import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics"; import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES, normalizeReadinessGateMode } from "../signals/focus-manifest"; @@ -951,9 +952,9 @@ export function createApp() { app.get("/v1/public/stats", async (c) => { if (!isPublicStatsEnabled(c.env)) return c.json({ error: "not_found" }, 404); try { - const stats = await getPublicStats(c.env); + const [stats, accuracyTrend] = await Promise.all([getPublicStats(c.env), loadPublicAccuracyTrend(c.env)]); c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300"); - return c.json(stats); + return c.json({ ...stats, accuracyTrend }); } catch { return c.json({ error: "public_stats_unavailable" }, 503); } diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index b7c1032718..ef350fd952 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -120,6 +120,17 @@ export const PublicStatsSchema = z accuracyPct: z.number().nullable(), }), ), + /** Trailing weekly history of totals.accuracyPct's SAME formula (#4447) -- null accuracyPct on a week means + * too few decided (merged+closed) PRs that week to publish a meaningful percentage, not zero accuracy. */ + accuracyTrend: z.array( + z.object({ + weekStart: z.string(), + merged: z.number(), + closed: z.number(), + reversed: z.number(), + accuracyPct: z.number().nullable(), + }), + ), }) .openapi("PublicStats"); diff --git a/src/review/public-stats.ts b/src/review/public-stats.ts index 5ce085479b..f133224c44 100644 --- a/src/review/public-stats.ts +++ b/src/review/public-stats.ts @@ -55,7 +55,7 @@ function storage(env: Env): D1Database { } /** Read-only helper that degrades a missing/empty table (or absent column in some envs) to []. */ -async function safeAll( +export async function safeAll( env: Env, sql: string, ...binds: unknown[] @@ -108,7 +108,7 @@ function accuracyPct( * allowlist correctly went empty, while the historical rows this worker already wrote for them remain real and * safe to publish. Empty allowlist => the own-ledger side reports zero (still fails safe), but does NOT * suppress the separately-gated Orb cross-fleet aggregate (see getPublicStats below). */ -function publicStatsProjects(env: { +export function publicStatsProjects(env: { GITTENSORY_PUBLIC_STATS_REPOS?: string | undefined; }): string[] { const seen = new Set(); @@ -165,7 +165,7 @@ export interface PublicStatsPayload { // legacy review_targets ledger, which the convergence cutover orphaned (nothing writes it anymore). `reversed` // (the accuracy numerator) is computed LIVE from the same ledger: a terminal engine auto-action (close/merge) // that a human later overturned (see the reversal query below). All reads are public-safe COUNTs, degrade to 0. -const PUBLISHED_PR_KEYS = ` +export const PUBLISHED_PR_KEYS = ` SELECT substr(target_key, 1, instr(target_key, '#') - 1) AS repo, CAST(substr(target_key, instr(target_key, '#') + 1) AS INTEGER) AS number, diff --git a/src/services/public-accuracy-trend.ts b/src/services/public-accuracy-trend.ts new file mode 100644 index 0000000000..a9e4834bcd --- /dev/null +++ b/src/services/public-accuracy-trend.ts @@ -0,0 +1,181 @@ +// Public "Decision accuracy %" weekly trend (#4447, part of epic #4445). The homepage already shows a LIVE, +// lifetime accuracyPct (public-stats.ts's own reversal-grounded formula: 1 - reversed/(merged+closed), over the +// SAME own-ledger allowlist + registered Orb fleet the rest of that payload uses) but no history, so there's no +// way to see whether accuracy is improving, stable, or degrading. +// +// DELIBERATELY NOT a persisted/cron rollup: `audit_events`, `pull_requests`, and `orb_pr_outcomes` are already +// durable, so a live weekly re-bucketing of those SAME rows (mirroring buildPublicQualityTrend's already-shipped +// #2568 pattern for the sibling per-repo quality trend) can recompute any historical week correctly on every +// request -- no cron-miss gap risk, no second copy of the number to keep in sync, and the SAME formula as the +// live figure by construction, so the two can never silently diverge or read as inconsistent to a public viewer. +import { PUBLISHED_PR_KEYS, publicStatsProjects, safeAll } from "../review/public-stats"; +import { isoWeekStart } from "./public-quality-metrics"; + +export const PUBLIC_ACCURACY_TREND_WEEKS = 8; +/** Below this many decided (merged+closed) PRs in a week, that week's accuracy is too noisy to publish. */ +export const MIN_ACCURACY_TREND_SAMPLE = 3; + +export type PublicAccuracyTrendWeek = { + /** UTC Monday (YYYY-MM-DD) that starts the bucket. */ + weekStart: string; + merged: number; + closed: number; + reversed: number; + accuracyPct: number | null; +}; + +type DayRow = { day: string; merged: number; closed: number; reversed: number }; + +const MS_PER_WEEK = 7 * 86_400_000; + +function roundPct(value: number): number { + return Math.round(value * 1000) / 10; +} + +/** Same formula as public-stats.ts's accuracyPct, reused so the trend and the live number can never drift + * apart into two competing definitions of "accuracy". */ +function accuracyPctOf(merged: number, closed: number, reversed: number): number | null { + const decided = merged + closed; + if (decided < MIN_ACCURACY_TREND_SAMPLE) return null; + const reversalRate = Math.min(1, reversed / decided); + return roundPct(1 - reversalRate); +} + +/** Fold day-granularity rows into `weeks` trailing UTC-Monday buckets ending in the week containing `nowMs`. + * Pure -- mirrors buildPublicQualityTrend's own bucketing shape (public-quality-metrics.ts). */ +export function buildPublicAccuracyTrend(dayRows: DayRow[], nowMs: number, weeks: number = PUBLIC_ACCURACY_TREND_WEEKS): PublicAccuracyTrendWeek[] { + const currentStartMs = Date.parse(isoWeekStart(nowMs)); + const oldestStartMs = currentStartMs - (weeks - 1) * MS_PER_WEEK; + const buckets = Array.from({ length: weeks }, () => ({ merged: 0, closed: 0, reversed: 0 })); + + for (const row of dayRows) { + const dayMs = Date.parse(`${row.day}T00:00:00.000Z`); + if (!Number.isFinite(dayMs)) continue; + const weekOffset = Math.floor((dayMs - oldestStartMs) / MS_PER_WEEK); + if (weekOffset < 0 || weekOffset >= weeks) continue; + const bucket = buckets[weekOffset]!; + bucket.merged += row.merged; + bucket.closed += row.closed; + bucket.reversed += row.reversed; + } + + return buckets.map((bucket, offset) => ({ + weekStart: isoWeekStart(oldestStartMs + offset * MS_PER_WEEK), + merged: bucket.merged, + closed: bucket.closed, + reversed: bucket.reversed, + accuracyPct: accuracyPctOf(bucket.merged, bucket.closed, bucket.reversed), + })); +} + +/** Day-bucketed own-ledger merged/closed, matching public-stats.ts's `dispositions` query exactly except for the + * added `GROUP BY day` -- `closed` uses `pr.updated_at` as the close-date proxy (no dedicated closed_at column + * exists), the same convention buildPublicQualityTrend already established for the sibling quality trend. */ +async function loadOwnLedgerDayRows(env: Env, projects: string[], sinceIso: string): Promise> { + const map = new Map(); + if (projects.length === 0) return map; + const inList = projects.map(() => "?").join(", "); + const [mergedRows, closedRows] = await Promise.all([ + safeAll<{ day: string; n: number }>( + env, + `SELECT date(pr.merged_at) AS day, COUNT(*) AS n + FROM (SELECT DISTINCT repo, number FROM (${PUBLISHED_PR_KEYS})) ev + JOIN pull_requests pr ON pr.repo_full_name = ev.repo AND pr.number = ev.number + WHERE LOWER(ev.repo) IN (${inList}) AND pr.merged_at IS NOT NULL AND pr.merged_at >= ? + GROUP BY day`, + ...projects, + sinceIso, + ), + safeAll<{ day: string; n: number }>( + env, + `SELECT date(pr.updated_at) AS day, COUNT(*) AS n + FROM (SELECT DISTINCT repo, number FROM (${PUBLISHED_PR_KEYS})) ev + JOIN pull_requests pr ON pr.repo_full_name = ev.repo AND pr.number = ev.number + WHERE LOWER(ev.repo) IN (${inList}) AND pr.state = 'closed' AND pr.merged_at IS NULL AND pr.updated_at >= ? + GROUP BY day`, + ...projects, + sinceIso, + ), + ]); + for (const row of mergedRows) map.set(row.day, { merged: row.n, closed: (map.get(row.day)?.closed ?? 0) }); + for (const row of closedRows) map.set(row.day, { merged: (map.get(row.day)?.merged ?? 0), closed: row.n }); + return map; +} + +/** Day-bucketed reversal count, matching public-stats.ts's `reversalRows` query exactly except bucketed by the + * ORIGINAL auto-action's own created_at (not the later reversal's timestamp) so a reversal always credits the + * week the decision was actually made, and never retroactively shifts a past week's published trend. */ +async function loadReversalDayRows(env: Env, projects: string[], sinceIso: string): Promise> { + const map = new Map(); + if (projects.length === 0) return map; + const inList = projects.map(() => "?").join(", "); + const rows = await safeAll<{ day: string; n: number }>( + env, + `SELECT date(ev.created_at) AS day, COUNT(DISTINCT ev.pr_number) AS n FROM ( + SELECT substr(target_key, 1, instr(target_key, '#') - 1) AS project, + CAST(substr(target_key, instr(target_key, '#') + 1) AS INTEGER) AS pr_number, + event_type, created_at + FROM audit_events + WHERE event_type IN ('agent.action.close', 'agent.action.merge') + AND outcome = 'completed' AND instr(target_key, '#') > 0 + AND COALESCE(json_extract(metadata_json, '$.mode'), 'live') <> 'dry_run' + AND created_at >= ? + ) ev + JOIN pull_requests pr ON pr.repo_full_name = ev.project AND pr.number = ev.pr_number + WHERE LOWER(ev.project) IN (${inList}) + AND ( (ev.event_type = 'agent.action.close' AND (pr.state = 'open' OR pr.merged_at IS NOT NULL)) + OR (ev.event_type = 'agent.action.merge' AND pr.state = 'open') ) + GROUP BY day`, + sinceIso, + ...projects, + ); + for (const row of rows) map.set(row.day, row.n); + return map; +} + +/** Day-bucketed Orb-fleet merged/closed, matching getOrbGlobalStats (orb/outcomes.ts) exactly except for the + * added `GROUP BY day`. No excludeAccount here, mirroring getPublicStats's own choice not to exclude any + * account from the homepage total (see public-stats.ts's file header). */ +async function loadOrbDayRows(env: Env, sinceIso: string): Promise> { + const map = new Map(); + const rows = await safeAll<{ day: string; merged: number; closed: number }>( + env, + `SELECT date(o.occurred_at) AS day, + SUM(CASE WHEN o.outcome = 'merged' THEN 1 ELSE 0 END) AS merged, + SUM(CASE WHEN o.outcome = 'closed' THEN 1 ELSE 0 END) AS closed + FROM orb_pr_outcomes o + JOIN orb_github_installations i ON i.installation_id = o.installation_id AND i.registered = 1 + WHERE o.occurred_at >= ? + GROUP BY day`, + sinceIso, + ); + /* v8 ignore next -- SUM(CASE WHEN ... THEN 1 ELSE 0 END) over an existing GROUP BY day always yields a defined + * integer (0 or more), never SQL NULL, so the ?? 0 fallback can't currently be exercised; kept for defense + * against a future query-shape change. */ + for (const row of rows) map.set(row.day, { merged: row.merged ?? 0, closed: row.closed ?? 0 }); + return map; +} + +/** Assemble the public accuracy trend from the SAME live tables getPublicStats already reads. Fail-safe: each + * underlying query degrades to [] on error (safeAll), so a single bad query yields under-counted weeks rather + * than throwing the whole public stats payload. */ +export async function loadPublicAccuracyTrend(env: Env, nowMs: number = Date.now()): Promise { + const projects = publicStatsProjects(env); + const sinceIso = new Date(Date.parse(isoWeekStart(nowMs)) - (PUBLIC_ACCURACY_TREND_WEEKS - 1) * MS_PER_WEEK).toISOString(); + + const [ownLedger, reversals, orb] = await Promise.all([ + loadOwnLedgerDayRows(env, projects, sinceIso), + loadReversalDayRows(env, projects, sinceIso), + loadOrbDayRows(env, sinceIso), + ]); + + const days = new Set([...ownLedger.keys(), ...reversals.keys(), ...orb.keys()]); + const dayRows: DayRow[] = [...days].map((day) => ({ + day, + merged: (ownLedger.get(day)?.merged ?? 0) + (orb.get(day)?.merged ?? 0), + closed: (ownLedger.get(day)?.closed ?? 0) + (orb.get(day)?.closed ?? 0), + reversed: reversals.get(day) ?? 0, + })); + + return buildPublicAccuracyTrend(dayRows, nowMs); +} diff --git a/test/integration/public-stats-route.test.ts b/test/integration/public-stats-route.test.ts index b03799c759..9dc6927957 100644 --- a/test/integration/public-stats-route.test.ts +++ b/test/integration/public-stats-route.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { createApp } from "../../src/api/routes"; import { createTestEnv } from "../helpers/d1"; +import { PUBLIC_ACCURACY_TREND_WEEKS } from "../../src/services/public-accuracy-trend"; /** Seed the LIVE ledger: a published-review surface per reviewed PR (audit_events) + each PR's terminal * disposition (pull_requests state/merged_at), plus one live reversal (an engine close on a now-reopened PR). */ @@ -60,6 +61,7 @@ describe("GET /v1/public/stats (#1059)", () => { totals: Record; weekly: { reviewed: number; merged: number }; byProject: Array<{ project: string; reviewed: number }>; + accuracyTrend: Array<{ weekStart: string; merged: number; closed: number; reversed: number; accuracyPct: number | null }>; }; expect(body.totals.handled).toBe(5); // distinct reviewed PRs expect(body.totals.merged).toBe(3); @@ -76,5 +78,8 @@ describe("GET /v1/public/stats (#1059)", () => { expect(body.byProject.map((p) => p.project)).toContain( "JSONbored/awesome-claude", ); + // #4447: the weekly accuracy trend rides along on the SAME response, one entry per trailing week. + expect(body.accuracyTrend).toHaveLength(PUBLIC_ACCURACY_TREND_WEEKS); + for (const week of body.accuracyTrend) expect(typeof week.weekStart).toBe("string"); }); }); diff --git a/test/unit/public-accuracy-trend.test.ts b/test/unit/public-accuracy-trend.test.ts new file mode 100644 index 0000000000..7dc552c5ad --- /dev/null +++ b/test/unit/public-accuracy-trend.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + MIN_ACCURACY_TREND_SAMPLE, + PUBLIC_ACCURACY_TREND_WEEKS, + buildPublicAccuracyTrend, + loadPublicAccuracyTrend, +} from "../../src/services/public-accuracy-trend"; +import { isoWeekStart } from "../../src/services/public-quality-metrics"; +import { recordAuditEvent, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +const NOW = Date.parse("2026-06-22T12:00:00.000Z"); + +describe("buildPublicAccuracyTrend", () => { + it("buckets day rows into weekly totals and computes the SAME accuracy formula as the live number", () => { + const currentMonday = isoWeekStart(NOW); + const priorMonday = isoWeekStart(NOW - 7 * 86_400_000); + const trend = buildPublicAccuracyTrend( + [ + { day: priorMonday, merged: 4, closed: 2, reversed: 1 }, + { day: priorMonday, merged: 1, closed: 0, reversed: 0 }, // a second day in the SAME week -- must accumulate + { day: currentMonday, merged: 3, closed: 3, reversed: 0 }, + ], + NOW, + 2, + ); + expect(trend).toHaveLength(2); + expect(trend[0]).toEqual({ + weekStart: priorMonday, + merged: 5, + closed: 2, + reversed: 1, + // 1 - 1/(5+2) = 85.7% + accuracyPct: 85.7, + }); + expect(trend[1]).toEqual({ + weekStart: currentMonday, + merged: 3, + closed: 3, + reversed: 0, + accuracyPct: 100, + }); + }); + + it("REGRESSION: ignores day rows outside the trailing window instead of letting them corrupt the oldest bucket", () => { + const currentMonday = isoWeekStart(NOW); + const tooOld = isoWeekStart(NOW - 30 * 86_400_000); + const trend = buildPublicAccuracyTrend([{ day: tooOld, merged: 999, closed: 999, reversed: 999 }, { day: currentMonday, merged: 1, closed: 0, reversed: 0 }], NOW, 2); + expect(trend[0]).toMatchObject({ merged: 0, closed: 0, reversed: 0 }); + expect(trend[1]).toMatchObject({ merged: 1, closed: 0, reversed: 0 }); + }); + + it("ignores an unparseable day string rather than throwing or corrupting a bucket", () => { + const currentMonday = isoWeekStart(NOW); + const trend = buildPublicAccuracyTrend([{ day: "not-a-date", merged: 5, closed: 5, reversed: 5 }, { day: currentMonday, merged: 1, closed: 0, reversed: 0 }], NOW, 1); + expect(trend).toHaveLength(1); + expect(trend[0]).toMatchObject({ merged: 1, closed: 0, reversed: 0 }); + }); + + it("returns null accuracyPct (not a misleading 0% or 100%) below MIN_ACCURACY_TREND_SAMPLE decided PRs", () => { + const week = isoWeekStart(NOW); + const trend = buildPublicAccuracyTrend([{ day: week, merged: MIN_ACCURACY_TREND_SAMPLE - 1, closed: 0, reversed: 0 }], NOW, 1); + expect(trend[0]?.accuracyPct).toBeNull(); + }); + + it("returns a real percentage at exactly MIN_ACCURACY_TREND_SAMPLE decided PRs", () => { + const week = isoWeekStart(NOW); + const trend = buildPublicAccuracyTrend([{ day: week, merged: MIN_ACCURACY_TREND_SAMPLE, closed: 0, reversed: 0 }], NOW, 1); + expect(trend[0]?.accuracyPct).toBe(100); + }); + + it("clamps a reversed count that exceeds decided (a reopened auto-close dropped from merged+closed) to 0%, never negative", () => { + const week = isoWeekStart(NOW); + const trend = buildPublicAccuracyTrend([{ day: week, merged: 0, closed: MIN_ACCURACY_TREND_SAMPLE, reversed: MIN_ACCURACY_TREND_SAMPLE + 5 }], NOW, 1); + expect(trend[0]?.accuracyPct).toBe(0); + }); + + it("defaults to PUBLIC_ACCURACY_TREND_WEEKS trailing weeks when weeks is omitted", () => { + const trend = buildPublicAccuracyTrend([], NOW); + expect(trend).toHaveLength(PUBLIC_ACCURACY_TREND_WEEKS); + }); + + it("returns all-zero, null-accuracy buckets for an empty input (a brand-new / not-yet-enabled deployment)", () => { + const trend = buildPublicAccuracyTrend([], NOW, 3); + expect(trend).toHaveLength(3); + for (const week of trend) expect(week).toMatchObject({ merged: 0, closed: 0, reversed: 0, accuracyPct: null }); + }); +}); + +describe("loadPublicAccuracyTrend — end-to-end over the real live tables", () => { + it("combines own-ledger merged/closed/reversed and Orb-fleet merged/closed into a consistent weekly trend", async () => { + const env = createTestEnv({ GITTENSORY_PUBLIC_STATS_REPOS: "JSONbored/gittensory" }); + const thisMonday = isoWeekStart(NOW); + const thisWeekIso = `${thisMonday}T09:00:00.000Z`; + const laterInWeekIso = new Date(Date.parse(thisWeekIso) + 86_400_000).toISOString(); + + // Own-ledger: PR #1 published+merged this week (no reversal). + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 1); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 1, title: "PR 1", state: "closed", merged_at: thisWeekIso, user: { login: "a" }, head: { sha: "s1" }, labels: [] }); + await recordAuditEvent(env, { eventType: "github_app.pr_public_surface_published", targetKey: "JSONbored/gittensory#1", outcome: "completed", createdAt: thisWeekIso }); + + // Own-ledger: PR #2 auto-closed by the engine this week, then REVERTED (reopened) -- must count in `reversed`. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 2, title: "PR 2", state: "open", user: { login: "b" }, head: { sha: "s2" }, labels: [] }); + await recordAuditEvent(env, { eventType: "github_app.pr_public_surface_published", targetKey: "JSONbored/gittensory#2", outcome: "completed", createdAt: thisWeekIso }); + await recordAuditEvent(env, { eventType: "agent.action.close", targetKey: "JSONbored/gittensory#2", outcome: "completed", createdAt: thisWeekIso }); + + // Own-ledger: PR #3 closes WITHOUT merging (no reversal), on a DAY WITH NO PRIOR own-ledger merge -- exercises + // the closedRows fold's `map.get(day)?.merged ?? 0` fallback branch (a day the mergedRows loop never touched), + // distinct from the mergedRows loop above. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 3, title: "PR 3", state: "closed", user: { login: "c" }, head: { sha: "s3" }, labels: [] }); + await env.DB.prepare("UPDATE pull_requests SET updated_at = ? WHERE repo_full_name = ? AND number = 3").bind(laterInWeekIso, "JSONbored/gittensory").run(); + await recordAuditEvent(env, { eventType: "github_app.pr_public_surface_published", targetKey: "JSONbored/gittensory#3", outcome: "completed", createdAt: laterInWeekIso }); + + // Orb fleet: a registered installation with a merge on the SAME later day as PR #3's close -- so the + // own-ledger and Orb day-maps each have a day the OTHER source has no entry for at all (exercises both + // directions of the ownLedger/orb `?? 0` fallback in loadPublicAccuracyTrend's day-merge step, not just the + // "both sources active on the same day" case already covered by the shared thisWeekIso above). + await env.DB.prepare("INSERT INTO orb_github_installations (installation_id, registered) VALUES (?, 1)").bind(9001).run(); + await env.DB.prepare("INSERT INTO orb_pr_outcomes (repository_full_name, pr_number, installation_id, outcome, occurred_at) VALUES (?, ?, ?, ?, ?)") + .bind("other-org/other-repo", 5, 9001, "merged", laterInWeekIso) + .run(); + + const trend = await loadPublicAccuracyTrend(env, NOW); + const currentWeek = trend[trend.length - 1]; + expect(currentWeek?.weekStart).toBe(thisMonday); + // merged: own-ledger PR#1 (1) + orb PR#5 (1, a different day) = 2. closed: own-ledger PR#3 (1). PR#2 stays + // open (not merged/closed) but its close-then-reopen still counts toward reversed. + expect(currentWeek?.merged).toBe(2); + expect(currentWeek?.closed).toBe(1); + expect(currentWeek?.reversed).toBe(1); + }); + + it("still reports the Orb-fleet side when GITTENSORY_PUBLIC_STATS_REPOS is empty (no own-ledger allowlist)", async () => { + const env = createTestEnv({ GITTENSORY_PUBLIC_STATS_REPOS: "" }); + const thisMonday = isoWeekStart(NOW); + const thisWeekIso = `${thisMonday}T09:00:00.000Z`; + await env.DB.prepare("INSERT INTO orb_github_installations (installation_id, registered) VALUES (?, 1)").bind(9002).run(); + await env.DB.prepare("INSERT INTO orb_pr_outcomes (repository_full_name, pr_number, installation_id, outcome, occurred_at) VALUES (?, ?, ?, ?, ?)") + .bind("other-org/other-repo", 6, 9002, "closed", thisWeekIso) + .run(); + + const trend = await loadPublicAccuracyTrend(env, NOW); + const currentWeek = trend[trend.length - 1]; + expect(currentWeek?.closed).toBe(1); + expect(currentWeek?.merged).toBe(0); + expect(currentWeek?.reversed).toBe(0); + }); +});