Skip to content

Commit 46a19f8

Browse files
feat(notifications): deliver the maintainer recap digest to Discord (#2245) (#4317)
Add deliverRecapToDiscord(env, report): post a multi-repo maintainer RecapReport to the operator's global DISCORD_WEBHOOK_URL as an embed, reusing notify-discord.ts's isValidDiscordWebhook validation + best-effort postWebhook send pattern. A maintainer recap is ONE operator-level digest spanning many repos (report.repos), so — unlike the per-repo ReviewRecap sender sendReviewRecapToDiscord / notifyActionToDiscord, which route per-repo via resolveDiscordWebhook — there is no single repo to route by and it posts to the flat global webhook. Best-effort and observable, mirroring sendReviewRecapToDiscord: an unset/invalid webhook or a send failure is recorded to the audit ledger (maintainer_recap_notification.discord) and returned as { sent, reason } but never thrown, so a Discord outage never breaks the recap job. The RecapReport is already public-safe (the builder sanitizes every free-text field). Closes #2245
1 parent 90da00d commit 46a19f8

2 files changed

Lines changed: 118 additions & 1 deletion

File tree

src/services/notify-discord.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { recordAuditEvent } from "../db/repositories";
22
import { errorMessage } from "../utils/json";
3+
import type { RecapReport } from "../types";
34

45
// Per-repo Discord notifications (reviewbot parity). Each repo notifies its OWN channel on a terminal action —
56
// merged / closed / changes-requested(manual) — so the operator sees what the bot did, like the old Reviewbott
@@ -149,6 +150,56 @@ export async function notifyActionToDiscord(
149150
}
150151
}
151152

153+
/**
154+
* Deliver a maintainer recap digest (#2245, the Discord channel of #1963) as an embed. Unlike the per-repo
155+
* `ReviewRecap` sender {@link sendReviewRecapToDiscord} (review-recap.ts) — which resolves a per-repo channel via
156+
* {@link resolveDiscordWebhook}, exactly like {@link notifyActionToDiscord} — a maintainer `RecapReport` is ONE
157+
* operator-level digest spanning many repos (`report.repos`), so there is no single repo to route by: it posts to
158+
* the flat global `DISCORD_WEBHOOK_URL`. Best-effort and observable, mirroring `sendReviewRecapToDiscord`: an
159+
* unset/invalid webhook or a send failure is recorded to the audit ledger (`maintainer_recap_notification.discord`)
160+
* and returned as `{ sent: false, reason }` but never thrown, so a Discord outage never breaks the recap job. The
161+
* `RecapReport` is already public-safe (buildMaintainerRecap sanitizes every free-text field), so no re-scrub here.
162+
*/
163+
export async function deliverRecapToDiscord(env: Env, report: RecapReport): Promise<{ sent: boolean; reason?: string }> {
164+
const targetKey = `maintainer-recap:${report.windowDays}d`;
165+
const auditMeta = { windowDays: report.windowDays, repoCount: report.repos.length };
166+
const url = envString(env, "DISCORD_WEBHOOK_URL");
167+
if (!url || !isValidDiscordWebhook(url)) {
168+
const reason = url ? "invalid_global_webhook" : "missing_global_webhook";
169+
await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "denied", detail: reason, metadata: auditMeta });
170+
return { sent: false, reason };
171+
}
172+
const body = {
173+
username: "Gittensory",
174+
embeds: [
175+
{
176+
title: `Maintainer recap · ${report.repos.length} repo(s) · ${report.windowDays}d`,
177+
description: report.summary.join("\n").slice(0, 1800),
178+
color: 0x0969da,
179+
fields: [
180+
{ name: "Reviewed", value: `${report.totals.reviewed}`, inline: true },
181+
{ name: "Merged", value: `${report.totals.merged}`, inline: true },
182+
{ name: "Closed", value: `${report.totals.closed}`, inline: true },
183+
{ name: "Gate false positives", value: `${report.totals.gateFalsePositives}/${report.totals.blocked}`, inline: true },
184+
{ name: "Overrides", value: `${report.totals.gateOverrides}`, inline: true },
185+
{ name: "Reversals", value: `${report.totals.reversals}`, inline: true },
186+
],
187+
footer: { text: `Gittensory · generated ${report.generatedAt}` },
188+
},
189+
],
190+
};
191+
try {
192+
await postWebhook(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) }, "discord");
193+
await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "completed", detail: "sent", metadata: auditMeta });
194+
return { sent: true };
195+
} catch (error) {
196+
const detail = errorMessage(error).slice(0, 160);
197+
console.warn(JSON.stringify({ event: "maintainer_recap_discord_failed", message: detail }));
198+
await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "error", detail, metadata: auditMeta });
199+
return { sent: false, reason: detail };
200+
}
201+
}
202+
152203
/** Slack incoming-webhook URL validation — only `https://hooks.slack.com/services/…`. Exported so other
153204
* Slack senders (e.g. the recap digest's {@link deliverRecapToSlack}, review-recap.ts) reuse the SAME
154205
* validation instead of re-typing the host/path allowlist. */

test/unit/notify-discord.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
2-
import { notifyActionToDiscord, notifyActionToSlack, resolveDiscordWebhook } from "../../src/services/notify-discord";
2+
import { deliverRecapToDiscord, notifyActionToDiscord, notifyActionToSlack, resolveDiscordWebhook } from "../../src/services/notify-discord";
33
import { createTestEnv } from "../helpers/d1";
4+
import type { RecapReport } from "../../src/types";
45

56
const HOOK = "https://discord.com/api/webhooks/123/abc";
67
const FALLBACK = "https://discord.com/api/webhooks/999/zzz";
@@ -238,3 +239,68 @@ describe("notifyActionToSlack (#11 — modular self-host Slack channel)", () =>
238239
expect(await externalNotificationAudit(env, "slack")).toEqual([expect.objectContaining({ outcome: "error", detail: "slack_webhook_http_403" })]);
239240
});
240241
});
242+
243+
const SAMPLE_RECAP: RecapReport = {
244+
generatedAt: "2026-07-08T00:00:00.000Z",
245+
windowDays: 7,
246+
repos: [{ repoFullName: "acme/widgets", reviewed: 5, merged: 3, closed: 2, gateFalsePositives: 1, gateOverrides: 1, reversals: 0 }],
247+
totals: { reviewed: 5, merged: 3, closed: 2, blocked: 4, gateFalsePositives: 1, gateOverrides: 1, reversals: 0, gateFalsePositiveRate: 0.25 },
248+
summary: [
249+
"Maintainer recap over the last 7 day(s): 1 repo(s), 5 reviewed, 3 merged, 2 closed.",
250+
"Gate false-positive rate: 25% (1/4 block(s) later merged).",
251+
"1 maintainer override(s), 0 recommendation reversal(s).",
252+
],
253+
};
254+
255+
async function recapAudit(env: Env): Promise<Array<{ outcome: string; detail: string }>> {
256+
const rows = await env.DB.prepare("select outcome, detail from audit_events where event_type = ? order by created_at").bind("maintainer_recap_notification.discord").all<{ outcome: string; detail: string }>();
257+
return rows.results ?? [];
258+
}
259+
260+
describe("deliverRecapToDiscord (#2245 maintainer recap → Discord)", () => {
261+
it("posts the recap as an embed to the global DISCORD_WEBHOOK_URL and records a completed audit when configured", async () => {
262+
let posted: { url: string; body: string } | null = null;
263+
vi.stubGlobal("fetch", async (url: RequestInfo | URL, init?: RequestInit) => {
264+
posted = { url: String(url), body: init?.body ? String(init.body) : "" };
265+
return new Response(null, { status: 204 });
266+
});
267+
const env = withEnv({ DISCORD_WEBHOOK_URL: HOOK });
268+
expect(await deliverRecapToDiscord(env, SAMPLE_RECAP)).toEqual({ sent: true });
269+
expect(posted).not.toBeNull();
270+
expect(posted!.url).toBe(HOOK);
271+
const parsed = JSON.parse(posted!.body) as { embeds: { title: string; description: string; fields: { name: string; value: string }[] }[] };
272+
const embed = parsed.embeds[0]!;
273+
expect(embed.title).toContain("Maintainer recap");
274+
expect(embed.description).toContain("Gate false-positive rate");
275+
expect(embed.fields.map((f) => f.name)).toContain("Reversals");
276+
// public-safe: the digest must never leak an economic/identity term
277+
expect(posted!.body.toLowerCase()).not.toMatch(/reward|wallet|hotkey|coldkey|trustscore/);
278+
expect(await recapAudit(env)).toEqual([expect.objectContaining({ outcome: "completed", detail: "sent" })]);
279+
});
280+
281+
it("no-ops (never fetches) and records a denied audit when DISCORD_WEBHOOK_URL is unset", async () => {
282+
delete process.env.DISCORD_WEBHOOK_URL;
283+
const calls = stubFetch();
284+
const env = createTestEnv();
285+
expect(await deliverRecapToDiscord(env, SAMPLE_RECAP)).toEqual({ sent: false, reason: "missing_global_webhook" });
286+
expect(calls).toEqual([]);
287+
expect(await recapAudit(env)).toEqual([expect.objectContaining({ outcome: "denied", detail: "missing_global_webhook" })]);
288+
});
289+
290+
it("no-ops (never fetches) and records a denied audit when DISCORD_WEBHOOK_URL fails validation (non-https)", async () => {
291+
const calls = stubFetch();
292+
const env = withEnv({ DISCORD_WEBHOOK_URL: "http://discord.com/api/webhooks/1/x" });
293+
expect(await deliverRecapToDiscord(env, SAMPLE_RECAP)).toEqual({ sent: false, reason: "invalid_global_webhook" });
294+
expect(calls).toEqual([]);
295+
expect(await recapAudit(env)).toEqual([expect.objectContaining({ outcome: "denied", detail: "invalid_global_webhook" })]);
296+
});
297+
298+
it("swallows a send failure — best-effort, records an error audit, never throws", async () => {
299+
vi.stubGlobal("fetch", async () => {
300+
throw new Error("network down");
301+
});
302+
const env = withEnv({ DISCORD_WEBHOOK_URL: HOOK });
303+
expect(await deliverRecapToDiscord(env, SAMPLE_RECAP)).toEqual({ sent: false, reason: "network down" });
304+
expect(await recapAudit(env)).toEqual([expect.objectContaining({ outcome: "error", detail: "network down" })]);
305+
});
306+
});

0 commit comments

Comments
 (0)