foo
This commit is contained in:
parent
5e308efe66
commit
88ddcfba39
13 changed files with 53 additions and 690 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -4,6 +4,4 @@ coverage
|
|||
*.log
|
||||
.env
|
||||
data/*.jsonl
|
||||
data/events
|
||||
data/jobs
|
||||
.DS_Store
|
||||
|
|
|
|||
18
README.md
18
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
|
||||
|
|
|
|||
116
src/discord.ts
116
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<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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
31
src/queue.ts
31
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<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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
46
src/types.ts
46
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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue