From db74746bae4dea6e2b17cae781b9fd1dc57443eb Mon Sep 17 00:00:00 2001 From: e11734937-beep Date: Thu, 9 Jul 2026 13:05:35 +0200 Subject: [PATCH] feat(notifications): render the maintainer recap digest body (#2240) --- src/services/maintainer-recap.ts | 53 ++++++++++++- test/unit/maintainer-recap-format.test.ts | 93 +++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 test/unit/maintainer-recap-format.test.ts diff --git a/src/services/maintainer-recap.ts b/src/services/maintainer-recap.ts index 9bcb1ee5bc..329d2304c3 100644 --- a/src/services/maintainer-recap.ts +++ b/src/services/maintainer-recap.ts @@ -10,7 +10,7 @@ // 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 { PUBLIC_LOCAL_PATH_SCRUB_PATTERN, PUBLIC_UNSAFE_PATTERN } from "../signals/redaction"; import type { GatePrecisionReport } from "./gate-precision"; import type { OutcomeCalibration } from "./outcome-calibration"; import type { MaintainerRecapRepo, RecapReport } from "../types"; @@ -99,3 +99,54 @@ export function buildMaintainerRecap(args: MaintainerRecapInputs): RecapReport { ].map(sanitizeRecapText); return { generatedAt: args.generatedAt, windowDays, repos, totals, summary }; } + +/** Redact one free-text line bound for the public digest body. Two arms mirroring weekly-value-report.ts's + * sanitizeReportText: scrub any absolute local path to ``, then blank the WHOLE line to + * `` if any economic/identity term (reward/score/wallet/payout/…) survives. Defense in depth — the + * builder already sanitizes free-text fields, but the formatter re-guards every emitted line so a hand-built + * or future report can never leak a private term into a digest. Capped at 240 chars like sanitizeRecapText. */ +function redactRecapLine(value: string): string { + const scrubbed = value.replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "").slice(0, 240); + return PUBLIC_UNSAFE_PATTERN.test(scrubbed) ? "" : scrubbed; +} + +/** Render a titled section's body: one `- ` bullet per redacted item, or a single italic fallback line when the + * section is empty so a header never dangles over a blank body. */ +function recapSectionLines(items: string[], fallback: string): string[] { + return items.length === 0 ? [fallback] : items.map((item) => `- ${redactRecapLine(item)}`); +} + +/** Render a {@link RecapReport} into a formatted maintainer-digest body: a header plus titled sections + * (Summary, Totals, Per-repo), mirroring formatWeeklyValueReportMarkdown at weekly-value-report.ts. PURE + * string function — no delivery, no I/O. Every free-text value is routed through {@link redactRecapLine} so no + * reward/trust/score/path term can leak into the digest even if the input report was hand-built. (#2240) */ +export function formatMaintainerRecap(report: RecapReport): string { + const { totals } = report; + const rate = totals.gateFalsePositiveRate !== null ? `${Math.round(totals.gateFalsePositiveRate * 100)}%` : "n/a"; + const perRepoLines = report.repos.map( + (repo) => + `${redactRecapLine(repo.repoFullName)} — ${repo.reviewed} reviewed, ${repo.merged} merged, ${repo.closed} closed, ${repo.gateFalsePositives} gate false-positive(s), ${repo.gateOverrides} override(s), ${repo.reversals} reversal(s)`, + ); + const lines = [ + "# Maintainer recap", + "", + `- Generated: ${redactRecapLine(report.generatedAt)}`, + `- Window: ${report.windowDays} day(s)`, + `- Repos: ${report.repos.length}`, + "", + "## Summary", + ...recapSectionLines(report.summary, "_No summary lines for this window._"), + "", + "## Totals", + `- Reviewed: ${totals.reviewed}`, + `- Merged: ${totals.merged}`, + `- Closed: ${totals.closed}`, + `- Gate false positives: ${totals.gateFalsePositives}/${totals.blocked} (${rate})`, + `- Overrides: ${totals.gateOverrides}`, + `- Reversals: ${totals.reversals}`, + "", + "## Per-repo", + ...recapSectionLines(perRepoLines, "_No repositories in this window._"), + ]; + return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; +} diff --git a/test/unit/maintainer-recap-format.test.ts b/test/unit/maintainer-recap-format.test.ts new file mode 100644 index 0000000000..289df589bf --- /dev/null +++ b/test/unit/maintainer-recap-format.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { formatMaintainerRecap } from "../../src/services/maintainer-recap"; +import type { RecapReport } from "../../src/types"; + +const GEN = "2026-07-08T00:00:00.000Z"; + +/** A zeroed report: no repos, no summary lines, null false-positive rate — the empty-window shape. */ +function emptyReport(): RecapReport { + return { + generatedAt: GEN, + windowDays: 7, + repos: [], + totals: { + reviewed: 0, + merged: 0, + closed: 0, + blocked: 0, + gateFalsePositives: 0, + gateOverrides: 0, + reversals: 0, + gateFalsePositiveRate: null, + }, + summary: [], + }; +} + +describe("formatMaintainerRecap (#2240)", () => { + it("renders the header and every titled section, with fallback lines and an n/a rate for an empty window", () => { + const body = formatMaintainerRecap(emptyReport()); + // Header + all three titled section headers render. + expect(body).toContain("# Maintainer recap"); + expect(body).toContain("## Summary"); + expect(body).toContain("## Totals"); + expect(body).toContain("## Per-repo"); + // Empty sections show a single fallback line instead of dangling under the header. + expect(body).toContain("_No summary lines for this window._"); + expect(body).toContain("_No repositories in this window._"); + // Null rate ⇒ the "n/a" arm. + expect(body).toContain("- Gate false positives: 0/0 (n/a)"); + expect(body).toContain("- Repos: 0"); + // Trailing single newline, no run of >2 blank lines. + expect(body.endsWith("\n")).toBe(true); + expect(body).not.toMatch(/\n{3,}/); + }); + + it("renders per-repo rows, a percent rate, and redacts both regex arms (path + economic term)", () => { + const report: RecapReport = { + generatedAt: GEN, + windowDays: 14, + repos: [ + { + repoFullName: "acme/widgets", + reviewed: 5, + merged: 3, + closed: 2, + gateFalsePositives: 1, + gateOverrides: 1, + reversals: 0, + }, + ], + totals: { + reviewed: 5, + merged: 3, + closed: 2, + blocked: 4, + gateFalsePositives: 1, + gateOverrides: 1, + reversals: 0, + gateFalsePositiveRate: 0.25, + }, + summary: [ + "Normal recap line about resolved reviews.", + "leaked path /root/secrets/config.json here", + "payout was 500 tao last window", + ], + }; + const body = formatMaintainerRecap(report); + + // Numeric / non-null rate arm. + expect(body).toContain("- Gate false positives: 1/4 (25%)"); + expect(body).toContain("- Repos: 1"); + // Per-repo row rendered (non-empty section arm). + expect(body).toContain("acme/widgets — 5 reviewed, 3 merged, 2 closed, 1 gate false-positive(s), 1 override(s), 0 reversal(s)"); + // Clean summary line survives verbatim (redaction no-op arm). + expect(body).toContain("- Normal recap line about resolved reviews."); + // Arm 1: local path scrubbed to the placeholder, raw path gone. + expect(body).toContain(""); + expect(body).not.toContain("/root/secrets/config.json"); + // Arm 2: an economic term blanks the whole line. + expect(body).toContain("- "); + expect(body).not.toContain("payout"); + }); +});