Skip to content

Commit 5ff81d4

Browse files
authored
feat(notify): self-host Discord fallback via DISCORD_WEBHOOK_URL (#1298)
The per-repo Discord notification map was hard-coded to JSONbored's own repos — a self-host operator reviewing their OWN repos couldn't get notifications without editing the source map. Add a modular fallback: any repo NOT in the built-in map uses a single DISCORD_WEBHOOK_URL env, so a self-hoster wires one channel for all their repos without a source edit. The built-in map stays as gittensory's own per-channel config and takes precedence; a mapped repo whose secret is unset also falls through to DISCORD_WEBHOOK_URL. Unset → undefined → no-notify, byte-identical to today for any repo. First of the modularity campaign (engine-side config, not the website's global surfaces). Advances the modularity / not-single-repo directive.
1 parent 40b3f82 commit 5ff81d4

4 files changed

Lines changed: 64 additions & 3 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ GITTENSORY_REVIEW_DRAFT=false
131131
# # cache (prevents double-processing of GitHub retries). Off when unset.
132132
# QDRANT_URL= # set to http://qdrant:6333 to use Qdrant as the RAG vector store
133133
# # (--profile qdrant). Overrides the built-in sqlite-vec / pgvector.
134+
# DISCORD_WEBHOOK_URL= # one Discord channel for per-action notifications (merged/closed/
135+
# # manual) on ANY repo you review. Unset = no Discord notifications.
134136
# # Collection and schema are auto-created at startup. Off when unset.
135137
# QDRANT_API_KEY= # Bearer token for an authenticated Qdrant (cloud / on-prem). Omit for
136138
# # the local --profile qdrant container (unauthenticated).

src/env.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ declare global {
6060
GITTENSORY_AUTO_FILE_DRIFT_ISSUES?: string;
6161
GITTENSORY_DRIFT_ISSUE_REPO?: string;
6262
GITTENSORY_DRIFT_ISSUE_TOKEN?: string;
63+
/** Self-host default Discord webhook URL — per-action notifications (merged/closed/manual) for any repo
64+
* not in the built-in per-repo map. Lets a self-host operator wire one channel without a source edit. */
65+
DISCORD_WEBHOOK_URL?: string;
6366
GITTENSORY_CONTRIBUTOR_ISSUE_TOKEN?: string;
6467
PRODUCT_USAGE_HASH_SALT?: string;
6568
GITTENSORY_API_TOKEN: string;

src/services/notify-discord.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,17 @@ const WEBHOOK_SECRET_BY_REPO: Record<string, string> = {
2626
};
2727

2828
function resolveWebhook(env: Env, repoFullName: string): string | undefined {
29+
// A repo with a specific mapping uses its own channel secret (the built-in map is gittensory's OWN repos).
2930
const name = WEBHOOK_SECRET_BY_REPO[repoFullName.toLowerCase()];
30-
if (!name) return undefined;
31-
const value = (env as unknown as Record<string, unknown>)[name];
32-
return typeof value === "string" && value.length > 0 ? value : undefined;
31+
if (name) {
32+
const mapped = (env as unknown as Record<string, unknown>)[name];
33+
if (typeof mapped === "string" && mapped.length > 0) return mapped;
34+
}
35+
// Modular self-host default: ANY repo not in the built-in map falls back to a single DISCORD_WEBHOOK_URL, so a
36+
// self-host operator gets per-action notifications for THEIR repos without editing this source map. Unset →
37+
// undefined → no-notify, byte-identical to today.
38+
const fallback = (env as unknown as Record<string, unknown>).DISCORD_WEBHOOK_URL;
39+
return typeof fallback === "string" && fallback.length > 0 ? fallback : undefined;
3340
}
3441

3542
export type NotifyOutcome = "merged" | "closed" | "manual";

test/unit/notify-discord.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { notifyActionToDiscord } from "../../src/services/notify-discord";
3+
import { createTestEnv } from "../helpers/d1";
4+
5+
const HOOK = "https://discord.com/api/webhooks/123/abc";
6+
const FALLBACK = "https://discord.com/api/webhooks/999/zzz";
7+
8+
function stubFetch(): string[] {
9+
const calls: string[] = [];
10+
vi.stubGlobal("fetch", async (url: RequestInfo | URL) => {
11+
calls.push(String(url));
12+
return new Response(null, { status: 204 });
13+
});
14+
return calls;
15+
}
16+
afterEach(() => vi.unstubAllGlobals());
17+
18+
// The built-in per-repo secrets (GITTENSORY_DISCORD_WEBHOOK, …) are read via cast and not declared on Env, so
19+
// set them with Object.assign; DISCORD_WEBHOOK_URL is declared, so either path works.
20+
const withEnv = (over: Record<string, string>): Env => Object.assign(createTestEnv(), over) as Env;
21+
const notify = (env: Env, repo: string): Promise<void> =>
22+
notifyActionToDiscord(env, { repoFullName: repo, pullNumber: 1, outcome: "merged", summary: "ok" });
23+
24+
describe("notify-discord resolveWebhook (modular self-host fallback)", () => {
25+
it("a mapped repo uses its own per-channel secret", async () => {
26+
const calls = stubFetch();
27+
await notify(withEnv({ GITTENSORY_DISCORD_WEBHOOK: HOOK }), "JSONbored/gittensory");
28+
expect(calls).toEqual([HOOK]);
29+
});
30+
31+
it("any UNmapped repo (a self-hoster's) falls back to DISCORD_WEBHOOK_URL", async () => {
32+
const calls = stubFetch();
33+
await notify(withEnv({ DISCORD_WEBHOOK_URL: FALLBACK }), "acme/widgets");
34+
expect(calls).toEqual([FALLBACK]);
35+
});
36+
37+
it("no mapping + no DISCORD_WEBHOOK_URL → no notification (byte-identical to today)", async () => {
38+
const calls = stubFetch();
39+
await notify(createTestEnv(), "acme/widgets");
40+
expect(calls).toEqual([]);
41+
});
42+
43+
it("a mapped repo whose channel secret is unset falls back to DISCORD_WEBHOOK_URL", async () => {
44+
const calls = stubFetch();
45+
// JSONbored/metagraphed is in the map, but METAGRAPHED_DISCORD_WEBHOOK is unset → fall through.
46+
await notify(withEnv({ DISCORD_WEBHOOK_URL: FALLBACK }), "JSONbored/metagraphed");
47+
expect(calls).toEqual([FALLBACK]);
48+
});
49+
});

0 commit comments

Comments
 (0)