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.tsxBlame308 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";
9import { Layout } from "../views/layout";
10import { RepoHeader } from "../views/components";
11import { softAuth, requireAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
febd4f0Claude13import { requireRepoAccess } from "../middleware/repo-access";
bb0f894Claude14import {
15 Container,
16 Flex,
17 Form,
18 FormGroup,
19 Input,
20 Button,
21 Alert,
22} from "../views/ui";
c81ab7aClaude23
24const webhookRoutes = new Hono<AuthEnv>();
25
26webhookRoutes.use("*", softAuth);
27
28// List webhooks
29webhookRoutes.get(
30 "/:owner/:repo/settings/webhooks",
31 requireAuth,
febd4f0Claude32 requireRepoAccess("read"),
c81ab7aClaude33 async (c) => {
34 const { owner: ownerName, repo: repoName } = c.req.param();
35 const user = c.get("user")!;
36 const success = c.req.query("success");
37 const error = c.req.query("error");
38
39 const [owner] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, ownerName))
43 .limit(1);
44 if (!owner || owner.id !== user.id) {
45 return c.text("Unauthorized", 403);
46 }
47
48 const [repo] = await db
49 .select()
50 .from(repositories)
51 .where(
52 and(
53 eq(repositories.ownerId, owner.id),
54 eq(repositories.name, repoName)
55 )
56 )
57 .limit(1);
58 if (!repo) return c.notFound();
59
60 const hooks = await db
61 .select()
62 .from(webhooks)
63 .where(eq(webhooks.repositoryId, repo.id));
64
65 return c.html(
66 <Layout title={`Webhooks — ${ownerName}/${repoName}`} user={user}>
67 <RepoHeader owner={ownerName} repo={repoName} />
bb0f894Claude68 <Container maxWidth={700}>
c81ab7aClaude69 <h2 style="margin-bottom: 16px">Webhooks</h2>
70 {success && (
bb0f894Claude71 <Alert variant="success">{decodeURIComponent(success)}</Alert>
c81ab7aClaude72 )}
73 {error && (
bb0f894Claude74 <Alert variant="error">{decodeURIComponent(error)}</Alert>
c81ab7aClaude75 )}
76 {hooks.length > 0 && (
77 <div style="margin-bottom: 24px">
78 {hooks.map((hook) => (
79 <div class="ssh-key-item">
80 <div>
81 <strong>{hook.url}</strong>
82 <div class="ssh-key-meta">
83 Events: {hook.events} |{" "}
84 {hook.isActive ? (
85 <span style="color: var(--green)">Active</span>
86 ) : (
87 <span style="color: var(--red)">Inactive</span>
88 )}
89 {hook.lastDeliveredAt && (
90 <span>
91 {" "}
92 | Last: {hook.lastStatus}
93 </span>
94 )}
95 </div>
96 </div>
97 <form
001af43Claude98 method="post"
c81ab7aClaude99 action={`/${ownerName}/${repoName}/settings/webhooks/${hook.id}/delete`}
100 >
bb0f894Claude101 <Button type="submit" variant="danger" size="sm">
c81ab7aClaude102 Delete
bb0f894Claude103 </Button>
0316dbbClaude104 </form>
c81ab7aClaude105 </div>
106 ))}
107 </div>
108 )}
109
110 <h3 style="margin-bottom: 12px">Add webhook</h3>
0316dbbClaude111 <Form
001af43Claude112 method="post"
c81ab7aClaude113 action={`/${ownerName}/${repoName}/settings/webhooks`}
114 >
bb0f894Claude115 <FormGroup label="Payload URL">
116 <Input
c81ab7aClaude117 type="url"
118 name="url"
119 required
120 placeholder="https://example.com/hooks/gluecron"
121 />
bb0f894Claude122 </FormGroup>
123 <FormGroup label="Secret (optional)">
124 <Input
c81ab7aClaude125 type="text"
126 name="secret"
127 placeholder="Shared secret for HMAC verification"
128 />
bb0f894Claude129 </FormGroup>
130 <FormGroup label="Events">
131 <Flex gap={16} wrap>
c81ab7aClaude132 {["push", "issue", "pr", "star"].map((evt) => (
133 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
134 <input
135 type="checkbox"
136 name="events"
137 value={evt}
138 checked={evt === "push"}
139 />{" "}
140 {evt}
141 </label>
142 ))}
bb0f894Claude143 </Flex>
144 </FormGroup>
145 <Button type="submit" variant="primary">
c81ab7aClaude146 Add webhook
bb0f894Claude147 </Button>
148 </Form>
149 </Container>
c81ab7aClaude150 </Layout>
151 );
152 }
153);
154
155// Create webhook
156webhookRoutes.post(
157 "/:owner/:repo/settings/webhooks",
158 requireAuth,
febd4f0Claude159 requireRepoAccess("admin"),
c81ab7aClaude160 async (c) => {
161 const { owner: ownerName, repo: repoName } = c.req.param();
162 const user = c.get("user")!;
163 const body = await c.req.parseBody();
164 const url = String(body.url || "").trim();
165 const secret = String(body.secret || "").trim() || null;
166
167 // Events can be a string or array
168 let events: string;
169 const rawEvents = body.events;
170 if (Array.isArray(rawEvents)) {
171 events = rawEvents.join(",");
172 } else {
173 events = String(rawEvents || "push");
174 }
175
176 if (!url) {
177 return c.redirect(
178 `/${ownerName}/${repoName}/settings/webhooks?error=URL+is+required`
179 );
180 }
181
182 const [owner] = await db
183 .select()
184 .from(users)
185 .where(eq(users.username, ownerName))
186 .limit(1);
187 if (!owner || owner.id !== user.id) {
188 return c.redirect(`/${ownerName}/${repoName}`);
189 }
190
191 const [repo] = await db
192 .select()
193 .from(repositories)
194 .where(
195 and(
196 eq(repositories.ownerId, owner.id),
197 eq(repositories.name, repoName)
198 )
199 )
200 .limit(1);
201 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
202
203 await db.insert(webhooks).values({
204 repositoryId: repo.id,
205 url,
206 secret,
207 events,
208 });
209
210 return c.redirect(
211 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+added`
212 );
213 }
214);
215
216// Delete webhook
217webhookRoutes.post(
218 "/:owner/:repo/settings/webhooks/:id/delete",
219 requireAuth,
febd4f0Claude220 requireRepoAccess("admin"),
c81ab7aClaude221 async (c) => {
222 const { owner: ownerName, repo: repoName, id } = c.req.param();
223
224 await db.delete(webhooks).where(eq(webhooks.id, id));
225
226 return c.redirect(
227 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+deleted`
228 );
229 }
230);
231
232export default webhookRoutes;
233
234/**
235 * Fire webhooks for a repository event.
236 */
237export async function fireWebhooks(
238 repositoryId: string,
239 event: string,
240 payload: Record<string, unknown>
241): Promise<void> {
242 try {
243 const hooks = await db
244 .select()
245 .from(webhooks)
246 .where(eq(webhooks.repositoryId, repositoryId));
247
248 for (const hook of hooks) {
249 if (!hook.isActive) continue;
250 const hookEvents = hook.events.split(",");
251 if (!hookEvents.includes(event)) continue;
252
253 try {
254 const headers: Record<string, string> = {
255 "Content-Type": "application/json",
256 "X-Gluecron-Event": event,
257 };
258
259 if (hook.secret) {
260 const encoder = new TextEncoder();
261 const key = await crypto.subtle.importKey(
262 "raw",
263 encoder.encode(hook.secret),
264 { name: "HMAC", hash: "SHA-256" },
265 false,
266 ["sign"]
267 );
268 const signature = await crypto.subtle.sign(
269 "HMAC",
270 key,
271 encoder.encode(JSON.stringify(payload))
272 );
273 headers["X-Gluecron-Signature"] =
274 "sha256=" +
275 Array.from(new Uint8Array(signature))
276 .map((b) => b.toString(16).padStart(2, "0"))
277 .join("");
278 }
279
280 const res = await fetch(hook.url, {
281 method: "POST",
282 headers,
283 body: JSON.stringify(payload),
284 signal: AbortSignal.timeout(10000),
285 });
286
287 await db
288 .update(webhooks)
289 .set({
290 lastDeliveredAt: new Date(),
291 lastStatus: res.status,
292 })
293 .where(eq(webhooks.id, hook.id));
294 } catch (err) {
295 console.error(`[webhook] delivery failed for ${hook.url}:`, err);
296 await db
297 .update(webhooks)
298 .set({
299 lastDeliveredAt: new Date(),
300 lastStatus: 0,
301 })
302 .where(eq(webhooks.id, hook.id));
303 }
304 }
305 } catch (err) {
306 console.error("[webhook] failed to query webhooks:", err);
307 }
308}