Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/services/maintainer-recap.ts
Original file line number Diff line number Diff line change
@@ -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, "<redacted-path>").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 };
}
39 changes: 39 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};
98 changes: 98 additions & 0 deletions test/unit/maintainer-recap.test.ts
Original file line number Diff line number Diff line change
@@ -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("<redacted-path>");
expect(report.repos[0]?.repoFullName).not.toContain("/Users/secret");
});
});