diff --git a/src/services/maintainer-recap-per-repo.ts b/src/services/maintainer-recap-per-repo.ts new file mode 100644 index 0000000000..793ae11d7a --- /dev/null +++ b/src/services/maintainer-recap-per-repo.ts @@ -0,0 +1,96 @@ +// Maintainer-recap PER-REPO section (#2241, content slice of the #1963 recap digest). +// +// Pure section builder over a RecapReport projection: a compact per-repo breakdown of PRs +// reviewed / merged / closed in the window, sorted by volume (reviewed = the terminal-outcome +// sample size) and capped like the alerts embed (MAX_LISTED = 8 at src/review/alerts.ts:150), +// with a "(+N more)" remainder line mirroring listSuffix at src/review/alerts.ts:158. No delivery, +// no scheduling — just one titled section for the formatter. +// +// Compatible with the full RecapReport (#2239 / maintainer-recap.ts): this file only needs the +// window + `repos` projection (repoFullName + reviewed/merged/closed), so it stays decoupled from +// the foundation builder and from sibling sections (own file → zero shared-file conflict surface). +import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN } from "../signals/redaction"; + +// Mirror alerts.ts:150 — keep the digest readable; the remainder is noted, not dropped silently. +const MAX_LISTED = 8; + +/** One repo's window activity — structurally compatible with RecapReport's MaintainerRecapRepo. */ +export type PerRepoRecapInput = { + repoFullName: string; + /** PRs with a terminal outcome (merged or closed) over the window — the volume/sort key. */ + reviewed: number; + merged: number; + closed: number; +}; + +/** Projection of RecapReport used by the per-repo section (window + repos only). */ +export type PerRepoRecapSource = { + windowDays: number; + repos: PerRepoRecapInput[]; +}; + +/** One rendered row: the redacted repo label + its window counts. */ +export type PerRepoRecapRow = { + repo: string; + reviewed: number; + merged: number; + closed: number; +}; + +/** One titled digest section: structured rows for consumers + ready-to-emit lines for the formatter. */ +export type PerRepoRecapSection = { + title: string; + /** Active repos, sorted by volume and capped at MAX_LISTED. */ + rows: PerRepoRecapRow[]; + /** Active repos beyond the cap (drives the "(+N more)" line); 0 when nothing was truncated. */ + remainder: number; + lines: string[]; +}; + +/** Public-safe scrub for a repo label pulled into the section (defense in depth — repo full names are + * public, but a mis-shaped label must never leak a local path). Mirrors maintainer-recap-calibration.ts. */ +function sanitizeRecapText(value: string): string { + return value.replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "").slice(0, 240); +} + +/** + * Pure per-repo section over a RecapReport projection. + * + * - Zero-activity repos (`reviewed === 0`) are excluded — they contribute no outcome sample. + * - Sort is by `reviewed` descending (volume), tie-broken by repo label ascending for determinism. + * - The list is capped at {@link MAX_LISTED}; any surplus is reported via `remainder` + a "(+N more)" line. + */ +export function buildPerRepoRecapSection(report: PerRepoRecapSource): PerRepoRecapSection { + const active = report.repos + .filter((repo) => repo.reviewed > 0) + // Volume-first, then label — the `|| localeCompare` arm keeps ties deterministic across runs. + .sort((a, b) => b.reviewed - a.reviewed || a.repoFullName.localeCompare(b.repoFullName)); + + const shown = active.slice(0, MAX_LISTED); + const remainder = active.length - shown.length; + + const rows: PerRepoRecapRow[] = shown.map((repo) => ({ + repo: sanitizeRecapText(repo.repoFullName), + reviewed: repo.reviewed, + merged: repo.merged, + closed: repo.closed, + })); + + const title = "Per-repo"; + const lines = + rows.length === 0 + ? [`No repo activity in the last ${report.windowDays} day(s).`] + : [ + ...rows.map( + (row) => `${row.repo}: reviewed ${row.reviewed}, merged ${row.merged}, closed ${row.closed}`, + ), + ...(remainder > 0 ? [`(+${remainder} more)`] : []), + ]; + + return { + title, + rows, + remainder, + lines: lines.map(sanitizeRecapText), + }; +} diff --git a/test/unit/maintainer-recap-per-repo.test.ts b/test/unit/maintainer-recap-per-repo.test.ts new file mode 100644 index 0000000000..cb5c7fb986 --- /dev/null +++ b/test/unit/maintainer-recap-per-repo.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + buildPerRepoRecapSection, + type PerRepoRecapInput, + type PerRepoRecapSource, +} from "../../src/services/maintainer-recap-per-repo"; + +const WINDOW = 7; + +function repo(repoFullName: string, reviewed: number, merged = 0, closed = 0): PerRepoRecapInput { + return { repoFullName, reviewed, merged, closed }; +} + +function source(repos: PerRepoRecapInput[], windowDays = WINDOW): PerRepoRecapSource { + return { windowDays, repos }; +} + +describe("buildPerRepoRecapSection (#2241)", () => { + it("sorts active repos by volume (reviewed) descending and excludes zero-activity repos", () => { + const section = buildPerRepoRecapSection( + source([ + repo("acme/small", 2, 1, 1), + repo("acme/idle", 0, 0, 0), // zero-activity — must be excluded + repo("acme/big", 10, 7, 3), + repo("acme/mid", 5, 4, 1), + ]), + ); + + expect(section.title).toBe("Per-repo"); + expect(section.rows.map((r) => r.repo)).toEqual(["acme/big", "acme/mid", "acme/small"]); + expect(section.rows.map((r) => r.reviewed)).toEqual([10, 5, 2]); + expect(section.remainder).toBe(0); + expect(section.lines).toEqual([ + "acme/big: reviewed 10, merged 7, closed 3", + "acme/mid: reviewed 5, merged 4, closed 1", + "acme/small: reviewed 2, merged 1, closed 1", + ]); + // No "(+N more)" line when nothing is truncated (remainder === 0 arm). + expect(section.lines.some((l) => /\+\d+ more/.test(l))).toBe(false); + }); + + it("breaks a volume tie by repo label ascending (deterministic — the localeCompare arm)", () => { + // Equal `reviewed` forces the `|| a.repoFullName.localeCompare(b.repoFullName)` branch. + const section = buildPerRepoRecapSection( + source([repo("zeta/repo", 4), repo("alpha/repo", 4), repo("mid/repo", 4)]), + ); + expect(section.rows.map((r) => r.repo)).toEqual(["alpha/repo", "mid/repo", "zeta/repo"]); + }); + + it("caps the list at 8 and reports the surplus via remainder + a (+N more) line", () => { + // 10 active repos with distinct volumes ⇒ top 8 shown, remainder 2 (the remainder > 0 arm). + const many = Array.from({ length: 10 }, (_, i) => repo(`acme/r${i}`, 100 - i, 1, 0)); + const section = buildPerRepoRecapSection(source(many)); + + expect(section.rows).toHaveLength(8); + expect(section.rows[0]?.repo).toBe("acme/r0"); // reviewed 100 — highest volume first + expect(section.remainder).toBe(2); + expect(section.lines).toHaveLength(9); // 8 rows + the remainder line + expect(section.lines[8]).toBe("(+2 more)"); + }); + + it("shows exactly 8 with no remainder line at the cap boundary (remainder === 0 boundary)", () => { + const eight = Array.from({ length: 8 }, (_, i) => repo(`acme/r${i}`, 50 - i, 1, 0)); + const section = buildPerRepoRecapSection(source(eight)); + expect(section.rows).toHaveLength(8); + expect(section.remainder).toBe(0); + expect(section.lines.some((l) => /more/.test(l))).toBe(false); + }); + + it("emits a no-activity line when every repo is zero-activity (empty rows arm)", () => { + const section = buildPerRepoRecapSection(source([repo("acme/idle", 0), repo("acme/quiet", 0)])); + expect(section.rows).toEqual([]); + expect(section.remainder).toBe(0); + expect(section.lines).toEqual(["No repo activity in the last 7 day(s)."]); + }); + + it("emits a no-activity line for an empty repos list too (echoes the configured window)", () => { + const section = buildPerRepoRecapSection(source([], 30)); + expect(section.lines).toEqual(["No repo activity in the last 30 day(s)."]); + }); + + it("redacts a local-path leak in a repo label before emitting (defense-in-depth)", () => { + // A mis-shaped label that embeds an absolute local path must be scrubbed via PUBLIC_LOCAL_PATH_SCRUB_PATTERN. + const section = buildPerRepoRecapSection(source([repo("/tmp/evil-checkout/gittensory", 3, 2, 1)])); + expect(section.rows[0]?.repo).toBe(""); + expect(section.lines[0]).toBe(": reviewed 3, merged 2, closed 1"); + for (const line of section.lines) { + expect(line).not.toMatch(/\/tmp\//); + } + }); +});