diff --git a/src/services/notify-discord.ts b/src/services/notify-discord.ts index 08c0230ce2..dc2046b124 100644 --- a/src/services/notify-discord.ts +++ b/src/services/notify-discord.ts @@ -1,5 +1,6 @@ import { recordAuditEvent } from "../db/repositories"; import { errorMessage } from "../utils/json"; +import type { RecapReport } from "../types"; // Per-repo Discord notifications (reviewbot parity). Each repo notifies its OWN channel on a terminal action — // 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( } } +/** + * Deliver a maintainer recap digest (#2245, the Discord channel of #1963) as an embed. Unlike the per-repo + * `ReviewRecap` sender {@link sendReviewRecapToDiscord} (review-recap.ts) — which resolves a per-repo channel via + * {@link resolveDiscordWebhook}, exactly like {@link notifyActionToDiscord} — a maintainer `RecapReport` is ONE + * operator-level digest spanning many repos (`report.repos`), so there is no single repo to route by: it posts to + * the flat global `DISCORD_WEBHOOK_URL`. 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: false, reason }` but never thrown, so a Discord outage never breaks the recap job. The + * `RecapReport` is already public-safe (buildMaintainerRecap sanitizes every free-text field), so no re-scrub here. + */ +export async function deliverRecapToDiscord(env: Env, report: RecapReport): Promise<{ sent: boolean; reason?: string }> { + const targetKey = `maintainer-recap:${report.windowDays}d`; + const auditMeta = { windowDays: report.windowDays, repoCount: report.repos.length }; + const url = envString(env, "DISCORD_WEBHOOK_URL"); + if (!url || !isValidDiscordWebhook(url)) { + const reason = url ? "invalid_global_webhook" : "missing_global_webhook"; + await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "denied", detail: reason, metadata: auditMeta }); + return { sent: false, reason }; + } + const body = { + username: "Gittensory", + embeds: [ + { + title: `Maintainer recap · ${report.repos.length} repo(s) · ${report.windowDays}d`, + description: report.summary.join("\n").slice(0, 1800), + color: 0x0969da, + fields: [ + { name: "Reviewed", value: `${report.totals.reviewed}`, inline: true }, + { name: "Merged", value: `${report.totals.merged}`, inline: true }, + { name: "Closed", value: `${report.totals.closed}`, inline: true }, + { name: "Gate false positives", value: `${report.totals.gateFalsePositives}/${report.totals.blocked}`, inline: true }, + { name: "Overrides", value: `${report.totals.gateOverrides}`, inline: true }, + { name: "Reversals", value: `${report.totals.reversals}`, inline: true }, + ], + footer: { text: `Gittensory · generated ${report.generatedAt}` }, + }, + ], + }; + try { + await postWebhook(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) }, "discord"); + await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "completed", detail: "sent", metadata: auditMeta }); + return { sent: true }; + } catch (error) { + const detail = errorMessage(error).slice(0, 160); + console.warn(JSON.stringify({ event: "maintainer_recap_discord_failed", message: detail })); + await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "error", detail, metadata: auditMeta }); + return { sent: false, reason: detail }; + } +} + /** Slack incoming-webhook URL validation — only `https://hooks.slack.com/services/…`. Exported so other * Slack senders (e.g. the recap digest's {@link deliverRecapToSlack}, review-recap.ts) reuse the SAME * validation instead of re-typing the host/path allowlist. */ diff --git a/test/unit/notify-discord.test.ts b/test/unit/notify-discord.test.ts index a1a8528691..23bb0d2498 100644 --- a/test/unit/notify-discord.test.ts +++ b/test/unit/notify-discord.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { notifyActionToDiscord, notifyActionToSlack, resolveDiscordWebhook } from "../../src/services/notify-discord"; +import { deliverRecapToDiscord, notifyActionToDiscord, notifyActionToSlack, resolveDiscordWebhook } from "../../src/services/notify-discord"; import { createTestEnv } from "../helpers/d1"; +import type { RecapReport } from "../../src/types"; const HOOK = "https://discord.com/api/webhooks/123/abc"; const FALLBACK = "https://discord.com/api/webhooks/999/zzz"; @@ -238,3 +239,68 @@ describe("notifyActionToSlack (#11 — modular self-host Slack channel)", () => expect(await externalNotificationAudit(env, "slack")).toEqual([expect.objectContaining({ outcome: "error", detail: "slack_webhook_http_403" })]); }); }); + +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 }], + 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.", + "Gate false-positive rate: 25% (1/4 block(s) later merged).", + "1 maintainer override(s), 0 recommendation reversal(s).", + ], +}; + +async function recapAudit(env: Env): Promise> { + 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 }>(); + return rows.results ?? []; +} + +describe("deliverRecapToDiscord (#2245 maintainer recap → Discord)", () => { + it("posts the recap as an embed to the global DISCORD_WEBHOOK_URL and records a completed audit when configured", async () => { + let posted: { url: string; body: string } | null = null; + vi.stubGlobal("fetch", async (url: RequestInfo | URL, init?: RequestInit) => { + posted = { url: String(url), body: init?.body ? String(init.body) : "" }; + return new Response(null, { status: 204 }); + }); + const env = withEnv({ DISCORD_WEBHOOK_URL: HOOK }); + expect(await deliverRecapToDiscord(env, SAMPLE_RECAP)).toEqual({ sent: true }); + expect(posted).not.toBeNull(); + expect(posted!.url).toBe(HOOK); + const parsed = JSON.parse(posted!.body) as { embeds: { title: string; description: string; fields: { name: string; value: string }[] }[] }; + const embed = parsed.embeds[0]!; + expect(embed.title).toContain("Maintainer recap"); + expect(embed.description).toContain("Gate false-positive rate"); + expect(embed.fields.map((f) => f.name)).toContain("Reversals"); + // public-safe: the digest must never leak an economic/identity term + expect(posted!.body.toLowerCase()).not.toMatch(/reward|wallet|hotkey|coldkey|trustscore/); + expect(await recapAudit(env)).toEqual([expect.objectContaining({ outcome: "completed", detail: "sent" })]); + }); + + it("no-ops (never fetches) and records a denied audit when DISCORD_WEBHOOK_URL is unset", async () => { + delete process.env.DISCORD_WEBHOOK_URL; + const calls = stubFetch(); + const env = createTestEnv(); + expect(await deliverRecapToDiscord(env, SAMPLE_RECAP)).toEqual({ sent: false, reason: "missing_global_webhook" }); + expect(calls).toEqual([]); + expect(await recapAudit(env)).toEqual([expect.objectContaining({ outcome: "denied", detail: "missing_global_webhook" })]); + }); + + it("no-ops (never fetches) and records a denied audit when DISCORD_WEBHOOK_URL fails validation (non-https)", async () => { + const calls = stubFetch(); + const env = withEnv({ DISCORD_WEBHOOK_URL: "http://discord.com/api/webhooks/1/x" }); + expect(await deliverRecapToDiscord(env, SAMPLE_RECAP)).toEqual({ sent: false, reason: "invalid_global_webhook" }); + expect(calls).toEqual([]); + expect(await recapAudit(env)).toEqual([expect.objectContaining({ outcome: "denied", detail: "invalid_global_webhook" })]); + }); + + it("swallows a send failure — best-effort, records an error audit, never throws", async () => { + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + const env = withEnv({ DISCORD_WEBHOOK_URL: HOOK }); + expect(await deliverRecapToDiscord(env, SAMPLE_RECAP)).toEqual({ sent: false, reason: "network down" }); + expect(await recapAudit(env)).toEqual([expect.objectContaining({ outcome: "error", detail: "network down" })]); + }); +});