diff --git a/README.md b/README.md index cab3fc2..2a220c2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/discord.ts b/src/discord.ts new file mode 100644 index 0000000..6fb072c --- /dev/null +++ b/src/discord.ts @@ -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; + +export type DiscordConfig = { + webhookUrl?: string; + notifyEvents: Set; +}; + +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 { + return typeof event.raw === "object" && event.raw !== null ? event.raw as Record : {}; +} + +function objectRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null ? value as Record : 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 { + 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}`); + } +} diff --git a/src/server.ts b/src/server.ts index 5f88950..38c8c0b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { +async function persistAcceptedEvent(store: EventStore, event: GitWebhookEvent, discord?: DiscordConfig): Promise { 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 { @@ -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 { @@ -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({ diff --git a/test/discord.test.ts b/test/discord.test.ts new file mode 100644 index 0000000..bb87b29 --- /dev/null +++ b/test/discord.test.ts @@ -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"); + }); +}); diff --git a/test/server.test.ts b/test/server.test.ts index 05fceb0..3a8766e 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -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; + } + }); });