Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

hooks.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

hooks.tsBlame684 lines · 1 contributor
ad6d4adClaude1/**
2 * Inbound API hooks — endpoints that external systems (GateTest, CI runners,
3 * Crontech deploy) call into to report async results.
4 *
5 * Security: every hook is authenticated via a shared-secret Bearer token OR
6 * HMAC signature over the raw body. Configure secrets via env vars:
7 * GATETEST_CALLBACK_SECRET — bearer token GateTest must present
8 * GATETEST_HMAC_SECRET — optional HMAC-SHA256 secret over the raw body
9 *
10 * Endpoints:
11 * POST /api/hooks/gatetest — GateTest scan result callback
12 * POST /api/hooks/gatetest/status — periodic status / heartbeat (optional)
13 *
14 * The GateTest service should POST JSON of shape:
15 * {
16 * "repository": "owner/repo",
17 * "sha": "<full-commit-sha>",
18 * "ref": "refs/heads/main",
19 * "pullRequestNumber": 42, // optional
20 * "status": "passed" | "failed" | "error",
21 * "summary": "12 tests passed, 0 failed",
22 * "details": { ... } // optional, persisted as JSON
23 * }
24 *
25 * Response: 200 OK with {ok:true, gateRunId} on success, 401 on auth failure,
26 * 400 on malformed payload, 404 if the repo is unknown.
27 */
28
29import { Hono } from "hono";
30import { and, desc, eq } from "drizzle-orm";
31import { createHmac, timingSafeEqual } from "crypto";
32import { db } from "../db";
33import {
34 apiTokens,
35 gateRuns,
34e63b9Claude36 prComments,
ad6d4adClaude37 pullRequests,
38 repositories,
39 users,
40} from "../db/schema";
41import { notify, audit } from "../lib/notify";
eead172Claude42import {
43 generatePatchForGateTestFinding,
44 severityAtOrAboveMedium,
45 type GateTestFinding,
46} from "../lib/ai-patch-generator";
34e63b9Claude47import { triggerCiAutofix, applyAutofix } from "../lib/ci-autofix";
eead172Claude48import { config } from "../lib/config";
ad6d4adClaude49
50const hooks = new Hono();
51
eead172Claude52/**
53 * Best-effort extraction of an array of GateTest findings from a
54 * webhook payload's `details` blob. GateTest's schema isn't pinned in
55 * code yet, so we accept a handful of common shapes:
56 * - `details.findings: [...]`
57 * - `details.issues: [...]`
58 * - `details.results: [...]`
59 * - `details: [...]` (raw array)
60 */
61function extractFindings(details: unknown): GateTestFinding[] {
62 if (!details) return [];
63 if (Array.isArray(details)) return details as GateTestFinding[];
64 if (typeof details === "object") {
65 const d = details as Record<string, unknown>;
66 for (const key of ["findings", "issues", "results", "violations"]) {
67 const v = d[key];
68 if (Array.isArray(v)) return v as GateTestFinding[];
69 }
70 }
71 return [];
72}
73
ad6d4adClaude74interface GateTestPayload {
75 repository?: string;
76 sha?: string;
77 ref?: string;
78 pullRequestNumber?: number;
79 status?: "passed" | "failed" | "error" | "success";
80 summary?: string;
81 details?: unknown;
82 durationMs?: number;
83}
84
85function constantTimeEq(a: string, b: string): boolean {
86 const A = Buffer.from(a);
87 const B = Buffer.from(b);
88 if (A.length !== B.length) return false;
89 try {
90 return timingSafeEqual(A, B);
91 } catch {
92 return false;
93 }
94}
95
96function verifyGateTestAuth(c: any, rawBody: string): { ok: boolean; error?: string } {
97 const bearerSecret = process.env.GATETEST_CALLBACK_SECRET || "";
98 const hmacSecret = process.env.GATETEST_HMAC_SECRET || "";
99
100 // If no secret is configured, refuse by default — do NOT allow anonymous writes.
101 if (!bearerSecret && !hmacSecret) {
102 return {
103 ok: false,
104 error:
105 "Callback endpoint not configured: set GATETEST_CALLBACK_SECRET or GATETEST_HMAC_SECRET",
106 };
107 }
108
109 // Bearer auth (simpler, preferred for server-to-server)
110 if (bearerSecret) {
111 const auth = c.req.header("authorization") || "";
112 if (auth.startsWith("Bearer ")) {
113 const token = auth.slice(7).trim();
114 if (constantTimeEq(token, bearerSecret)) return { ok: true };
115 }
116 // Alternative header for tools that don't send Authorization
117 const xTok = c.req.header("x-gatetest-token") || "";
118 if (xTok && constantTimeEq(xTok, bearerSecret)) return { ok: true };
119 }
120
121 // HMAC signature over the raw body
122 if (hmacSecret) {
123 const sigHeader =
124 c.req.header("x-gatetest-signature") ||
125 c.req.header("x-signature-sha256") ||
126 "";
127 if (sigHeader) {
128 const expected =
129 "sha256=" +
130 createHmac("sha256", hmacSecret).update(rawBody).digest("hex");
131 if (constantTimeEq(sigHeader, expected)) return { ok: true };
132 }
133 }
134
135 return { ok: false, error: "Invalid or missing GateTest credentials" };
136}
137
138async function resolveRepo(full: string): Promise<{ id: string; ownerId: string; name: string } | null> {
139 if (!full || !full.includes("/")) return null;
140 const [owner, name] = full.split("/", 2);
141 try {
142 const [row] = await db
143 .select({
144 id: repositories.id,
145 ownerId: repositories.ownerId,
146 name: repositories.name,
147 })
148 .from(repositories)
149 .innerJoin(users, eq(repositories.ownerId, users.id))
150 .where(and(eq(users.username, owner), eq(repositories.name, name)))
151 .limit(1);
152 return row || null;
153 } catch {
154 return null;
155 }
156}
157
158async function resolvePullRequestId(
159 repositoryId: string,
160 number: number | undefined
161): Promise<string | null> {
162 if (!number) return null;
163 try {
164 const [row] = await db
165 .select({ id: pullRequests.id })
166 .from(pullRequests)
167 .where(
168 and(
169 eq(pullRequests.repositoryId, repositoryId),
170 eq(pullRequests.number, number)
171 )
172 )
173 .limit(1);
174 return row?.id || null;
175 } catch {
176 return null;
177 }
178}
179
180/**
181 * POST /api/hooks/gatetest
182 * Async scan result callback from GateTest.
183 */
184hooks.post("/api/hooks/gatetest", async (c) => {
185 const rawBody = await c.req.text();
186
187 const auth = verifyGateTestAuth(c, rawBody);
188 if (!auth.ok) {
189 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
190 }
191
192 let payload: GateTestPayload;
193 try {
194 payload = JSON.parse(rawBody) as GateTestPayload;
195 } catch {
196 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
197 }
198
199 if (!payload.repository || !payload.sha || !payload.status) {
200 return c.json(
201 {
202 ok: false,
203 error: "Required fields: repository (owner/name), sha, status",
204 },
205 400
206 );
207 }
208
209 const repo = await resolveRepo(payload.repository);
210 if (!repo) {
211 return c.json(
212 { ok: false, error: `Unknown repository: ${payload.repository}` },
213 404
214 );
215 }
216
217 const normalisedStatus =
218 payload.status === "passed" || payload.status === "success"
219 ? "passed"
220 : payload.status === "failed"
221 ? "failed"
222 : "failed"; // treat "error" as a hard fail
223
224 const pullRequestId = await resolvePullRequestId(
225 repo.id,
226 payload.pullRequestNumber
227 );
228
229 let gateRunId: string | null = null;
230 try {
231 const [row] = await db
232 .insert(gateRuns)
233 .values({
234 repositoryId: repo.id,
235 pullRequestId: pullRequestId || undefined,
236 commitSha: payload.sha,
237 ref: payload.ref || "refs/heads/main",
238 gateName: "GateTest",
239 status: normalisedStatus,
240 summary: payload.summary || `GateTest reported ${normalisedStatus}`,
241 details: payload.details ? JSON.stringify(payload.details) : null,
242 durationMs: payload.durationMs,
243 completedAt: new Date(),
244 })
245 .returning({ id: gateRuns.id });
246 gateRunId = row?.id || null;
247 } catch (err) {
248 console.error("[hooks/gatetest] insert failed:", err);
249 return c.json({ ok: false, error: "Failed to record gate run" }, 500);
250 }
251
252 // Notify the repo owner on failure
253 if (normalisedStatus === "failed") {
254 try {
255 await notify(repo.ownerId, {
256 kind: "gate_failed",
257 title: `GateTest failed on ${payload.repository}`,
258 body: payload.summary || "GateTest reported failure via callback",
259 repositoryId: repo.id,
260 });
261 } catch (err) {
262 console.error("[hooks/gatetest] notify failed:", err);
263 }
264 }
265
266 try {
267 await audit({
268 userId: null,
269 action: "gate_callback",
270 repositoryId: repo.id,
271 metadata: {
272 gateName: "GateTest",
273 sha: payload.sha,
274 status: normalisedStatus,
275 source: "gatetest-callback",
276 },
277 });
278 } catch {
279 /* swallow */
280 }
281
eead172Claude282 // AI patch generator — if the gate failed AND the env flag is on AND
283 // an Anthropic key is configured, fire-and-forget a patch PR for the
284 // first actionable medium+ severity finding. Never blocks the
285 // webhook response.
286 if (
287 normalisedStatus === "failed" &&
288 process.env.AI_PATCH_GENERATOR_ENABLED === "1" &&
289 config.anthropicApiKey
290 ) {
291 const findings = extractFindings(payload.details).filter((f) =>
292 severityAtOrAboveMedium(f.severity)
293 );
294 if (findings.length > 0) {
295 generatePatchForGateTestFinding({
296 repositoryId: repo.id,
297 baseSha: payload.sha,
298 findings,
299 reportUrl:
300 typeof (payload.details as Record<string, unknown> | null)?.reportUrl === "string"
301 ? ((payload.details as Record<string, string>).reportUrl)
302 : null,
303 }).catch((err) =>
304 console.error(
305 "[hooks/gatetest] AI patch generator crashed:",
306 err instanceof Error ? err.message : err
307 )
308 );
309 }
310 }
311
34e63b9Claude312 // CI auto-fix — if the gate failed on a PR, post a ready-to-apply patch
313 // comment. Fire-and-forget; never blocks the webhook response.
314 if (normalisedStatus === "failed" && gateRunId && pullRequestId) {
315 triggerCiAutofix(gateRunId).catch((err) =>
316 console.error(
317 "[hooks/gatetest] ci-autofix crashed:",
318 err instanceof Error ? err.message : err
319 )
320 );
321 }
322
ad6d4adClaude323 return c.json({ ok: true, gateRunId });
324});
325
326/**
327 * GET /api/hooks/gatetest/recent
328 * Small read-only endpoint GateTest can hit to verify connectivity +
329 * see the 10 most recent gate runs for sanity checks.
330 */
331hooks.get("/api/hooks/gatetest/recent", async (c) => {
332 const rawBody = "";
333 const auth = verifyGateTestAuth(c, rawBody);
334 if (!auth.ok) {
335 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
336 }
337
338 try {
339 const rows = await db
340 .select({
341 id: gateRuns.id,
342 gateName: gateRuns.gateName,
343 status: gateRuns.status,
344 summary: gateRuns.summary,
345 createdAt: gateRuns.createdAt,
346 })
347 .from(gateRuns)
348 .orderBy(desc(gateRuns.createdAt))
349 .limit(10);
350 return c.json({ ok: true, runs: rows });
351 } catch (err) {
352 console.error("[hooks/gatetest] recent failed:", err);
353 return c.json({ ok: false, error: "DB error" }, 500);
354 }
355});
356
357/**
358 * GET /api/hooks/ping
359 * Unauthenticated liveness — for GateTest to probe reachability without
360 * needing credentials. Returns 200 + version info.
361 */
362hooks.get("/api/hooks/ping", (c) => {
363 return c.json({
364 ok: true,
365 service: "gluecron",
366 hooks: ["gatetest", "gatetest/recent", "api/v1/gate-runs (backup)"],
367 timestamp: new Date().toISOString(),
368 });
369});
370
371// ---------------------------------------------------------------------------
372// Backup API: personal-access-token path
373//
374// If for any reason the shared-secret callback path is unavailable,
375// GateTest can authenticate with a standard GlueCron personal access token
376// and POST the same payload to /api/v1/gate-runs.
377//
378// Advantages of the backup path:
379// - no new secrets to provision (reuses existing PAT infra)
380// - scoped per-user (audit trail points at a real account)
381// - revocable from /settings/tokens
382//
383// Trade-off: slightly heavier auth (DB lookup per call), so prefer
384// /api/hooks/gatetest for high-volume traffic.
385// ---------------------------------------------------------------------------
386
387async function hashPat(token: string): Promise<string> {
388 const data = new TextEncoder().encode(token);
389 const hash = await crypto.subtle.digest("SHA-256", data);
390 return Array.from(new Uint8Array(hash))
391 .map((b) => b.toString(16).padStart(2, "0"))
392 .join("");
393}
394
395async function verifyPatAuth(
396 c: any
397): Promise<{ ok: boolean; userId?: string; error?: string }> {
398 const auth = c.req.header("authorization") || "";
399 const rawToken =
400 (auth.startsWith("Bearer ") ? auth.slice(7) : "").trim() ||
401 (c.req.header("x-api-token") || "").trim();
402 if (!rawToken) {
403 return { ok: false, error: "Missing Bearer token" };
404 }
405 if (!rawToken.startsWith("glc_")) {
406 return { ok: false, error: "Invalid token format" };
407 }
408 try {
409 const hashed = await hashPat(rawToken);
410 const [row] = await db
411 .select({ id: apiTokens.id, userId: apiTokens.userId, expiresAt: apiTokens.expiresAt })
412 .from(apiTokens)
413 .where(eq(apiTokens.tokenHash, hashed))
414 .limit(1);
415 if (!row) return { ok: false, error: "Unknown token" };
416 if (row.expiresAt && row.expiresAt < new Date()) {
417 return { ok: false, error: "Token expired" };
418 }
a28cedeClaude419 // Update last-used timestamp (fire and forget). Log persistent
420 // failures so DB issues surface (was silent .catch(() => {}).)
ad6d4adClaude421 db.update(apiTokens)
422 .set({ lastUsedAt: new Date() })
423 .where(eq(apiTokens.id, row.id))
a28cedeClaude424 .catch((err) => {
425 console.warn(
426 "[hooks] PAT lastUsedAt update failed:",
427 err instanceof Error ? err.message : err
428 );
429 });
ad6d4adClaude430 return { ok: true, userId: row.userId };
431 } catch {
432 return { ok: false, error: "Auth lookup failed" };
433 }
434}
435
436/**
437 * POST /api/v1/gate-runs
438 * Backup path — accepts a personal access token and records a gate run.
439 * Identical payload shape to /api/hooks/gatetest.
440 */
441hooks.post("/api/v1/gate-runs", async (c) => {
442 const rawBody = await c.req.text();
443
444 const auth = await verifyPatAuth(c);
445 if (!auth.ok) {
446 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
447 }
448
449 let payload: GateTestPayload & { gateName?: string };
450 try {
451 payload = JSON.parse(rawBody);
452 } catch {
453 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
454 }
455
456 if (!payload.repository || !payload.sha || !payload.status) {
457 return c.json(
458 { ok: false, error: "Required: repository, sha, status" },
459 400
460 );
461 }
462
463 const repo = await resolveRepo(payload.repository);
464 if (!repo) {
465 return c.json(
466 { ok: false, error: `Unknown repository: ${payload.repository}` },
467 404
468 );
469 }
470
471 // Scope check: the PAT's owner must own the repo OR have admin/write scope.
472 // For MVP, require the token owner to match the repo owner.
473 if (repo.ownerId !== auth.userId) {
474 return c.json(
475 { ok: false, error: "Token does not own this repository" },
476 403
477 );
478 }
479
480 const normalisedStatus =
481 payload.status === "passed" || payload.status === "success"
482 ? "passed"
483 : payload.status === "failed"
484 ? "failed"
485 : "failed";
486
487 const pullRequestId = await resolvePullRequestId(
488 repo.id,
489 payload.pullRequestNumber
490 );
491
492 let gateRunId: string | null = null;
493 try {
494 const [row] = await db
495 .insert(gateRuns)
496 .values({
497 repositoryId: repo.id,
498 pullRequestId: pullRequestId || undefined,
499 commitSha: payload.sha,
500 ref: payload.ref || "refs/heads/main",
501 gateName: payload.gateName || "GateTest",
502 status: normalisedStatus,
503 summary: payload.summary || `${payload.gateName || "GateTest"} reported ${normalisedStatus}`,
504 details: payload.details ? JSON.stringify(payload.details) : null,
505 durationMs: payload.durationMs,
506 completedAt: new Date(),
507 })
508 .returning({ id: gateRuns.id });
509 gateRunId = row?.id || null;
510 } catch (err) {
511 console.error("[hooks/backup] insert failed:", err);
512 return c.json({ ok: false, error: "Failed to record gate run" }, 500);
513 }
514
515 if (normalisedStatus === "failed") {
516 try {
517 await notify(repo.ownerId, {
518 kind: "gate_failed",
519 title: `${payload.gateName || "GateTest"} failed on ${payload.repository}`,
520 body: payload.summary || "Gate failure reported via backup API",
521 repositoryId: repo.id,
522 });
523 } catch {
524 /* swallow */
525 }
526 }
527
528 try {
529 await audit({
530 userId: auth.userId,
531 action: "gate_callback_backup",
532 repositoryId: repo.id,
533 metadata: {
534 gateName: payload.gateName || "GateTest",
535 sha: payload.sha,
536 status: normalisedStatus,
537 source: "pat-api",
538 },
539 });
540 } catch {
541 /* swallow */
542 }
543
eead172Claude544 // Same fire-and-forget AI patch hook as the primary callback path.
545 if (
546 normalisedStatus === "failed" &&
547 process.env.AI_PATCH_GENERATOR_ENABLED === "1" &&
548 config.anthropicApiKey
549 ) {
550 const findings = extractFindings(payload.details).filter((f) =>
551 severityAtOrAboveMedium(f.severity)
552 );
553 if (findings.length > 0) {
554 generatePatchForGateTestFinding({
555 repositoryId: repo.id,
556 baseSha: payload.sha,
557 findings,
558 reportUrl:
559 typeof (payload.details as Record<string, unknown> | null)?.reportUrl === "string"
560 ? ((payload.details as Record<string, string>).reportUrl)
561 : null,
562 }).catch((err) =>
563 console.error(
564 "[hooks/backup] AI patch generator crashed:",
565 err instanceof Error ? err.message : err
566 )
567 );
568 }
569 }
570
34e63b9Claude571 // CI auto-fix — post a ready-to-apply patch comment on the PR.
572 if (normalisedStatus === "failed" && gateRunId && pullRequestId) {
573 triggerCiAutofix(gateRunId).catch((err) =>
574 console.error(
575 "[hooks/backup] ci-autofix crashed:",
576 err instanceof Error ? err.message : err
577 )
578 );
579 }
580
ad6d4adClaude581 return c.json({ ok: true, gateRunId });
582});
583
584/**
585 * GET /api/v1/gate-runs?repository=owner/name&limit=20
586 * Backup read path — list recent gate runs for a repo (PAT-authed).
587 */
588hooks.get("/api/v1/gate-runs", async (c) => {
589 const auth = await verifyPatAuth(c);
590 if (!auth.ok) {
591 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
592 }
593
594 const repoFull = c.req.query("repository") || "";
595 const limit = Math.min(100, Math.max(1, Number(c.req.query("limit") || 20)));
596 if (!repoFull) {
597 return c.json({ ok: false, error: "Query param 'repository' required" }, 400);
598 }
599
600 const repo = await resolveRepo(repoFull);
601 if (!repo) return c.json({ ok: false, error: "Unknown repository" }, 404);
602 if (repo.ownerId !== auth.userId) {
603 return c.json({ ok: false, error: "Forbidden" }, 403);
604 }
605
606 try {
607 const rows = await db
608 .select()
609 .from(gateRuns)
610 .where(eq(gateRuns.repositoryId, repo.id))
611 .orderBy(desc(gateRuns.createdAt))
612 .limit(limit);
613 return c.json({ ok: true, runs: rows });
614 } catch {
615 return c.json({ ok: false, error: "DB error" }, 500);
616 }
617});
618
34e63b9Claude619/**
620 * POST /api/pr-comments/:commentId/apply-autofix
621 *
622 * Applies the patch embedded in a CI autofix comment to a new branch, then
623 * redirects to the compare view. Authenticated via a personal access token
624 * (same mechanism as /api/v1/gate-runs).
625 *
626 * Response on success (JSON): { ok: true, branchName, compareUrl }
627 * Response on failure (JSON): { ok: false, error }
628 */
629hooks.post("/api/pr-comments/:commentId/apply-autofix", async (c) => {
630 const auth = await verifyPatAuth(c);
631 if (!auth.ok || !auth.userId) {
632 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
633 }
634
635 const { commentId } = c.req.param();
636 if (!commentId) {
637 return c.json({ ok: false, error: "commentId param required" }, 400);
638 }
639
640 let result: { branchName: string };
641 try {
642 result = await applyAutofix(commentId, auth.userId);
643 } catch (err) {
644 const msg = err instanceof Error ? err.message : "Unknown error";
645 return c.json({ ok: false, error: msg }, 400);
646 }
647
648 // Look up the PR to build the compare URL
649 const commentRows = await db
650 .select({ pullRequestId: prComments.pullRequestId })
651 .from(prComments)
652 .where(eq(prComments.id, commentId))
653 .limit(1);
654
655 let compareUrl: string | null = null;
656 if (commentRows[0]) {
657 const prRows = await db
658 .select({
659 repositoryId: pullRequests.repositoryId,
660 number: pullRequests.number,
661 baseBranch: pullRequests.baseBranch,
662 })
663 .from(pullRequests)
664 .where(eq(pullRequests.id, commentRows[0].pullRequestId))
665 .limit(1);
666
667 if (prRows[0]) {
668 const repoRows = await db
669 .select({ name: repositories.name, ownerUsername: users.username })
670 .from(repositories)
671 .innerJoin(users, eq(repositories.ownerId, users.id))
672 .where(eq(repositories.id, prRows[0].repositoryId))
673 .limit(1);
674
675 if (repoRows[0]) {
676 compareUrl = `/${repoRows[0].ownerUsername}/${repoRows[0].name}/compare/${result.branchName}`;
677 }
678 }
679 }
680
681 return c.json({ ok: true, branchName: result.branchName, compareUrl });
682});
683
ad6d4adClaude684export default hooks;