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