Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commita74f4ed

fix(webhooks): wire fireWebhooks() into the 11 real event points it was missing from

fix(webhooks): wire fireWebhooks() into the 11 real event points it was missing from

fireWebhooks() (webhooks.tsx) was fully implemented with a real retry/
dead-letter queue behind it and a UI showing a live "last delivery"
status pill, but had zero callers anywhere in the codebase. Users could
configure a webhook and it would never fire.

Wired into:
  - post-receive.ts: push
  - issues.tsx: create, comment, close, reopen
  - pulls.tsx: create, comment, merge, close
  - pr-merge.ts: merge (shared path, covers auto-merge/autopilot)
  - web.tsx: star (added)

fireWebhooks() itself refactored to accept injectable deps (loadHooks/
enqueueWebhookDelivery/drainPendingDeliveries), defaulted to the real
implementations — same DI rationale as pr-workflow-sync.ts and
codeowners.ts's requiredOwnersApproved: avoids mock.module() on ../db
or ../lib/webhook-delivery, both imported by dozens of unrelated test
files. An earlier version of webhook-fire-wiring.test.ts used
mock.module() directly and it leaked globally, breaking week3.test.ts's
markdown-rendering test — same class of bug as pr-workflow-sync.test.ts
earlier this session, same fix.

scripts/agent-journey.ts extended with:
  - step 4c: register a probe webhook (pr events) pointed at the
    instance's own /healthz
  - step 10: poll the probe webhook's settings page for a recorded
    delivery attempt — proves fireWebhooks() is wired into the real
    PR create/merge handlers, not just present in the codebase
  - step 9 (activity feed) promoted from warnOnly to a hard assertion
    now that its underlying gap (fixed same-day, commit b7b5f75) is
    closed

Verified: grep confirms 11 real caller lines (was 0, only the
definition). webhook-fire-wiring.test.ts: 7/7 pass, no cross-file mock
leak (confirmed via full-suite re-run, 3080 pass / 4 pre-existing
unrelated fails). Live agent-journey.ts run against unpatched
production correctly FAILED at the new step 10 (webhooks genuinely
weren't firing yet, pre-deploy) — confirms the check itself is valid,
not a false-positive-prone assertion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ccanty labs committed on July 16, 2026Parent: ae2a071
8 files changed+26023a74f4ed630ad8ffd2687b2ca7508d5f84e718a1d
8 changed files+260−23
Modifiedscripts/agent-journey.ts+71−16View fileUnifiedSplit
66//
77// 1. identity GET /api/v2/user
88// 1b. sweep delete any journey-* repos >1h old left behind by a
9// prior run that never reached its own step 10 (e.g.
9// prior run that never reached its own step 11 (e.g.
1010// the process was killed mid-run) — WARN-only
1111// 2. create repo POST /api/v2/repos (no MCP create-repo tool exists — platform gap, noted)
1212// 3. git push smart-HTTP receive-pack with PAT basic auth
1313// 4. branch + edit gluecron_create_branch + gluecron_write_file
14// 4c. webhook register a probe webhook (pr events) via the same
15// PAT bearer, so step 10 can prove fireWebhooks() is
16// wired into the real create/merge call sites
1417// 5. open PR gluecron_create_pr
1518// 6. comment gluecron_comment_pr
1619// 7. AI review poll PR comments for the ai-review marker (WARN if absent)
1720// 8. merge gluecron_merge_pr → gluecron_get_pr state=merged
1821// 9. events GET /api/v2/repos/:o/:r/activity
19// 10. cleanup gluecron_delete_repo (always, unless KEEP_REPO=1)
22// 10. webhook delivery poll the probe webhook's settings page for a
23// recorded delivery attempt
24// 11. cleanup gluecron_delete_repo (always, unless KEEP_REPO=1)
2025//
2126// Run: bun run scripts/agent-journey.ts
2227// Env: GLUECRON_PAT REQUIRED — admin-scope PAT (merge + delete need it)
188193 await step("4a. gluecron_create_branch", () => mcp("gluecron_create_branch", { owner, repo: REPO, branch: "feature/journey", sha: mainSha }));
189194 await step("4b. gluecron_write_file on branch", () => mcp("gluecron_write_file", { owner, repo: REPO, path: "probe.md", branch: "feature/journey", message: "feat: journey probe file", content: `probe ${new Date().toISOString()}\n` }));
190195
196 // 4c. register a probe webhook (pr events) so step 11 below can prove
197 // fireWebhooks() is actually wired into the real PR create/merge call
198 // sites, not just present in the codebase. Points at this instance's
199 // own /healthz — fast, always answers with a real HTTP status, no
200 // external network dependency. requireAuth on this form route accepts
201 // the same PAT bearer as everything else (softAuth: "Bearer token takes
202 // precedence over cookie for API calls").
203 await step("4c. register probe webhook", async () => {
204 const res = await fetch(`${HOST}/${owner}/${REPO}/settings/webhooks`, {
205 method: "POST",
206 headers: {
207 authorization: `Bearer ${PAT}`,
208 "content-type": "application/x-www-form-urlencoded",
209 },
210 body: new URLSearchParams({ url: `${HOST}/healthz`, events: "pr" }),
211 redirect: "manual",
212 });
213 // The handler always redirects (3xx) on both success and validation
214 // error; anything else means something is actually broken.
215 if (res.status < 300 || res.status >= 400) {
216 throw new Error(`webhook registration: HTTP ${res.status}`);
217 }
218 });
219
191220 // 5. open PR
192221 const pr = await step("5. gluecron_create_pr", () => mcp("gluecron_create_pr", { owner, repo: REPO, title: "Journey probe PR", body: "Opened by scripts/agent-journey.ts — will be merged and the repo deleted.", head_branch: "feature/journey", base_branch: "main" }));
193222 const prNumber: number = pr!.number;
227256 }
228257 });
229258
230 // 9. events — WARN-only (known platform gap, 2026-07-15): activityFeed is
231 // only written by fork/use-template/claude-connect/reviewer-suggest.
232 // None of git push, MCP branch/PR/comment/merge, or POST /api/v2/repos
233 // insert an activityFeed row, so dashboards rolling it up are blind to
234 // everything this journey just did. Real gap, not a test bug — tracked
235 // separately; not a reason to fail the merge-lifecycle certification.
259 // 9. events — activityFeed wiring gap (found 2026-07-15) was fixed same
260 // day (commit b7b5f75): logActivity() is now called from git push, PR
261 // create/comment/merge, and the MCP write tools. Promoted from
262 // warnOnly to a real assertion now that the underlying gap is closed —
263 // this is exactly the check that caught the gap in the first place, so
264 // it stays a hard requirement going forward rather than reverting to
265 // silently-optional once fixed.
236266 await step("9. activity feed recorded push + PR events", async () => {
237267 const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/activity`);
238268 if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
240270 const events: Array<{ type?: string }> = Array.isArray(r.json)
241271 ? r.json
242272 : r.json?.events ?? r.json?.activity ?? [];
243 if (!Array.isArray(events) || events.length === 0) throw new Error("no activity events (known gap — see comment above)");
244 }, { warnOnly: true });
273 if (!Array.isArray(events) || events.length === 0) throw new Error("no activity events recorded — activity_feed wiring regressed");
274 });
275
276 // 10. webhook delivery recorded — proves fireWebhooks() is actually
277 // called from the real PR create/merge handlers (found 2026-07-16:
278 // fireWebhooks() had zero callers anywhere despite a full config UI and
279 // "last delivery" status pill; fixed by wiring it into post-receive.ts,
280 // issues.tsx, pulls.tsx, pr-merge.ts, and web.tsx). The probe webhook
281 // registered in step 4c points at this instance's own /healthz — any
282 // delivery attempt, success or failure, sets webhooks.lastStatus to a
283 // non-null value (see src/lib/webhook-delivery.ts's `lastStatus:
284 // statusCode ?? 0` on every branch), which flips the settings page's
285 // "no deliveries yet" text. Polls because fireWebhooks() enqueues
286 // fire-and-forget — the delivery worker runs asynchronously after the
287 // PR-open/merge response already returned.
288 await step("10. webhook delivery recorded (poll for lastStatus, ≤30s)", async () => {
289 const deadline = Date.now() + 30_000;
290 while (Date.now() < deadline) {
291 const res = await fetch(`${HOST}/${owner}/${REPO}/settings/webhooks`, {
292 headers: { authorization: `Bearer ${PAT}` },
293 });
294 const html = await res.text();
295 if (res.status === 200 && !html.includes("no deliveries yet")) return true;
296 await new Promise((r) => setTimeout(r, 3000));
297 }
298 throw new Error("no webhook delivery recorded within 30s — fireWebhooks() call sites may have regressed");
299 });
245300 } finally {
246 // 10. cleanup — always attempt when the repo was created
301 // 11. cleanup — always attempt when the repo was created
247302 if (repoCreated && !KEEP) {
248303 try {
249304 await mcp("gluecron_delete_repo", { owner, repo: REPO });
250 console.log(`PASS 10. cleanup — deleted ${owner}/${REPO}`);
251 results.push({ name: "10. cleanup (gluecron_delete_repo)", ok: true, ms: 0 });
305 console.log(`PASS 11. cleanup — deleted ${owner}/${REPO}`);
306 results.push({ name: "11. cleanup (gluecron_delete_repo)", ok: true, ms: 0 });
252307 } catch (err) {
253 console.error(`FAIL 10. cleanup — LEFTOVER REPO ${owner}/${REPO}: ${(err as Error).message}`);
254 results.push({ name: "10. cleanup (gluecron_delete_repo)", ok: false, ms: 0, detail: `LEFTOVER ${owner}/${REPO}` });
308 console.error(`FAIL 11. cleanup — LEFTOVER REPO ${owner}/${REPO}: ${(err as Error).message}`);
309 results.push({ name: "11. cleanup (gluecron_delete_repo)", ok: false, ms: 0, detail: `LEFTOVER ${owner}/${REPO}` });
255310 }
256311 } else if (repoCreated) {
257 console.log(`KEEP 10. cleanup skipped (KEEP_REPO=1) — ${owner}/${REPO}`);
312 console.log(`KEEP 11. cleanup skipped (KEEP_REPO=1) — ${owner}/${REPO}`);
258313 }
259314 }
260315
Addedsrc/__tests__/webhook-fire-wiring.test.ts+116−0View fileUnifiedSplit
1/**
2 * fireWebhooks() (src/routes/webhooks.tsx) — the retry/dead-letter-backed
3 * webhook dispatcher that, before this session, had ZERO callers anywhere
4 * in the codebase despite a full UI for configuring webhooks and a live
5 * "last delivery" status pill. Users could configure a webhook and it would
6 * never fire. Now wired into push (post-receive.ts), issue create/comment/
7 * close/reopen (issues.tsx), PR create/comment/merge(x2)/close (pulls.tsx +
8 * pr-merge.ts), and star (web.tsx) — see those files' `fireWebhooks(...)`
9 * call sites for the wiring itself (grep is the static proof of wiring;
10 * scripts/agent-journey.ts's webhook-delivery-row assertion is the live
11 * end-to-end proof; this file proves fireWebhooks()'s own selection logic).
12 *
13 * Pure dependency injection (loadHooks/enqueueWebhookDelivery/
14 * drainPendingDeliveries in deps) — no mock.module() on ../db or
15 * ../lib/webhook-delivery. An earlier version of this file used
16 * mock.module("../db", ...) and it leaked globally across the whole `bun
17 * test` run, breaking an unrelated markdown-rendering test in web.tsx that
18 * also queries ../db. Same class of bug as pr-workflow-sync.test.ts's
19 * original mock-module leak earlier this session — same fix.
20 */
21
22import { beforeEach, describe, expect, it } from "bun:test";
23import { fireWebhooks } from "../routes/webhooks";
24
25interface FakeHook {
26 id: string;
27 secret: string;
28 isActive: boolean;
29 events: string; // comma-separated, matches the real schema shape
30}
31
32let _hooks: FakeHook[] = [];
33let _selectShouldThrow = false;
34let _enqueueCalls: Array<{ webhookId: string; event: string; payload: unknown }> = [];
35let _drainCalls = 0;
36
37const fakeDeps = {
38 loadHooks: async (_repositoryId: string) => {
39 if (_selectShouldThrow) throw new Error("db unavailable");
40 return _hooks;
41 },
42 enqueueWebhookDelivery: async (opts: any) => {
43 _enqueueCalls.push(opts);
44 return "delivery-id-1";
45 },
46 drainPendingDeliveries: async () => {
47 _drainCalls++;
48 },
49} as any;
50
51beforeEach(() => {
52 _hooks = [];
53 _selectShouldThrow = false;
54 _enqueueCalls = [];
55 _drainCalls = 0;
56});
57
58describe("fireWebhooks", () => {
59 it("enqueues a delivery for an active hook subscribed to the event", async () => {
60 _hooks = [{ id: "hook-1", secret: "s", isActive: true, events: "push,pr" }];
61 await fireWebhooks("repo-1", "push", { branch: "main" }, fakeDeps);
62
63 expect(_enqueueCalls).toHaveLength(1);
64 expect(_enqueueCalls[0]).toMatchObject({ webhookId: "hook-1", event: "push" });
65 expect(_enqueueCalls[0]!.payload).toEqual({ branch: "main" });
66 });
67
68 it("skips a hook not subscribed to the fired event", async () => {
69 _hooks = [{ id: "hook-1", secret: "s", isActive: true, events: "issue,star" }];
70 await fireWebhooks("repo-1", "push", { branch: "main" }, fakeDeps);
71 expect(_enqueueCalls).toHaveLength(0);
72 });
73
74 it("skips an inactive hook even if subscribed to the event", async () => {
75 _hooks = [{ id: "hook-1", secret: "s", isActive: false, events: "push" }];
76 await fireWebhooks("repo-1", "push", {}, fakeDeps);
77 expect(_enqueueCalls).toHaveLength(0);
78 });
79
80 it("enqueues one delivery per matching hook when several are configured", async () => {
81 _hooks = [
82 { id: "hook-1", secret: "s", isActive: true, events: "pr" },
83 { id: "hook-2", secret: "s", isActive: true, events: "push,pr" },
84 { id: "hook-3", secret: "s", isActive: true, events: "star" },
85 ];
86 await fireWebhooks("repo-1", "pr", { action: "opened", number: 1 }, fakeDeps);
87
88 expect(_enqueueCalls).toHaveLength(2);
89 expect(_enqueueCalls.map((c) => c.webhookId).sort()).toEqual(["hook-1", "hook-2"]);
90 });
91
92 it("kicks the delivery worker only when something was actually enqueued", async () => {
93 _hooks = [{ id: "hook-1", secret: "s", isActive: true, events: "push" }];
94 await fireWebhooks("repo-1", "push", {}, fakeDeps);
95 expect(_drainCalls).toBe(1);
96
97 _drainCalls = 0;
98 _hooks = [{ id: "hook-1", secret: "s", isActive: true, events: "issue" }];
99 await fireWebhooks("repo-1", "push", {}, fakeDeps);
100 expect(_drainCalls).toBe(0);
101 });
102
103 it("never throws when the hook lookup fails — fail-open, matches logActivity()'s contract", async () => {
104 _selectShouldThrow = true;
105 await expect(fireWebhooks("repo-1", "push", {}, fakeDeps)).resolves.toBeUndefined();
106 expect(_enqueueCalls).toHaveLength(0);
107 });
108
109 it("real default deps resolve without throwing (production wiring sanity check)", async () => {
110 // No deps override — exercises the real db-backed default. A repo with
111 // no configured webhooks should resolve cleanly with zero enqueues.
112 await expect(
113 fireWebhooks("00000000-0000-0000-0000-000000000000", "push", {})
114 ).resolves.toBeUndefined();
115 });
116});
Modifiedsrc/hooks/post-receive.ts+6−0View fileUnifiedSplit
4040import { fireCloudDeploys } from "../lib/cloud-deploy";
4141import { ensureRepoOnboarding } from "../lib/repo-onboarding";
4242import { audit, logActivity } from "../lib/notify";
43import { fireWebhooks } from "../routes/webhooks";
4344import { syncAndEnqueuePushWorkflows } from "../lib/push-workflow-sync";
4445
4546// ---------------------------------------------------------------------------
153154 targetId: ref.newSha,
154155 metadata: { branch: branchName, oldSha: ref.oldSha },
155156 });
157 void fireWebhooks(repoId, "push", {
158 branch: branchName,
159 oldSha: ref.oldSha,
160 newSha: ref.newSha,
161 });
156162 }
157163
158164 // 0b. Workflow sync + push-triggered CI. Discover .gluecron/workflows/
Modifiedsrc/lib/pr-merge.ts+5−0View fileUnifiedSplit
3939import { isAiReviewEnabled } from "./ai-review";
4040import { extractClosingRefsMulti } from "./close-keywords";
4141import { logActivity } from "./notify";
42import { fireWebhooks } from "../routes/webhooks";
4243
4344export interface PerformMergeArgs {
4445 /** Full PR row — we need title/body/baseBranch/headBranch/repositoryId. */
265266 targetId: String(args.pr.number),
266267 metadata: { baseBranch: args.pr.baseBranch, headBranch: args.pr.headBranch },
267268 });
269 void fireWebhooks(args.pr.repositoryId, "pr", {
270 action: "merged",
271 number: args.pr.number,
272 });
268273
269274 return {
270275 ok: true,
Modifiedsrc/routes/issues.tsx+19−0View fileUnifiedSplit
55import { Hono } from "hono";
66import { eq, and, desc, asc, sql, ilike, inArray, or } from "drizzle-orm";
77import { db } from "../db";
8import { fireWebhooks } from "./webhooks";
89import {
910 issues,
1011 issueComments,
15371538 .set({ issueCount: resolved.repo.issueCount + 1 })
15381539 .where(eq(repositories.id, resolved.repo.id));
15391540
1541 void fireWebhooks(resolved.repo.id, "issue", {
1542 action: "opened",
1543 number: issue.number,
1544 title,
1545 });
1546
15401547 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
15411548 // suggested labels, priority, summary, and a possible-duplicate
15421549 // callout. Suggestions only — nothing applied automatically.
22362243
22372244 // Live update only when visible. Don't leak pending/rejected via SSE.
22382245 if (inserted && decision.status === "approved") {
2246 void fireWebhooks(resolved.repo.id, "issue", {
2247 action: "commented",
2248 number: issueNum,
2249 });
22392250 try {
22402251 const { publish } = await import("../lib/sse");
22412252 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
23152326 eq(issues.number, issueNum)
23162327 )
23172328 );
2329 void fireWebhooks(resolved.repo.id, "issue", {
2330 action: "closed",
2331 number: issueNum,
2332 });
23182333
23192334 return c.redirect(
23202335 `/${ownerName}/${repoName}/issues/${issueNum}`
23442359 eq(issues.number, issueNum)
23452360 )
23462361 );
2362 void fireWebhooks(resolved.repo.id, "issue", {
2363 action: "reopened",
2364 number: issueNum,
2365 });
23472366
23482367 return c.redirect(
23492368 `/${ownerName}/${repoName}/issues/${issueNum}`
Modifiedsrc/routes/pulls.tsx+20−0View fileUnifiedSplit
6868} from "../lib/ai-test-generator";
6969import { triggerPrTriage } from "../lib/pr-triage";
7070import { logActivity } from "../lib/notify";
71import { fireWebhooks } from "./webhooks";
7172import { generatePrSummary } from "../lib/ai-generators";
7273import { isAiAvailable } from "../lib/ai-client";
7374import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
38503851 targetId: String(pr.number),
38513852 metadata: { baseBranch, headBranch, isDraft },
38523853 });
3854 void fireWebhooks(resolved.repo.id, "pr", {
3855 action: "opened",
3856 number: pr.number,
3857 baseBranch,
3858 headBranch,
3859 });
38533860
38543861 // CODEOWNERS — auto-request reviewers based on changed files.
38553862 // Fire-and-forget; errors never block PR creation.
56845691 targetId: String(prNum),
56855692 metadata: { commentId: inserted.id },
56865693 });
5694 void fireWebhooks(resolved.repo.id, "pr", {
5695 action: "commented",
5696 number: prNum,
5697 });
56875698
56885699 try {
56895700 const { publish } = await import("../lib/sse");
65076518 targetId: String(pr.number),
65086519 metadata: { baseBranch: pr.baseBranch, headBranch: pr.headBranch, mergeStrategy },
65096520 });
6521 void fireWebhooks(resolved.repo.id, "pr", {
6522 action: "merged",
6523 number: pr.number,
6524 mergeStrategy,
6525 });
65106526
65116527 // Chat notifier — fan out merge event to Slack/Discord/Teams.
65126528 import("../lib/chat-notifier")
66866702 eq(pullRequests.number, prNum)
66876703 )
66886704 );
6705 void fireWebhooks(resolved.repo.id, "pr", {
6706 action: "closed",
6707 number: prNum,
6708 });
66896709
66906710 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
66916711 }
Modifiedsrc/routes/web.tsx+2−0View fileUnifiedSplit
77import { html } from "hono/html";
88import { eq, and, desc, inArray, sql, gte, count } from "drizzle-orm";
99import { db } from "../db";
10import { fireWebhooks } from "./webhooks";
1011import { config } from "../lib/config";
1112import {
1213 users,
24602461 .update(repositories)
24612462 .set({ starCount: repo.starCount + 1 })
24622463 .where(eq(repositories.id, repo.id));
2464 void fireWebhooks(repo.id, "star", { action: "created" });
24632465 }
24642466 } catch {
24652467 // DB error — ignore
Modifiedsrc/routes/webhooks.tsx+21−7View fileUnifiedSplit
802802export async function fireWebhooks(
803803 repositoryId: string,
804804 event: string,
805 payload: Record<string, unknown>
805 payload: Record<string, unknown>,
806 // Injectable for tests — avoids mock.module() on ../db or
807 // ../lib/webhook-delivery, both of which are imported by dozens of
808 // unrelated test files and would leak a global mock across the whole
809 // `bun test` run (same rationale as pr-workflow-sync.ts / codeowners.ts's
810 // requiredOwnersApproved).
811 deps: {
812 loadHooks: (repositoryId: string) => Promise<
813 Array<{ id: string; secret: string; isActive: boolean; events: string }>
814 >;
815 enqueueWebhookDelivery: typeof enqueueWebhookDelivery;
816 drainPendingDeliveries: typeof drainPendingDeliveries;
817 } = {
818 loadHooks: (repositoryId: string) =>
819 db.select().from(webhooks).where(eq(webhooks.repositoryId, repositoryId)),
820 enqueueWebhookDelivery,
821 drainPendingDeliveries,
822 }
806823): Promise<void> {
807824 try {
808 const hooks = await db
809 .select()
810 .from(webhooks)
811 .where(eq(webhooks.repositoryId, repositoryId));
825 const hooks = await deps.loadHooks(repositoryId);
812826
813827 let enqueued = 0;
814828 for (const hook of hooks) {
816830 const hookEvents = hook.events.split(",");
817831 if (!hookEvents.includes(event)) continue;
818832
819 const id = await enqueueWebhookDelivery({
833 const id = await deps.enqueueWebhookDelivery({
820834 webhookId: hook.id,
821835 secret: hook.secret,
822836 event,
828842 // Kick the worker for fresh enqueues so we don't wait up to the poll
829843 // interval. Best-effort and never awaited from the caller's perspective.
830844 if (enqueued > 0) {
831 void drainPendingDeliveries().catch((err) => {
845 void deps.drainPendingDeliveries().catch((err) => {
832846 console.error("[webhook] kick drain failed:", err);
833847 });
834848 }
835849