diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 5bd04f3df..0267e9fc7 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -474,13 +474,16 @@ "type": "string" }, "merged": { - "type": "number" + "type": "number", + "nullable": true }, "closed": { - "type": "number" + "type": "number", + "nullable": true }, "reversed": { - "type": "number" + "type": "number", + "nullable": true }, "accuracyPct": { "type": "number", diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index fb384a00b..e92af604c 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -120,14 +120,14 @@ 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. */ + /** Trailing weekly history of totals.accuracyPct's SAME formula (#4447) -- null counts/accuracyPct on a week means + * too few decided (merged+closed) PRs to publish meaningful or non-identifying details. */ accuracyTrend: z.array( z.object({ weekStart: z.string(), - merged: z.number(), - closed: z.number(), - reversed: z.number(), + merged: z.number().nullable(), + closed: z.number().nullable(), + reversed: z.number().nullable(), accuracyPct: z.number().nullable(), }), ), diff --git a/src/services/public-accuracy-trend.ts b/src/services/public-accuracy-trend.ts index 0ba7f5286..dd7eed665 100644 --- a/src/services/public-accuracy-trend.ts +++ b/src/services/public-accuracy-trend.ts @@ -18,9 +18,9 @@ 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; + merged: number | null; + closed: number | null; + reversed: number | null; accuracyPct: number | null; }; @@ -34,11 +34,11 @@ function roundPct(value: number): number { /** 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); +function publicBucketOf(bucket: { merged: number; closed: number; reversed: number }): Omit { + const decided = bucket.merged + bucket.closed; + if (decided < MIN_ACCURACY_TREND_SAMPLE) return { merged: null, closed: null, reversed: null, accuracyPct: null }; + const reversalRate = Math.min(1, bucket.reversed / decided); + return { merged: bucket.merged, closed: bucket.closed, reversed: bucket.reversed, accuracyPct: roundPct(1 - reversalRate) }; } /** Fold day-granularity rows into `weeks` trailing UTC-Monday buckets ending in the week containing `nowMs`. @@ -61,10 +61,7 @@ export function buildPublicAccuracyTrend(dayRows: DayRow[], nowMs: number, weeks 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), + ...publicBucketOf(bucket), })); } diff --git a/test/integration/public-stats-route.test.ts b/test/integration/public-stats-route.test.ts index 13ccfb40c..254f4b259 100644 --- a/test/integration/public-stats-route.test.ts +++ b/test/integration/public-stats-route.test.ts @@ -63,7 +63,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 }>; + accuracyTrend: Array<{ weekStart: string; merged: number | null; closed: number | null; reversed: number | null; 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 }>; }; diff --git a/test/unit/public-accuracy-trend.test.ts b/test/unit/public-accuracy-trend.test.ts index 7dc552c5a..7763f4632 100644 --- a/test/unit/public-accuracy-trend.test.ts +++ b/test/unit/public-accuracy-trend.test.ts @@ -45,22 +45,22 @@ describe("buildPublicAccuracyTrend", () => { 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 }); + const trend = buildPublicAccuracyTrend([{ day: tooOld, merged: 999, closed: 999, reversed: 999 }, { day: currentMonday, merged: MIN_ACCURACY_TREND_SAMPLE, closed: 0, reversed: 0 }], NOW, 2); + expect(trend[0]).toMatchObject({ merged: null, closed: null, reversed: null }); + expect(trend[1]).toMatchObject({ merged: MIN_ACCURACY_TREND_SAMPLE, 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); + const trend = buildPublicAccuracyTrend([{ day: "not-a-date", merged: 5, closed: 5, reversed: 5 }, { day: currentMonday, merged: MIN_ACCURACY_TREND_SAMPLE, closed: 0, reversed: 0 }], NOW, 1); expect(trend).toHaveLength(1); - expect(trend[0]).toMatchObject({ merged: 1, closed: 0, reversed: 0 }); + expect(trend[0]).toMatchObject({ merged: MIN_ACCURACY_TREND_SAMPLE, closed: 0, reversed: 0 }); }); - it("returns null accuracyPct (not a misleading 0% or 100%) below MIN_ACCURACY_TREND_SAMPLE decided PRs", () => { + it("REGRESSION: redacts counts and accuracyPct 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(); + expect(trend[0]).toMatchObject({ merged: null, closed: null, reversed: null, accuracyPct: null }); }); it("returns a real percentage at exactly MIN_ACCURACY_TREND_SAMPLE decided PRs", () => { @@ -83,7 +83,7 @@ describe("buildPublicAccuracyTrend", () => { 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 }); + for (const week of trend) expect(week).toMatchObject({ merged: null, closed: null, reversed: null, accuracyPct: null }); }); }); @@ -130,7 +130,7 @@ describe("loadPublicAccuracyTrend — end-to-end over the real live tables", () expect(currentWeek?.reversed).toBe(1); }); - it("still reports the Orb-fleet side when GITTENSORY_PUBLIC_STATS_REPOS is empty (no own-ledger allowlist)", async () => { + it("redacts a sparse Orb-fleet week 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`; @@ -141,8 +141,6 @@ describe("loadPublicAccuracyTrend — end-to-end over the real live tables", () 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); + expect(currentWeek).toMatchObject({ merged: null, closed: null, reversed: null, accuracyPct: null }); }); });