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
96 changes: 96 additions & 0 deletions src/services/maintainer-recap-per-repo.ts
Original file line number Diff line number Diff line change
@@ -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, "<redacted-path>").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),
};
}
91 changes: 91 additions & 0 deletions test/unit/maintainer-recap-per-repo.test.ts
Original file line number Diff line number Diff line change
@@ -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("<redacted-path>");
expect(section.lines[0]).toBe("<redacted-path>: reviewed 3, merged 2, closed 1");
for (const line of section.lines) {
expect(line).not.toMatch(/\/tmp\//);
}
});
});