Add optional Discord webhook notifications
All checks were successful
check / check (push) Successful in 35s

This commit is contained in:
matamune 2026-05-12 21:33:24 +00:00
parent f49af83d2b
commit b455647580
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
5 changed files with 312 additions and 3 deletions

View file

@ -18,8 +18,14 @@ PORT=3000
DATA_DIR=/app/data
JOJO_WEBHOOK_SECRET=...
GITHUB_WEBHOOK_SECRET=...
DISCORD_WEBHOOK_URL=
DISCORD_NOTIFY_EVENTS=push,pull_request,release
```
Discord notifications are optional. When `DISCORD_WEBHOOK_URL` is unset, the
service skips Discord output. `DISCORD_NOTIFY_EVENTS` is a comma-separated
allow list and defaults to `push,pull_request,release`.
## Development
```bash

159
src/discord.ts Normal file
View file

@ -0,0 +1,159 @@
import type { GitWebhookEvent, QueuedJob } from "./types";
type DiscordEmbedField = {
name: string;
value: string;
inline?: boolean;
};
type DiscordEmbed = {
title: string;
description?: string;
url?: string;
color: number;
fields: DiscordEmbedField[];
timestamp: string;
footer: {
text: string;
};
};
type DiscordPayload = {
username: string;
embeds: DiscordEmbed[];
};
type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
export type DiscordConfig = {
webhookUrl?: string;
notifyEvents: Set<string>;
};
export type DiscordNotification = {
event: GitWebhookEvent;
job?: QueuedJob | null;
};
const defaultNotifyEvents = ["push", "pull_request", "release"];
export function parseDiscordConfig(input: {
webhookUrl?: string;
notifyEvents?: string;
}): DiscordConfig {
const notifyEvents = new Set(
(input.notifyEvents?.trim() ? input.notifyEvents : defaultNotifyEvents.join(","))
.split(",")
.map((event) => event.trim())
.filter(Boolean),
);
return {
webhookUrl: input.webhookUrl?.trim() || undefined,
notifyEvents,
};
}
function branchName(ref?: string): string | undefined {
return ref?.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
}
function shortSha(sha?: string): string | undefined {
return sha ? sha.slice(0, 12) : undefined;
}
function eventTitle(event: GitWebhookEvent): string {
const repo = event.repo?.fullName ?? "unknown repo";
if (event.event === "push") {
return `[${event.provider}] ${repo} push${branchName(event.ref) ? ` to ${branchName(event.ref)}` : ""}`;
}
if (event.event === "pull_request") {
return `[${event.provider}] ${repo} pull_request${event.action ? ` ${event.action}` : ""}`;
}
if (event.event === "release") {
return `[${event.provider}] ${repo} release${event.action ? ` ${event.action}` : ""}`;
}
return `[${event.provider}] ${repo} ${event.event}`;
}
function rawRecord(event: GitWebhookEvent): Record<string, unknown> {
return typeof event.raw === "object" && event.raw !== null ? event.raw as Record<string, unknown> : {};
}
function objectRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function eventUrl(event: GitWebhookEvent): string | undefined {
const raw = rawRecord(event);
if (event.event === "push") {
const headCommit = objectRecord(raw.head_commit);
return stringValue(headCommit?.url) ?? stringValue(raw.compare_url);
}
if (event.event === "pull_request") {
return stringValue(objectRecord(raw.pull_request)?.html_url);
}
if (event.event === "release") {
return stringValue(objectRecord(raw.release)?.html_url);
}
return undefined;
}
function field(name: string, value?: string, inline = true): DiscordEmbedField | null {
if (!value) return null;
return { name, value: value.slice(0, 1024), inline };
}
export function buildDiscordPayload(input: DiscordNotification): DiscordPayload {
const { event, job } = input;
const fields = [
field("Provider", event.provider),
field("Repo", event.repo?.fullName),
field("Event", event.action ? `${event.event}:${event.action}` : event.event),
field("Branch", branchName(event.ref)),
field("Sender", event.sender?.username),
field("SHA", shortSha(event.after)),
field("Queued", job ? job.kind : undefined),
field("Delivery", event.deliveryId, false),
].filter((item): item is DiscordEmbedField => item !== null);
return {
username: "git-webhooks",
embeds: [
{
title: eventTitle(event).slice(0, 256),
url: eventUrl(event),
color: event.provider === "github" ? 0x24292f : 0xf97316,
fields,
timestamp: event.receivedAt,
footer: {
text: "git-webhooks",
},
},
],
};
}
export async function notifyDiscord(
config: DiscordConfig,
notification: DiscordNotification,
fetchImpl: FetchLike = fetch,
): Promise<void> {
if (!config.webhookUrl || !config.notifyEvents.has(notification.event.event)) {
return;
}
const response = await fetchImpl(config.webhookUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(buildDiscordPayload(notification)),
});
if (!response.ok) {
throw new Error(`Discord webhook returned ${response.status}`);
}
}

View file

@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { notifyDiscord, parseDiscordConfig, type DiscordConfig } from "./discord";
import { jsonResponse, methodNotAllowed, textResponse } from "./http";
import { normalizeGithubEvent } from "./providers/github";
import { normalizeJojoEvent } from "./providers/jojo";
@ -12,6 +13,7 @@ export type ServerConfig = {
githubSecret: string;
jojoSecret: string;
dataDir: string;
discord?: DiscordConfig;
};
function getHeader(headers: Headers, name: string, fallback: string): string {
@ -31,12 +33,25 @@ async function parseJsonBody(request: Request): Promise<{ body: string; payload:
}
}
async function persistAcceptedEvent(store: EventStore, event: GitWebhookEvent): Promise<Response> {
async function persistAcceptedEvent(store: EventStore, event: GitWebhookEvent, discord?: DiscordConfig): Promise<Response> {
await store.appendEvent(event);
const job = jobForEvent(event);
if (job) {
await store.appendJob(job);
}
try {
await notifyDiscord(discord ?? parseDiscordConfig({}), { event, job });
} catch (error) {
console.error(JSON.stringify({
type: "discord.notify_failed",
provider: event.provider,
event: event.event,
deliveryId: event.deliveryId,
error: error instanceof Error ? error.message : String(error),
}));
}
console.log(JSON.stringify({ type: "webhook.accepted", provider: event.provider, event: event.event, deliveryId: event.deliveryId, job: job?.id }));
return jsonResponse({ status: event.event === "ping" ? "ok" : "accepted", event: event.event, deliveryId: event.deliveryId }, {
status: event.event === "ping" ? 200 : 202,
@ -57,7 +72,7 @@ async function handleGithub(request: Request, config: ServerConfig, store: Event
receivedAt: new Date().toISOString(),
payload: parsed.payload as never,
});
return persistAcceptedEvent(store, event);
return persistAcceptedEvent(store, event, config.discord);
}
async function handleJojo(request: Request, config: ServerConfig, store: EventStore): Promise<Response> {
@ -74,7 +89,7 @@ async function handleJojo(request: Request, config: ServerConfig, store: EventSt
receivedAt: new Date().toISOString(),
payload: parsed.payload as never,
});
return persistAcceptedEvent(store, event);
return persistAcceptedEvent(store, event, config.discord);
}
export function createHandler(config: ServerConfig): (request: Request) => Promise<Response> | Response {
@ -102,6 +117,10 @@ if (import.meta.main) {
githubSecret: process.env.GITHUB_WEBHOOK_SECRET ?? "",
jojoSecret: process.env.JOJO_WEBHOOK_SECRET ?? "",
dataDir: process.env.DATA_DIR ?? "./data",
discord: parseDiscordConfig({
webhookUrl: process.env.DISCORD_WEBHOOK_URL,
notifyEvents: process.env.DISCORD_NOTIFY_EVENTS,
}),
};
Bun.serve({

91
test/discord.test.ts Normal file
View file

@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import { buildDiscordPayload, notifyDiscord, parseDiscordConfig } from "../src/discord";
import type { GitWebhookEvent } from "../src/types";
const pushEvent: GitWebhookEvent = {
provider: "jojo",
event: "push",
providerEvent: "push",
deliveryId: "delivery-1",
receivedAt: "2026-05-12T21:00:00.000Z",
repo: {
owner: "peezy-tech",
name: "git-webhooks",
fullName: "peezy-tech/git-webhooks",
},
sender: {
username: "matamune",
},
ref: "refs/heads/main",
after: "0123456789abcdef",
raw: {
head_commit: {
url: "https://jojo.build/peezy-tech/git-webhooks/commit/0123456789abcdef",
},
},
};
describe("discord notifications", () => {
test("parses default notify events", () => {
const config = parseDiscordConfig({});
expect(config.notifyEvents.has("push")).toBe(true);
expect(config.notifyEvents.has("pull_request")).toBe(true);
expect(config.notifyEvents.has("release")).toBe(true);
expect(config.notifyEvents.has("ping")).toBe(false);
});
test("builds readable push embeds", () => {
const payload = buildDiscordPayload({
event: pushEvent,
job: {
id: "jojo:delivery-1:main_push",
kind: "main_push",
provider: "jojo",
repoFullName: "peezy-tech/git-webhooks",
ref: "refs/heads/main",
sha: "0123456789abcdef",
deliveryId: "delivery-1",
createdAt: "2026-05-12T21:00:00.000Z",
},
});
expect(payload.username).toBe("git-webhooks");
expect(payload.embeds[0].title).toBe("[jojo] peezy-tech/git-webhooks push to main");
expect(payload.embeds[0].url).toBe("https://jojo.build/peezy-tech/git-webhooks/commit/0123456789abcdef");
expect(payload.embeds[0].fields).toContainEqual({ name: "Queued", value: "main_push", inline: true });
});
test("does nothing without a webhook URL", async () => {
let calls = 0;
await notifyDiscord(parseDiscordConfig({}), { event: pushEvent }, async () => {
calls += 1;
return new Response(null, { status: 204 });
});
expect(calls).toBe(0);
});
test("skips unconfigured events", async () => {
let calls = 0;
await notifyDiscord(parseDiscordConfig({ webhookUrl: "https://discord.example/webhook", notifyEvents: "release" }), { event: pushEvent }, async () => {
calls += 1;
return new Response(null, { status: 204 });
});
expect(calls).toBe(0);
});
test("posts configured events", async () => {
let body = "";
await notifyDiscord(parseDiscordConfig({ webhookUrl: "https://discord.example/webhook", notifyEvents: "push" }), { event: pushEvent }, async (_url, init) => {
body = String(init?.body);
return new Response(null, { status: 204 });
});
expect(JSON.parse(body).embeds[0].title).toBe("[jojo] peezy-tech/git-webhooks push to main");
});
test("throws on Discord failure", async () => {
await expect(notifyDiscord(parseDiscordConfig({ webhookUrl: "https://discord.example/webhook" }), { event: pushEvent }, async () => {
return new Response("bad", { status: 500 });
})).rejects.toThrow("Discord webhook returned 500");
});
});

View file

@ -57,4 +57,38 @@ describe("server", () => {
expect(await readFile(join(dataDir, "events.jsonl"), "utf8")).toContain("\"provider\":\"jojo\"");
expect(await readFile(join(dataDir, "jobs.jsonl"), "utf8")).toContain("\"kind\":\"main_push\"");
});
test("continues accepting webhooks when Discord returns an error", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response("bad", { status: 500 })) as unknown as typeof fetch;
try {
const dataDir = await mkdtemp(join(tmpdir(), "git-webhooks-"));
const handler = createHandler({
githubSecret: "gh",
jojoSecret: "jojo",
dataDir,
discord: {
webhookUrl: "https://discord.example/webhook",
notifyEvents: new Set(["push"]),
},
});
const request = await signedRequest("/git-webhooks/jojo", "jojo", "jojo", {
ref: "refs/heads/main",
after: "abc123",
repository: {
name: "git-webhooks",
full_name: "peezy-tech/git-webhooks",
owner: { username: "peezy-tech" },
},
});
const response = await handler(request);
expect(response.status).toBe(202);
expect(await readFile(join(dataDir, "events.jsonl"), "utf8")).toContain("\"provider\":\"jojo\"");
expect(await readFile(join(dataDir, "jobs.jsonl"), "utf8")).toContain("\"kind\":\"main_push\"");
} finally {
globalThis.fetch = originalFetch;
}
});
});