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
51 changes: 51 additions & 0 deletions src/services/notify-discord.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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. */
Expand Down
68 changes: 67 additions & 1 deletion test/unit/notify-discord.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<Array<{ outcome: string; detail: string }>> {
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" })]);
});
});