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
53 changes: 52 additions & 1 deletion src/services/maintainer-recap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 `<redacted-path>`, then blank the WHOLE line to
* `<redacted>` 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, "<redacted-path>").slice(0, 240);
return PUBLIC_UNSAFE_PATTERN.test(scrubbed) ? "<redacted>" : 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`;
}
93 changes: 93 additions & 0 deletions test/unit/maintainer-recap-format.test.ts
Original file line number Diff line number Diff line change
@@ -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("<redacted-path>");
expect(body).not.toContain("/root/secrets/config.json");
// Arm 2: an economic term blanks the whole line.
expect(body).toContain("- <redacted>");
expect(body).not.toContain("payout");
});
});