diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index eb4d35a72e..058ceb7fdb 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -522,6 +522,33 @@ "reuseRatePct" ] } + }, + "reviewVolumeTrend": { + "type": "array", + "items": { + "type": "object", + "properties": { + "weekStart": { + "type": "string" + }, + "reviewed": { + "type": "number" + }, + "merged": { + "type": "number" + }, + "filteredPct": { + "type": "number", + "nullable": true + } + }, + "required": [ + "weekStart", + "reviewed", + "merged", + "filteredPct" + ] + } } }, "required": [ @@ -531,7 +558,8 @@ "weekly", "byProject", "accuracyTrend", - "reuseRateTrend" + "reuseRateTrend", + "reviewVolumeTrend" ] }, "PublicQualityMetrics": { diff --git a/apps/gittensory-ui/src/components/site/proof-of-power-stats-model.ts b/apps/gittensory-ui/src/components/site/proof-of-power-stats-model.ts index a081836b53..0c9c18dc92 100644 --- a/apps/gittensory-ui/src/components/site/proof-of-power-stats-model.ts +++ b/apps/gittensory-ui/src/components/site/proof-of-power-stats-model.ts @@ -41,6 +41,15 @@ export type PublicStats = { misses: number; reuseRatePct: number | null; }>; + /** Trailing weekly review-volume/filtered-rate trend (#4445 follow-up) -- each week is the cohort of PRs + * first published that week; `merged` reflects their CURRENT disposition, not necessarily merged that + * same week. */ + reviewVolumeTrend: Array<{ + weekStart: string; + reviewed: number; + merged: number; + filteredPct: number | null; + }>; }; /** Relative "updated Ns ago" label from the payload's updatedAt (mirrors MetaStrip's freshness logic). */ diff --git a/apps/gittensory-ui/src/components/site/proof-of-power-stats.test.tsx b/apps/gittensory-ui/src/components/site/proof-of-power-stats.test.tsx index 9f930e61b8..7f0e05b914 100644 --- a/apps/gittensory-ui/src/components/site/proof-of-power-stats.test.tsx +++ b/apps/gittensory-ui/src/components/site/proof-of-power-stats.test.tsx @@ -73,6 +73,16 @@ const PAYLOAD: PublicStats = { { weekStart: "2026-06-15", hits: 80, misses: 20, reuseRatePct: 80 }, { weekStart: "2026-06-22", hits: 83, misses: 17, reuseRatePct: 83 }, ], + reviewVolumeTrend: [ + { weekStart: "2026-05-04", reviewed: 2, merged: 1, filteredPct: null }, + { weekStart: "2026-05-11", reviewed: 300, merged: 150, filteredPct: 50 }, + { weekStart: "2026-05-18", reviewed: 310, merged: 160, filteredPct: 48.4 }, + { weekStart: "2026-05-25", reviewed: 320, merged: 165, filteredPct: 48.4 }, + { weekStart: "2026-06-01", reviewed: 330, merged: 170, filteredPct: 48.5 }, + { weekStart: "2026-06-08", reviewed: 340, merged: 175, filteredPct: 48.5 }, + { weekStart: "2026-06-15", reviewed: 350, merged: 180, filteredPct: 48.6 }, + { weekStart: "2026-06-22", reviewed: 358, merged: 185, filteredPct: 48.3 }, + ], }; function renderWithClient(ui: ReactNode) { @@ -146,12 +156,13 @@ describe("ProofOfPowerStats", () => { expect(screen.getByText("avoided redoing prior AI work")).toBeTruthy(); }); - it("renders a sparkline beside accuracy and reuse-rate, each labeled by its own week count", async () => { + it("renders a sparkline beside all four trend-backed tiles, each labeled by its own week count", async () => { apiFetch.mockResolvedValue({ ok: true, status: 200, durationMs: 1, data: PAYLOAD }); renderWithClient(); await screen.findByText("Decision accuracy"); const sparklines = screen.getAllByRole("img", { name: "Trend over the last 8 weeks" }); - expect(sparklines).toHaveLength(2); // accuracy + reuse-rate, both 8-week payloads + // PRs reviewed + Filtered without merge + Decision accuracy + AI work reused, all 8-week payloads. + expect(sparklines).toHaveLength(4); }); it("settles the count-up on the real reviewed total (not stuck at 0 when rAF never fires)", async () => { diff --git a/apps/gittensory-ui/src/components/site/proof-of-power-stats.tsx b/apps/gittensory-ui/src/components/site/proof-of-power-stats.tsx index c0cb76dcce..9692fc9bd8 100644 --- a/apps/gittensory-ui/src/components/site/proof-of-power-stats.tsx +++ b/apps/gittensory-ui/src/components/site/proof-of-power-stats.tsx @@ -97,9 +97,12 @@ export function ProofOfPowerStats({ className }: { className?: string }) { const { totals, weekly, byProject } = data; const repoCount = byProject.length; const timeSaved = formatTimeSaved(totals.minutesSaved); - // #4447/#4448: 8-week sparklines riding beside the two tiles that have a weekly trend to show. The other - // tiles (PRs reviewed, filtered %, time saved) have no persisted weekly series, only a lifetime total plus a - // single "this week" delta -- nothing for a sparkline to plot yet. + // #4447/#4448/#4445-follow-up: 8-week sparklines riding beside every tile that has a weekly trend to show. + // "Maintainer time saved" is the one tile left without one -- it's a fixed multiple of PRs-reviewed + // (minutesSaved = reviewed × ~20min), so its own trend line would just be a rescaled copy of the reviewed + // sparkline, not new information. + const reviewedSparkline = toTrendPoints(data.reviewVolumeTrend, (week) => week.reviewed); + const filteredSparkline = toTrendPoints(data.reviewVolumeTrend, (week) => week.filteredPct); const accuracySparkline = toTrendPoints(data.accuracyTrend, (week) => week.accuracyPct); const reuseRateSparkline = toTrendPoints(data.reuseRateTrend, (week) => week.reuseRatePct); const latestReuseRatePct = @@ -123,11 +126,13 @@ export function ProofOfPowerStats({ className }: { className?: string }) { label="PRs reviewed" value={} hint={`${intFmt.format(totals.merged)} merged across ${repoCount} repo${repoCount === 1 ? "" : "s"}${weekly.reviewed > 0 ? ` · +${intFmt.format(weekly.reviewed)} this week` : ""}`} + trend={} /> } /> { if (!isPublicStatsEnabled(c.env)) return c.json({ error: "not_found" }, 404); try { - const [stats, accuracyTrend, reuseRateTrend] = await Promise.all([getPublicStats(c.env), loadPublicAccuracyTrend(c.env), loadPublicReuseRateTrend(c.env)]); + const [stats, accuracyTrend, reuseRateTrend, reviewVolumeTrend] = await Promise.all([ + getPublicStats(c.env), + loadPublicAccuracyTrend(c.env), + loadPublicReuseRateTrend(c.env), + loadPublicReviewVolumeTrend(c.env), + ]); c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300"); - return c.json({ ...stats, accuracyTrend, reuseRateTrend }); + return c.json({ ...stats, accuracyTrend, reuseRateTrend, reviewVolumeTrend }); } catch { return c.json({ error: "public_stats_unavailable" }, 503); } diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 6ffda995ec..599297a7f9 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -144,6 +144,19 @@ export const PublicStatsSchema = z reuseRatePct: z.number().nullable(), }), ), + /** Trailing weekly PR-review-volume/filtered-rate trend (#4445 follow-up) -- each week is the COHORT of PRs + * first published that week, `merged` reflects their CURRENT disposition (not necessarily merged the same + * week), and null filteredPct means too few reviewed PRs that week to publish a meaningful percentage. The + * most recent 1-2 weeks can read a lower filteredPct than they'll eventually settle at, since some of that + * cohort may still be in flight. */ + reviewVolumeTrend: z.array( + z.object({ + weekStart: z.string(), + reviewed: z.number(), + merged: z.number(), + filteredPct: z.number().nullable(), + }), + ), }) .openapi("PublicStats"); diff --git a/src/services/public-accuracy-trend.ts b/src/services/public-accuracy-trend.ts index a9e4834bcd..0ba7f5286b 100644 --- a/src/services/public-accuracy-trend.ts +++ b/src/services/public-accuracy-trend.ts @@ -135,8 +135,9 @@ async function loadReversalDayRows(env: Env, projects: string[], sinceIso: strin /** 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> { + * account from the homepage total (see public-stats.ts's file header). Exported for reuse by the sibling + * review-volume trend (#4445 follow-up), which needs the SAME per-day Orb split for its own "reviewed" total. */ +export async function loadOrbDayRows(env: Env, sinceIso: string): Promise> { const map = new Map(); const rows = await safeAll<{ day: string; merged: number; closed: number }>( env, diff --git a/src/services/public-review-volume-trend.ts b/src/services/public-review-volume-trend.ts new file mode 100644 index 0000000000..50b86c4cb5 --- /dev/null +++ b/src/services/public-review-volume-trend.ts @@ -0,0 +1,134 @@ +// Public "PRs reviewed" / "Filtered without merge %" weekly trend (#4445 follow-up, sibling to #4447's +// accuracy trend and #4448's reuse-rate trend). The homepage already shows LIVE lifetime totals.reviewed and +// totals.filteredPct (public-stats.ts's own formula: (reviewed - merged) / reviewed) but no history. +// +// DELIBERATELY a per-week COHORT, not an independent per-day event count (unlike accuracyTrend, which buckets +// merged/closed by their OWN respective event dates as two independent series): filteredPct only means anything +// evaluated against a FIXED set of reviewed PRs, so each week's bucket is "of the PRs first published THAT +// week, how many are (as of now) merged" -- mirrors getPublicStats's own weeklyRows subquery (own-ledger side), +// just grouped by day instead of filtered by a single `sinceIso` threshold. A side effect worth knowing: the +// most recent 1-2 weeks' cohorts include PRs still in flight (not yet merged or closed), so their filteredPct +// can read lower than it will once those PRs resolve -- an honest "not enough time has passed yet" artifact, +// not a bug. +// +// DELIBERATELY NOT a persisted/cron rollup, mirroring #4447/#4448's own design: audit_events and pull_requests +// are already durable, so a live weekly re-bucketing of the SAME rows 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. +import { PUBLISHED_PR_KEYS, publicStatsProjects, safeAll } from "../review/public-stats"; +import { isoWeekStart } from "./public-quality-metrics"; +import { loadOrbDayRows } from "./public-accuracy-trend"; + +export const PUBLIC_REVIEW_VOLUME_TREND_WEEKS = 8; +/** Below this many reviewed PRs in a week, that week's filteredPct is too noisy to publish (the raw `reviewed` + * count itself is always shown -- a count needs no sample-size guard the way a ratio does). */ +export const MIN_REVIEW_VOLUME_TREND_SAMPLE = 3; + +export type PublicReviewVolumeTrendWeek = { + /** UTC Monday (YYYY-MM-DD) that starts the bucket. */ + weekStart: string; + reviewed: number; + merged: number; + filteredPct: number | null; +}; + +type DayRow = { day: string; reviewed: number; merged: 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 filteredPct, reused so the trend and the live number can never drift + * apart into two competing definitions of "filtered". */ +function filteredPctOf(reviewed: number, merged: number): number | null { + if (reviewed < MIN_REVIEW_VOLUME_TREND_SAMPLE) return null; + return roundPct((reviewed - merged) / reviewed); +} + +/** Fold day-granularity rows into `weeks` trailing UTC-Monday buckets ending in the week containing `nowMs`. + * Pure -- mirrors buildPublicAccuracyTrend's own bucketing shape (public-accuracy-trend.ts, #4447). */ +export function buildPublicReviewVolumeTrend(dayRows: DayRow[], nowMs: number, weeks: number = PUBLIC_REVIEW_VOLUME_TREND_WEEKS): PublicReviewVolumeTrendWeek[] { + const currentStartMs = Date.parse(isoWeekStart(nowMs)); + const oldestStartMs = currentStartMs - (weeks - 1) * MS_PER_WEEK; + const buckets = Array.from({ length: weeks }, () => ({ reviewed: 0, merged: 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.reviewed += row.reviewed; + bucket.merged += row.merged; + } + + return buckets.map((bucket, offset) => ({ + weekStart: isoWeekStart(oldestStartMs + offset * MS_PER_WEEK), + reviewed: bucket.reviewed, + merged: bucket.merged, + filteredPct: filteredPctOf(bucket.reviewed, bucket.merged), + })); +} + +/** Day-bucketed own-ledger reviewed/merged COHORTS: for each PR first published on a given day, `reviewed` + * credits that day and `merged` credits it too IF the PR is (as of now) merged -- regardless of which day the + * merge itself happened on. Matches getPublicStats's own weeklyRows subquery (same MIN(created_at)/ + * MAX(merged_at) shape, same GROUP BY ev.repo, ev.number), just grouped by day and scoped by a HAVING clause + * instead of a single sinceIso threshold. */ +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 rows = await safeAll<{ day: string; reviewed: number; merged: number }>( + env, + `SELECT date(first_seen) AS day, + COUNT(*) AS reviewed, + SUM(CASE WHEN merged_at IS NOT NULL THEN 1 ELSE 0 END) AS merged + FROM ( + SELECT ev.repo, ev.number, MIN(ev.created_at) AS first_seen, MAX(pr.merged_at) AS merged_at + FROM (${PUBLISHED_PR_KEYS}) ev + LEFT JOIN pull_requests pr ON pr.repo_full_name = ev.repo AND pr.number = ev.number + WHERE LOWER(ev.repo) IN (${inList}) + GROUP BY ev.repo, ev.number + ) + GROUP BY day + HAVING date(first_seen) >= date(?)`, + ...projects, + 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 (mirrors public-accuracy-trend.ts's identical guard). */ + for (const row of rows) map.set(row.day, { reviewed: row.reviewed ?? 0, merged: row.merged ?? 0 }); + return map; +} + +/** Assemble the public review-volume trend from the SAME live tables getPublicStats already reads, folding the + * Orb fleet's per-day merged+closed into `reviewed`/`merged` exactly as getPublicStats folds orb.total/ + * orb.merged into totals.handled/totals.merged for the lifetime figure. 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 loadPublicReviewVolumeTrend(env: Env, nowMs: number = Date.now()): Promise { + const projects = publicStatsProjects(env); + const sinceIso = new Date(Date.parse(isoWeekStart(nowMs)) - (PUBLIC_REVIEW_VOLUME_TREND_WEEKS - 1) * MS_PER_WEEK).toISOString(); + + const [ownLedger, orb] = await Promise.all([ + loadOwnLedgerDayRows(env, projects, sinceIso), + loadOrbDayRows(env, sinceIso), + ]); + + const days = new Set([...ownLedger.keys(), ...orb.keys()]); + const dayRows: DayRow[] = [...days].map((day) => { + const orbDay = orb.get(day); + const orbReviewed = orbDay ? orbDay.merged + orbDay.closed : 0; + return { + day, + reviewed: (ownLedger.get(day)?.reviewed ?? 0) + orbReviewed, + merged: (ownLedger.get(day)?.merged ?? 0) + (orbDay?.merged ?? 0), + }; + }); + + return buildPublicReviewVolumeTrend(dayRows, nowMs); +} diff --git a/test/integration/public-stats-route.test.ts b/test/integration/public-stats-route.test.ts index 764ba5bf56..13ccfb40c1 100644 --- a/test/integration/public-stats-route.test.ts +++ b/test/integration/public-stats-route.test.ts @@ -3,6 +3,7 @@ import { createApp } from "../../src/api/routes"; import { createTestEnv } from "../helpers/d1"; import { PUBLIC_ACCURACY_TREND_WEEKS } from "../../src/services/public-accuracy-trend"; import { PUBLIC_REUSE_RATE_TREND_WEEKS } from "../../src/services/public-reuse-rate-trend"; +import { PUBLIC_REVIEW_VOLUME_TREND_WEEKS } from "../../src/services/public-review-volume-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). */ @@ -64,6 +65,7 @@ describe("GET /v1/public/stats (#1059)", () => { byProject: Array<{ project: string; reviewed: number }>; accuracyTrend: Array<{ weekStart: string; merged: number; closed: number; reversed: number; accuracyPct: number | null }>; reuseRateTrend: Array<{ weekStart: string; hits: number; misses: number; reuseRatePct: number | null }>; + reviewVolumeTrend: Array<{ weekStart: string; reviewed: number; merged: number; filteredPct: number | null }>; }; expect(body.totals.handled).toBe(5); // distinct reviewed PRs expect(body.totals.merged).toBe(3); @@ -86,5 +88,13 @@ describe("GET /v1/public/stats (#1059)", () => { // #4448: the weekly AI-work reuse-rate trend rides along on the SAME response too. expect(body.reuseRateTrend).toHaveLength(PUBLIC_REUSE_RATE_TREND_WEEKS); for (const week of body.reuseRateTrend) expect(typeof week.weekStart).toBe("string"); + // #4445 follow-up: the weekly review-volume/filtered-rate trend rides along on the SAME response too. + expect(body.reviewVolumeTrend).toHaveLength(PUBLIC_REVIEW_VOLUME_TREND_WEEKS); + for (const week of body.reviewVolumeTrend) expect(typeof week.weekStart).toBe("string"); + // All 5 seeded PRs were published "now" (no explicit created_at in the seed), so the whole cohort lands in + // the current week: reviewed 5, merged 3 -- the SAME totals as the lifetime totals.reviewed/merged above. + const currentWeek = body.reviewVolumeTrend[body.reviewVolumeTrend.length - 1]; + expect(currentWeek?.reviewed).toBe(5); + expect(currentWeek?.merged).toBe(3); }); }); diff --git a/test/unit/public-review-volume-trend.test.ts b/test/unit/public-review-volume-trend.test.ts new file mode 100644 index 0000000000..0d90388ee7 --- /dev/null +++ b/test/unit/public-review-volume-trend.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { + MIN_REVIEW_VOLUME_TREND_SAMPLE, + PUBLIC_REVIEW_VOLUME_TREND_WEEKS, + buildPublicReviewVolumeTrend, + loadPublicReviewVolumeTrend, +} from "../../src/services/public-review-volume-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("buildPublicReviewVolumeTrend", () => { + it("buckets day rows into weekly totals and computes the SAME filteredPct formula as the live number", () => { + const currentMonday = isoWeekStart(NOW); + const priorMonday = isoWeekStart(NOW - 7 * 86_400_000); + const trend = buildPublicReviewVolumeTrend( + [ + { day: priorMonday, reviewed: 4, merged: 2 }, + { day: priorMonday, reviewed: 1, merged: 1 }, // a second day in the SAME week -- must accumulate + { day: currentMonday, reviewed: 3, merged: 3 }, + ], + NOW, + 2, + ); + expect(trend).toHaveLength(2); + expect(trend[0]).toEqual({ + weekStart: priorMonday, + reviewed: 5, + merged: 3, + // (5 - 3) / 5 = 40% + filteredPct: 40, + }); + expect(trend[1]).toEqual({ + weekStart: currentMonday, + reviewed: 3, + merged: 3, + filteredPct: 0, + }); + }); + + 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 = buildPublicReviewVolumeTrend([{ day: tooOld, reviewed: 999, merged: 999 }, { day: currentMonday, reviewed: 1, merged: 0 }], NOW, 2); + expect(trend[0]).toMatchObject({ reviewed: 0, merged: 0 }); + expect(trend[1]).toMatchObject({ reviewed: 1, merged: 0 }); + }); + + it("ignores an unparseable day string rather than throwing or corrupting a bucket", () => { + const currentMonday = isoWeekStart(NOW); + const trend = buildPublicReviewVolumeTrend([{ day: "not-a-date", reviewed: 5, merged: 5 }, { day: currentMonday, reviewed: 1, merged: 0 }], NOW, 1); + expect(trend).toHaveLength(1); + expect(trend[0]).toMatchObject({ reviewed: 1, merged: 0 }); + }); + + it("returns null filteredPct (not a misleading 0%) below MIN_REVIEW_VOLUME_TREND_SAMPLE reviewed PRs, but still reports the raw reviewed count", () => { + const week = isoWeekStart(NOW); + const trend = buildPublicReviewVolumeTrend([{ day: week, reviewed: MIN_REVIEW_VOLUME_TREND_SAMPLE - 1, merged: 0 }], NOW, 1); + expect(trend[0]?.filteredPct).toBeNull(); + expect(trend[0]?.reviewed).toBe(MIN_REVIEW_VOLUME_TREND_SAMPLE - 1); + }); + + it("returns a real percentage at exactly MIN_REVIEW_VOLUME_TREND_SAMPLE reviewed PRs", () => { + const week = isoWeekStart(NOW); + const trend = buildPublicReviewVolumeTrend([{ day: week, reviewed: MIN_REVIEW_VOLUME_TREND_SAMPLE, merged: 0 }], NOW, 1); + expect(trend[0]?.filteredPct).toBe(100); + }); + + it("defaults to PUBLIC_REVIEW_VOLUME_TREND_WEEKS trailing weeks when weeks is omitted", () => { + const trend = buildPublicReviewVolumeTrend([], NOW); + expect(trend).toHaveLength(PUBLIC_REVIEW_VOLUME_TREND_WEEKS); + }); + + it("returns all-zero, null-filteredPct buckets for an empty input (a brand-new / not-yet-enabled deployment)", () => { + const trend = buildPublicReviewVolumeTrend([], NOW, 3); + expect(trend).toHaveLength(3); + for (const week of trend) expect(week).toMatchObject({ reviewed: 0, merged: 0, filteredPct: null }); + }); +}); + +describe("loadPublicReviewVolumeTrend — end-to-end over the real live tables", () => { + it("credits a PR's week by its FIRST-PUBLISHED day, not its (possibly later) merge day, and folds in the Orb fleet", 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(); + const priorMonday = isoWeekStart(NOW - 7 * 86_400_000); + const priorWeekIso = `${priorMonday}T09:00:00.000Z`; + + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 1); + + // PR #1: published LAST week, merged THIS week -- must credit `reviewed`/`merged` to LAST week's cohort + // (its publish day), not to the week it actually merged in. + 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: priorWeekIso }); + + // PR #2: published and closed (no merge) THIS week -- a genuinely filtered PR in THIS week's own cohort, on + // a day with no prior own-ledger publish (exercises the day-map's `?? 0` fallback branch for a fresh day). + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 2, title: "PR 2", state: "closed", user: { login: "b" }, head: { sha: "s2" }, labels: [] }); + await env.DB.prepare("UPDATE pull_requests SET updated_at = ? WHERE repo_full_name = ? AND number = 2").bind(laterInWeekIso, "JSONbored/gittensory").run(); + await recordAuditEvent(env, { eventType: "github_app.pr_public_surface_published", targetKey: "JSONbored/gittensory#2", outcome: "completed", createdAt: laterInWeekIso }); + + // Orb fleet: a registered installation with a merge on the SAME later day as PR #2 -- own-ledger and Orb + // day-maps each have a day the OTHER source has no entry for (exercises both directions of the ownLedger/ + // orb `?? 0` fallback), and Orb's own "reviewed" (merged+closed) must fold into the week total too. + await env.DB.prepare("INSERT INTO orb_github_installations (installation_id, registered) VALUES (?, 1)").bind(9101).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", 7, 9101, "merged", laterInWeekIso) + .run(); + + const trend = await loadPublicReviewVolumeTrend(env, NOW); + const priorWeek = trend[trend.length - 2]; + const currentWeek = trend[trend.length - 1]; + + // PR #1's publish credits LAST week's cohort with reviewed=1, merged=1 (its later merge still counts, + // since merged reflects CURRENT disposition, not the merge's own day). + expect(priorWeek?.weekStart).toBe(priorMonday); + expect(priorWeek?.reviewed).toBe(1); + expect(priorWeek?.merged).toBe(1); + + // THIS week: own-ledger PR #2 (reviewed, not merged) + Orb PR #7 (reviewed AND merged) = reviewed 2, merged 1. + expect(currentWeek?.weekStart).toBe(thisMonday); + expect(currentWeek?.reviewed).toBe(2); + expect(currentWeek?.merged).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(9102).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", 8, 9102, "closed", thisWeekIso) + .run(); + + const trend = await loadPublicReviewVolumeTrend(env, NOW); + const currentWeek = trend[trend.length - 1]; + expect(currentWeek?.reviewed).toBe(1); + expect(currentWeek?.merged).toBe(0); + }); +});