Add optional Discord webhook notifications
All checks were successful
check / check (push) Successful in 35s
All checks were successful
check / check (push) Successful in 35s
This commit is contained in:
parent
f49af83d2b
commit
b455647580
5 changed files with 312 additions and 3 deletions
|
|
@ -18,8 +18,14 @@ PORT=3000
|
||||||
DATA_DIR=/app/data
|
DATA_DIR=/app/data
|
||||||
JOJO_WEBHOOK_SECRET=...
|
JOJO_WEBHOOK_SECRET=...
|
||||||
GITHUB_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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
159
src/discord.ts
Normal file
159
src/discord.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { notifyDiscord, parseDiscordConfig, type DiscordConfig } from "./discord";
|
||||||
import { jsonResponse, methodNotAllowed, textResponse } from "./http";
|
import { jsonResponse, methodNotAllowed, textResponse } from "./http";
|
||||||
import { normalizeGithubEvent } from "./providers/github";
|
import { normalizeGithubEvent } from "./providers/github";
|
||||||
import { normalizeJojoEvent } from "./providers/jojo";
|
import { normalizeJojoEvent } from "./providers/jojo";
|
||||||
|
|
@ -12,6 +13,7 @@ export type ServerConfig = {
|
||||||
githubSecret: string;
|
githubSecret: string;
|
||||||
jojoSecret: string;
|
jojoSecret: string;
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
|
discord?: DiscordConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getHeader(headers: Headers, name: string, fallback: string): string {
|
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);
|
await store.appendEvent(event);
|
||||||
const job = jobForEvent(event);
|
const job = jobForEvent(event);
|
||||||
if (job) {
|
if (job) {
|
||||||
await store.appendJob(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 }));
|
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 }, {
|
return jsonResponse({ status: event.event === "ping" ? "ok" : "accepted", event: event.event, deliveryId: event.deliveryId }, {
|
||||||
status: event.event === "ping" ? 200 : 202,
|
status: event.event === "ping" ? 200 : 202,
|
||||||
|
|
@ -57,7 +72,7 @@ async function handleGithub(request: Request, config: ServerConfig, store: Event
|
||||||
receivedAt: new Date().toISOString(),
|
receivedAt: new Date().toISOString(),
|
||||||
payload: parsed.payload as never,
|
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> {
|
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(),
|
receivedAt: new Date().toISOString(),
|
||||||
payload: parsed.payload as never,
|
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 {
|
export function createHandler(config: ServerConfig): (request: Request) => Promise<Response> | Response {
|
||||||
|
|
@ -102,6 +117,10 @@ if (import.meta.main) {
|
||||||
githubSecret: process.env.GITHUB_WEBHOOK_SECRET ?? "",
|
githubSecret: process.env.GITHUB_WEBHOOK_SECRET ?? "",
|
||||||
jojoSecret: process.env.JOJO_WEBHOOK_SECRET ?? "",
|
jojoSecret: process.env.JOJO_WEBHOOK_SECRET ?? "",
|
||||||
dataDir: process.env.DATA_DIR ?? "./data",
|
dataDir: process.env.DATA_DIR ?? "./data",
|
||||||
|
discord: parseDiscordConfig({
|
||||||
|
webhookUrl: process.env.DISCORD_WEBHOOK_URL,
|
||||||
|
notifyEvents: process.env.DISCORD_NOTIFY_EVENTS,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
|
|
|
||||||
91
test/discord.test.ts
Normal file
91
test/discord.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -57,4 +57,38 @@ describe("server", () => {
|
||||||
expect(await readFile(join(dataDir, "events.jsonl"), "utf8")).toContain("\"provider\":\"jojo\"");
|
expect(await readFile(join(dataDir, "events.jsonl"), "utf8")).toContain("\"provider\":\"jojo\"");
|
||||||
expect(await readFile(join(dataDir, "jobs.jsonl"), "utf8")).toContain("\"kind\":\"main_push\"");
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue