From 88ddcfba39ed08006c9bdbf1e1958b51ea2dc983 Mon Sep 17 00:00:00 2001 From: matamune Date: Fri, 15 May 2026 23:00:31 +0000 Subject: [PATCH] foo --- .gitignore | 2 - README.md | 18 +++---- src/discord.ts | 116 +++++++--------------------------------- src/providers/github.ts | 76 -------------------------- src/providers/jojo.ts | 76 -------------------------- src/queue.ts | 31 +---------- src/server.ts | 97 +-------------------------------- src/signatures.ts | 49 ----------------- src/types.ts | 46 ---------------- test/discord.test.ts | 60 +++------------------ test/providers.test.ts | 54 ------------------- test/server.test.ts | 95 +++----------------------------- test/signatures.test.ts | 23 +++----- 13 files changed, 53 insertions(+), 690 deletions(-) delete mode 100644 src/providers/github.ts delete mode 100644 src/providers/jojo.ts delete mode 100644 test/providers.test.ts diff --git a/.gitignore b/.gitignore index 3309191..e7f2df2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,4 @@ coverage *.log .env data/*.jsonl -data/events -data/jobs .DS_Store diff --git a/README.md b/README.md index e8e30aa..19c71ba 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/discord.ts b/src/discord.ts index 266bc17..d04570e 100644 --- a/src/discord.ts +++ b/src/discord.ts @@ -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 { - 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 { - 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 { - 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; } diff --git a/src/providers/github.ts b/src/providers/github.ts deleted file mode 100644 index b9c4a5b..0000000 --- a/src/providers/github.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/providers/jojo.ts b/src/providers/jojo.ts deleted file mode 100644 index 95b729e..0000000 --- a/src/providers/jojo.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/queue.ts b/src/queue.ts index e7d654b..76aabba 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -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 { await mkdir(dirname(path), { recursive: true }); @@ -30,30 +30,18 @@ function limitNewest(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 { - await appendJsonLine(this.eventsPath, event); - } - - async appendJob(job: QueuedJob): Promise { - await appendJsonLine(this.jobsPath, job); - } - async appendFeedSignal(signal: FeedSignal): Promise { 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; diff --git a/src/server.ts b/src/server.ts index 16158fe..4f52985 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { - 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 { - 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 { - 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 { 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({ diff --git a/src/signatures.ts b/src/signatures.ts index 3058ebc..aa23ff3 100644 --- a/src/signatures.ts +++ b/src/signatures.ts @@ -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 { const key = await crypto.subtle.importKey( "raw", @@ -22,41 +11,3 @@ export async function hmacSha256Hex(secret: string, body: string): Promise { - 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 { - 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); - }); -} diff --git a/src/types.ts b/src/types.ts index 0fb1db3..361fe7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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"; diff --git a/test/discord.test.ts b/test/discord.test.ts index cde0c35..a8cb87a 100644 --- a/test/discord.test.ts +++ b/test/discord.test.ts @@ -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"); }); diff --git a/test/providers.test.ts b/test/providers.test.ts deleted file mode 100644 index bd7e9bd..0000000 --- a/test/providers.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/test/server.test.ts b/test/server.test.ts index 72c1f24..41fb2fc 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -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 { - const raw = JSON.stringify(body); - const digest = await hmacSha256Hex(secret, raw); - const headers: Record = { "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", }); diff --git a/test/signatures.test.ts b/test/signatures.test.ts index 5214422..fae10f6 100644 --- a/test/signatures.test.ts +++ b/test/signatures.test.ts @@ -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", + ); }); });