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

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.tsBlame719 lines · 1 contributor
40e7738Claude1/**
2 * Third-party integrations registry.
3 *
4 * Each integration is a row in the `integrations` table that maps a repo +
5 * event-kind to an outbound webhook in some other product's native shape.
6 * v1 ships connectors for Slack, Discord, Linear, Vercel, Jira (Atlassian
7 * webhook), PagerDuty (Events V2), Sentry (project webhook), Datadog
8 * (events API), Figma (file comments — placeholder), Cursor (generic), and
9 * a generic JSON webhook fallback.
10 *
11 * `deliverEvent(repoId, event, payload)` looks up every enabled integration
12 * subscribed to `event` and POSTs the connector's rendered payload. Each
13 * delivery records a row in `integration_deliveries` (status, http code,
14 * duration). Never throws into the caller.
15 *
16 * Auth model:
17 * - Each integration's `config` JSON carries the connector-specific
18 * credentials (webhookUrl, channel, projectKey, integrationKey, ...).
19 * - We do NOT round-trip secrets to the UI. Reads go through `redactConfig`.
20 */
21
22import { and, eq } from "drizzle-orm";
23import { db } from "../db";
24import {
25 integrations,
26 integrationDeliveries,
27 type Integration,
28 type NewIntegration,
29} from "../db/schema";
30
31export const INTEGRATION_KINDS = [
32 "slack",
33 "discord",
34 "linear",
35 "vercel",
36 "jira",
37 "pagerduty",
38 "sentry",
39 "datadog",
40 "figma",
41 "cursor",
42 "generic_webhook",
43] as const;
44export type IntegrationKind = (typeof INTEGRATION_KINDS)[number];
45
46export const INTEGRATION_EVENTS = [
47 "push",
48 "pr.opened",
49 "pr.merged",
50 "pr.closed",
51 "issue.opened",
52 "issue.closed",
53 "deploy.success",
54 "deploy.failed",
55 "gate.failed",
56 "ai.repair",
57 "ai.incident",
58] as const;
59export type IntegrationEvent = (typeof INTEGRATION_EVENTS)[number];
60
61export interface ConnectorMeta {
62 kind: IntegrationKind;
63 label: string;
64 description: string;
65 /** Names of the config fields users must fill in. */
66 configFields: { name: string; label: string; required: boolean; secret?: boolean }[];
67}
68
69export const CONNECTORS: ConnectorMeta[] = [
70 {
71 kind: "slack",
72 label: "Slack",
73 description: "Post events to a Slack channel via an incoming webhook.",
74 configFields: [
75 { name: "webhookUrl", label: "Incoming webhook URL", required: true, secret: true },
76 { name: "channel", label: "Channel override (optional)", required: false },
77 ],
78 },
79 {
80 kind: "discord",
81 label: "Discord",
82 description: "Post events to a Discord channel via a server webhook.",
83 configFields: [
84 { name: "webhookUrl", label: "Webhook URL", required: true, secret: true },
85 ],
86 },
87 {
88 kind: "linear",
89 label: "Linear",
90 description: "Mirror gluecron events into Linear via their generic webhook.",
91 configFields: [
92 { name: "webhookUrl", label: "Linear webhook URL", required: true, secret: true },
93 { name: "teamKey", label: "Team key (e.g. ENG)", required: false },
94 ],
95 },
96 {
97 kind: "vercel",
98 label: "Vercel",
99 description: "Trigger a Vercel deploy hook on every push to main.",
100 configFields: [
101 { name: "deployHookUrl", label: "Deploy hook URL", required: true, secret: true },
102 ],
103 },
104 {
105 kind: "jira",
106 label: "Jira",
107 description: "Forward gluecron events into Jira (incoming webhook).",
108 configFields: [
109 { name: "webhookUrl", label: "Jira webhook URL", required: true, secret: true },
110 { name: "projectKey", label: "Project key", required: false },
111 ],
112 },
113 {
114 kind: "pagerduty",
115 label: "PagerDuty",
116 description:
117 "Open / resolve incidents via the PagerDuty Events V2 API. Best for deploy.failed and gate.failed.",
118 configFields: [
119 { name: "integrationKey", label: "Routing key (integration key)", required: true, secret: true },
120 { name: "severity", label: "Severity (info|warning|error|critical)", required: false },
121 ],
122 },
123 {
124 kind: "sentry",
125 label: "Sentry",
126 description: "Notify Sentry (alert webhook) on AI incidents and gate failures.",
127 configFields: [
128 { name: "webhookUrl", label: "Sentry alert webhook URL", required: true, secret: true },
129 ],
130 },
131 {
132 kind: "datadog",
133 label: "Datadog",
134 description: "Post gluecron events to the Datadog events API.",
135 configFields: [
136 { name: "apiKey", label: "DD-API-KEY", required: true, secret: true },
137 { name: "site", label: "Site (e.g. datadoghq.com)", required: false },
138 ],
139 },
140 {
141 kind: "figma",
142 label: "Figma",
143 description: "Generic webhook into Figma plugins. v1 sends generic JSON.",
144 configFields: [
145 { name: "webhookUrl", label: "Webhook URL", required: true, secret: true },
146 ],
147 },
148 {
149 kind: "cursor",
150 label: "Cursor",
151 description: "Mirror events into Cursor / any IDE bridge that listens on a webhook.",
152 configFields: [
153 { name: "webhookUrl", label: "Webhook URL", required: true, secret: true },
154 ],
155 },
156 {
157 kind: "generic_webhook",
158 label: "Generic webhook",
159 description: "POST raw JSON to any URL. Use this for anything not above.",
160 configFields: [
161 { name: "webhookUrl", label: "URL", required: true, secret: true },
162 { name: "secret", label: "HMAC secret (optional)", required: false, secret: true },
163 ],
164 },
165];
166
167export function getConnector(kind: string): ConnectorMeta | null {
168 return CONNECTORS.find((c) => c.kind === kind) ?? null;
169}
170
171export function isValidKind(k: string): k is IntegrationKind {
172 return (INTEGRATION_KINDS as readonly string[]).includes(k);
173}
174
175export function isValidEvent(e: string): e is IntegrationEvent {
176 return (INTEGRATION_EVENTS as readonly string[]).includes(e);
177}
178
179/** Strip secret-marked fields before round-tripping config to the UI. */
180export function redactConfig(
181 kind: IntegrationKind,
182 config: Record<string, unknown>
183): Record<string, unknown> {
184 const meta = getConnector(kind);
185 if (!meta) return {};
186 const out: Record<string, unknown> = {};
187 for (const field of meta.configFields) {
188 const v = config[field.name];
189 if (v == null) continue;
190 if (field.secret) {
191 const s = String(v);
192 out[field.name] = s.length > 8 ? `${s.slice(0, 4)}…${s.slice(-2)}` : "***";
193 } else {
194 out[field.name] = v;
195 }
196 }
197 return out;
198}
199
200export interface CreateInput {
201 repositoryId: string;
202 kind: IntegrationKind;
203 name: string;
204 config: Record<string, unknown>;
205 events: IntegrationEvent[];
206 createdBy?: string | null;
207}
208
209export async function createIntegration(input: CreateInput): Promise<Integration | null> {
210 const validation = validateConfig(input.kind, input.config);
211 if (!validation.ok) {
212 throw new Error(validation.error);
213 }
214 const validEvents = (input.events ?? []).filter(isValidEvent);
215 const insert: NewIntegration = {
216 repositoryId: input.repositoryId,
217 kind: input.kind,
218 name: input.name.trim().slice(0, 80) || "(unnamed)",
219 enabled: true,
220 config: input.config,
221 events: validEvents,
222 createdBy: input.createdBy ?? null,
223 };
224 try {
225 const [row] = await db.insert(integrations).values(insert).returning();
226 return row ?? null;
227 } catch (err) {
228 console.error("[integrations] create failed:", err);
229 return null;
230 }
231}
232
233export async function updateIntegration(
234 id: string,
235 patch: Partial<{
236 name: string;
237 enabled: boolean;
238 config: Record<string, unknown>;
239 events: IntegrationEvent[];
240 }>
241): Promise<Integration | null> {
242 const updates: Record<string, unknown> = { updatedAt: new Date() };
243 if (typeof patch.name === "string") updates.name = patch.name.trim().slice(0, 80);
244 if (typeof patch.enabled === "boolean") updates.enabled = patch.enabled;
245 if (patch.config) updates.config = patch.config;
246 if (patch.events) updates.events = patch.events.filter(isValidEvent);
247 try {
248 const [row] = await db
249 .update(integrations)
250 .set(updates as never)
251 .where(eq(integrations.id, id))
252 .returning();
253 return row ?? null;
254 } catch (err) {
255 console.error("[integrations] update failed:", err);
256 return null;
257 }
258}
259
260export async function deleteIntegration(id: string): Promise<boolean> {
261 try {
262 await db.delete(integrations).where(eq(integrations.id, id));
263 return true;
264 } catch (err) {
265 console.error("[integrations] delete failed:", err);
266 return false;
267 }
268}
269
270export async function listForRepo(repositoryId: string): Promise<Integration[]> {
271 try {
272 return await db
273 .select()
274 .from(integrations)
275 .where(eq(integrations.repositoryId, repositoryId));
276 } catch {
277 return [];
278 }
279}
280
281export async function getById(id: string): Promise<Integration | null> {
282 try {
283 const [row] = await db
284 .select()
285 .from(integrations)
286 .where(eq(integrations.id, id))
287 .limit(1);
288 return row ?? null;
289 } catch {
290 return null;
291 }
292}
293
294export async function listDeliveries(
295 integrationId: string,
296 limit = 25
297): Promise<Array<typeof integrationDeliveries.$inferSelect>> {
298 try {
299 const rows = await db
300 .select()
301 .from(integrationDeliveries)
302 .where(eq(integrationDeliveries.integrationId, integrationId))
303 .limit(Math.max(1, Math.min(200, limit)));
304 return rows;
305 } catch {
306 return [];
307 }
308}
309
310export interface ValidationResult {
311 ok: boolean;
312 error?: string;
313}
314
315export function validateConfig(
316 kind: IntegrationKind,
317 config: Record<string, unknown>
318): ValidationResult {
319 const meta = getConnector(kind);
320 if (!meta) return { ok: false, error: `Unknown integration kind: ${kind}` };
321 for (const field of meta.configFields) {
322 if (!field.required) continue;
323 const v = config[field.name];
324 if (typeof v !== "string" || !v.trim()) {
325 return { ok: false, error: `Missing required field: ${field.label}` };
326 }
327 if (field.name.toLowerCase().endsWith("url") && !isHttpUrl(String(v))) {
328 return { ok: false, error: `${field.label} must be a valid http(s) URL` };
329 }
330 }
331 return { ok: true };
332}
333
334export function isHttpUrl(s: string): boolean {
335 try {
336 const u = new URL(s);
337 return u.protocol === "https:" || u.protocol === "http:";
338 } catch {
339 return false;
340 }
341}
342
343// ─── Delivery ────────────────────────────────────────────────
344
345export interface DeliveryResult {
346 integrationId: string;
347 status: "ok" | "fail" | "skipped";
348 httpStatus?: number;
349 error?: string;
350 durationMs: number;
351}
352
353/**
354 * Find every integration on `repositoryId` subscribed to `event` and POST the
355 * connector-rendered payload. Returns one DeliveryResult per integration.
356 * Always resolves; never throws.
357 */
358export async function deliverEvent(
359 repositoryId: string,
360 event: IntegrationEvent | string,
361 payload: Record<string, unknown>
362): Promise<DeliveryResult[]> {
363 if (!isValidEvent(event)) return [];
364 let rows: Integration[] = [];
365 try {
366 rows = await db
367 .select()
368 .from(integrations)
369 .where(
370 and(
371 eq(integrations.repositoryId, repositoryId),
372 eq(integrations.enabled, true)
373 )
374 );
375 } catch (err) {
376 console.error("[integrations] list-for-delivery failed:", err);
377 return [];
378 }
379 const subscribed = rows.filter((r) => {
380 const evs = Array.isArray(r.events) ? (r.events as string[]) : [];
381 return evs.includes(event);
382 });
383 const results: DeliveryResult[] = [];
384 for (const row of subscribed) {
385 const r = await deliverOne(row, event, payload);
386 results.push(r);
387 void recordDelivery(row.id, event, r);
388 }
389 return results;
390}
391
392/** Deliver a single integration. Used by the test-button on the UI. */
393export async function deliverOne(
394 integration: Integration,
395 event: IntegrationEvent | string,
396 payload: Record<string, unknown>
397): Promise<DeliveryResult> {
398 const t0 = Date.now();
399 try {
400 const rendered = render(
401 integration.kind as IntegrationKind,
402 integration.config as Record<string, unknown>,
403 event,
404 payload
405 );
406 if (!rendered) {
407 return {
408 integrationId: integration.id,
409 status: "skipped",
410 durationMs: Date.now() - t0,
411 };
412 }
413 const response = await fetch(rendered.url, {
414 method: rendered.method ?? "POST",
415 headers: rendered.headers,
416 body: rendered.body,
417 signal: AbortSignal.timeout(10_000),
418 });
419 return {
420 integrationId: integration.id,
421 status: response.ok ? "ok" : "fail",
422 httpStatus: response.status,
423 durationMs: Date.now() - t0,
424 };
425 } catch (err) {
426 return {
427 integrationId: integration.id,
428 status: "fail",
429 error: err instanceof Error ? err.message : String(err),
430 durationMs: Date.now() - t0,
431 };
432 }
433}
434
435async function recordDelivery(
436 integrationId: string,
437 event: string,
438 r: DeliveryResult
439): Promise<void> {
440 try {
441 await db.insert(integrationDeliveries).values({
442 integrationId,
443 event,
444 status: r.status,
445 httpStatus: r.httpStatus ?? null,
446 error: r.error ?? null,
447 durationMs: r.durationMs,
448 });
449 await db
450 .update(integrations)
451 .set({
452 lastDeliveryAt: new Date(),
453 lastStatus: r.status,
454 updatedAt: new Date(),
455 })
456 .where(eq(integrations.id, integrationId));
457 } catch {
458 /* observability-only — never break delivery */
459 }
460}
461
462// ─── Connector renderers ─────────────────────────────────────
463
464interface RenderedRequest {
465 url: string;
466 method?: string;
467 headers: Record<string, string>;
468 body: string;
469}
470
471export function render(
472 kind: IntegrationKind,
473 config: Record<string, unknown>,
474 event: string,
475 payload: Record<string, unknown>
476): RenderedRequest | null {
477 switch (kind) {
478 case "slack":
479 return renderSlack(config, event, payload);
480 case "discord":
481 return renderDiscord(config, event, payload);
482 case "linear":
483 return renderLinear(config, event, payload);
484 case "vercel":
485 return renderVercel(config, event, payload);
486 case "jira":
487 return renderJira(config, event, payload);
488 case "pagerduty":
489 return renderPagerDuty(config, event, payload);
490 case "sentry":
491 return renderSentry(config, event, payload);
492 case "datadog":
493 return renderDatadog(config, event, payload);
494 case "figma":
495 case "cursor":
496 case "generic_webhook":
497 return renderGeneric(config, event, payload);
498 default:
499 return null;
500 }
501}
502
503function summary(event: string, payload: Record<string, unknown>): string {
504 const repo = String(payload.repository ?? "?");
505 if (event === "push") return `Push to ${repo}`;
506 if (event === "pr.opened") return `PR opened on ${repo}: ${payload.title ?? ""}`;
507 if (event === "pr.merged") return `PR merged on ${repo}: ${payload.title ?? ""}`;
508 if (event === "pr.closed") return `PR closed on ${repo}: ${payload.title ?? ""}`;
509 if (event === "issue.opened") return `Issue opened on ${repo}: ${payload.title ?? ""}`;
510 if (event === "issue.closed") return `Issue closed on ${repo}: ${payload.title ?? ""}`;
511 if (event === "deploy.success") return `Deploy succeeded on ${repo}`;
512 if (event === "deploy.failed") return `Deploy FAILED on ${repo}`;
513 if (event === "gate.failed") return `Gate FAILED on ${repo}`;
514 if (event === "ai.repair") return `Auto-repair on ${repo}`;
515 if (event === "ai.incident") return `AI incident on ${repo}: ${payload.title ?? ""}`;
516 return `${event} on ${repo}`;
517}
518
519function renderSlack(
520 config: Record<string, unknown>,
521 event: string,
522 payload: Record<string, unknown>
523): RenderedRequest | null {
524 const url = String(config.webhookUrl ?? "");
525 if (!isHttpUrl(url)) return null;
526 const text = `*gluecron* — ${summary(event, payload)}`;
527 const body: Record<string, unknown> = { text };
528 if (config.channel) body.channel = config.channel;
529 return {
530 url,
531 headers: { "Content-Type": "application/json" },
532 body: JSON.stringify(body),
533 };
534}
535
536function renderDiscord(
537 config: Record<string, unknown>,
538 event: string,
539 payload: Record<string, unknown>
540): RenderedRequest | null {
541 const url = String(config.webhookUrl ?? "");
542 if (!isHttpUrl(url)) return null;
543 const content = `**gluecron** — ${summary(event, payload)}`;
544 return {
545 url,
546 headers: { "Content-Type": "application/json" },
547 body: JSON.stringify({ content }),
548 };
549}
550
551function renderLinear(
552 config: Record<string, unknown>,
553 event: string,
554 payload: Record<string, unknown>
555): RenderedRequest | null {
556 const url = String(config.webhookUrl ?? "");
557 if (!isHttpUrl(url)) return null;
558 return {
559 url,
560 headers: { "Content-Type": "application/json", "User-Agent": "gluecron-linear/1" },
561 body: JSON.stringify({
562 type: "gluecron." + event,
563 data: payload,
564 teamKey: config.teamKey ?? null,
565 }),
566 };
567}
568
569function renderVercel(
570 config: Record<string, unknown>,
571 event: string,
572 _payload: Record<string, unknown>
573): RenderedRequest | null {
574 // Vercel deploy hooks are POST-with-empty-body URLs; only fire on push +
575 // deploy.success so we don't redeploy on noise.
576 if (event !== "push" && event !== "deploy.success") return null;
577 const url = String(config.deployHookUrl ?? "");
578 if (!isHttpUrl(url)) return null;
579 return { url, headers: {}, body: "" };
580}
581
582function renderJira(
583 config: Record<string, unknown>,
584 event: string,
585 payload: Record<string, unknown>
586): RenderedRequest | null {
587 const url = String(config.webhookUrl ?? "");
588 if (!isHttpUrl(url)) return null;
589 return {
590 url,
591 headers: { "Content-Type": "application/json" },
592 body: JSON.stringify({
593 webhookEvent: "gluecron:" + event,
594 projectKey: config.projectKey ?? null,
595 payload,
596 }),
597 };
598}
599
600function renderPagerDuty(
601 config: Record<string, unknown>,
602 event: string,
603 payload: Record<string, unknown>
604): RenderedRequest | null {
605 const routing_key = String(config.integrationKey ?? "");
606 if (!routing_key) return null;
607 // Only escalate failures into PD by default.
608 if (
609 event !== "deploy.failed" &&
610 event !== "gate.failed" &&
611 event !== "ai.incident"
612 ) {
613 return null;
614 }
615 const severity =
616 typeof config.severity === "string" &&
617 ["info", "warning", "error", "critical"].includes(config.severity)
618 ? config.severity
619 : "error";
620 const body = {
621 routing_key,
622 event_action: "trigger",
623 payload: {
624 summary: summary(event, payload),
625 severity,
626 source: "gluecron",
627 custom_details: payload,
628 },
629 };
630 return {
631 url: "https://events.pagerduty.com/v2/enqueue",
632 headers: { "Content-Type": "application/json" },
633 body: JSON.stringify(body),
634 };
635}
636
637function renderSentry(
638 config: Record<string, unknown>,
639 event: string,
640 payload: Record<string, unknown>
641): RenderedRequest | null {
642 const url = String(config.webhookUrl ?? "");
643 if (!isHttpUrl(url)) return null;
644 return {
645 url,
646 headers: { "Content-Type": "application/json" },
647 body: JSON.stringify({
648 action: event,
649 message: summary(event, payload),
650 data: payload,
651 }),
652 };
653}
654
655function renderDatadog(
656 config: Record<string, unknown>,
657 event: string,
658 payload: Record<string, unknown>
659): RenderedRequest | null {
660 const apiKey = String(config.apiKey ?? "");
661 if (!apiKey) return null;
662 const site = typeof config.site === "string" ? config.site : "datadoghq.com";
663 const url = `https://api.${site}/api/v1/events`;
664 return {
665 url,
666 headers: {
667 "Content-Type": "application/json",
668 "DD-API-KEY": apiKey,
669 },
670 body: JSON.stringify({
671 title: summary(event, payload),
672 text: JSON.stringify(payload),
673 tags: [`source:gluecron`, `event:${event}`],
674 alert_type:
675 event === "deploy.failed" || event === "gate.failed" ? "error" : "info",
676 }),
677 };
678}
679
680function renderGeneric(
681 config: Record<string, unknown>,
682 event: string,
683 payload: Record<string, unknown>
684): RenderedRequest | null {
685 const url = String(config.webhookUrl ?? "");
686 if (!isHttpUrl(url)) return null;
687 const body = JSON.stringify({ event, payload, ts: new Date().toISOString() });
688 const headers: Record<string, string> = {
689 "Content-Type": "application/json",
690 "User-Agent": "gluecron-webhook/1",
691 "X-Gluecron-Event": event,
692 };
693 if (typeof config.secret === "string" && config.secret.length > 0) {
694 headers["X-Gluecron-Signature"] = hmacHex(String(config.secret), body);
695 }
696 return { url, headers, body };
697}
698
699function hmacHex(secret: string, body: string): string {
700 // We need a sync-friendly call inside render(); subtle.crypto is async, so
701 // fall back to a Node-style HMAC via dynamic require to avoid an upfront
702 // dependency on `node:crypto`. Render() is hot but small.
703 try {
704 // eslint-disable-next-line @typescript-eslint/no-var-requires
705 const crypto = require("node:crypto") as typeof import("node:crypto");
706 return "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");
707 } catch {
708 return "";
709 }
710}
711
712export const __test = {
713 render,
714 validateConfig,
715 redactConfig,
716 summary,
717 isHttpUrl,
718 hmacHex,
719};