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]>
3 files changed · +98 −0
scripts/p620/tdd-md.container
+1
−0
| @@ -31,6 +31,7 @@ Environment=GITHUB_CLIENT_ID=Ov23li9O1wWWJDjlm6dX | ||
| 31 | 31 | Secret=tdd_github_client_secret,type=env,target=GITHUB_CLIENT_SECRET |
| 32 | 32 | Secret=tdd_forgejo_admin_token,type=env,target=FORGEJO_ADMIN_TOKEN |
| 33 | 33 | Secret=tdd_session_secret,type=env,target=SESSION_SECRET |
| 34 | +Secret=tdd_webhook_secret,type=env,target=WEBHOOK_SECRET | |
| 34 | 35 | |
| 35 | 36 | # Geen PublishPort — pod publisht al :44390 → :3000. |
| 36 | 37 | |
src/forgejo.ts
+52
−0
| @@ -69,6 +69,43 @@ export const repoExists = async (owner: string, repo: string): Promise<boolean> | ||
| 69 | 69 | return res.status === 200; |
| 70 | 70 | }; |
| 71 | 71 | |
| 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 | + | |
| 72 | 109 | export const createRepoForUser = async (params: { |
| 73 | 110 | username: string; |
| 74 | 111 | name: string; |
| @@ -194,6 +231,21 @@ export const registerAgent = async (params: { | ||
| 194 | 231 | } |
| 195 | 232 | |
| 196 | 233 | 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 | + | |
| 197 | 249 | return { |
| 198 | 250 | username: params.username, |
| 199 | 251 | pushToken, |
src/server.ts
+45
−0
| @@ -138,6 +138,20 @@ const timingSafeEqual = (a: string, b: string): boolean => { | ||
| 138 | 138 | return r === 0; |
| 139 | 139 | }; |
| 140 | 140 | |
| 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 | + | |
| 141 | 155 | // Forward git protocol + Forgejo API/asset requests to Forgejo via the host |
| 142 | 156 | // network. Lets us serve everything under tdd.md (GitHub-style) without |
| 143 | 157 | // exposing git.tdd.md externally. |
| @@ -443,6 +457,37 @@ const server = Bun.serve({ | ||
| 443 | 457 | } |
| 444 | 458 | }, |
| 445 | 459 | |
| 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 | + | |
| 446 | 491 | "/auth/github/start": (_req) => { |
| 447 | 492 | if (!github.isConfigured() || !forgejo.isConfigured()) { |
| 448 | 493 | return errorPage("registration is not configured on this server", 503); |