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

integrations.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.

integrations.tsxBlame377 lines · 1 contributor
40e7738Claude1/**
2 * Per-repo integrations management.
3 *
4 * UI lives at `/:owner/:repo/settings/integrations`:
5 * - List existing connectors with status / last delivery
6 * - Add a connector (kind + name + config + events)
7 * - Toggle enable, edit, delete
8 * - "Send test" button → fires a synthetic event through the connector
9 *
10 * Owner-only via requireRepoAccess("admin"). Reads redact secret config
11 * fields before rendering.
12 */
13
14import { Hono } from "hono";
15import { and, eq } from "drizzle-orm";
16import { db } from "../db";
17import { repositories, users } from "../db/schema";
18import { Layout } from "../views/layout";
19import { RepoHeader } from "../views/components";
20import { softAuth, requireAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { requireRepoAccess } from "../middleware/repo-access";
23import {
24 Container,
25 Form,
26 FormGroup,
27 Input,
28 Button,
29 Alert,
30 Select,
31} from "../views/ui";
32import {
33 CONNECTORS,
34 INTEGRATION_EVENTS,
35 createIntegration,
36 deleteIntegration,
37 deliverOne,
38 getById,
39 getConnector,
40 isValidEvent,
41 isValidKind,
42 listDeliveries,
43 listForRepo,
44 redactConfig,
45 updateIntegration,
46 type IntegrationEvent,
47 type IntegrationKind,
48} from "../lib/integrations";
49
50const app = new Hono<AuthEnv>();
51
52app.use("*", softAuth);
53
54async function ownedRepo(ownerName: string, repoName: string, userId: string) {
55 const [owner] = await db
56 .select()
57 .from(users)
58 .where(eq(users.username, ownerName))
59 .limit(1);
60 if (!owner || owner.id !== userId) return null;
61 const [repo] = await db
62 .select()
63 .from(repositories)
64 .where(
65 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
66 )
67 .limit(1);
68 return repo ?? null;
69}
70
71app.get(
72 "/:owner/:repo/settings/integrations",
73 requireAuth,
74 requireRepoAccess("admin"),
75 async (c) => {
76 const { owner: ownerName, repo: repoName } = c.req.param();
77 const user = c.get("user")!;
78 const success = c.req.query("success");
79 const error = c.req.query("error");
80
81 const repo = await ownedRepo(ownerName, repoName, user.id);
82 if (!repo) return c.text("Unauthorized", 403);
83
84 const integrations = await listForRepo(repo.id);
85
86 return c.html(
87 <Layout title={`Integrations — ${ownerName}/${repoName}`} user={user}>
88 <RepoHeader owner={ownerName} repo={repoName} />
89 <Container maxWidth={760}>
90 <h2 style="margin-bottom: 6px">Integrations</h2>
91 <p style="color: var(--text-muted); margin-bottom: 16px">
92 Forward gluecron events into Slack, Discord, Linear, Vercel, Jira,
93 PagerDuty, Sentry, Datadog, and any custom webhook target.
94 </p>
95 {success && <Alert variant="success">{decodeURIComponent(success)}</Alert>}
96 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
97
98 {integrations.length === 0 && (
99 <div
100 style="
101 background: var(--bg-secondary);
102 border: 1px dashed var(--border);
103 border-radius: 6px;
104 padding: 16px;
105 margin-bottom: 24px;
106 color: var(--text-muted);
107 "
108 >
109 No integrations yet. Add one below.
110 </div>
111 )}
112
113 {integrations.map((row) => {
114 const meta = getConnector(row.kind as IntegrationKind);
115 const evs = Array.isArray(row.events) ? (row.events as string[]) : [];
116 const config = redactConfig(
117 row.kind as IntegrationKind,
118 (row.config as Record<string, unknown>) ?? {}
119 );
120 return (
121 <div
122 style="
123 background: var(--bg-secondary);
124 border: 1px solid var(--border);
125 border-radius: 6px;
126 padding: 14px;
127 margin-bottom: 12px;
128 "
129 >
130 <div style="display:flex; justify-content:space-between; gap:12px; align-items:flex-start;">
131 <div>
132 <strong>{row.name}</strong>
133 <span style="margin-left:8px; padding:2px 6px; background:rgba(99,102,241,0.15); color:#a5b4fc; border-radius:4px; font-size:11px;">
134 {meta?.label ?? row.kind}
135 </span>
136 {!row.enabled && (
137 <span style="margin-left:6px; color: var(--red); font-size:11px">
138 disabled
139 </span>
140 )}
141 <div style="font-size:12px; color: var(--text-muted); margin-top:4px;">
142 Events: {evs.length > 0 ? evs.join(", ") : "(none)"}
143 </div>
144 <div style="font-size:11px; color: var(--text-muted); margin-top:4px; font-family:ui-monospace,monospace;">
145 {Object.entries(config)
146 .map(([k, v]) => `${k}=${v}`)
147 .join(" ")}
148 </div>
149 <div style="font-size:11px; color: var(--text-muted); margin-top:4px;">
150 {row.lastDeliveryAt
151 ? `last: ${new Date(row.lastDeliveryAt).toISOString()} (${row.lastStatus ?? "unknown"})`
152 : "never delivered"}
153 </div>
154 </div>
155 <div style="display:flex; gap:6px; flex-direction:column; align-items:flex-end;">
156 <form
157 method="post"
158 action={`/${ownerName}/${repoName}/settings/integrations/${row.id}/test`}
159 >
160 <Button type="submit" size="sm">Send test</Button>
161 </form>
162 <form
163 method="post"
164 action={`/${ownerName}/${repoName}/settings/integrations/${row.id}/toggle`}
165 >
166 <Button type="submit" size="sm">
167 {row.enabled ? "Disable" : "Enable"}
168 </Button>
169 </form>
170 <form
171 method="post"
172 action={`/${ownerName}/${repoName}/settings/integrations/${row.id}/delete`}
173 >
174 <Button type="submit" variant="danger" size="sm">
175 Delete
176 </Button>
177 </form>
178 </div>
179 </div>
180 </div>
181 );
182 })}
183
184 <h3 style="margin-top: 24px; margin-bottom: 12px">Add integration</h3>
185 <Form
186 method="post"
187 action={`/${ownerName}/${repoName}/settings/integrations`}
188 >
189 <FormGroup label="Connector">
190 <Select name="kind">
191 {CONNECTORS.map((c) => (
192 <option value={c.kind}>
193 {c.label} — {c.description}
194 </option>
195 ))}
196 </Select>
197 </FormGroup>
198 <FormGroup label="Name (free-form label)">
199 <Input
200 type="text"
201 name="name"
202 required
203 placeholder="Engineering channel"
204 />
205 </FormGroup>
206 <FormGroup label="Config (JSON)">
207 <textarea
208 name="config"
209 rows={5}
210 style="width:100%; font-family: ui-monospace, monospace; padding:8px; background: var(--bg); color: var(--text); border:1px solid var(--border); border-radius:6px"
211 placeholder='{"webhookUrl": "https://hooks.slack.com/..."}'
212 required
213 ></textarea>
214 </FormGroup>
215 <FormGroup label="Events">
216 <div style="display:flex; flex-wrap:wrap; gap:8px">
217 {INTEGRATION_EVENTS.map((e) => (
218 <label style="display:inline-flex; align-items:center; gap:4px; font-size:12px; background: var(--bg); padding:4px 8px; border-radius:4px; border:1px solid var(--border);">
219 <input type="checkbox" name="events" value={e} />
220 <span style="font-family: ui-monospace, monospace">{e}</span>
221 </label>
222 ))}
223 </div>
224 </FormGroup>
225 <Button type="submit" variant="primary">Add integration</Button>
226 </Form>
227 </Container>
228 </Layout>
229 );
230 }
231);
232
233app.post(
234 "/:owner/:repo/settings/integrations",
235 requireAuth,
236 requireRepoAccess("admin"),
237 async (c) => {
238 const { owner: ownerName, repo: repoName } = c.req.param();
239 const user = c.get("user")!;
240 const repo = await ownedRepo(ownerName, repoName, user.id);
241 if (!repo) return c.text("Unauthorized", 403);
242
243 const form = await c.req.formData();
244 const kindRaw = String(form.get("kind") ?? "");
245 const name = String(form.get("name") ?? "").trim();
246 const configRaw = String(form.get("config") ?? "{}");
247 const events = form
248 .getAll("events")
249 .map(String)
250 .filter(isValidEvent) as IntegrationEvent[];
251 if (!isValidKind(kindRaw)) {
252 return c.redirect(
253 `/${ownerName}/${repoName}/settings/integrations?error=Invalid+connector`
254 );
255 }
256 let config: Record<string, unknown> = {};
257 try {
258 config = JSON.parse(configRaw) as Record<string, unknown>;
259 } catch {
260 return c.redirect(
261 `/${ownerName}/${repoName}/settings/integrations?error=Config+must+be+valid+JSON`
262 );
263 }
264 try {
265 await createIntegration({
266 repositoryId: repo.id,
267 kind: kindRaw,
268 name,
269 config,
270 events,
271 createdBy: user.id,
272 });
273 } catch (err) {
274 const msg = err instanceof Error ? err.message : "Create failed";
275 return c.redirect(
276 `/${ownerName}/${repoName}/settings/integrations?error=${encodeURIComponent(msg)}`
277 );
278 }
279 return c.redirect(
280 `/${ownerName}/${repoName}/settings/integrations?success=Integration+added`
281 );
282 }
283);
284
285app.post(
286 "/:owner/:repo/settings/integrations/:id/toggle",
287 requireAuth,
288 requireRepoAccess("admin"),
289 async (c) => {
290 const { owner: ownerName, repo: repoName, id } = c.req.param();
291 const user = c.get("user")!;
292 const repo = await ownedRepo(ownerName, repoName, user.id);
293 if (!repo) return c.text("Unauthorized", 403);
294 const row = await getById(id);
295 if (!row || row.repositoryId !== repo.id) {
296 return c.redirect(
297 `/${ownerName}/${repoName}/settings/integrations?error=Not+found`
298 );
299 }
300 await updateIntegration(id, { enabled: !row.enabled });
301 return c.redirect(
302 `/${ownerName}/${repoName}/settings/integrations?success=Updated`
303 );
304 }
305);
306
307app.post(
308 "/:owner/:repo/settings/integrations/:id/delete",
309 requireAuth,
310 requireRepoAccess("admin"),
311 async (c) => {
312 const { owner: ownerName, repo: repoName, id } = c.req.param();
313 const user = c.get("user")!;
314 const repo = await ownedRepo(ownerName, repoName, user.id);
315 if (!repo) return c.text("Unauthorized", 403);
316 const row = await getById(id);
317 if (!row || row.repositoryId !== repo.id) {
318 return c.redirect(
319 `/${ownerName}/${repoName}/settings/integrations?error=Not+found`
320 );
321 }
322 await deleteIntegration(id);
323 return c.redirect(
324 `/${ownerName}/${repoName}/settings/integrations?success=Deleted`
325 );
326 }
327);
328
329app.post(
330 "/:owner/:repo/settings/integrations/:id/test",
331 requireAuth,
332 requireRepoAccess("admin"),
333 async (c) => {
334 const { owner: ownerName, repo: repoName, id } = c.req.param();
335 const user = c.get("user")!;
336 const repo = await ownedRepo(ownerName, repoName, user.id);
337 if (!repo) return c.text("Unauthorized", 403);
338 const row = await getById(id);
339 if (!row || row.repositoryId !== repo.id) {
340 return c.redirect(
341 `/${ownerName}/${repoName}/settings/integrations?error=Not+found`
342 );
343 }
344 const result = await deliverOne(row, "push", {
345 repository: `${ownerName}/${repoName}`,
346 title: "test event from gluecron",
347 synthetic: true,
348 });
349 const msg =
350 result.status === "ok"
351 ? `Test delivered (${result.httpStatus ?? "?"}) in ${result.durationMs}ms`
352 : `Test failed: ${result.error ?? result.httpStatus ?? "unknown"}`;
353 return c.redirect(
354 `/${ownerName}/${repoName}/settings/integrations?${
355 result.status === "ok" ? "success" : "error"
356 }=${encodeURIComponent(msg)}`
357 );
358 }
359);
360
361app.get(
362 "/:owner/:repo/settings/integrations/:id/deliveries",
363 requireAuth,
364 requireRepoAccess("admin"),
365 async (c) => {
366 const { owner: ownerName, repo: repoName, id } = c.req.param();
367 const user = c.get("user")!;
368 const repo = await ownedRepo(ownerName, repoName, user.id);
369 if (!repo) return c.text("Unauthorized", 403);
370 const row = await getById(id);
371 if (!row || row.repositoryId !== repo.id) return c.notFound();
372 const deliveries = await listDeliveries(id, 50);
373 return c.json({ deliveries });
374 }
375);
376
377export default app;