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
26 changes: 22 additions & 4 deletions src/review/maintainer-recap-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@
// single-repo ReviewRecap job, which is manually-triggerable only (review-recap.ts). Flag-gated and OFF by
// default, mirroring isOpsEnabled: flag-OFF, the cron enqueues no job and this module's exports are never
// invoked, so the deploy is byte-identical to today.
import { claimMaintainerRecapPeriod, listRepositories, recordAuditEvent } from "../db/repositories";
import { claimMaintainerRecapPeriod, listRecentMergedPullRequests, listRepositories, recordAuditEvent } from "../db/repositories";
import { isAgentConfigured } from "../settings/autonomy";
import { resolveRepositorySettings } from "../settings/repository-settings";
import { buildRepoOutcomeCalibration } from "../services/outcome-calibration";
import { runMaintainerRecap, type MaintainerRecapRepoInput, type RunMaintainerRecapResult } from "../services/maintainer-recap";
import {
contributorTotalsToList,
foldMergedPrContributorTotals,
runMaintainerRecap,
type MaintainerRecapRepoInput,
type RunMaintainerRecapResult,
} from "../services/maintainer-recap";
import { loadGatePrecisionReport } from "../services/gate-precision";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { resolveLoopOverSelfRepoFullName } from "../config/loopover-repo-focus-manifest";
import { errorMessage } from "../utils/json";
import { errorMessage, nowIso } from "../utils/json";

/** A manifest-sourced enable/cadence override (#2250) -- the `maintainerRecap` block of the loopover
* self-repo's `.loopover.yml` (see FocusManifestMaintainerRecapConfig). `present: false` (no block, or the
Expand Down Expand Up @@ -177,8 +183,11 @@ export async function runMaintainerRecapJob(
if (!claimed) return { skipped: true, reason: "already_sent_this_period" };

const resolvedWindowDays = windowDays ?? DEFAULT_RECAP_WINDOW_DAYS;
const generatedAt = nowIso();
const contributorSinceMs = Date.parse(generatedAt) - resolvedWindowDays * 24 * 60 * 60 * 1000;
const repoNames = await recapScanRepos(env);
const repos: MaintainerRecapRepoInput[] = [];
const contributorTotals = new Map<string, number>();
for (const repoFullName of repoNames) {
try {
const [gatePrecision, calibration] = await Promise.all([
Expand All @@ -188,13 +197,22 @@ export async function runMaintainerRecapJob(
buildRepoOutcomeCalibration(env, repoFullName, resolvedWindowDays),
]);
repos.push({ gatePrecision, calibration });
try {
const mergedPrs = await listRecentMergedPullRequests(env, repoFullName);
foldMergedPrContributorTotals(contributorTotals, mergedPrs, contributorSinceMs);
} catch (error) {
console.warn(
JSON.stringify({ event: "maintainer_recap_repo_error", repo: repoFullName, message: errorMessage(error).slice(0, 200) }),
);
}
} catch (error) {
console.warn(
JSON.stringify({ event: "maintainer_recap_repo_error", repo: repoFullName, message: errorMessage(error).slice(0, 200) }),
);
}
}
const result = await runMaintainerRecap(env, { windowDays: resolvedWindowDays, repos });
const contributors = contributorTotalsToList(contributorTotals);
const result = await runMaintainerRecap(env, { windowDays: resolvedWindowDays, generatedAt, repos, contributors });
// unreachable implicit-else: runMaintainerRecap only returns skipped:true when explicitly passed
// `enabled: false`, which this call site never does -- the enable/disable decision already happened
// before runMaintainerRecapJob was ever invoked (isRecapEnabled, checked by the cron and the processor).
Expand Down
42 changes: 40 additions & 2 deletions src/services/maintainer-recap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ import type { DriftRecapSection } from "./maintainer-recap-drift";
import { buildCalibrationRecapSection } from "./maintainer-recap-calibration";
import { buildGateOutcomesRecapSection } from "./maintainer-recap-gate-outcomes";
import { buildPerRepoRecapSection } from "./maintainer-recap-per-repo";
import { buildTopContributorsRecapSection } from "./maintainer-recap-top-contributors";
import { buildRoutingRecapSection } from "./maintainer-recap-routing";
import { REVIEWER_ROUTING_SHADOW_EVENT_TYPE, type RoutingShadowDecision } from "./reviewer-routing";
import type { OutcomeCalibration } from "./outcome-calibration";
import type { MaintainerRecapCohortCounts, MaintainerRecapRepo, RecapReport } from "../types";
import type { MaintainerRecapCohortCounts, MaintainerRecapContributor, MaintainerRecapRepo, RecentMergedPullRequestRecord, RecapReport } from "../types";
import { nowIso } from "../utils/json";

const DEFAULT_WINDOW_DAYS = 7;
Expand Down Expand Up @@ -51,8 +52,29 @@ export type MaintainerRecapInputs = {
generatedAt: string;
windowDays?: number | null | undefined;
repos: MaintainerRecapRepoInput[];
/** Window-filtered per-login merged-PR tally (#9291); defaults to empty when omitted. */
contributors?: MaintainerRecapContributor[] | undefined;
};

/** Fold window-filtered merged-PR rows into a per-login tally (#9291). Mirrors maintainer-quality-dashboard's
* contributorTotals Map pattern — group by `authorLogin ?? "unknown"`, sum counts. Pure. */
export function foldMergedPrContributorTotals(
totals: Map<string, number>,
mergedPrs: RecentMergedPullRequestRecord[],
sinceMs: number,
): void {
for (const pr of mergedPrs) {
if (!pr.mergedAt || Date.parse(pr.mergedAt) < sinceMs) continue;
const login = pr.authorLogin ?? "unknown";
totals.set(login, (totals.get(login) ?? 0) + 1);
}
}

/** Convert a contributor tally Map into the RecapReport list shape. Pure. */
export function contributorTotalsToList(totals: Map<string, number>): MaintainerRecapContributor[] {
return [...totals.entries()].map(([login, merged]) => ({ login, merged }));
}

/** #4521: convert one GatePrecisionCohortReport's `overall` bucket into the recap's own cohort-counts shape
* (renamed fields to match this file's gateFalsePositives/gateFalsePositiveRate convention). Pure. */
function toRecapCohortCounts(overall: { blocked: number; blockedThenMerged: number; falsePositiveRate: number | null }): MaintainerRecapCohortCounts {
Expand Down Expand Up @@ -146,7 +168,14 @@ export function buildMaintainerRecap(args: MaintainerRecapInputs): RecapReport {
rateLine,
`${totals.gateOverrides} maintainer override(s), ${totals.reversals} recommendation reversal(s).`,
].map(sanitizeRecapText);
return { generatedAt: args.generatedAt, windowDays, repos, totals: { ...totals, ...(cohorts ? { cohorts } : {}) }, summary };
return {
generatedAt: args.generatedAt,
windowDays,
repos,
contributors: args.contributors ?? [],
totals: { ...totals, ...(cohorts ? { cohorts } : {}) },
summary,
};
}

/** Redact one free-text line bound for the public digest body. Two arms mirroring weekly-value-report.ts's
Expand Down Expand Up @@ -178,6 +207,8 @@ export function formatMaintainerRecap(report: RecapReport, options: { configDrif
const perRepoLines = perRepoSection.lines.map(redactRecapLine);
const calibrationSection = buildCalibrationRecapSection({ windowDays: report.windowDays, totals: report.totals });
const gateOutcomesSection = buildGateOutcomesRecapSection({ windowDays: report.windowDays, totals: report.totals });
const topContributorsSection = buildTopContributorsRecapSection({ windowDays: report.windowDays, contributors: report.contributors });
const topContributorLines = topContributorsSection.lines.map(redactRecapLine);
const lines = [
"# Maintainer recap",
"",
Expand Down Expand Up @@ -206,6 +237,10 @@ export function formatMaintainerRecap(report: RecapReport, options: { configDrif
"",
`## ${redactRecapLine(gateOutcomesSection.title)}`,
...recapSectionLines(gateOutcomesSection.lines.map(redactRecapLine), "_No gate-outcome lines for this window._"),
"",
// #9291: unconditional — contributors are always present on RecapReport once the wire populates them.
`## ${redactRecapLine(topContributorsSection.title)}`,
...recapSectionLines(topContributorLines, "_No contributors for this window._"),
// #8214: optional config-drift section (maintainer-recap-drift.ts) — appended only when the caller has a
// sentinel projection to render, so every existing digest stays byte-identical until the sentinel wires in.
...(options.configDrift
Expand Down Expand Up @@ -270,6 +305,8 @@ export async function runMaintainerRecap(
repos?: MaintainerRecapRepoInput[];
/** Pre-built report for test injection; skips {@link buildMaintainerRecap} when set. */
report?: RecapReport;
/** Window-filtered per-login merged-PR tally (#9291); forwarded to {@link buildMaintainerRecap}. */
contributors?: MaintainerRecapContributor[] | undefined;
/** When explicitly false, short-circuits before build/format/delivery. Default: run. */
enabled?: boolean;
/** #8372: forwarded to {@link formatMaintainerRecap} so a caller holding a drift projection can have it
Expand All @@ -287,6 +324,7 @@ export async function runMaintainerRecap(
generatedAt: options.generatedAt ?? nowIso(),
windowDays: options.windowDays,
repos: options.repos ?? [],
contributors: options.contributors,
});
// #8229 stage 1: the routing-shadow section reads the window's recorded decisions straight from the
// audit trail — fail-safe to an absent section (the recap must never break on a read blip).
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2925,6 +2925,10 @@ export type MaintainerRecapCohortCounts = {
gateFalsePositiveRate: number | null;
};

/** One contributor's merged-PR count for a maintainer recap window (#9291). Public-safe by construction — login
* plus a count only, no score/reward internals. */
export type MaintainerRecapContributor = { login: string; merged: number };

/** One repo's realized review-outcome roll-up inside a maintainer recap window (#2239, foundation for #1963).
* Counts are ground-truth PR outcomes + gate/recommendation calibration totals — never predictions. */
export type MaintainerRecapRepo = {
Expand Down Expand Up @@ -2953,6 +2957,8 @@ export type RecapReport = {
generatedAt: string;
windowDays: number;
repos: MaintainerRecapRepo[];
/** Per-login merged-PR tally for the scan window (#9291), summed across every scanned repo. */
contributors: MaintainerRecapContributor[];
totals: {
reviewed: number;
merged: number;
Expand Down
12 changes: 12 additions & 0 deletions test/unit/maintainer-recap-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ function emptyReport(): RecapReport {
generatedAt: GEN,
windowDays: 7,
repos: [],
contributors: [],
totals: {
reviewed: 0,
merged: 0,
Expand All @@ -36,6 +37,8 @@ describe("formatMaintainerRecap (#2240)", () => {
// #8372: both builders read only totals/windowDays, so their sections are unconditional.
expect(body).toContain("## Calibration");
expect(body).toContain("## Gate outcomes");
// #9291: top-contributors section is unconditional once RecapReport carries contributors.
expect(body).toContain("## Top contributors");
// #8214: without a sentinel projection the drift section is entirely absent — the digest stays
// byte-identical to the pre-drift shape, not a dangling empty header.
expect(body).not.toContain("## Config drift");
Expand All @@ -45,6 +48,7 @@ describe("formatMaintainerRecap (#2240)", () => {
// windowed empty-state line, so the section is never empty and the generic fallback never fires.
expect(body).toContain("No repo activity in the last 7 day(s).");
expect(body).not.toContain("_No repositories in this window._");
expect(body).toContain("- No contributor activity in the last 7 day(s).");
// Null rate ⇒ the "n/a" arm.
expect(body).toContain("- Gate false positives: 0/0 (n/a)");
expect(body).toContain("- Repos: 0");
Expand Down Expand Up @@ -83,6 +87,10 @@ describe("formatMaintainerRecap (#2240)", () => {
reversals: 0,
},
],
contributors: [
{ login: "bob", merged: 9 },
{ login: "alice", merged: 2 },
],
totals: {
reviewed: 5,
merged: 3,
Expand All @@ -108,6 +116,10 @@ describe("formatMaintainerRecap (#2240)", () => {
// The gate/override/reversal counts this row used to carry are unchanged in ## Totals above, and are
// broken out per-dimension by the ## Gate outcomes section this digest now composes.
expect(body).toContain("acme/widgets: reviewed 5, merged 3, closed 2");
// #9291 REGRESSION: ranked merged-PR lines from buildTopContributorsRecapSection.
expect(body).toContain("## Top contributors");
expect(body).toContain("- bob: 9 merged");
expect(body).toContain("- alice: 2 merged");
// 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.
Expand Down
70 changes: 67 additions & 3 deletions test/unit/maintainer-recap-wire.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as repositories from "../../src/db/repositories";
import { isRecapEnabled, resolveMaintainerRecapManifestOverride, runMaintainerRecapJob, shouldFireMaintainerRecap } from "../../src/review/maintainer-recap-wire";
import type { MaintainerRecapJobSkipped } from "../../src/review/maintainer-recap-wire";
import type { RunMaintainerRecapResult } from "../../src/services/maintainer-recap";
import { recordGateBlockOutcome, updatePullRequestSlopAssessment, upsertPullRequestFromGitHub, upsertRepositorySettings } from "../../src/db/repositories";
import { recordGateBlockOutcome, updatePullRequestSlopAssessment, upsertPullRequestFromGitHub, upsertRecentMergedPullRequest, upsertRepositorySettings } from "../../src/db/repositories";
import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader";
import { createTestEnv } from "../helpers/d1";

Expand Down Expand Up @@ -41,9 +42,21 @@ async function seedRegisteredRepo(env: Env, fullName: string, installationId?: n
}

// A resolved, merged PR carrying a slop assessment so it counts in buildRepoOutcomeCalibration's slop bands.
async function seedMergedPr(env: Env, repoFullName: string, number: number): Promise<void> {
await upsertPullRequestFromGitHub(env, repoFullName, { number, title: `PR ${number}`, state: "closed", merged_at: "2026-06-01T00:00:00.000Z" });
async function seedMergedPr(env: Env, repoFullName: string, number: number, authorLogin = "contributor"): Promise<void> {
const mergedAt = "2026-07-09T12:00:00.000Z";
await upsertPullRequestFromGitHub(env, repoFullName, { number, title: `PR ${number}`, state: "closed", merged_at: mergedAt, user: { login: authorLogin } });
await updatePullRequestSlopAssessment(env, repoFullName, number, { slopRisk: 0, slopBand: "clean" });
await upsertRecentMergedPullRequest(env, {
repoFullName,
number,
title: `PR ${number}`,
authorLogin,
mergedAt,
labels: [],
linkedIssues: [],
changedFiles: [],
payload: {},
});
}

// Only RECORDS calls to the Discord webhook itself -- recapScanRepos's resolveRepositorySettings also fetches
Expand Down Expand Up @@ -308,6 +321,57 @@ describe("runMaintainerRecapJob — cross-repo digest (#1963, #2248)", () => {
expect(logged).toBeDefined();
});

it("#9291: aggregates merged-PR counts by login across repos and renders ## Top contributors in the digest", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-09T14:00:00.000Z"));
const env = createTestEnv({ DISCORD_WEBHOOK_URL: HOOK });
await seedRegisteredRepo(env, "owner/alpha");
await seedMergedPr(env, "owner/alpha", 1, "alice");
await seedMergedPr(env, "owner/alpha", 2, "alice");
await seedRegisteredRepo(env, "owner/beta");
await seedMergedPr(env, "owner/beta", 1, "bob");
await seedMergedPr(env, "owner/beta", 2, "alice");
stubDiscordFetch();

const { report, formatted } = ranRecap(await runMaintainerRecapJob(env));

expect(report.contributors.sort((a, b) => a.login.localeCompare(b.login))).toEqual([
{ login: "alice", merged: 3 },
{ login: "bob", merged: 1 },
]);
expect(formatted).toContain("## Top contributors");
expect(formatted).toContain("- alice: 3 merged");
expect(formatted).toContain("- bob: 1 merged");
vi.useRealTimers();
});

it("#9291: fails safe when listRecentMergedPullRequests errors for one repo — other repos still contribute", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-09T14:00:00.000Z"));
const env = createTestEnv({ DISCORD_WEBHOOK_URL: HOOK });
await seedRegisteredRepo(env, "owner/alpha");
await seedMergedPr(env, "owner/alpha", 1, "alice");
await seedRegisteredRepo(env, "owner/beta");
await seedMergedPr(env, "owner/beta", 1, "bob");
const realList = repositories.listRecentMergedPullRequests;
vi.spyOn(repositories, "listRecentMergedPullRequests").mockImplementation(async (listEnv, fullName) => {
if (fullName === "owner/alpha") throw new Error("merged-pr load failed");
return realList(listEnv, fullName);
});
const warnings = vi.spyOn(console, "warn").mockImplementation(() => {});
stubDiscordFetch();

const { report, formatted } = ranRecap(await runMaintainerRecapJob(env));

expect(report.repos.map((r) => r.repoFullName).sort()).toEqual(["owner/alpha", "owner/beta"]);
expect(report.contributors).toEqual([{ login: "bob", merged: 1 }]);
expect(formatted).toContain("## Top contributors");
expect(formatted).toContain("- bob: 1 merged");
const logged = warnings.mock.calls.map((c) => String(c[0])).find((line) => line.includes("maintainer_recap_repo_error") && line.includes("owner/alpha"));
expect(logged).toBeDefined();
vi.useRealTimers();
});

it("still delivers a zeroed report to Discord when there are no registered repos at all", async () => {
const env = createTestEnv({ DISCORD_WEBHOOK_URL: HOOK });
stubDiscordFetch();
Expand Down
Loading