This commit is contained in:
matamune 2026-05-15 23:00:31 +00:00
parent 5e308efe66
commit 88ddcfba39
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
13 changed files with 53 additions and 690 deletions

2
.gitignore vendored
View file

@ -4,6 +4,4 @@ coverage
*.log
.env
data/*.jsonl
data/events
data/jobs
.DS_Store

View file

@ -1,6 +1,6 @@
# patch
Containerized Bun service for GitHub and jojo.build webhooks.
Containerized Bun service for upstream feed watching and flow dispatch.
Canonical public host: `https://patch.moi`.
@ -8,8 +8,11 @@ Canonical public host: `https://patch.moi`.
```text
GET /healthz
POST /jojo
POST /github
GET /flow-events
GET /flow-events/:id
POST /flow-events/:id/retry
POST /flow-events/:id/replay
GET /flow-dispatches
```
## Environment
@ -18,11 +21,9 @@ POST /github
HOST=0.0.0.0
PORT=3000
DATA_DIR=/app/data
JOJO_WEBHOOK_SECRET=...
GITHUB_WEBHOOK_SECRET=...
DISCORD_OUTPUT_ENABLED=false
DISCORD_WEBHOOK_URL=
DISCORD_NOTIFY_EVENTS=push,pull_request,release
DISCORD_NOTIFY_EVENTS=push,release
FEED_SOURCES_PATH=./feed-sources.json
PATCH_FLOW_DISPATCH_URL=
PATCH_FLOW_DISPATCH_SECRET=
@ -31,7 +32,7 @@ PATCH_ADMIN_TOKEN=
Discord notifications are off by default. Set `DISCORD_OUTPUT_ENABLED=true`
and `DISCORD_WEBHOOK_URL` to send Discord output. `DISCORD_NOTIFY_EVENTS` is a
comma-separated allow list and defaults to `push,pull_request,release`.
comma-separated allow list and defaults to `push,release`.
## Development
@ -41,9 +42,6 @@ bun run check
bun run dev
```
Accepted webhook events are appended to `DATA_DIR/events.jsonl`; queued work
items are appended to `DATA_DIR/jobs.jsonl`.
Feed watcher events are configured in `feed-sources.json`. The first poll primes
`DATA_DIR/feed-state.json`; later polls append upstream activity to
`DATA_DIR/feed-events.jsonl`. Targets using `mode: "fork_sync"` append legacy

View file

@ -1,4 +1,4 @@
import type { FeedJob, FeedSignal, GitWebhookEvent, QueuedJob } from "./types";
import type { FeedJob, FeedSignal } from "./types";
type DiscordEmbedField = {
name: string;
@ -32,12 +32,11 @@ export type DiscordConfig = {
};
export type DiscordNotification = {
event?: GitWebhookEvent;
job?: QueuedJob | FeedJob | null;
signal?: FeedSignal;
signal: FeedSignal;
job?: FeedJob | null;
};
const defaultNotifyEvents = ["push", "pull_request", "release"];
const defaultNotifyEvents = ["push", "release"];
const serviceName = "patch";
function parseEnabled(value?: string): boolean {
@ -72,20 +71,6 @@ 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 feedTitle(signal: FeedSignal): string {
const branch = signal.ref?.startsWith("refs/heads/") ? signal.ref.slice("refs/heads/".length) : undefined;
if (signal.event === "push") {
@ -94,97 +79,36 @@ function feedTitle(signal: FeedSignal): string {
return `[${signal.provider}] ${signal.repo.fullName} release ${signal.title}`;
}
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 {
if (input.signal) {
const { signal, job } = input;
const fields = [
field("Provider", signal.provider),
field("Repo", signal.repo.fullName),
field("Event", signal.event),
field("Branch", branchName(signal.ref)),
field("Author", signal.author),
field("SHA", shortSha(signal.sha)),
field("Queued", job ? job.kind : undefined),
field("Source", signal.sourceId, false),
].filter((item): item is DiscordEmbedField => item !== null);
return {
username: serviceName,
embeds: [
{
title: feedTitle(signal).slice(0, 256),
description: signal.title.slice(0, 2048),
url: signal.url,
color: signal.provider === "github" ? 0x24292f : 0x2185d0,
fields,
timestamp: signal.publishedAt,
footer: {
text: "feed watcher",
},
},
],
};
}
if (!input.event) {
throw new Error("Discord notification missing event or signal");
}
const { event, job } = input;
const { signal, 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("Provider", signal.provider),
field("Repo", signal.repo.fullName),
field("Event", signal.event),
field("Branch", branchName(signal.ref)),
field("Author", signal.author),
field("SHA", shortSha(signal.sha)),
field("Queued", job ? job.kind : undefined),
field("Delivery", event.deliveryId, false),
field("Source", signal.sourceId, false),
].filter((item): item is DiscordEmbedField => item !== null);
return {
username: serviceName,
embeds: [
{
title: eventTitle(event).slice(0, 256),
url: eventUrl(event),
color: event.provider === "github" ? 0x24292f : 0xf97316,
title: feedTitle(signal).slice(0, 256),
description: signal.title.slice(0, 2048),
url: signal.url,
color: signal.provider === "github" ? 0x24292f : 0x2185d0,
fields,
timestamp: event.receivedAt,
timestamp: signal.publishedAt,
footer: {
text: serviceName,
text: "feed watcher",
},
},
],
@ -196,8 +120,8 @@ export async function notifyDiscord(
notification: DiscordNotification,
fetchImpl: FetchLike = fetch,
): Promise<void> {
const eventName = notification.signal?.event ?? notification.event?.event;
if (!config.enabled || !config.webhookUrl || !eventName || !config.notifyEvents.has(eventName)) {
const eventName = notification.signal.event;
if (!config.enabled || !config.webhookUrl || !config.notifyEvents.has(eventName)) {
return;
}

View file

@ -1,76 +0,0 @@
import type { GitWebhookEvent, NormalizedEventName } from "../types";
type GitHubPayload = {
action?: string;
zen?: string;
hook_id?: number;
ref?: string;
before?: string;
after?: string;
repository?: {
name?: string;
full_name?: string;
clone_url?: string;
ssh_url?: string;
default_branch?: string;
owner?: {
login?: string;
username?: string;
name?: string;
};
};
sender?: {
login?: string;
html_url?: string;
};
};
function normalizeEventName(providerEvent: string, payload: GitHubPayload): NormalizedEventName {
if (providerEvent === "ping" || payload.zen || payload.hook_id) return "ping";
if (providerEvent === "push") return "push";
if (providerEvent === "pull_request") return "pull_request";
if (providerEvent === "workflow_run") return "workflow_run";
if (providerEvent === "release") return "release";
return "unknown";
}
export function normalizeGithubEvent(input: {
providerEvent: string;
deliveryId: string;
receivedAt: string;
payload: GitHubPayload;
}): GitWebhookEvent {
const { payload } = input;
const repoOwner = payload.repository?.owner?.login ?? payload.repository?.owner?.username ?? payload.repository?.owner?.name;
const repoName = payload.repository?.name;
const fullName = payload.repository?.full_name ?? (repoOwner && repoName ? `${repoOwner}/${repoName}` : undefined);
return {
provider: "github",
event: normalizeEventName(input.providerEvent, payload),
providerEvent: input.providerEvent,
deliveryId: input.deliveryId,
receivedAt: input.receivedAt,
repo: repoOwner && repoName && fullName
? {
owner: repoOwner,
name: repoName,
fullName,
cloneUrl: payload.repository?.clone_url,
sshUrl: payload.repository?.ssh_url,
defaultBranch: payload.repository?.default_branch,
}
: undefined,
sender: payload.sender?.login
? {
username: payload.sender.login,
htmlUrl: payload.sender.html_url,
}
: undefined,
ref: payload.ref,
before: payload.before,
after: payload.after,
action: payload.action,
raw: payload,
};
}

View file

@ -1,76 +0,0 @@
import type { GitWebhookEvent, NormalizedEventName } from "../types";
type JojoPayload = {
action?: string;
ref?: string;
before?: string;
after?: string;
repository?: {
name?: string;
full_name?: string;
clone_url?: string;
ssh_url?: string;
default_branch?: string;
owner?: {
login?: string;
username?: string;
name?: string;
};
};
sender?: {
login?: string;
username?: string;
html_url?: string;
};
};
function normalizeEventName(providerEvent: string): NormalizedEventName {
if (providerEvent === "ping") return "ping";
if (providerEvent === "push") return "push";
if (providerEvent === "pull_request") return "pull_request";
if (providerEvent === "workflow_run") return "workflow_run";
if (providerEvent === "release") return "release";
return "unknown";
}
export function normalizeJojoEvent(input: {
providerEvent: string;
deliveryId: string;
receivedAt: string;
payload: JojoPayload;
}): GitWebhookEvent {
const { payload } = input;
const repoOwner = payload.repository?.owner?.login ?? payload.repository?.owner?.username ?? payload.repository?.owner?.name;
const repoName = payload.repository?.name;
const fullName = payload.repository?.full_name ?? (repoOwner && repoName ? `${repoOwner}/${repoName}` : undefined);
const senderUsername = payload.sender?.login ?? payload.sender?.username;
return {
provider: "jojo",
event: normalizeEventName(input.providerEvent),
providerEvent: input.providerEvent,
deliveryId: input.deliveryId,
receivedAt: input.receivedAt,
repo: repoOwner && repoName && fullName
? {
owner: repoOwner,
name: repoName,
fullName,
cloneUrl: payload.repository?.clone_url,
sshUrl: payload.repository?.ssh_url,
defaultBranch: payload.repository?.default_branch,
}
: undefined,
sender: senderUsername
? {
username: senderUsername,
htmlUrl: payload.sender?.html_url,
}
: undefined,
ref: payload.ref,
before: payload.before,
after: payload.after,
action: payload.action,
raw: payload,
};
}

View file

@ -1,6 +1,6 @@
import { appendFile, mkdir, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { FeedJob, FeedSignal, FlowDispatchRecord, FlowEvent, GitWebhookEvent, QueuedJob } from "./types";
import type { FeedJob, FeedSignal, FlowDispatchRecord, FlowEvent } from "./types";
async function appendJsonLine(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
@ -30,30 +30,18 @@ function limitNewest<T>(items: T[], limit = 50): T[] {
}
export class EventStore {
readonly eventsPath: string;
readonly jobsPath: string;
readonly feedEventsPath: string;
readonly feedJobsPath: string;
readonly flowEventsPath: string;
readonly flowDispatchesPath: string;
constructor(dataDir: string) {
this.eventsPath = join(dataDir, "events.jsonl");
this.jobsPath = join(dataDir, "jobs.jsonl");
this.feedEventsPath = join(dataDir, "feed-events.jsonl");
this.feedJobsPath = join(dataDir, "feed-jobs.jsonl");
this.flowEventsPath = join(dataDir, "flow-events.jsonl");
this.flowDispatchesPath = join(dataDir, "flow-dispatches.jsonl");
}
async appendEvent(event: GitWebhookEvent): Promise<void> {
await appendJsonLine(this.eventsPath, event);
}
async appendJob(job: QueuedJob): Promise<void> {
await appendJsonLine(this.jobsPath, job);
}
async appendFeedSignal(signal: FeedSignal): Promise<void> {
await appendJsonLine(this.feedEventsPath, signal);
}
@ -100,23 +88,6 @@ export class EventStore {
}
}
export function jobForEvent(event: GitWebhookEvent): QueuedJob | null {
if (event.event !== "push" || event.ref !== "refs/heads/main" || !event.repo || !event.after) {
return null;
}
return {
id: `${event.provider}:${event.deliveryId}:main_push`,
kind: "main_push",
provider: event.provider,
repoFullName: event.repo.fullName,
ref: event.ref,
sha: event.after,
deliveryId: event.deliveryId,
createdAt: event.receivedAt,
};
}
export function jobForFeedSignal(signal: FeedSignal): FeedJob | null {
if (signal.event !== "release" || signal.target?.mode !== "fork_sync") {
return null;

View file

@ -1,66 +1,15 @@
import { randomUUID } from "node:crypto";
import { notifyDiscord, parseDiscordConfig, type DiscordConfig } from "./discord";
import { parseDiscordConfig, type DiscordConfig } from "./discord";
import { startFeedPolling } from "./feed";
import { dispatchFlowEvent, replayFlowEvent } from "./flow";
import { jsonResponse, methodNotAllowed, textResponse } from "./http";
import { normalizeGithubEvent } from "./providers/github";
import { normalizeJojoEvent } from "./providers/jojo";
import { EventStore, jobForEvent } from "./queue";
import { verifyGithubSignature, verifyJojoSignature } from "./signatures";
import type { GitWebhookEvent } from "./types";
const maxBodyBytes = 1024 * 1024;
import { EventStore } from "./queue";
export type ServerConfig = {
githubSecret: string;
jojoSecret: string;
dataDir: string;
discord?: DiscordConfig;
adminToken?: string;
};
function getHeader(headers: Headers, name: string, fallback: string): string {
return headers.get(name) ?? fallback;
}
async function parseJsonBody(request: Request): Promise<{ body: string; payload: unknown } | Response> {
const body = await request.text();
if (body.length > maxBodyBytes) {
return jsonResponse({ error: "payload_too_large" }, { status: 413 });
}
try {
return { body, payload: JSON.parse(body) };
} catch {
return jsonResponse({ error: "invalid_json" }, { status: 400 });
}
}
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,
});
}
function adminAuthorized(request: Request, config: ServerConfig): boolean {
if (!config.adminToken) {
return true;
@ -141,40 +90,6 @@ async function handleFlowDispatches(request: Request, config: ServerConfig, stor
});
}
async function handleGithub(request: Request, config: ServerConfig, store: EventStore): Promise<Response> {
if (request.method !== "POST") return methodNotAllowed();
const parsed = await parseJsonBody(request);
if (parsed instanceof Response) return parsed;
const verified = await verifyGithubSignature(config.githubSecret, parsed.body, request.headers.get("x-hub-signature-256"));
if (!verified) return jsonResponse({ error: "invalid_signature" }, { status: 401 });
const event = normalizeGithubEvent({
providerEvent: getHeader(request.headers, "x-github-event", "unknown"),
deliveryId: getHeader(request.headers, "x-github-delivery", randomUUID()),
receivedAt: new Date().toISOString(),
payload: parsed.payload as never,
});
return persistAcceptedEvent(store, event, config.discord);
}
async function handleJojo(request: Request, config: ServerConfig, store: EventStore): Promise<Response> {
if (request.method !== "POST") return methodNotAllowed();
const parsed = await parseJsonBody(request);
if (parsed instanceof Response) return parsed;
const verified = await verifyJojoSignature(config.jojoSecret, parsed.body, request.headers);
if (!verified) return jsonResponse({ error: "invalid_signature" }, { status: 401 });
const event = normalizeJojoEvent({
providerEvent: getHeader(request.headers, "x-forgejo-event", request.headers.get("x-gitea-event") ?? "unknown"),
deliveryId: getHeader(request.headers, "x-forgejo-delivery", request.headers.get("x-gitea-delivery") ?? randomUUID()),
receivedAt: new Date().toISOString(),
payload: parsed.payload as never,
});
return persistAcceptedEvent(store, event, config.discord);
}
export function createHandler(config: ServerConfig): (request: Request) => Promise<Response> | Response {
const store = new EventStore(config.dataDir);
@ -183,12 +98,6 @@ export function createHandler(config: ServerConfig): (request: Request) => Promi
if (url.pathname === "/healthz") {
return textResponse("ok\n");
}
if (url.pathname === "/github") {
return handleGithub(request, config, store);
}
if (url.pathname === "/jojo") {
return handleJojo(request, config, store);
}
if (url.pathname === "/flow-events" || url.pathname.startsWith("/flow-events/")) {
return handleFlowEvents(request, config, store);
}
@ -203,8 +112,6 @@ if (import.meta.main) {
const port = Number(process.env.PORT ?? "3000");
const hostname = process.env.HOST ?? "0.0.0.0";
const config: ServerConfig = {
githubSecret: process.env.GITHUB_WEBHOOK_SECRET ?? "",
jojoSecret: process.env.JOJO_WEBHOOK_SECRET ?? "",
dataDir: process.env.DATA_DIR ?? "./data",
adminToken: process.env.PATCH_ADMIN_TOKEN,
discord: parseDiscordConfig({

View file

@ -1,16 +1,5 @@
import { timingSafeEqual } from "node:crypto";
const encoder = new TextEncoder();
function timingSafeEqualHex(a: string, b: string): boolean {
const left = Buffer.from(a, "hex");
const right = Buffer.from(b, "hex");
if (left.length !== right.length) {
return false;
}
return timingSafeEqual(left, right);
}
export async function hmacSha256Hex(secret: string, body: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
@ -22,41 +11,3 @@ export async function hmacSha256Hex(secret: string, body: string): Promise<strin
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
return Buffer.from(signature).toString("hex");
}
export async function verifyGithubSignature(
secret: string,
body: string,
signatureHeader: string | null,
): Promise<boolean> {
if (!secret || !signatureHeader?.startsWith("sha256=")) {
return false;
}
const expected = await hmacSha256Hex(secret, body);
const actual = signatureHeader.slice("sha256=".length).toLowerCase();
return /^[0-9a-f]{64}$/.test(actual) && timingSafeEqualHex(actual, expected);
}
export async function verifyJojoSignature(
secret: string,
body: string,
headers: Headers,
): Promise<boolean> {
if (!secret) {
return false;
}
const candidates = [
headers.get("x-forgejo-signature-256"),
headers.get("x-forgejo-signature"),
headers.get("x-gitea-signature-256"),
headers.get("x-gitea-signature"),
].filter((value): value is string => Boolean(value));
const expected = await hmacSha256Hex(secret, body);
return candidates.some((candidate) => {
const actual = candidate.startsWith("sha256=")
? candidate.slice("sha256=".length).toLowerCase()
: candidate.toLowerCase();
return /^[0-9a-f]{64}$/.test(actual) && timingSafeEqualHex(actual, expected);
});
}

View file

@ -1,49 +1,3 @@
export type Provider = "jojo" | "github";
export type NormalizedEventName =
| "ping"
| "push"
| "pull_request"
| "workflow_run"
| "release"
| "unknown";
export type GitWebhookEvent = {
provider: Provider;
event: NormalizedEventName;
providerEvent: string;
deliveryId: string;
receivedAt: string;
repo?: {
owner: string;
name: string;
fullName: string;
cloneUrl?: string;
sshUrl?: string;
defaultBranch?: string;
};
sender?: {
username: string;
htmlUrl?: string;
};
ref?: string;
before?: string;
after?: string;
action?: string;
raw: unknown;
};
export type QueuedJob = {
id: string;
kind: "main_push";
provider: Provider;
repoFullName: string;
ref: string;
sha: string;
deliveryId: string;
createdAt: string;
};
export type FeedProvider = "codeberg" | "github" | "jojo";
export type FeedEventName = "push" | "release";

View file

@ -1,29 +1,6 @@
import { describe, expect, test } from "bun:test";
import { buildDiscordPayload, notifyDiscord, parseDiscordConfig } from "../src/discord";
import type { FeedSignal, 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: "patch.moi",
fullName: "peezy-tech/patch.moi",
},
sender: {
username: "matamune",
},
ref: "refs/heads/main",
after: "0123456789abcdef",
raw: {
head_commit: {
url: "https://jojo.build/peezy-tech/patch.moi/commit/0123456789abcdef",
},
},
};
import type { FeedSignal } from "../src/types";
const feedSignal: FeedSignal = {
sourceId: "github-openai-codex-main",
@ -57,7 +34,6 @@ describe("discord notifications", () => {
const config = parseDiscordConfig({});
expect(config.enabled).toBe(false);
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);
});
@ -69,29 +45,9 @@ describe("discord notifications", () => {
expect(parseDiscordConfig({ enabled: "false" }).enabled).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/patch.moi",
ref: "refs/heads/main",
sha: "0123456789abcdef",
deliveryId: "delivery-1",
createdAt: "2026-05-12T21:00:00.000Z",
},
});
expect(payload.username).toBe("patch");
expect(payload.embeds[0].title).toBe("[jojo] peezy-tech/patch.moi push to main");
expect(payload.embeds[0].url).toBe("https://jojo.build/peezy-tech/patch.moi/commit/0123456789abcdef");
expect(payload.embeds[0].fields).toContainEqual({ name: "Queued", value: "main_push", inline: true });
});
test("builds readable feed embeds", () => {
const payload = buildDiscordPayload({ signal: feedSignal });
expect(payload.username).toBe("patch");
expect(payload.embeds[0].title).toBe("[github] openai/codex upstream update on main");
expect(payload.embeds[0].description).toBe("Tighten sandbox setup");
expect(payload.embeds[0].url).toBe("https://github.com/openai/codex/commit/0123456789abcdef0123456789abcdef01234567");
@ -100,7 +56,7 @@ describe("discord notifications", () => {
test("does nothing without a webhook URL", async () => {
let calls = 0;
await notifyDiscord(parseDiscordConfig({ enabled: "true" }), { event: pushEvent }, async () => {
await notifyDiscord(parseDiscordConfig({ enabled: "true" }), { signal: feedSignal }, async () => {
calls += 1;
return new Response(null, { status: 204 });
});
@ -109,7 +65,7 @@ describe("discord notifications", () => {
test("does nothing when Discord output is disabled", async () => {
let calls = 0;
await notifyDiscord(parseDiscordConfig({ webhookUrl: "https://discord.example/webhook", notifyEvents: "push" }), { event: pushEvent }, async () => {
await notifyDiscord(parseDiscordConfig({ webhookUrl: "https://discord.example/webhook", notifyEvents: "push" }), { signal: feedSignal }, async () => {
calls += 1;
return new Response(null, { status: 204 });
});
@ -118,7 +74,7 @@ describe("discord notifications", () => {
test("skips unconfigured events", async () => {
let calls = 0;
await notifyDiscord(parseDiscordConfig({ enabled: "true", webhookUrl: "https://discord.example/webhook", notifyEvents: "release" }), { event: pushEvent }, async () => {
await notifyDiscord(parseDiscordConfig({ enabled: "true", webhookUrl: "https://discord.example/webhook", notifyEvents: "release" }), { signal: feedSignal }, async () => {
calls += 1;
return new Response(null, { status: 204 });
});
@ -127,16 +83,16 @@ describe("discord notifications", () => {
test("posts configured events", async () => {
let body = "";
await notifyDiscord(parseDiscordConfig({ enabled: "true", webhookUrl: "https://discord.example/webhook", notifyEvents: "push" }), { event: pushEvent }, async (_url, init) => {
await notifyDiscord(parseDiscordConfig({ enabled: "true", webhookUrl: "https://discord.example/webhook", notifyEvents: "push" }), { signal: feedSignal }, async (_url, init) => {
body = String(init?.body);
return new Response(null, { status: 204 });
});
expect(JSON.parse(body).embeds[0].title).toBe("[jojo] peezy-tech/patch.moi push to main");
expect(JSON.parse(body).embeds[0].title).toBe("[github] openai/codex upstream update on main");
});
test("throws on Discord failure", async () => {
await expect(notifyDiscord(parseDiscordConfig({ enabled: "true", webhookUrl: "https://discord.example/webhook" }), { event: pushEvent }, async () => {
await expect(notifyDiscord(parseDiscordConfig({ enabled: "true", webhookUrl: "https://discord.example/webhook" }), { signal: feedSignal }, async () => {
return new Response("bad", { status: 500 });
})).rejects.toThrow("Discord webhook returned 500");
});

View file

@ -1,54 +0,0 @@
import { describe, expect, test } from "bun:test";
import { normalizeGithubEvent } from "../src/providers/github";
import { normalizeJojoEvent } from "../src/providers/jojo";
const repository = {
name: "patch.moi",
full_name: "peezy-tech/patch.moi",
clone_url: "https://example.test/peezy-tech/patch.moi.git",
ssh_url: "git@example.test:peezy-tech/patch.moi.git",
default_branch: "main",
owner: { login: "peezy-tech" },
};
describe("provider normalization", () => {
test("normalizes GitHub push events", () => {
const event = normalizeGithubEvent({
providerEvent: "push",
deliveryId: "delivery-1",
receivedAt: "2026-05-12T00:00:00.000Z",
payload: {
ref: "refs/heads/main",
before: "before",
after: "after",
repository,
sender: { login: "peezy", html_url: "https://github.com/peezy" },
},
});
expect(event.provider).toBe("github");
expect(event.event).toBe("push");
expect(event.repo?.fullName).toBe("peezy-tech/patch.moi");
expect(event.sender?.username).toBe("peezy");
});
test("normalizes jojo push events", () => {
const event = normalizeJojoEvent({
providerEvent: "push",
deliveryId: "delivery-2",
receivedAt: "2026-05-12T00:00:00.000Z",
payload: {
ref: "refs/heads/main",
before: "before",
after: "after",
repository,
sender: { username: "peezy", html_url: "https://jojo.build/peezy" },
},
});
expect(event.provider).toBe("jojo");
expect(event.event).toBe("push");
expect(event.repo?.owner).toBe("peezy-tech");
expect(event.sender?.username).toBe("peezy");
});
});

View file

@ -1,107 +1,30 @@
import { mkdtemp, readFile } from "node:fs/promises";
import { mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test } from "bun:test";
import { EventStore } from "../src/queue";
import { createHandler } from "../src/server";
import { hmacSha256Hex } from "../src/signatures";
async function signedRequest(path: string, provider: "github" | "jojo", secret: string, body: unknown): Promise<Request> {
const raw = JSON.stringify(body);
const digest = await hmacSha256Hex(secret, raw);
const headers: Record<string, string> = { "content-type": "application/json" };
if (provider === "github") {
headers["x-hub-signature-256"] = `sha256=${digest}`;
headers["x-github-event"] = "push";
headers["x-github-delivery"] = "github-delivery";
} else {
headers["x-forgejo-signature-256"] = `sha256=${digest}`;
headers["x-forgejo-event"] = "push";
headers["x-forgejo-delivery"] = "jojo-delivery";
}
return new Request(`http://localhost${path}`, { method: "POST", headers, body: raw });
}
describe("server", () => {
test("healthz returns ok", async () => {
const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir: await mkdtemp(join(tmpdir(), "patch-")) });
const handler = createHandler({ dataDir: await mkdtemp(join(tmpdir(), "patch-")) });
const response = await handler(new Request("http://localhost/healthz"));
expect(response.status).toBe(200);
expect(await response.text()).toBe("ok\n");
});
test("rejects invalid signatures", async () => {
const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir: await mkdtemp(join(tmpdir(), "patch-")) });
const response = await handler(new Request("http://localhost/github", {
method: "POST",
headers: { "x-hub-signature-256": "sha256=bad" },
body: "{}",
}));
expect(response.status).toBe(401);
});
test("does not serve old path-prefixed routes", async () => {
const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir: await mkdtemp(join(tmpdir(), "patch-")) });
test("does not serve provider intake routes", async () => {
const handler = createHandler({ dataDir: await mkdtemp(join(tmpdir(), "patch-")) });
const github = await handler(new Request("http://localhost/github", { method: "POST", body: "{}" }));
const jojo = await handler(new Request("http://localhost/jojo", { method: "POST", body: "{}" }));
const prefixedJojo = await handler(new Request("http://localhost/prefix/jojo", { method: "POST", body: "{}" }));
const prefixedGithub = await handler(new Request("http://localhost/prefix/github", { method: "POST", body: "{}" }));
expect(github.status).toBe(404);
expect(jojo.status).toBe(404);
expect(prefixedJojo.status).toBe(404);
expect(prefixedGithub.status).toBe(404);
});
test("accepts jojo main pushes and queues a job", async () => {
const dataDir = await mkdtemp(join(tmpdir(), "patch-"));
const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir });
const request = await signedRequest("/jojo", "jojo", "jojo", {
ref: "refs/heads/main",
after: "abc123",
repository: {
name: "patch.moi",
full_name: "peezy-tech/patch.moi",
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\"");
});
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(), "patch-"));
const handler = createHandler({
githubSecret: "gh",
jojoSecret: "jojo",
dataDir,
discord: {
enabled: true,
webhookUrl: "https://discord.example/webhook",
notifyEvents: new Set(["push"]),
},
});
const request = await signedRequest("/jojo", "jojo", "jojo", {
ref: "refs/heads/main",
after: "abc123",
repository: {
name: "patch.moi",
full_name: "peezy-tech/patch.moi",
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;
}
});
test("lists, retries, and replays stored flow events behind admin auth", async () => {
const originalFetch = globalThis.fetch;
const originalDispatchUrl = process.env.PATCH_FLOW_DISPATCH_URL;
@ -138,8 +61,6 @@ describe("server", () => {
try {
const handler = createHandler({
githubSecret: "gh",
jojoSecret: "jojo",
dataDir,
adminToken: "admin",
});

View file

@ -1,21 +1,10 @@
import { describe, expect, test } from "bun:test";
import { hmacSha256Hex, verifyGithubSignature, verifyJojoSignature } from "../src/signatures";
import { hmacSha256Hex } from "../src/signatures";
describe("webhook signatures", () => {
test("verifies GitHub sha256 signatures", async () => {
const body = JSON.stringify({ ok: true });
const digest = await hmacSha256Hex("secret", body);
expect(await verifyGithubSignature("secret", body, `sha256=${digest}`)).toBe(true);
expect(await verifyGithubSignature("wrong", body, `sha256=${digest}`)).toBe(false);
});
test("verifies jojo Forgejo/Gitea signature headers", async () => {
const body = JSON.stringify({ ok: true });
const digest = await hmacSha256Hex("secret", body);
const headers = new Headers({ "x-forgejo-signature-256": `sha256=${digest}` });
expect(await verifyJojoSignature("secret", body, headers)).toBe(true);
expect(await verifyJojoSignature("wrong", body, headers)).toBe(false);
describe("flow signatures", () => {
test("builds HMAC-SHA256 digests for flow dispatch signing", async () => {
expect(await hmacSha256Hex("secret", "payload")).toBe(
"b82fcb791acec57859b989b430a826488ce2e479fdf92326bd0a2e8375a42ba4",
);
});
});