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.tsxBlame284 lines · 1 contributor
c81ab7aClaude1/**
2 * Webhooks management — register, list, delete, test.
3 */
4
5import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
7import { db } from "../db";
8import { webhooks, repositories, users } from "../db/schema";
8405c43Claude9import {
10 enqueueWebhookDelivery,
11 drainPendingDeliveries,
12} from "../lib/webhook-delivery";
c81ab7aClaude13import { Layout } from "../views/layout";
14import { RepoHeader } from "../views/components";
15import { softAuth, requireAuth } from "../middleware/auth";
16import type { AuthEnv } from "../middleware/auth";
febd4f0Claude17import { requireRepoAccess } from "../middleware/repo-access";
bb0f894Claude18import {
19 Container,
20 Flex,
21 Form,
22 FormGroup,
23 Input,
24 Button,
25 Alert,
26} from "../views/ui";
c81ab7aClaude27
28const webhookRoutes = new Hono<AuthEnv>();
29
30webhookRoutes.use("*", softAuth);
31
32// List webhooks
33webhookRoutes.get(
34 "/:owner/:repo/settings/webhooks",
35 requireAuth,
febd4f0Claude36 requireRepoAccess("read"),
c81ab7aClaude37 async (c) => {
38 const { owner: ownerName, repo: repoName } = c.req.param();
39 const user = c.get("user")!;
40 const success = c.req.query("success");
41 const error = c.req.query("error");
42
43 const [owner] = await db
44 .select()
45 .from(users)
46 .where(eq(users.username, ownerName))
47 .limit(1);
48 if (!owner || owner.id !== user.id) {
49 return c.text("Unauthorized", 403);
50 }
51
52 const [repo] = await db
53 .select()
54 .from(repositories)
55 .where(
56 and(
57 eq(repositories.ownerId, owner.id),
58 eq(repositories.name, repoName)
59 )
60 )
61 .limit(1);
62 if (!repo) return c.notFound();
63
64 const hooks = await db
65 .select()
66 .from(webhooks)
67 .where(eq(webhooks.repositoryId, repo.id));
68
69 return c.html(
70 <Layout title={`Webhooks — ${ownerName}/${repoName}`} user={user}>
71 <RepoHeader owner={ownerName} repo={repoName} />
bb0f894Claude72 <Container maxWidth={700}>
c81ab7aClaude73 <h2 style="margin-bottom: 16px">Webhooks</h2>
74 {success && (
bb0f894Claude75 <Alert variant="success">{decodeURIComponent(success)}</Alert>
c81ab7aClaude76 )}
77 {error && (
bb0f894Claude78 <Alert variant="error">{decodeURIComponent(error)}</Alert>
c81ab7aClaude79 )}
80 {hooks.length > 0 && (
81 <div style="margin-bottom: 24px">
82 {hooks.map((hook) => (
83 <div class="ssh-key-item">
84 <div>
85 <strong>{hook.url}</strong>
86 <div class="ssh-key-meta">
87 Events: {hook.events} |{" "}
88 {hook.isActive ? (
89 <span style="color: var(--green)">Active</span>
90 ) : (
91 <span style="color: var(--red)">Inactive</span>
92 )}
93 {hook.lastDeliveredAt && (
94 <span>
95 {" "}
96 | Last: {hook.lastStatus}
97 </span>
98 )}
99 </div>
100 </div>
101 <form
001af43Claude102 method="post"
c81ab7aClaude103 action={`/${ownerName}/${repoName}/settings/webhooks/${hook.id}/delete`}
104 >
bb0f894Claude105 <Button type="submit" variant="danger" size="sm">
c81ab7aClaude106 Delete
bb0f894Claude107 </Button>
0316dbbClaude108 </form>
c81ab7aClaude109 </div>
110 ))}
111 </div>
112 )}
113
114 <h3 style="margin-bottom: 12px">Add webhook</h3>
0316dbbClaude115 <Form
001af43Claude116 method="post"
c81ab7aClaude117 action={`/${ownerName}/${repoName}/settings/webhooks`}
118 >
bb0f894Claude119 <FormGroup label="Payload URL">
120 <Input
c81ab7aClaude121 type="url"
122 name="url"
123 required
124 placeholder="https://example.com/hooks/gluecron"
125 />
bb0f894Claude126 </FormGroup>
127 <FormGroup label="Secret (optional)">
128 <Input
c81ab7aClaude129 type="text"
130 name="secret"
131 placeholder="Shared secret for HMAC verification"
132 />
bb0f894Claude133 </FormGroup>
134 <FormGroup label="Events">
135 <Flex gap={16} wrap>
c81ab7aClaude136 {["push", "issue", "pr", "star"].map((evt) => (
137 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
138 <input
139 type="checkbox"
140 name="events"
141 value={evt}
142 checked={evt === "push"}
143 />{" "}
144 {evt}
145 </label>
146 ))}
bb0f894Claude147 </Flex>
148 </FormGroup>
149 <Button type="submit" variant="primary">
c81ab7aClaude150 Add webhook
bb0f894Claude151 </Button>
152 </Form>
153 </Container>
c81ab7aClaude154 </Layout>
155 );
156 }
157);
158
159// Create webhook
160webhookRoutes.post(
161 "/:owner/:repo/settings/webhooks",
162 requireAuth,
febd4f0Claude163 requireRepoAccess("admin"),
c81ab7aClaude164 async (c) => {
165 const { owner: ownerName, repo: repoName } = c.req.param();
166 const user = c.get("user")!;
167 const body = await c.req.parseBody();
168 const url = String(body.url || "").trim();
169 const secret = String(body.secret || "").trim() || null;
170
171 // Events can be a string or array
172 let events: string;
173 const rawEvents = body.events;
174 if (Array.isArray(rawEvents)) {
175 events = rawEvents.join(",");
176 } else {
177 events = String(rawEvents || "push");
178 }
179
180 if (!url) {
181 return c.redirect(
182 `/${ownerName}/${repoName}/settings/webhooks?error=URL+is+required`
183 );
184 }
185
186 const [owner] = await db
187 .select()
188 .from(users)
189 .where(eq(users.username, ownerName))
190 .limit(1);
191 if (!owner || owner.id !== user.id) {
192 return c.redirect(`/${ownerName}/${repoName}`);
193 }
194
195 const [repo] = await db
196 .select()
197 .from(repositories)
198 .where(
199 and(
200 eq(repositories.ownerId, owner.id),
201 eq(repositories.name, repoName)
202 )
203 )
204 .limit(1);
205 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
206
207 await db.insert(webhooks).values({
208 repositoryId: repo.id,
209 url,
210 secret,
211 events,
212 });
213
214 return c.redirect(
215 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+added`
216 );
217 }
218);
219
220// Delete webhook
221webhookRoutes.post(
222 "/:owner/:repo/settings/webhooks/:id/delete",
223 requireAuth,
febd4f0Claude224 requireRepoAccess("admin"),
c81ab7aClaude225 async (c) => {
226 const { owner: ownerName, repo: repoName, id } = c.req.param();
227
228 await db.delete(webhooks).where(eq(webhooks.id, id));
229
230 return c.redirect(
231 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+deleted`
232 );
233 }
234);
235
236export default webhookRoutes;
237
238/**
239 * Fire webhooks for a repository event.
8405c43Claude240 *
241 * Instead of POSTing inline, this enqueues one `webhook_deliveries` row per
242 * matching hook. The background worker in `src/lib/webhook-delivery.ts`
243 * picks them up immediately (and retries with exponential backoff on
244 * failure, eventually transitioning to status='dead' after MAX_ATTEMPTS).
245 *
246 * This is fire-and-forget: enqueue failures are logged but never propagate.
c81ab7aClaude247 */
248export async function fireWebhooks(
249 repositoryId: string,
250 event: string,
251 payload: Record<string, unknown>
252): Promise<void> {
253 try {
254 const hooks = await db
255 .select()
256 .from(webhooks)
257 .where(eq(webhooks.repositoryId, repositoryId));
258
8405c43Claude259 let enqueued = 0;
c81ab7aClaude260 for (const hook of hooks) {
261 if (!hook.isActive) continue;
262 const hookEvents = hook.events.split(",");
263 if (!hookEvents.includes(event)) continue;
264
8405c43Claude265 const id = await enqueueWebhookDelivery({
266 webhookId: hook.id,
267 secret: hook.secret,
268 event,
269 payload,
270 });
271 if (id) enqueued++;
272 }
c81ab7aClaude273
8405c43Claude274 // Kick the worker for fresh enqueues so we don't wait up to the poll
275 // interval. Best-effort and never awaited from the caller's perspective.
276 if (enqueued > 0) {
277 void drainPendingDeliveries().catch((err) => {
278 console.error("[webhook] kick drain failed:", err);
279 });
c81ab7aClaude280 }
281 } catch (err) {
282 console.error("[webhook] failed to query webhooks:", err);
283 }
284}