Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame831 lines · 1 contributor
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";
29import { RepoHeader } from "../views/components";
30import { 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 = `
44 .wh-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
45
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;
64 background: rgba(140,109,255,0.14);
65 color: #b69dff;
66 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
67 }
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 {
78 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
79 -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;
223 background: rgba(140,109,255,0.10);
224 border: 1px solid rgba(140,109,255,0.30);
225 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 {
305 background: linear-gradient(135deg, #8c6dff 0%, #6d4ee0 100%);
306 color: #ffffff;
307 border-color: rgba(140,109,255,0.55);
308 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45);
309 }
310 .wh-btn-primary:hover {
311 transform: translateY(-1px);
312 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
313 }
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;
331 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
332 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;
385 background: rgba(140,109,255,0.12);
386 color: #b69dff;
387 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
388 }
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);
415 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
416 }
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 }
436 .wh-evt-label:hover { border-color: rgba(140,109,255,0.45); }
437 .wh-evt-label input { accent-color: #8c6dff; cursor: pointer; }
438 .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
497 const hooks = await db
498 .select()
499 .from(webhooks)
500 .where(eq(webhooks.repositoryId, repo.id));
501
502 return c.html(
503 <Layout title={`Webhooks — ${ownerName}/${repoName}`} user={user}>
504 <RepoHeader owner={ownerName} repo={repoName} />
304ce2aClaude505 <div class="wh-wrap">
506 <header class="wh-head">
507 <div class="wh-eyebrow">
508 <span class="wh-eyebrow-pill" aria-hidden="true">
509 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
510 <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
511 <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
512 </svg>
513 </span>
514 Webhooks · {ownerName}/{repoName}
515 </div>
516 <h2 class="wh-title">
517 <span class="wh-title-grad">Pipe events</span> out of the repo.
518 </h2>
519 <p class="wh-sub">
520 Webhooks POST to your URL on push, issue, PR, and star events.
521 Every delivery is signed with HMAC-SHA256 using your shared
522 secret — verify with the <code>X-Gluecron-Signature</code>{" "}
523 header.
524 </p>
525 </header>
526
c81ab7aClaude527 {success && (
304ce2aClaude528 <div class="wh-banner is-ok" role="status">
529 <span class="wh-banner-dot" aria-hidden="true" />
530 {decodeURIComponent(success)}
531 </div>
c81ab7aClaude532 )}
533 {error && (
304ce2aClaude534 <div class="wh-banner is-error" role="status">
535 <span class="wh-banner-dot" aria-hidden="true" />
536 {decodeURIComponent(error)}
537 </div>
c81ab7aClaude538 )}
304ce2aClaude539
540 {hooks.length > 0 ? (
541 <div class="wh-list">
542 {hooks.map((hook) => {
543 const events = hook.events
544 .split(",")
545 .map((e) => e.trim())
546 .filter(Boolean);
547 const last = hook.lastStatus ?? null;
548 const deliveryClass =
549 last == null
550 ? "is-pending"
551 : last >= 200 && last < 300
552 ? "is-ok"
553 : "is-bad";
554 const deliveryText =
555 last == null
556 ? "no deliveries yet"
557 : `${last} · ${whRelativeTime(hook.lastDeliveredAt)}`;
558 return (
559 <article class="wh-card">
560 <div class="wh-card-top">
561 <div class="wh-card-id">
562 <span class="wh-card-url">{hook.url}</span>
563 <div class="wh-card-meta">
564 <span class="wh-time">
565 Created {whRelativeTime(hook.createdAt)}
566 </span>
567 <span aria-hidden="true">·</span>
568 <span
569 class={"wh-delivery " + deliveryClass}
570 title={
571 last == null
572 ? "No deliveries yet"
573 : `Last HTTP status ${last}`
574 }
575 >
576 <span class="dot" aria-hidden="true" />
577 {deliveryText}
578 </span>
579 </div>
580 </div>
581 <span
582 class={
583 "wh-pill " +
584 (hook.isActive ? "is-active" : "is-disabled")
585 }
586 >
587 <span class="dot" aria-hidden="true" />
588 {hook.isActive ? "active" : "disabled"}
589 </span>
c81ab7aClaude590 </div>
304ce2aClaude591
592 {events.length > 0 && (
593 <div class="wh-chips" aria-label="Subscribed events">
594 {events.map((evt) => (
595 <span class="wh-chip">{evt}</span>
596 ))}
597 </div>
598 )}
599
600 <div class="wh-actions">
601 <form
602 class="wh-card-foot-form"
603 method="post"
604 action={`/${ownerName}/${repoName}/settings/webhooks/${hook.id}/delete`}
605 >
606 <button type="submit" class="wh-btn wh-btn-danger">
607 Revoke
608 </button>
609 </form>
610 </div>
611 </article>
612 );
613 })}
614 </div>
615 ) : (
616 <div class="wh-empty">
617 <div class="wh-empty-orb" aria-hidden="true" />
618 <div class="wh-empty-inner">
619 <p class="wh-empty-title">No webhooks yet</p>
620 <p class="wh-empty-sub">
621 Webhooks fire on push, issue, PR, and star events. Point
622 one at your CI, your Slack relay, or your own service to
623 react in real time.
624 </p>
625 </div>
c81ab7aClaude626 </div>
627 )}
628
304ce2aClaude629 <section class="wh-form-card" aria-labelledby="wh-add-title">
630 <header class="wh-form-head">
631 <h3 class="wh-form-title" id="wh-add-title">
632 <span class="wh-form-title-icon" aria-hidden="true">
633 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
634 <line x1="12" y1="5" x2="12" y2="19" />
635 <line x1="5" y1="12" x2="19" y2="12" />
636 </svg>
637 </span>
638 Add webhook
639 </h3>
640 </header>
641 <form
642 method="post"
643 action={`/${ownerName}/${repoName}/settings/webhooks`}
644 >
645 <div class="wh-form-body">
646 <div class="wh-field">
647 <label class="wh-label" for="wh-url">
648 Payload URL
c81ab7aClaude649 </label>
304ce2aClaude650 <input
651 id="wh-url"
652 class="wh-input"
653 type="url"
654 name="url"
655 required
656 placeholder="https://example.com/hooks/gluecron"
657 autocomplete="off"
658 spellcheck={false}
659 />
660 </div>
661 <div class="wh-field">
662 <label class="wh-label" for="wh-secret">
663 Secret (optional)
664 </label>
665 <input
666 id="wh-secret"
667 class="wh-input"
668 type="text"
669 name="secret"
670 placeholder="Shared secret for HMAC verification"
671 autocomplete="off"
672 spellcheck={false}
673 />
674 </div>
675 <div class="wh-field">
676 <span class="wh-label">Events</span>
677 <div class="wh-events">
678 {["push", "issue", "pr", "star"].map((evt) => (
679 <label class="wh-evt-label">
680 <input
681 type="checkbox"
682 name="events"
683 value={evt}
684 checked={evt === "push"}
685 />
686 {evt}
687 </label>
688 ))}
689 </div>
690 </div>
691 </div>
692 <div class="wh-form-foot">
693 <button type="submit" class="wh-btn wh-btn-primary">
694 Add webhook
695 </button>
696 </div>
697 </form>
698 </section>
699 </div>
700 <style dangerouslySetInnerHTML={{ __html: webhookStyles }} />
c81ab7aClaude701 </Layout>
702 );
703 }
704);
705
706// Create webhook
707webhookRoutes.post(
708 "/:owner/:repo/settings/webhooks",
709 requireAuth,
febd4f0Claude710 requireRepoAccess("admin"),
c81ab7aClaude711 async (c) => {
712 const { owner: ownerName, repo: repoName } = c.req.param();
713 const user = c.get("user")!;
714 const body = await c.req.parseBody();
715 const url = String(body.url || "").trim();
716 const secret = String(body.secret || "").trim() || null;
717
718 // Events can be a string or array
719 let events: string;
720 const rawEvents = body.events;
721 if (Array.isArray(rawEvents)) {
722 events = rawEvents.join(",");
723 } else {
724 events = String(rawEvents || "push");
725 }
726
727 if (!url) {
728 return c.redirect(
729 `/${ownerName}/${repoName}/settings/webhooks?error=URL+is+required`
730 );
731 }
732
733 const [owner] = await db
734 .select()
735 .from(users)
736 .where(eq(users.username, ownerName))
737 .limit(1);
738 if (!owner || owner.id !== user.id) {
739 return c.redirect(`/${ownerName}/${repoName}`);
740 }
741
742 const [repo] = await db
743 .select()
744 .from(repositories)
745 .where(
746 and(
747 eq(repositories.ownerId, owner.id),
748 eq(repositories.name, repoName)
749 )
750 )
751 .limit(1);
752 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
753
754 await db.insert(webhooks).values({
755 repositoryId: repo.id,
756 url,
757 secret,
758 events,
759 });
760
761 return c.redirect(
762 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+added`
763 );
764 }
765);
766
767// Delete webhook
768webhookRoutes.post(
769 "/:owner/:repo/settings/webhooks/:id/delete",
770 requireAuth,
febd4f0Claude771 requireRepoAccess("admin"),
c81ab7aClaude772 async (c) => {
773 const { owner: ownerName, repo: repoName, id } = c.req.param();
774
775 await db.delete(webhooks).where(eq(webhooks.id, id));
776
777 return c.redirect(
778 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+deleted`
779 );
780 }
781);
782
783export default webhookRoutes;
784
785/**
786 * Fire webhooks for a repository event.
8405c43Claude787 *
788 * Instead of POSTing inline, this enqueues one `webhook_deliveries` row per
789 * matching hook. The background worker in `src/lib/webhook-delivery.ts`
790 * picks them up immediately (and retries with exponential backoff on
791 * failure, eventually transitioning to status='dead' after MAX_ATTEMPTS).
792 *
793 * This is fire-and-forget: enqueue failures are logged but never propagate.
c81ab7aClaude794 */
795export async function fireWebhooks(
796 repositoryId: string,
797 event: string,
798 payload: Record<string, unknown>
799): Promise<void> {
800 try {
801 const hooks = await db
802 .select()
803 .from(webhooks)
804 .where(eq(webhooks.repositoryId, repositoryId));
805
8405c43Claude806 let enqueued = 0;
c81ab7aClaude807 for (const hook of hooks) {
808 if (!hook.isActive) continue;
809 const hookEvents = hook.events.split(",");
810 if (!hookEvents.includes(event)) continue;
811
8405c43Claude812 const id = await enqueueWebhookDelivery({
813 webhookId: hook.id,
814 secret: hook.secret,
815 event,
816 payload,
817 });
818 if (id) enqueued++;
819 }
c81ab7aClaude820
8405c43Claude821 // Kick the worker for fresh enqueues so we don't wait up to the poll
822 // interval. Best-effort and never awaited from the caller's perspective.
823 if (enqueued > 0) {
824 void drainPendingDeliveries().catch((err) => {
825 console.error("[webhook] kick drain failed:", err);
826 });
c81ab7aClaude827 }
828 } catch (err) {
829 console.error("[webhook] failed to query webhooks:", err);
830 }
831}