From 4f3ea9719b81a19a3a7bae244bcd564dde7f11f1 Mon Sep 17 00:00:00 2001 From: phamngocquy Date: Mon, 27 Jul 2026 23:56:32 +0800 Subject: [PATCH] fix(notifications): wire maintainer-recap's top-contributors section builder into the actual digest output Fixes #9291 --- src/review/maintainer-recap-wire.ts | 26 +++++++-- src/services/maintainer-recap.ts | 42 +++++++++++++- src/types.ts | 6 ++ test/unit/maintainer-recap-format.test.ts | 12 ++++ test/unit/maintainer-recap-wire.test.ts | 70 ++++++++++++++++++++++- test/unit/maintainer-recap.test.ts | 45 ++++++++++++++- test/unit/notify-discord.test.ts | 1 + 7 files changed, 192 insertions(+), 10 deletions(-) diff --git a/src/review/maintainer-recap-wire.ts b/src/review/maintainer-recap-wire.ts index 8eed37be1f..597949d397 100644 --- a/src/review/maintainer-recap-wire.ts +++ b/src/review/maintainer-recap-wire.ts @@ -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 @@ -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(); for (const repoFullName of repoNames) { try { const [gatePrecision, calibration] = await Promise.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). diff --git a/src/services/maintainer-recap.ts b/src/services/maintainer-recap.ts index 8da8c4f187..1ab681f87e 100644 --- a/src/services/maintainer-recap.ts +++ b/src/services/maintainer-recap.ts @@ -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; @@ -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, + 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): 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 { @@ -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 @@ -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", "", @@ -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 @@ -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 @@ -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). diff --git a/src/types.ts b/src/types.ts index 2288230873..ab49590c88 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = { @@ -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; diff --git a/test/unit/maintainer-recap-format.test.ts b/test/unit/maintainer-recap-format.test.ts index aba1edf41f..767056b90d 100644 --- a/test/unit/maintainer-recap-format.test.ts +++ b/test/unit/maintainer-recap-format.test.ts @@ -11,6 +11,7 @@ function emptyReport(): RecapReport { generatedAt: GEN, windowDays: 7, repos: [], + contributors: [], totals: { reviewed: 0, merged: 0, @@ -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"); @@ -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"); @@ -83,6 +87,10 @@ describe("formatMaintainerRecap (#2240)", () => { reversals: 0, }, ], + contributors: [ + { login: "bob", merged: 9 }, + { login: "alice", merged: 2 }, + ], totals: { reviewed: 5, merged: 3, @@ -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. diff --git a/test/unit/maintainer-recap-wire.test.ts b/test/unit/maintainer-recap-wire.test.ts index 856ba32872..f13ce95792 100644 --- a/test/unit/maintainer-recap-wire.test.ts +++ b/test/unit/maintainer-recap-wire.test.ts @@ -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"; @@ -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 { - 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 { + 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 @@ -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(); diff --git a/test/unit/maintainer-recap.test.ts b/test/unit/maintainer-recap.test.ts index ef64700003..d5da819ba9 100644 --- a/test/unit/maintainer-recap.test.ts +++ b/test/unit/maintainer-recap.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildMaintainerRecap, runMaintainerRecap, type MaintainerRecapRepoInput } from "../../src/services/maintainer-recap"; +import { buildMaintainerRecap, contributorTotalsToList, foldMergedPrContributorTotals, runMaintainerRecap, type MaintainerRecapRepoInput } from "../../src/services/maintainer-recap"; import { buildDriftRecapSection } from "../../src/services/maintainer-recap-drift"; import type { OutcomeCalibration } from "../../src/services/outcome-calibration"; import type { RecapReport } from "../../src/types"; @@ -61,12 +61,28 @@ describe("buildMaintainerRecap (#2239)", () => { const report = buildMaintainerRecap({ generatedAt: GEN, repos: [] }); expect(report.windowDays).toBe(7); expect(report.repos).toEqual([]); + expect(report.contributors).toEqual([]); expect(report.totals).toMatchObject({ reviewed: 0, merged: 0, closed: 0, blocked: 0, gateFalsePositives: 0, gateOverrides: 0, reversals: 0, gateFalsePositiveRate: null }); // blocked === 0 ⇒ rate is null ⇒ the "not enough blocked PRs" summary arm. expect(report.summary[1]).toContain("not enough blocked PRs"); expect(report.summary[0]).toContain("0 repo(s)"); }); + it("passes through injected contributor tallies (#9291)", () => { + const report = buildMaintainerRecap({ + generatedAt: GEN, + repos: [], + contributors: [ + { login: "alice", merged: 2 }, + { login: "bob", merged: 5 }, + ], + }); + expect(report.contributors).toEqual([ + { login: "alice", merged: 2 }, + { login: "bob", merged: 5 }, + ]); + }); + it("folds a single repo's counts and computes the gate false-positive rate", () => { const report = buildMaintainerRecap({ generatedAt: GEN, @@ -173,6 +189,29 @@ describe("buildMaintainerRecap cohort split (#4521)", () => { }); }); +describe("foldMergedPrContributorTotals (#9291)", () => { + const sinceMs = Date.parse("2026-07-02T00:00:00.000Z"); + + it("tallies by login, skips out-of-window and missing mergedAt rows, and folds unknown authors", () => { + const totals = new Map(); + foldMergedPrContributorTotals( + totals, + [ + { repoFullName: "owner/a", number: 1, title: "in", authorLogin: "alice", mergedAt: "2026-07-08T00:00:00.000Z", labels: [], linkedIssues: [], changedFiles: [], payload: {} }, + { repoFullName: "owner/a", number: 2, title: "in2", authorLogin: "alice", mergedAt: "2026-07-03T00:00:00.000Z", labels: [], linkedIssues: [], changedFiles: [], payload: {} }, + { repoFullName: "owner/a", number: 3, title: "old", authorLogin: "alice", mergedAt: "2026-06-01T00:00:00.000Z", labels: [], linkedIssues: [], changedFiles: [], payload: {} }, + { repoFullName: "owner/a", number: 4, title: "no-date", authorLogin: "bob", mergedAt: null, labels: [], linkedIssues: [], changedFiles: [], payload: {} }, + { repoFullName: "owner/b", number: 5, title: "unknown", authorLogin: null, mergedAt: "2026-07-09T00:00:00.000Z", labels: [], linkedIssues: [], changedFiles: [], payload: {} }, + ], + sinceMs, + ); + expect(contributorTotalsToList(totals).sort((a, b) => a.login.localeCompare(b.login))).toEqual([ + { login: "alice", merged: 2 }, + { login: "unknown", merged: 1 }, + ]); + }); +}); + function envWithBothWebhooks(): Env { return createTestEnv({ DISCORD_WEBHOOK_URL: DISCORD_HOOK, SLACK_WEBHOOK_URL: SLACK_HOOK }) as Env; } @@ -216,6 +255,7 @@ function leakyRecapReport(): RecapReport { gateFalsePositiveRate: null, }, summary: ["Clean recap line.", "payout was 500 tao last window", "path /root/secrets/config.json here"], + contributors: [], }; } @@ -232,6 +272,9 @@ describe("runMaintainerRecap (#2252 end-to-end orchestration)", () => { expect(result.report.repos).toEqual([]); // #8372: the ## Per-repo body is buildPerRepoRecapSection's, which carries its own empty-state line. expect(result.formatted).toContain("No repo activity in the last 7 day(s)."); + // #9291 REGRESSION: top-contributors section is always composed into the delivered digest. + expect(result.formatted).toContain("## Top contributors"); + expect(result.formatted).toContain("- No contributor activity in the last 7 day(s)."); expect(result.formatted).toContain("(n/a)"); }); diff --git a/test/unit/notify-discord.test.ts b/test/unit/notify-discord.test.ts index 2a89cc94f0..5eaae0044c 100644 --- a/test/unit/notify-discord.test.ts +++ b/test/unit/notify-discord.test.ts @@ -315,6 +315,7 @@ const SAMPLE_RECAP: RecapReport = { generatedAt: "2026-07-08T00:00:00.000Z", windowDays: 7, repos: [{ repoFullName: "acme/widgets", reviewed: 5, merged: 3, closed: 2, gateFalsePositives: 1, gateOverrides: 1, reversals: 0 }], + contributors: [], totals: { reviewed: 5, merged: 3, closed: 2, blocked: 4, gateFalsePositives: 1, gateOverrides: 1, reversals: 0, gateFalsePositiveRate: 0.25 }, summary: [ "Maintainer recap over the last 7 day(s): 1 repo(s), 5 reviewed, 3 merged, 2 closed.",