CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.
| 40e7738 | 1 | /** |
| 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 | ||
| 22 | import { and, eq } from "drizzle-orm"; | |
| 23 | import { db } from "../db"; | |
| 24 | import { | |
| 25 | integrations, | |
| 26 | integrationDeliveries, | |
| 27 | type Integration, | |
| 28 | type NewIntegration, | |
| 29 | } from "../db/schema"; | |
| 30 | ||
| 31 | export 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; | |
| 44 | export type IntegrationKind = (typeof INTEGRATION_KINDS)[number]; | |
| 45 | ||
| 46 | export 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; | |
| 59 | export type IntegrationEvent = (typeof INTEGRATION_EVENTS)[number]; | |
| 60 | ||
| 61 | export 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 | ||
| 69 | export 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 | ||
| 167 | export function getConnector(kind: string): ConnectorMeta | null { | |
| 168 | return CONNECTORS.find((c) => c.kind === kind) ?? null; | |
| 169 | } | |
| 170 | ||
| 171 | export function isValidKind(k: string): k is IntegrationKind { | |
| 172 | return (INTEGRATION_KINDS as readonly string[]).includes(k); | |
| 173 | } | |
| 174 | ||
| 175 | export 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. */ | |
| 180 | export 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 | ||
| 200 | export 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 | ||
| 209 | export 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 | ||
| 233 | export 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 | ||
| 260 | export 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 | ||
| 270 | export 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 | ||
| 281 | export 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 | ||
| 294 | export 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 | ||
| 310 | export interface ValidationResult { | |
| 311 | ok: boolean; | |
| 312 | error?: string; | |
| 313 | } | |
| 314 | ||
| 315 | export 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 | ||
| 334 | export 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 | ||
| 345 | export 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 | */ | |
| 358 | export 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. */ | |
| 393 | export 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 | ||
| 435 | async 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 | ||
| 464 | interface RenderedRequest { | |
| 465 | url: string; | |
| 466 | method?: string; | |
| 467 | headers: Record<string, string>; | |
| 468 | body: string; | |
| 469 | } | |
| 470 | ||
| 471 | export 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 | ||
| 503 | function 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 | ||
| 519 | function 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 | ||
| 536 | function 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 | ||
| 551 | function 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 | ||
| 569 | function 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 | ||
| 582 | function 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 | ||
| 600 | function 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 | ||
| 637 | function 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 | ||
| 655 | function 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 | ||
| 680 | function 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 | ||
| 699 | function 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 | ||
| 712 | export const __test = { | |
| 713 | render, | |
| 714 | validateConfig, | |
| 715 | redactConfig, | |
| 716 | summary, | |
| 717 | isHttpUrl, | |
| 718 | hmacHex, | |
| 719 | }; |