diff --git a/src/services/maintainer-recap-top-contributors.ts b/src/services/maintainer-recap-top-contributors.ts new file mode 100644 index 0000000000..fb78d2c829 --- /dev/null +++ b/src/services/maintainer-recap-top-contributors.ts @@ -0,0 +1,72 @@ +// Maintainer-recap TOP-CONTRIBUTORS section (#2244, content slice of the #1963 recap digest). +// +// Pure section builder over a RecapReport projection: a leaderboard of the window's most-merged +// contributor logins with merged-PR counts ONLY — NO scoring / reward / trust internals. Every emitted +// line is gated through isPublicSafeText (src/signals/redaction.ts) before it can surface, matching the +// public-safe framing the notifications service already enforces (src/notifications/service.ts). +// +// Own file (mirroring maintainer-recap-calibration.ts) so it stays decoupled from the foundation builder +// and sibling sections — zero shared-file conflict surface. No delivery, no scheduling. +import { isPublicSafeText } from "../signals/redaction"; + +// Readability cap when the caller does not specify one — mirrors alerts.ts MAX_LISTED. +const DEFAULT_LIMIT = 8; + +/** One contributor's window activity — merged-PR count only (public-safe by construction). */ +export type TopContributor = { login: string; merged: number }; + +/** Projection of RecapReport used by the top-contributors section (window + contributors only). */ +export type TopContributorsRecapSource = { + windowDays: number; + contributors: TopContributor[]; +}; + +/** One titled digest section: structured rows for consumers + ready-to-emit lines for the formatter. */ +export type TopContributorsRecapSection = { + title: string; + /** Public-safe contributors, sorted by merged desc (ties by login asc), capped at the limit. */ + rows: TopContributor[]; + /** Contributors dropped because their emitted line failed the public-safe gate. */ + dropped: number; + lines: string[]; +}; + +/** + * Pure top-contributors section over a RecapReport projection. + * + * - Each contributor's emitted line (`login: N merged`) must pass {@link isPublicSafeText}; any that would + * leak a reward/score/trust term (or a local path) is DROPPED and counted in `dropped`. + * - Survivors are sorted by merged descending, ties broken by login ascending (deterministic), then capped + * at `limit` (a non-positive limit yields an empty leaderboard). + */ +export function buildTopContributorsRecapSection( + report: TopContributorsRecapSource, + limit = DEFAULT_LIMIT, +): TopContributorsRecapSection { + const withLines = report.contributors.map((c) => ({ + login: c.login, + merged: c.merged, + line: `${c.login}: ${c.merged} merged`, + })); + // Reject any line that fails the public-safe gate (defense in depth — a login must never carry an + // economic/identity term or a local path onto a public digest surface). + const safe = withLines.filter((c) => isPublicSafeText(c.line)); + const dropped = withLines.length - safe.length; + + const ranked = safe + .sort((a, b) => b.merged - a.merged || a.login.localeCompare(b.login)) + .slice(0, Math.max(0, limit)); + + const title = "Top contributors"; + const lines = + ranked.length === 0 + ? [`No contributor activity in the last ${report.windowDays} day(s).`] + : ranked.map((c) => c.line); + + return { + title, + rows: ranked.map(({ login, merged }) => ({ login, merged })), + dropped, + lines, + }; +} diff --git a/test/unit/maintainer-recap-top-contributors.test.ts b/test/unit/maintainer-recap-top-contributors.test.ts new file mode 100644 index 0000000000..eaca081bd7 --- /dev/null +++ b/test/unit/maintainer-recap-top-contributors.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + buildTopContributorsRecapSection, + type TopContributorsRecapSource, +} from "../../src/services/maintainer-recap-top-contributors"; + +const WINDOW = 7; + +function source( + contributors: TopContributorsRecapSource["contributors"], + windowDays = WINDOW, +): TopContributorsRecapSource { + return { windowDays, contributors }; +} + +describe("buildTopContributorsRecapSection (#2244)", () => { + it("sorts by merged descending and emits count-only lines", () => { + const section = buildTopContributorsRecapSection( + source([ + { login: "alice", merged: 2 }, + { login: "bob", merged: 9 }, + { login: "carol", merged: 5 }, + ]), + ); + expect(section.title).toBe("Top contributors"); + expect(section.rows.map((r) => r.login)).toEqual(["bob", "carol", "alice"]); + expect(section.dropped).toBe(0); + expect(section.lines).toEqual(["bob: 9 merged", "carol: 5 merged", "alice: 2 merged"]); + }); + + it("breaks a merged-count tie by login ascending (deterministic — localeCompare arm)", () => { + const section = buildTopContributorsRecapSection( + source([ + { login: "zoe", merged: 4 }, + { login: "amy", merged: 4 }, + { login: "max", merged: 4 }, + ]), + ); + expect(section.rows.map((r) => r.login)).toEqual(["amy", "max", "zoe"]); + }); + + it("caps the leaderboard at the given limit", () => { + const many = Array.from({ length: 10 }, (_, i) => ({ login: `u${i}`, merged: 100 - i })); + const section = buildTopContributorsRecapSection(source(many), 3); + expect(section.rows).toHaveLength(3); + expect(section.rows.map((r) => r.login)).toEqual(["u0", "u1", "u2"]); + expect(section.lines).toHaveLength(3); + }); + + it("keeps public-safe logins and DROPS ones whose line carries a reward/score term (both gate arms)", () => { + const section = buildTopContributorsRecapSection( + source([ + { login: "honest-dev", merged: 7 }, + { login: "reward-farm", merged: 99 }, // "reward" ⇒ isPublicSafeText false ⇒ dropped + { login: "score-bot", merged: 50 }, // "score" ⇒ dropped + ]), + ); + expect(section.dropped).toBe(2); + expect(section.rows.map((r) => r.login)).toEqual(["honest-dev"]); + for (const line of section.lines) { + expect(line).not.toMatch(/reward|score/i); + } + }); + + it("emits a no-activity line when nothing survives (empty input, all-dropped, and non-positive limit arms)", () => { + expect(buildTopContributorsRecapSection(source([])).lines).toEqual([ + "No contributor activity in the last 7 day(s).", + ]); + // all contributors dropped by the public-safe gate ⇒ still the empty arm + const allUnsafe = buildTopContributorsRecapSection(source([{ login: "payout-king", merged: 3 }])); + expect(allUnsafe.rows).toEqual([]); + expect(allUnsafe.dropped).toBe(1); + expect(allUnsafe.lines).toEqual(["No contributor activity in the last 7 day(s)."]); + // a non-positive limit yields an empty leaderboard (Math.max(0, limit) arm) + const zeroLimit = buildTopContributorsRecapSection(source([{ login: "alice", merged: 1 }]), 0); + expect(zeroLimit.rows).toEqual([]); + expect(zeroLimit.lines).toEqual(["No contributor activity in the last 7 day(s)."]); + }); + + it("defaults the limit to 8 when the caller omits it", () => { + const nine = Array.from({ length: 9 }, (_, i) => ({ login: `c${i}`, merged: 20 - i })); + const section = buildTopContributorsRecapSection(source(nine)); + expect(section.rows).toHaveLength(8); + }); +});