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.tsxBlame837 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 = `
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;
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
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} />
304ce2aClaude511 <div class="wh-wrap">
512 <header class="wh-head">
513 <div class="wh-eyebrow">
514 <span class="wh-eyebrow-pill" aria-hidden="true">
515 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
516 <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
517 <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
518 </svg>
519 </span>
520 Webhooks · {ownerName}/{repoName}
521 </div>
522 <h2 class="wh-title">
523 <span class="wh-title-grad">Pipe events</span> out of the repo.
524 </h2>
525 <p class="wh-sub">
526 Webhooks POST to your URL on push, issue, PR, and star events.
527 Every delivery is signed with HMAC-SHA256 using your shared
528 secret — verify with the <code>X-Gluecron-Signature</code>{" "}
529 header.
530 </p>
531 </header>
532
c81ab7aClaude533 {success && (
304ce2aClaude534 <div class="wh-banner is-ok" role="status">
535 <span class="wh-banner-dot" aria-hidden="true" />
536 {decodeURIComponent(success)}
537 </div>
c81ab7aClaude538 )}
539 {error && (
304ce2aClaude540 <div class="wh-banner is-error" role="status">
541 <span class="wh-banner-dot" aria-hidden="true" />
542 {decodeURIComponent(error)}
543 </div>
c81ab7aClaude544 )}
304ce2aClaude545
546 {hooks.length > 0 ? (
547 <div class="wh-list">
548 {hooks.map((hook) => {
549 const events = hook.events
550 .split(",")
551 .map((e) => e.trim())
552 .filter(Boolean);
553 const last = hook.lastStatus ?? null;
554 const deliveryClass =
555 last == null
556 ? "is-pending"
557 : last >= 200 && last < 300
558 ? "is-ok"
559 : "is-bad";
560 const deliveryText =
561 last == null
562 ? "no deliveries yet"
563 : `${last} · ${whRelativeTime(hook.lastDeliveredAt)}`;
564 return (
565 <article class="wh-card">
566 <div class="wh-card-top">
567 <div class="wh-card-id">
568 <span class="wh-card-url">{hook.url}</span>
569 <div class="wh-card-meta">
570 <span class="wh-time">
571 Created {whRelativeTime(hook.createdAt)}
572 </span>
573 <span aria-hidden="true">·</span>
574 <span
575 class={"wh-delivery " + deliveryClass}
576 title={
577 last == null
578 ? "No deliveries yet"
579 : `Last HTTP status ${last}`
580 }
581 >
582 <span class="dot" aria-hidden="true" />
583 {deliveryText}
584 </span>
585 </div>
586 </div>
587 <span
588 class={
589 "wh-pill " +
590 (hook.isActive ? "is-active" : "is-disabled")
591 }
592 >
593 <span class="dot" aria-hidden="true" />
594 {hook.isActive ? "active" : "disabled"}
595 </span>
c81ab7aClaude596 </div>
304ce2aClaude597
598 {events.length > 0 && (
599 <div class="wh-chips" aria-label="Subscribed events">
600 {events.map((evt) => (
601 <span class="wh-chip">{evt}</span>
602 ))}
603 </div>
604 )}
605
606 <div class="wh-actions">
607 <form
608 class="wh-card-foot-form"
609 method="post"
610 action={`/${ownerName}/${repoName}/settings/webhooks/${hook.id}/delete`}
611 >
612 <button type="submit" class="wh-btn wh-btn-danger">
613 Revoke
614 </button>
615 </form>
616 </div>
617 </article>
618 );
619 })}
620 </div>
621 ) : (
622 <div class="wh-empty">
623 <div class="wh-empty-orb" aria-hidden="true" />
624 <div class="wh-empty-inner">
625 <p class="wh-empty-title">No webhooks yet</p>
626 <p class="wh-empty-sub">
627 Webhooks fire on push, issue, PR, and star events. Point
628 one at your CI, your Slack relay, or your own service to
629 react in real time.
630 </p>
631 </div>
c81ab7aClaude632 </div>
633 )}
634
304ce2aClaude635 <section class="wh-form-card" aria-labelledby="wh-add-title">
636 <header class="wh-form-head">
637 <h3 class="wh-form-title" id="wh-add-title">
638 <span class="wh-form-title-icon" aria-hidden="true">
639 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
640 <line x1="12" y1="5" x2="12" y2="19" />
641 <line x1="5" y1="12" x2="19" y2="12" />
642 </svg>
643 </span>
644 Add webhook
645 </h3>
646 </header>
647 <form
648 method="post"
649 action={`/${ownerName}/${repoName}/settings/webhooks`}
650 >
651 <div class="wh-form-body">
652 <div class="wh-field">
653 <label class="wh-label" for="wh-url">
654 Payload URL
c81ab7aClaude655 </label>
304ce2aClaude656 <input
657 id="wh-url"
658 class="wh-input"
659 type="url"
660 name="url"
661 required
662 placeholder="https://example.com/hooks/gluecron"
663 autocomplete="off"
664 spellcheck={false}
665 />
666 </div>
667 <div class="wh-field">
668 <label class="wh-label" for="wh-secret">
669 Secret (optional)
670 </label>
671 <input
672 id="wh-secret"
673 class="wh-input"
674 type="text"
675 name="secret"
676 placeholder="Shared secret for HMAC verification"
677 autocomplete="off"
678 spellcheck={false}
679 />
680 </div>
681 <div class="wh-field">
682 <span class="wh-label">Events</span>
683 <div class="wh-events">
684 {["push", "issue", "pr", "star"].map((evt) => (
685 <label class="wh-evt-label">
686 <input
687 type="checkbox"
688 name="events"
689 value={evt}
690 checked={evt === "push"}
691 />
692 {evt}
693 </label>
694 ))}
695 </div>
696 </div>
697 </div>
698 <div class="wh-form-foot">
699 <button type="submit" class="wh-btn wh-btn-primary">
700 Add webhook
701 </button>
702 </div>
703 </form>
704 </section>
705 </div>
706 <style dangerouslySetInnerHTML={{ __html: webhookStyles }} />
c81ab7aClaude707 </Layout>
708 );
709 }
710);
711
712// Create webhook
713webhookRoutes.post(
714 "/:owner/:repo/settings/webhooks",
715 requireAuth,
febd4f0Claude716 requireRepoAccess("admin"),
c81ab7aClaude717 async (c) => {
718 const { owner: ownerName, repo: repoName } = c.req.param();
719 const user = c.get("user")!;
720 const body = await c.req.parseBody();
721 const url = String(body.url || "").trim();
722 const secret = String(body.secret || "").trim() || null;
723
724 // Events can be a string or array
725 let events: string;
726 const rawEvents = body.events;
727 if (Array.isArray(rawEvents)) {
728 events = rawEvents.join(",");
729 } else {
730 events = String(rawEvents || "push");
731 }
732
733 if (!url) {
734 return c.redirect(
735 `/${ownerName}/${repoName}/settings/webhooks?error=URL+is+required`
736 );
737 }
738
739 const [owner] = await db
740 .select()
741 .from(users)
742 .where(eq(users.username, ownerName))
743 .limit(1);
744 if (!owner || owner.id !== user.id) {
745 return c.redirect(`/${ownerName}/${repoName}`);
746 }
747
748 const [repo] = await db
749 .select()
750 .from(repositories)
751 .where(
752 and(
753 eq(repositories.ownerId, owner.id),
754 eq(repositories.name, repoName)
755 )
756 )
757 .limit(1);
758 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
759
760 await db.insert(webhooks).values({
761 repositoryId: repo.id,
762 url,
763 secret,
764 events,
765 });
766
767 return c.redirect(
768 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+added`
769 );
770 }
771);
772
773// Delete webhook
774webhookRoutes.post(
775 "/:owner/:repo/settings/webhooks/:id/delete",
776 requireAuth,
febd4f0Claude777 requireRepoAccess("admin"),
c81ab7aClaude778 async (c) => {
779 const { owner: ownerName, repo: repoName, id } = c.req.param();
780
781 await db.delete(webhooks).where(eq(webhooks.id, id));
782
783 return c.redirect(
784 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+deleted`
785 );
786 }
787);
788
789export default webhookRoutes;
790
791/**
792 * Fire webhooks for a repository event.
8405c43Claude793 *
794 * Instead of POSTing inline, this enqueues one `webhook_deliveries` row per
795 * matching hook. The background worker in `src/lib/webhook-delivery.ts`
796 * picks them up immediately (and retries with exponential backoff on
797 * failure, eventually transitioning to status='dead' after MAX_ATTEMPTS).
798 *
799 * This is fire-and-forget: enqueue failures are logged but never propagate.
c81ab7aClaude800 */
801export async function fireWebhooks(
802 repositoryId: string,
803 event: string,
804 payload: Record<string, unknown>
805): Promise<void> {
806 try {
807 const hooks = await db
808 .select()
809 .from(webhooks)
810 .where(eq(webhooks.repositoryId, repositoryId));
811
8405c43Claude812 let enqueued = 0;
c81ab7aClaude813 for (const hook of hooks) {
814 if (!hook.isActive) continue;
815 const hookEvents = hook.events.split(",");
816 if (!hookEvents.includes(event)) continue;
817
8405c43Claude818 const id = await enqueueWebhookDelivery({
819 webhookId: hook.id,
820 secret: hook.secret,
821 event,
822 payload,
823 });
824 if (id) enqueued++;
825 }
c81ab7aClaude826
8405c43Claude827 // Kick the worker for fresh enqueues so we don't wait up to the poll
828 // interval. Best-effort and never awaited from the caller's perspective.
829 if (enqueued > 0) {
830 void drainPendingDeliveries().catch((err) => {
831 console.error("[webhook] kick drain failed:", err);
832 });
c81ab7aClaude833 }
834 } catch (err) {
835 console.error("[webhook] failed to query webhooks:", err);
836 }
837}