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
|
||||
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
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 { 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
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, "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