syntaxai/tdd.md · commit 533def4

Forgejo webhook auto-triggers the judge on push

POST /api/forgejo/webhook verifies the HMAC-SHA256 signature
(X-Forgejo-Signature / X-Gitea-Signature) using the shared secret in
podman secret tdd_webhook_secret, parses the push payload to get
owner/repo, and fires judge() in the background. Forgejo gets an
immediate 200 so its delivery doesn't time out while we're cloning.

Agent registration now also installs the per-repo webhook (idempotent —
checks for an existing hook with the same URL before creating). Failure
to install the webhook doesn't block the registration; it just means
the agent has to use the manual /api/judge endpoint.

End-to-end: red+green push → webhook → judge → SQLite verdict → repo
page reflects the new score, no manual trigger needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-03 17:37:45 +01:00
parent
02961df
commit
533def44dc136b5bd9166f42028bea31bd70b618

3 files changed · +98 −0

modified scripts/p620/tdd-md.container +1 −0
@@ -31,6 +31,7 @@ Environment=GITHUB_CLIENT_ID=Ov23li9O1wWWJDjlm6dX
3131 Secret=tdd_github_client_secret,type=env,target=GITHUB_CLIENT_SECRET
3232 Secret=tdd_forgejo_admin_token,type=env,target=FORGEJO_ADMIN_TOKEN
3333 Secret=tdd_session_secret,type=env,target=SESSION_SECRET
34+Secret=tdd_webhook_secret,type=env,target=WEBHOOK_SECRET
3435
3536 # Geen PublishPort — pod publisht al :44390 → :3000.
3637
modified src/forgejo.ts +52 −0
@@ -69,6 +69,43 @@ export const repoExists = async (owner: string, repo: string): Promise<boolean>
6969 return res.status === 200;
7070 };
7171
72+// Creates a per-repo webhook that fires on push events. The webhook
73+// posts to /api/forgejo/webhook on tdd.md, signed with WEBHOOK_SECRET so
74+// our endpoint can verify it. Idempotent — checks for an existing hook
75+// with the same URL before creating.
76+export const ensureRepoWebhook = async (params: {
77+ owner: string;
78+ repo: string;
79+ webhookUrl: string;
80+ secret: string;
81+}): Promise<void> => {
82+ const base = `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(params.owner)}/${encodeURIComponent(params.repo)}/hooks`;
83+ const listRes = await fetch(base, { headers: adminAuth() });
84+ if (listRes.ok) {
85+ const hooks = (await listRes.json()) as { id: number; config: { url?: string } }[];
86+ const exists = hooks.some((h) => h.config?.url === params.webhookUrl);
87+ if (exists) return;
88+ }
89+ const res = await fetch(base, {
90+ method: "POST",
91+ headers: { ...adminAuth(), "Content-Type": "application/json" },
92+ body: JSON.stringify({
93+ type: "forgejo",
94+ active: true,
95+ events: ["push"],
96+ config: {
97+ url: params.webhookUrl,
98+ content_type: "json",
99+ secret: params.secret,
100+ },
101+ }),
102+ });
103+ if (!res.ok) {
104+ const text = await res.text();
105+ throw new Error(`forgejo ensureRepoWebhook ${res.status}: ${text}`);
106+ }
107+};
108+
72109 export const createRepoForUser = async (params: {
73110 username: string;
74111 name: string;
@@ -194,6 +231,21 @@ export const registerAgent = async (params: {
194231 }
195232
196233 const baseUrl = process.env.BASE_URL ?? "https://tdd.md";
234+ const webhookSecret = process.env.WEBHOOK_SECRET;
235+ if (webhookSecret) {
236+ try {
237+ await ensureRepoWebhook({
238+ owner: params.username,
239+ repo: kata,
240+ webhookUrl: `${baseUrl}/api/forgejo/webhook`,
241+ secret: webhookSecret,
242+ });
243+ } catch (err) {
244+ // Webhook is convenience; registration must still succeed without it.
245+ console.error(`webhook setup failed for ${params.username}/${kata}:`, err);
246+ }
247+ }
248+
197249 return {
198250 username: params.username,
199251 pushToken,
modified src/server.ts +45 −0
@@ -138,6 +138,20 @@ const timingSafeEqual = (a: string, b: string): boolean => {
138138 return r === 0;
139139 };
140140
141+const hmacSha256Hex = async (secret: string, body: string): Promise<string> => {
142+ const key = await crypto.subtle.importKey(
143+ "raw",
144+ new TextEncoder().encode(secret),
145+ { name: "HMAC", hash: "SHA-256" },
146+ false,
147+ ["sign"],
148+ );
149+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
150+ return Array.from(new Uint8Array(sig))
151+ .map((b) => b.toString(16).padStart(2, "0"))
152+ .join("");
153+};
154+
141155 // Forward git protocol + Forgejo API/asset requests to Forgejo via the host
142156 // network. Lets us serve everything under tdd.md (GitHub-style) without
143157 // exposing git.tdd.md externally.
@@ -443,6 +457,37 @@ const server = Bun.serve({
443457 }
444458 },
445459
460+ "/api/forgejo/webhook": async (req) => {
461+ if (req.method !== "POST") return new Response("POST only", { status: 405 });
462+ const secret = process.env.WEBHOOK_SECRET;
463+ if (!secret) return new Response("webhook not configured", { status: 503 });
464+
465+ const body = await req.text();
466+ const provided =
467+ req.headers.get("x-forgejo-signature") ?? req.headers.get("x-gitea-signature") ?? "";
468+ const expected = await hmacSha256Hex(secret, body);
469+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
470+ return new Response("invalid signature", { status: 401 });
471+ }
472+
473+ let payload: { repository?: { owner?: { login?: string }; name?: string }; ref?: string };
474+ try {
475+ payload = JSON.parse(body);
476+ } catch {
477+ return new Response("invalid json", { status: 400 });
478+ }
479+ const owner = payload.repository?.owner?.login;
480+ const repo = payload.repository?.name;
481+ if (!owner || !repo) return new Response("missing owner/repo", { status: 400 });
482+
483+ // Fire the judge in the background; ack immediately so Forgejo
484+ // doesn't time out while we're checking out commits.
485+ void judge(owner, repo).catch((err) => {
486+ console.error(`judge failed for ${owner}/${repo}:`, err);
487+ });
488+ return Response.json({ accepted: true, owner, repo });
489+ },
490+
446491 "/auth/github/start": (_req) => {
447492 if (!github.isConfigured() || !forgejo.isConfigured()) {
448493 return errorPage("registration is not configured on this server", 503);