From 0717dfe268f29069e471128b6acd3d15b53ecf9c Mon Sep 17 00:00:00 2001 From: real-venus Date: Wed, 8 Jul 2026 05:10:21 -0700 Subject: [PATCH] feat(notifications): add the pure maintainer-recap builder (#2239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the #1963 recap digest: a pure, injected-inputs builder that folds a window of gittensory's own review-outcome data across repos into a serializable RecapReport — per-repo reviewed/merged/closed counts plus top-line gate false- positive / override / recommendation-reversal totals and an aggregate false- positive rate. No delivery, no scheduling, no I/O, no model call — just the data- shaping seam, mirroring weekly-value-report.ts's buildWeeklyValueReport. It reuses the already-computed GatePrecisionReport (services/gate-precision.ts) and OutcomeCalibration (services/outcome-calibration.ts) aggregators the caller injects, so no new D1 queries are added. Distinct from services/review-recap.ts's buildReviewRecap, which is single-repo and sourced from gate merge-PREDICTION precision; this is multi-repo and sourced from the realized gate-block + recommendation-outcome calibration ledgers. Closes #2239 --- src/services/maintainer-recap.ts | 101 +++++++++++++++++++++++++++++ src/types.ts | 39 +++++++++++ test/unit/maintainer-recap.test.ts | 98 ++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 src/services/maintainer-recap.ts create mode 100644 test/unit/maintainer-recap.test.ts diff --git a/src/services/maintainer-recap.ts b/src/services/maintainer-recap.ts new file mode 100644 index 0000000000..9bcb1ee5bc --- /dev/null +++ b/src/services/maintainer-recap.ts @@ -0,0 +1,101 @@ +// Maintainer-recap BUILDER (#2239, foundation for the #1963 recap digest). +// +// A PURE data-shaping seam: fold a window of gittensory's own review-outcome data across repos into a single +// serializable RecapReport. No delivery, no scheduling, no I/O, no model call — exactly the shape +// weekly-value-report.ts's buildWeeklyValueReport uses (inputs injected, report returned). The caller supplies +// each repo's two already-computed aggregators (services/gate-precision.ts buildGatePrecisionReport + +// services/outcome-calibration.ts buildRepoOutcomeCalibration, the same pair src/review/ops-wire.ts already +// loads together) so NO new D1 queries are added here. +// +// Distinct from services/review-recap.ts's buildReviewRecap: that is SINGLE-repo and sourced from gate merge- +// PREDICTION precision; this is MULTI-repo and sourced from the realized gate-block + recommendation-outcome +// calibration ledgers (blocked-then-merged false positives, maintainer overrides, recommendation reversals). +import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN } from "../signals/redaction"; +import type { GatePrecisionReport } from "./gate-precision"; +import type { OutcomeCalibration } from "./outcome-calibration"; +import type { MaintainerRecapRepo, RecapReport } from "../types"; + +const DEFAULT_WINDOW_DAYS = 7; +const MIN_WINDOW_DAYS = 1; +const MAX_WINDOW_DAYS = 90; + +/** Clamp an arbitrary window-days input to a sane range; non-finite/omitted falls back to the weekly default. + * Mirrors review-recap.ts's normalizeWindowDays (same bounds). */ +function normalizeWindowDays(value: number | null | undefined): number { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return DEFAULT_WINDOW_DAYS; + return Math.max(MIN_WINDOW_DAYS, Math.min(MAX_WINDOW_DAYS, Math.round(numeric))); +} + +/** Public-safe scrub for any free text pulled into the recap (defense in depth — repo full names are the only + * free-text input today). Mirrors review-recap.ts's sanitizeRecapText. */ +function sanitizeRecapText(value: string): string { + return value.replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "").slice(0, 240); +} + +/** One repo's two already-computed aggregators. Both carry the SAME repoFullName; the gate report drives repo + * identity. Injected by the caller (no new D1 read here), exactly like buildWeeklyValueReport's inputs. */ +export type MaintainerRecapRepoInput = { gatePrecision: GatePrecisionReport; calibration: OutcomeCalibration }; + +export type MaintainerRecapInputs = { + generatedAt: string; + windowDays?: number | null | undefined; + repos: MaintainerRecapRepoInput[]; +}; + +/** PURE recap builder: fold each repo's gate-precision + outcome-calibration reports into a {@link RecapReport} + * with per-repo counts and top-line gate/reversal totals. Never throws; an empty repo list yields a zeroed + * report with a null false-positive rate (nothing blocked ⇒ nothing to divide by). */ +export function buildMaintainerRecap(args: MaintainerRecapInputs): RecapReport { + const windowDays = normalizeWindowDays(args.windowDays); + const repos: MaintainerRecapRepo[] = []; + const totals = { + reviewed: 0, + merged: 0, + closed: 0, + blocked: 0, + gateFalsePositives: 0, + gateOverrides: 0, + reversals: 0, + gateFalsePositiveRate: null as number | null, + }; + for (const { gatePrecision, calibration } of args.repos) { + let merged = 0; + let closed = 0; + for (const band of calibration.slop.bands) { + merged += band.merged; + closed += band.closed; + } + let gateOverrides = 0; + for (const perType of gatePrecision.perGateType) gateOverrides += perType.overridden; + const repo: MaintainerRecapRepo = { + repoFullName: sanitizeRecapText(gatePrecision.repoFullName), + reviewed: calibration.slop.totalResolved, + merged, + closed, + gateFalsePositives: gatePrecision.overall.blockedThenMerged, + gateOverrides, + reversals: calibration.recommendations.negative, + }; + repos.push(repo); + totals.reviewed += repo.reviewed; + totals.merged += repo.merged; + totals.closed += repo.closed; + totals.blocked += gatePrecision.overall.blocked; + totals.gateFalsePositives += repo.gateFalsePositives; + totals.gateOverrides += repo.gateOverrides; + totals.reversals += repo.reversals; + } + totals.gateFalsePositiveRate = + totals.blocked > 0 ? Math.round((totals.gateFalsePositives / totals.blocked) * 100) / 100 : null; + const rateLine = + totals.gateFalsePositiveRate !== null + ? `Gate false-positive rate: ${Math.round(totals.gateFalsePositiveRate * 100)}% (${totals.gateFalsePositives}/${totals.blocked} block(s) later merged).` + : `Gate false-positive rate: not enough blocked PRs in the window to report.`; + const summary = [ + `Maintainer recap over the last ${windowDays} day(s): ${repos.length} repo(s), ${totals.reviewed} reviewed, ${totals.merged} merged, ${totals.closed} closed.`, + rateLine, + `${totals.gateOverrides} maintainer override(s), ${totals.reversals} recommendation reversal(s).`, + ].map(sanitizeRecapText); + return { generatedAt: args.generatedAt, windowDays, repos, totals, summary }; +} diff --git a/src/types.ts b/src/types.ts index bf815d585b..e9f9c9eab7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2372,3 +2372,42 @@ export type ReviewRecap = { gateDecided: number; summary: string[]; }; + +/** One repo's realized review-outcome roll-up inside a maintainer recap window (#2239, foundation for #1963). + * Counts are ground-truth PR outcomes + gate/recommendation calibration totals — never predictions. */ +export type MaintainerRecapRepo = { + repoFullName: string; + /** PRs with a terminal outcome (merged or closed) over the window — the outcome-calibration sample size. */ + reviewed: number; + merged: number; + closed: number; + /** Gate blocks that later MERGED anyway over the window (a gate FALSE POSITIVE), from GatePrecisionReport. */ + gateFalsePositives: number; + /** Blocks a maintainer explicitly OVERRODE (the strongest false-positive signal), summed across gate types. */ + gateOverrides: number; + /** Recommendations that resolved NEGATIVELY (a reversal) over the window, from the outcome calibration. */ + reversals: number; +}; + +/** A serializable maintainer recap: a window of gittensory's OWN review-outcome data folded across repos. + * Foundation for the #1963 recap digest — the pure data-shaping seam only (no delivery, no scheduling). + * Distinct from {@link ReviewRecap} (single-repo, sourced from gate merge-precision predictions); this is + * multi-repo and sourced from the gate-precision + outcome-calibration aggregators. (#2239) */ +export type RecapReport = { + generatedAt: string; + windowDays: number; + repos: MaintainerRecapRepo[]; + totals: { + reviewed: number; + merged: number; + closed: number; + /** Total gate blocks over the window (the denominator of {@link gateFalsePositiveRate}). */ + blocked: number; + gateFalsePositives: number; + gateOverrides: number; + reversals: number; + /** Aggregate false-positive rate (gateFalsePositives / blocked), null when nothing was blocked. */ + gateFalsePositiveRate: number | null; + }; + summary: string[]; +}; diff --git a/test/unit/maintainer-recap.test.ts b/test/unit/maintainer-recap.test.ts new file mode 100644 index 0000000000..08057220e2 --- /dev/null +++ b/test/unit/maintainer-recap.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { buildMaintainerRecap, type MaintainerRecapRepoInput } from "../../src/services/maintainer-recap"; +import type { OutcomeCalibration } from "../../src/services/outcome-calibration"; + +const GEN = "2026-07-08T00:00:00.000Z"; + +/** Build one repo's injected inputs from the handful of counts this builder actually reads. */ +function repoInput( + repoFullName: string, + c: { + blocked?: number; + blockedThenMerged?: number; + overridden?: number; + totalResolved?: number; + merged?: number; + closed?: number; + reversals?: number; + emptyBands?: boolean; + } = {}, +): MaintainerRecapRepoInput { + const blocked = c.blocked ?? 0; + const blockedThenMerged = c.blockedThenMerged ?? 0; + const bands: OutcomeCalibration["slop"]["bands"] = c.emptyBands + ? [] + : [{ band: "clean", sampleSize: 0, merged: c.merged ?? 0, closed: c.closed ?? 0, mergeRate: 0 }]; + return { + gatePrecision: { + repoFullName, + generatedAt: GEN, + windowDays: 7, + perGateType: [{ gateType: "missing_linked_issue", blocked, blockedThenMerged, overridden: c.overridden ?? 0, falsePositiveRate: null }], + overall: { blocked, blockedThenMerged, falsePositiveRate: null }, + signals: [], + }, + calibration: { + repoFullName, + generatedAt: GEN, + windowDays: 7, + slop: { totalResolved: c.totalResolved ?? 0, bands, overallMergeRate: null, discriminates: null }, + recommendations: { total: 0, positive: 0, negative: c.reversals ?? 0, pending: 0, positiveRate: null }, + signals: [], + }, + }; +} + +describe("buildMaintainerRecap (#2239)", () => { + it("zeroes everything for an empty window and reports the null false-positive rate", () => { + // windowDays omitted ⇒ normalizeWindowDays' non-finite arm ⇒ the 7-day default. + const report = buildMaintainerRecap({ generatedAt: GEN, repos: [] }); + expect(report.windowDays).toBe(7); + expect(report.repos).toEqual([]); + expect(report.totals).toMatchObject({ reviewed: 0, merged: 0, closed: 0, blocked: 0, gateFalsePositives: 0, gateOverrides: 0, reversals: 0, gateFalsePositiveRate: null }); + // blocked === 0 ⇒ rate is null ⇒ the "not enough blocked PRs" summary arm. + expect(report.summary[1]).toContain("not enough blocked PRs"); + expect(report.summary[0]).toContain("0 repo(s)"); + }); + + it("folds a single repo's counts and computes the gate false-positive rate", () => { + const report = buildMaintainerRecap({ + generatedAt: GEN, + windowDays: 14, // provided ⇒ normalizeWindowDays' finite/clamp arm + repos: [repoInput("owner/repo-a", { blocked: 10, blockedThenMerged: 2, overridden: 3, totalResolved: 8, merged: 6, closed: 2, reversals: 1 })], + }); + expect(report.windowDays).toBe(14); + expect(report.repos).toHaveLength(1); + expect(report.repos[0]).toMatchObject({ repoFullName: "owner/repo-a", reviewed: 8, merged: 6, closed: 2, gateFalsePositives: 2, gateOverrides: 3, reversals: 1 }); + expect(report.totals).toMatchObject({ reviewed: 8, merged: 6, closed: 2, blocked: 10, gateFalsePositives: 2, gateOverrides: 3, reversals: 1, gateFalsePositiveRate: 0.2 }); + // blocked > 0 ⇒ the populated summary arm with the percentage. + expect(report.summary[1]).toContain("Gate false-positive rate: 20%"); + expect(report.summary[1]).toContain("(2/10 block(s) later merged)"); + expect(report.summary[2]).toContain("3 maintainer override(s), 1 recommendation reversal(s)"); + }); + + it("aggregates across multiple repos (including one with no slop bands)", () => { + const report = buildMaintainerRecap({ + generatedAt: GEN, + windowDays: 30, + repos: [ + repoInput("owner/repo-a", { blocked: 4, blockedThenMerged: 1, overridden: 1, totalResolved: 5, merged: 4, closed: 1, reversals: 2 }), + repoInput("owner/repo-b", { blocked: 6, blockedThenMerged: 3, overridden: 2, totalResolved: 0, reversals: 1, emptyBands: true }), + ], + }); + expect(report.repos).toHaveLength(2); + expect(report.repos[1]).toMatchObject({ repoFullName: "owner/repo-b", reviewed: 0, merged: 0, closed: 0, gateFalsePositives: 3, gateOverrides: 2, reversals: 1 }); + expect(report.totals).toMatchObject({ reviewed: 5, merged: 4, closed: 1, blocked: 10, gateFalsePositives: 4, gateOverrides: 3, reversals: 3, gateFalsePositiveRate: 0.4 }); + }); + + it("clamps an out-of-range window to the max and a zero to the min", () => { + expect(buildMaintainerRecap({ generatedAt: GEN, windowDays: 999, repos: [] }).windowDays).toBe(90); + expect(buildMaintainerRecap({ generatedAt: GEN, windowDays: 0, repos: [] }).windowDays).toBe(1); + }); + + it("scrubs a local-path leak out of the repo name (public-safe by construction)", () => { + const report = buildMaintainerRecap({ generatedAt: GEN, repos: [repoInput("/Users/secret/repo", { blocked: 1, blockedThenMerged: 0 })] }); + expect(report.repos[0]?.repoFullName).toContain(""); + expect(report.repos[0]?.repoFullName).not.toContain("/Users/secret"); + }); +});