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.
| 40e7738 | 1 | /** |
| 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 | ||
| 14 | import { Hono } from "hono"; | |
| 15 | import { and, eq } from "drizzle-orm"; | |
| 16 | import { db } from "../db"; | |
| 17 | import { repositories, users } from "../db/schema"; | |
| 18 | import { Layout } from "../views/layout"; | |
| 19 | import { RepoHeader } from "../views/components"; | |
| 20 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 21 | import type { AuthEnv } from "../middleware/auth"; | |
| 22 | import { requireRepoAccess } from "../middleware/repo-access"; | |
| 23 | import { | |
| 24 | Container, | |
| 25 | Form, | |
| 26 | FormGroup, | |
| 27 | Input, | |
| 28 | Button, | |
| 29 | Alert, | |
| 30 | Select, | |
| 31 | } from "../views/ui"; | |
| 32 | import { | |
| 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 | ||
| 50 | const app = new Hono<AuthEnv>(); | |
| 51 | ||
| 52 | app.use("*", softAuth); | |
| 53 | ||
| 54 | async 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 | ||
| 71 | app.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 | ||
| 233 | app.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 | ||
| 285 | app.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 | ||
| 307 | app.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 | ||
| 329 | app.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 | ||
| 361 | app.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 | ||
| 377 | export default app; |