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

webhooks.tsx

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

webhooks.tsxBlame852 lines · 2 contributors
c81ab7aClaude1/**
2 * Webhooks management — register, list, delete, test.
304ce2aClaude3 *
4 * 2026 polish:
5 * - Page-level eyebrow + display headline + subtitle (the settings layout
6 * already supplies the polished sidebar, so we don't render a hero block).
7 * - Each webhook is a card showing URL (mono), event chips, created
8 * timestamp (tabular-nums, relative), status pill, last-delivery dot.
9 * - Add-new form is its own card with focus rings + primary gradient submit.
10 * - Empty state is a dashed card with an orb + helpful CTA copy.
11 * - All CSS scoped under `.wh-*` to avoid bleed into other surfaces.
12 *
13 * Hard rules preserved:
14 * - Every route, form action, POST/DELETE handler, and DB query is
15 * unchanged. Only the rendered HTML/CSS changes.
16 * - The shared Layout / settings sidebar / ui.tsx components are not
17 * modified — the polish happens inside the page body only.
c81ab7aClaude18 */
19
20import { Hono } from "hono";
21import { eq, and } from "drizzle-orm";
22import { db } from "../db";
23import { webhooks, repositories, users } from "../db/schema";
8405c43Claude24import {
25 enqueueWebhookDelivery,
26 drainPendingDeliveries,
27} from "../lib/webhook-delivery";
c81ab7aClaude28import { Layout } from "../views/layout";
ae2a071ccanty labs29import { RepoHeader, RepoNav } from "../views/components";
c81ab7aClaude30import { softAuth, requireAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
febd4f0Claude32import { requireRepoAccess } from "../middleware/repo-access";
c81ab7aClaude33
34const webhookRoutes = new Hono<AuthEnv>();
35
36webhookRoutes.use("*", softAuth);
37
304ce2aClaude38/* ─────────────────────────────────────────────────────────────────────────
39 * Scoped CSS — every class prefixed `.wh-` so this page can't bleed into
40 * other settings surfaces. Mirrors the section-card + traffic-light
41 * patterns from admin-integrations.tsx and admin-ops.tsx.
42 * ───────────────────────────────────────────────────────────────────── */
43const webhookStyles = `
eed4684Claude44 .wh-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
304ce2aClaude45
46 /* ─── Page heading (no hero — settings sidebar already supplies it) ─── */
47 .wh-head { margin-bottom: var(--space-5); }
48 .wh-eyebrow {
49 font-size: 12px;
50 color: var(--text-muted);
51 margin-bottom: var(--space-2);
52 letter-spacing: 0.02em;
53 display: inline-flex;
54 align-items: center;
55 gap: 8px;
56 text-transform: uppercase;
57 }
58 .wh-eyebrow-pill {
59 display: inline-flex;
60 align-items: center;
61 justify-content: center;
62 width: 18px; height: 18px;
63 border-radius: 6px;
6fd5915Claude64 background: rgba(91,110,232,0.14);
65 color: #5b6ee8;
66 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
304ce2aClaude67 }
68 .wh-title {
69 font-size: clamp(24px, 3.2vw, 32px);
70 font-family: var(--font-display);
71 font-weight: 800;
72 letter-spacing: -0.024em;
73 line-height: 1.08;
74 margin: 0 0 var(--space-2);
75 color: var(--text-strong);
76 }
77 .wh-title-grad {
6fd5915Claude78 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
304ce2aClaude79 -webkit-background-clip: text;
80 background-clip: text;
81 -webkit-text-fill-color: transparent;
82 color: transparent;
83 }
84 .wh-sub {
85 font-size: 14.5px;
86 color: var(--text-muted);
87 margin: 0;
88 line-height: 1.55;
89 max-width: 620px;
90 }
91 .wh-sub code {
92 font-family: var(--font-mono);
93 font-size: 12.5px;
94 background: var(--bg-tertiary);
95 padding: 1px 5px;
96 border-radius: 4px;
97 }
98
99 /* ─── Banners ─── */
100 .wh-banner {
101 margin-bottom: var(--space-4);
102 padding: 10px 14px;
103 border-radius: 10px;
104 font-size: 13.5px;
105 border: 1px solid var(--border);
106 background: rgba(255,255,255,0.025);
107 color: var(--text);
108 display: flex;
109 align-items: center;
110 gap: 10px;
111 }
112 .wh-banner.is-ok {
113 border-color: rgba(52,211,153,0.40);
114 background: rgba(52,211,153,0.08);
115 color: #bbf7d0;
116 }
117 .wh-banner.is-error {
118 border-color: rgba(248,113,113,0.40);
119 background: rgba(248,113,113,0.08);
120 color: #fecaca;
121 }
122 .wh-banner-dot {
123 width: 8px; height: 8px;
124 border-radius: 9999px;
125 background: currentColor;
126 flex-shrink: 0;
127 }
128
129 /* ─── List of hooks (cards) ─── */
130 .wh-list {
131 display: flex;
132 flex-direction: column;
133 gap: var(--space-3);
134 margin-bottom: var(--space-5);
135 }
136 .wh-card {
137 background: var(--bg-elevated);
138 border: 1px solid var(--border);
139 border-radius: 14px;
140 padding: var(--space-4) var(--space-5);
141 display: flex;
142 flex-direction: column;
143 gap: var(--space-3);
144 transition: border-color 140ms ease, transform 140ms ease;
145 }
146 .wh-card:hover {
147 border-color: var(--border-strong);
148 }
149 .wh-card-top {
150 display: flex;
151 align-items: flex-start;
152 justify-content: space-between;
153 gap: var(--space-3);
154 flex-wrap: wrap;
155 }
156 .wh-card-id { flex: 1; min-width: 200px; }
157 .wh-card-url {
158 font-family: var(--font-mono);
159 font-size: 13px;
160 color: var(--text-strong);
161 word-break: break-all;
162 overflow-wrap: anywhere;
163 font-weight: 600;
164 line-height: 1.45;
165 display: block;
166 }
167 .wh-card-meta {
168 margin-top: 6px;
169 font-size: 12px;
170 color: var(--text-muted);
171 display: flex;
172 align-items: center;
173 gap: 10px;
174 flex-wrap: wrap;
175 }
176 .wh-time {
177 font-variant-numeric: tabular-nums;
178 font-size: 12px;
179 color: var(--text-muted);
180 }
181
182 /* ─── Status pill (active / inactive) ─── */
183 .wh-pill {
184 display: inline-flex;
185 align-items: center;
186 gap: 6px;
187 padding: 3px 10px;
188 border-radius: 9999px;
189 font-size: 11px;
190 font-weight: 600;
191 letter-spacing: 0.04em;
192 text-transform: uppercase;
193 flex-shrink: 0;
194 }
195 .wh-pill .dot {
196 width: 6px; height: 6px;
197 border-radius: 9999px;
198 background: currentColor;
199 }
200 .wh-pill.is-active {
201 background: rgba(52,211,153,0.14);
202 color: #6ee7b7;
203 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
204 }
205 .wh-pill.is-disabled {
206 background: rgba(107,114,128,0.16);
207 color: #d1d5db;
208 box-shadow: inset 0 0 0 1px rgba(107,114,128,0.32);
209 }
210
211 /* ─── Event chips ─── */
212 .wh-chips {
213 display: flex;
214 flex-wrap: wrap;
215 gap: 6px;
216 }
217 .wh-chip {
218 display: inline-flex;
219 align-items: center;
220 gap: 6px;
221 padding: 3px 9px;
222 border-radius: 9999px;
6fd5915Claude223 background: rgba(91,110,232,0.10);
224 border: 1px solid rgba(91,110,232,0.30);
304ce2aClaude225 color: #c4b5fd;
226 font-size: 11px;
227 font-weight: 600;
228 font-family: var(--font-mono);
229 letter-spacing: -0.005em;
230 }
231
232 /* ─── Last-delivery dot + status row ─── */
233 .wh-delivery {
234 display: inline-flex;
235 align-items: center;
236 gap: 6px;
237 padding: 2px 8px;
238 border-radius: 9999px;
239 font-size: 11.5px;
240 background: rgba(255,255,255,0.03);
241 border: 1px solid var(--border);
242 color: var(--text-muted);
243 font-variant-numeric: tabular-nums;
244 }
245 .wh-delivery .dot {
246 width: 7px; height: 7px;
247 border-radius: 9999px;
248 background: #6b7280;
249 box-shadow: 0 0 0 2px rgba(107,114,128,0.16);
250 }
251 .wh-delivery.is-ok { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
252 .wh-delivery.is-ok .dot {
253 background: #34d399;
254 box-shadow: 0 0 0 2px rgba(52,211,153,0.22), 0 0 6px rgba(52,211,153,0.40);
255 }
256 .wh-delivery.is-bad { color: #fca5a5; border-color: rgba(248,113,113,0.35); }
257 .wh-delivery.is-bad .dot {
258 background: #f87171;
259 box-shadow: 0 0 0 2px rgba(248,113,113,0.22), 0 0 6px rgba(248,113,113,0.40);
260 }
261 .wh-delivery.is-pending { color: var(--text-muted); }
262
263 /* ─── Card actions ─── */
264 .wh-actions {
265 display: flex;
266 align-items: center;
267 gap: 8px;
268 flex-wrap: wrap;
269 }
270 .wh-btn {
271 display: inline-flex;
272 align-items: center;
273 gap: 6px;
274 padding: 7px 14px;
275 font-size: 12.5px;
276 font-weight: 600;
277 border-radius: 8px;
278 cursor: pointer;
279 font-family: inherit;
280 text-decoration: none;
281 border: 1px solid transparent;
282 transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
283 }
284 .wh-btn-ghost {
285 background: rgba(255,255,255,0.03);
286 border-color: var(--border);
287 color: var(--text);
288 }
289 .wh-btn-ghost:hover {
290 background: rgba(255,255,255,0.06);
291 border-color: var(--border-strong);
292 color: var(--text-strong);
293 }
294 .wh-btn-danger {
295 background: rgba(248,113,113,0.08);
296 border-color: rgba(248,113,113,0.30);
297 color: #fca5a5;
298 }
299 .wh-btn-danger:hover {
300 background: rgba(248,113,113,0.14);
301 border-color: rgba(248,113,113,0.50);
302 color: #fecaca;
303 }
304 .wh-btn-primary {
6fd5915Claude305 background: linear-gradient(135deg, #5b6ee8 0%, #6d4ee0 100%);
304ce2aClaude306 color: #ffffff;
6fd5915Claude307 border-color: rgba(91,110,232,0.55);
308 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45);
304ce2aClaude309 }
310 .wh-btn-primary:hover {
311 transform: translateY(-1px);
6fd5915Claude312 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55);
304ce2aClaude313 }
314 .wh-card-foot-form { margin: 0; }
315
316 /* ─── Empty state (dashed) ─── */
317 .wh-empty {
318 position: relative;
319 padding: var(--space-6) var(--space-5);
320 margin-bottom: var(--space-5);
321 border: 1px dashed var(--border-strong);
322 border-radius: 16px;
323 background: rgba(255,255,255,0.02);
324 text-align: center;
325 overflow: hidden;
326 }
327 .wh-empty-orb {
328 position: absolute;
329 inset: -40% -10% auto auto;
330 width: 320px; height: 320px;
6fd5915Claude331 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
304ce2aClaude332 filter: blur(70px);
333 opacity: 0.6;
334 pointer-events: none;
335 z-index: 0;
336 }
337 .wh-empty-inner { position: relative; z-index: 1; }
338 .wh-empty-title {
339 font-family: var(--font-display);
340 font-size: 17px;
341 font-weight: 700;
342 color: var(--text-strong);
343 margin: 0 0 6px;
344 letter-spacing: -0.018em;
345 }
346 .wh-empty-sub {
347 font-size: 13.5px;
348 color: var(--text-muted);
349 margin: 0 auto;
350 max-width: 460px;
351 line-height: 1.5;
352 }
353
354 /* ─── Add-new form (its own card) ─── */
355 .wh-form-card {
356 background: var(--bg-elevated);
357 border: 1px solid var(--border);
358 border-radius: 14px;
359 overflow: hidden;
360 }
361 .wh-form-head {
362 padding: var(--space-4) var(--space-5);
363 border-bottom: 1px solid var(--border);
364 display: flex;
365 align-items: center;
366 gap: 10px;
367 }
368 .wh-form-title {
369 margin: 0;
370 font-family: var(--font-display);
371 font-size: 16px;
372 font-weight: 700;
373 letter-spacing: -0.018em;
374 color: var(--text-strong);
375 display: flex;
376 align-items: center;
377 gap: 10px;
378 }
379 .wh-form-title-icon {
380 display: inline-flex;
381 align-items: center;
382 justify-content: center;
383 width: 26px; height: 26px;
384 border-radius: 8px;
6fd5915Claude385 background: rgba(91,110,232,0.12);
386 color: #5b6ee8;
387 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
304ce2aClaude388 }
389 .wh-form-body { padding: var(--space-4) var(--space-5); }
390 .wh-field { margin-bottom: var(--space-4); }
391 .wh-field:last-of-type { margin-bottom: 0; }
392 .wh-label {
393 display: block;
394 font-size: 12.5px;
395 font-weight: 600;
396 color: var(--text-strong);
397 margin-bottom: 6px;
398 letter-spacing: -0.005em;
399 }
400 .wh-input {
401 width: 100%;
402 padding: 9px 12px;
403 font-size: 13.5px;
404 color: var(--text);
405 background: var(--bg);
406 border: 1px solid var(--border-strong);
407 border-radius: 8px;
408 outline: none;
409 font-family: var(--font-mono);
410 transition: border-color 120ms ease, box-shadow 120ms ease;
411 box-sizing: border-box;
412 }
413 .wh-input:focus {
414 border-color: var(--border-focus);
6fd5915Claude415 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
304ce2aClaude416 }
417 .wh-events {
418 display: flex;
419 flex-wrap: wrap;
420 gap: 8px;
421 }
422 .wh-evt-label {
423 display: inline-flex;
424 align-items: center;
425 gap: 6px;
426 padding: 6px 12px;
427 border-radius: 9999px;
428 background: rgba(255,255,255,0.025);
429 border: 1px solid var(--border-strong);
430 font-size: 12.5px;
431 color: var(--text);
432 cursor: pointer;
433 font-family: var(--font-mono);
434 transition: border-color 120ms ease, background 120ms ease;
435 }
6fd5915Claude436 .wh-evt-label:hover { border-color: rgba(91,110,232,0.45); }
437 .wh-evt-label input { accent-color: #5b6ee8; cursor: pointer; }
304ce2aClaude438 .wh-form-foot {
439 padding: var(--space-3) var(--space-5);
440 border-top: 1px solid var(--border);
441 background: rgba(255,255,255,0.012);
442 display: flex;
443 justify-content: flex-end;
444 gap: var(--space-2);
445 align-items: center;
446 flex-wrap: wrap;
447 }
448`;
449
450/** Render a relative time like "12s ago", "3m ago", "2h ago", "3d ago". */
451function whRelativeTime(from: Date | null, now: Date = new Date()): string {
452 if (!from) return "—";
453 const ms = now.getTime() - new Date(from).getTime();
454 if (ms < 5_000) return "just now";
455 const s = Math.floor(ms / 1_000);
456 if (s < 60) return `${s}s ago`;
457 const m = Math.floor(s / 60);
458 if (m < 60) return `${m}m ago`;
459 const h = Math.floor(m / 60);
460 if (h < 24) return `${h}h ago`;
461 const d = Math.floor(h / 24);
462 return `${d}d ago`;
463}
464
c81ab7aClaude465// List webhooks
466webhookRoutes.get(
467 "/:owner/:repo/settings/webhooks",
468 requireAuth,
febd4f0Claude469 requireRepoAccess("read"),
c81ab7aClaude470 async (c) => {
471 const { owner: ownerName, repo: repoName } = c.req.param();
472 const user = c.get("user")!;
473 const success = c.req.query("success");
474 const error = c.req.query("error");
475
476 const [owner] = await db
477 .select()
478 .from(users)
479 .where(eq(users.username, ownerName))
480 .limit(1);
481 if (!owner || owner.id !== user.id) {
482 return c.text("Unauthorized", 403);
483 }
484
485 const [repo] = await db
486 .select()
487 .from(repositories)
488 .where(
489 and(
490 eq(repositories.ownerId, owner.id),
491 eq(repositories.name, repoName)
492 )
493 )
494 .limit(1);
495 if (!repo) return c.notFound();
496
0c3eee5Claude497 // Exclude `chat-bridge` shadow rows — those are created lazily by
498 // src/lib/chat-notifier.ts to pipe events through the existing
499 // webhook_deliveries retry queue, and showing them here would let
500 // users accidentally delete a Slack/Discord install from the wrong
501 // surface (the source of truth lives at /settings/integrations).
502 const allHooks = await db
c81ab7aClaude503 .select()
504 .from(webhooks)
505 .where(eq(webhooks.repositoryId, repo.id));
0c3eee5Claude506 const hooks = allHooks.filter((h) => h.events !== "chat-bridge");
c81ab7aClaude507
508 return c.html(
509 <Layout title={`Webhooks — ${ownerName}/${repoName}`} user={user}>
510 <RepoHeader owner={ownerName} repo={repoName} />
ae2a071ccanty labs511 <RepoNav owner={ownerName} repo={repoName} active="settings" />
304ce2aClaude512 <div class="wh-wrap">
513 <header class="wh-head">
514 <div class="wh-eyebrow">
515 <span class="wh-eyebrow-pill" aria-hidden="true">
516 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
517 <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
518 <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
519 </svg>
520 </span>
521 Webhooks · {ownerName}/{repoName}
522 </div>
523 <h2 class="wh-title">
524 <span class="wh-title-grad">Pipe events</span> out of the repo.
525 </h2>
526 <p class="wh-sub">
527 Webhooks POST to your URL on push, issue, PR, and star events.
528 Every delivery is signed with HMAC-SHA256 using your shared
529 secret — verify with the <code>X-Gluecron-Signature</code>{" "}
530 header.
531 </p>
532 </header>
533
c81ab7aClaude534 {success && (
304ce2aClaude535 <div class="wh-banner is-ok" role="status">
536 <span class="wh-banner-dot" aria-hidden="true" />
537 {decodeURIComponent(success)}
538 </div>
c81ab7aClaude539 )}
540 {error && (
304ce2aClaude541 <div class="wh-banner is-error" role="status">
542 <span class="wh-banner-dot" aria-hidden="true" />
543 {decodeURIComponent(error)}
544 </div>
c81ab7aClaude545 )}
304ce2aClaude546
547 {hooks.length > 0 ? (
548 <div class="wh-list">
549 {hooks.map((hook) => {
550 const events = hook.events
551 .split(",")
552 .map((e) => e.trim())
553 .filter(Boolean);
554 const last = hook.lastStatus ?? null;
555 const deliveryClass =
556 last == null
557 ? "is-pending"
558 : last >= 200 && last < 300
559 ? "is-ok"
560 : "is-bad";
561 const deliveryText =
562 last == null
563 ? "no deliveries yet"
564 : `${last} · ${whRelativeTime(hook.lastDeliveredAt)}`;
565 return (
566 <article class="wh-card">
567 <div class="wh-card-top">
568 <div class="wh-card-id">
569 <span class="wh-card-url">{hook.url}</span>
570 <div class="wh-card-meta">
571 <span class="wh-time">
572 Created {whRelativeTime(hook.createdAt)}
573 </span>
574 <span aria-hidden="true">·</span>
575 <span
576 class={"wh-delivery " + deliveryClass}
577 title={
578 last == null
579 ? "No deliveries yet"
580 : `Last HTTP status ${last}`
581 }
582 >
583 <span class="dot" aria-hidden="true" />
584 {deliveryText}
585 </span>
586 </div>
587 </div>
588 <span
589 class={
590 "wh-pill " +
591 (hook.isActive ? "is-active" : "is-disabled")
592 }
593 >
594 <span class="dot" aria-hidden="true" />
595 {hook.isActive ? "active" : "disabled"}
596 </span>
c81ab7aClaude597 </div>
304ce2aClaude598
599 {events.length > 0 && (
600 <div class="wh-chips" aria-label="Subscribed events">
601 {events.map((evt) => (
602 <span class="wh-chip">{evt}</span>
603 ))}
604 </div>
605 )}
606
607 <div class="wh-actions">
608 <form
609 class="wh-card-foot-form"
610 method="post"
611 action={`/${ownerName}/${repoName}/settings/webhooks/${hook.id}/delete`}
612 >
613 <button type="submit" class="wh-btn wh-btn-danger">
614 Revoke
615 </button>
616 </form>
617 </div>
618 </article>
619 );
620 })}
621 </div>
622 ) : (
623 <div class="wh-empty">
624 <div class="wh-empty-orb" aria-hidden="true" />
625 <div class="wh-empty-inner">
626 <p class="wh-empty-title">No webhooks yet</p>
627 <p class="wh-empty-sub">
628 Webhooks fire on push, issue, PR, and star events. Point
629 one at your CI, your Slack relay, or your own service to
630 react in real time.
631 </p>
632 </div>
c81ab7aClaude633 </div>
634 )}
635
304ce2aClaude636 <section class="wh-form-card" aria-labelledby="wh-add-title">
637 <header class="wh-form-head">
638 <h3 class="wh-form-title" id="wh-add-title">
639 <span class="wh-form-title-icon" aria-hidden="true">
640 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
641 <line x1="12" y1="5" x2="12" y2="19" />
642 <line x1="5" y1="12" x2="19" y2="12" />
643 </svg>
644 </span>
645 Add webhook
646 </h3>
647 </header>
648 <form
649 method="post"
650 action={`/${ownerName}/${repoName}/settings/webhooks`}
651 >
652 <div class="wh-form-body">
653 <div class="wh-field">
654 <label class="wh-label" for="wh-url">
655 Payload URL
c81ab7aClaude656 </label>
304ce2aClaude657 <input
658 id="wh-url"
659 class="wh-input"
660 type="url"
661 name="url"
662 required
663 placeholder="https://example.com/hooks/gluecron"
664 autocomplete="off"
665 spellcheck={false}
666 />
667 </div>
668 <div class="wh-field">
669 <label class="wh-label" for="wh-secret">
670 Secret (optional)
671 </label>
672 <input
673 id="wh-secret"
674 class="wh-input"
675 type="text"
676 name="secret"
677 placeholder="Shared secret for HMAC verification"
678 autocomplete="off"
679 spellcheck={false}
680 />
681 </div>
682 <div class="wh-field">
683 <span class="wh-label">Events</span>
684 <div class="wh-events">
685 {["push", "issue", "pr", "star"].map((evt) => (
686 <label class="wh-evt-label">
687 <input
688 type="checkbox"
689 name="events"
690 value={evt}
691 checked={evt === "push"}
692 />
693 {evt}
694 </label>
695 ))}
696 </div>
697 </div>
698 </div>
699 <div class="wh-form-foot">
700 <button type="submit" class="wh-btn wh-btn-primary">
701 Add webhook
702 </button>
703 </div>
704 </form>
705 </section>
706 </div>
707 <style dangerouslySetInnerHTML={{ __html: webhookStyles }} />
c81ab7aClaude708 </Layout>
709 );
710 }
711);
712
713// Create webhook
714webhookRoutes.post(
715 "/:owner/:repo/settings/webhooks",
716 requireAuth,
febd4f0Claude717 requireRepoAccess("admin"),
c81ab7aClaude718 async (c) => {
719 const { owner: ownerName, repo: repoName } = c.req.param();
720 const user = c.get("user")!;
721 const body = await c.req.parseBody();
722 const url = String(body.url || "").trim();
723 const secret = String(body.secret || "").trim() || null;
724
725 // Events can be a string or array
726 let events: string;
727 const rawEvents = body.events;
728 if (Array.isArray(rawEvents)) {
729 events = rawEvents.join(",");
730 } else {
731 events = String(rawEvents || "push");
732 }
733
734 if (!url) {
735 return c.redirect(
736 `/${ownerName}/${repoName}/settings/webhooks?error=URL+is+required`
737 );
738 }
739
740 const [owner] = await db
741 .select()
742 .from(users)
743 .where(eq(users.username, ownerName))
744 .limit(1);
745 if (!owner || owner.id !== user.id) {
746 return c.redirect(`/${ownerName}/${repoName}`);
747 }
748
749 const [repo] = await db
750 .select()
751 .from(repositories)
752 .where(
753 and(
754 eq(repositories.ownerId, owner.id),
755 eq(repositories.name, repoName)
756 )
757 )
758 .limit(1);
759 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
760
761 await db.insert(webhooks).values({
762 repositoryId: repo.id,
763 url,
764 secret,
765 events,
766 });
767
768 return c.redirect(
769 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+added`
770 );
771 }
772);
773
774// Delete webhook
775webhookRoutes.post(
776 "/:owner/:repo/settings/webhooks/:id/delete",
777 requireAuth,
febd4f0Claude778 requireRepoAccess("admin"),
c81ab7aClaude779 async (c) => {
780 const { owner: ownerName, repo: repoName, id } = c.req.param();
781
782 await db.delete(webhooks).where(eq(webhooks.id, id));
783
784 return c.redirect(
785 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+deleted`
786 );
787 }
788);
789
790export default webhookRoutes;
791
792/**
793 * Fire webhooks for a repository event.
8405c43Claude794 *
795 * Instead of POSTing inline, this enqueues one `webhook_deliveries` row per
796 * matching hook. The background worker in `src/lib/webhook-delivery.ts`
797 * picks them up immediately (and retries with exponential backoff on
798 * failure, eventually transitioning to status='dead' after MAX_ATTEMPTS).
799 *
800 * This is fire-and-forget: enqueue failures are logged but never propagate.
c81ab7aClaude801 */
802export async function fireWebhooks(
803 repositoryId: string,
804 event: string,
a74f4edccanty labs805 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 }
c81ab7aClaude823): Promise<void> {
824 try {
a74f4edccanty labs825 const hooks = await deps.loadHooks(repositoryId);
c81ab7aClaude826
8405c43Claude827 let enqueued = 0;
c81ab7aClaude828 for (const hook of hooks) {
829 if (!hook.isActive) continue;
830 const hookEvents = hook.events.split(",");
831 if (!hookEvents.includes(event)) continue;
832
a74f4edccanty labs833 const id = await deps.enqueueWebhookDelivery({
8405c43Claude834 webhookId: hook.id,
835 secret: hook.secret,
836 event,
837 payload,
838 });
839 if (id) enqueued++;
840 }
c81ab7aClaude841
8405c43Claude842 // Kick the worker for fresh enqueues so we don't wait up to the poll
843 // interval. Best-effort and never awaited from the caller's perspective.
844 if (enqueued > 0) {
a74f4edccanty labs845 void deps.drainPendingDeliveries().catch((err) => {
8405c43Claude846 console.error("[webhook] kick drain failed:", err);
847 });
c81ab7aClaude848 }
849 } catch (err) {
850 console.error("[webhook] failed to query webhooks:", err);
851 }
852}