CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
chat-notifier.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.
| 0c3eee5 | 1 | /** |
| 2 | * Outbound chat notifications — pipes PR / issue / AI-review events into | |
| 3 | * Slack, Discord, and Teams via the existing `webhook_deliveries` retry | |
| 4 | * queue (NO parallel queue). | |
| 5 | * | |
| 6 | * How it reuses the existing queue: | |
| 7 | * - The retry queue (`src/lib/webhook-delivery.ts`) requires a `webhooks` | |
| 8 | * row to point at. We therefore lazily create one synthetic "shadow" | |
| 9 | * webhook per (repo, integration) on first use, with | |
| 10 | * `events='chat-bridge'` so the user-facing /settings/webhooks UI can | |
| 11 | * filter it out (`routes/webhooks.tsx` already does). | |
| 12 | * - Subsequent events for the same (repo, integration) reuse the same | |
| 13 | * shadow row. `enqueueWebhookDelivery` then schedules retries with | |
| 14 | * the standard exponential backoff (30s → 6h → dead after 6 attempts). | |
| 15 | * | |
| 16 | * Why a shadow row rather than a separate table: | |
| 17 | * - One source of truth for retries, signatures, and the worker. | |
| 18 | * - Slack/Discord don't actually consume the `X-Gluecron-Signature` | |
| 19 | * header, but having it set costs nothing and is harmless. | |
| 20 | * | |
| 21 | * Public API: | |
| 22 | * - notifyChatChannels(ownerUserId, repositoryId, repoLabel, event) | |
| 23 | * - Fire-and-forget; errors are swallowed/logged so a notification | |
| 24 | * outage can never block a PR merge or issue create. | |
| 25 | */ | |
| 26 | ||
| 27 | import { and, eq } from "drizzle-orm"; | |
| 28 | import { db } from "../db"; | |
| 29 | import { | |
| 30 | chatIntegrations, | |
| 31 | webhooks, | |
| 32 | type ChatIntegration, | |
| 33 | } from "../db/schema"; | |
| 34 | import { | |
| 35 | enqueueWebhookDelivery, | |
| 36 | drainPendingDeliveries, | |
| 37 | } from "./webhook-delivery"; | |
| 38 | import { formatOutboundEvent, type ChatKind, type OutboundEvent } from "./chat-bot"; | |
| 39 | ||
| 40 | const SHADOW_EVENT = "chat-bridge"; | |
| 41 | ||
| 42 | /** | |
| 43 | * Fan out a single event to every enabled chat integration owned by | |
| 44 | * `ownerUserId`. The event is rendered per-kind (Slack blocks vs Discord | |
| 45 | * embeds) and queued via the shared `webhook_deliveries` table. | |
| 46 | * | |
| 47 | * Never throws — failures are logged but swallowed. | |
| 48 | */ | |
| 49 | export async function notifyChatChannels(opts: { | |
| 50 | ownerUserId: string; | |
| 51 | repositoryId: string; | |
| 52 | event: OutboundEvent; | |
| 53 | }): Promise<void> { | |
| 54 | try { | |
| 55 | const integrations = await db | |
| 56 | .select() | |
| 57 | .from(chatIntegrations) | |
| 58 | .where( | |
| 59 | and( | |
| 60 | eq(chatIntegrations.ownerUserId, opts.ownerUserId), | |
| 61 | eq(chatIntegrations.enabled, true) | |
| 62 | ) | |
| 63 | ); | |
| 64 | ||
| 65 | if (integrations.length === 0) return; | |
| 66 | ||
| 67 | let enqueued = 0; | |
| 68 | for (const integ of integrations) { | |
| 69 | if (!integ.webhookUrl) continue; | |
| 70 | const kind = integ.kind as ChatKind; | |
| 71 | if (kind !== "slack" && kind !== "discord" && kind !== "teams") continue; | |
| 72 | ||
| 73 | const payload = formatOutboundEvent(kind, opts.event); | |
| 74 | const shadowId = await ensureShadowWebhook( | |
| 75 | opts.repositoryId, | |
| 76 | integ | |
| 77 | ); | |
| 78 | if (!shadowId) continue; | |
| 79 | ||
| 80 | const id = await enqueueWebhookDelivery({ | |
| 81 | webhookId: shadowId, | |
| 82 | secret: integ.signingSecret, | |
| 83 | event: opts.event.event, | |
| 84 | payload, | |
| 85 | }); | |
| 86 | if (id) { | |
| 87 | enqueued++; | |
| 88 | // Touch last_used_at — best-effort. | |
| 89 | db.update(chatIntegrations) | |
| 90 | .set({ lastUsedAt: new Date() }) | |
| 91 | .where(eq(chatIntegrations.id, integ.id)) | |
| 92 | .catch(() => {}); | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | if (enqueued > 0) { | |
| 97 | void drainPendingDeliveries().catch((err) => { | |
| 98 | console.error("[chat-notifier] kick drain failed:", err); | |
| 99 | }); | |
| 100 | } | |
| 101 | } catch (err) { | |
| 102 | console.error("[chat-notifier] notify failed:", err); | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * Find-or-create the synthetic webhook row that pipes events for a given | |
| 108 | * (repo, integration) pair through the retry queue. Returns the row id, or | |
| 109 | * null on insert failure. | |
| 110 | * | |
| 111 | * We key on URL+repository — if a user re-installs the bot with the same | |
| 112 | * webhook URL the existing shadow row is reused so retry stats don't reset. | |
| 113 | */ | |
| 114 | async function ensureShadowWebhook( | |
| 115 | repositoryId: string, | |
| 116 | integ: ChatIntegration | |
| 117 | ): Promise<string | null> { | |
| 118 | if (!integ.webhookUrl) return null; | |
| 119 | try { | |
| 120 | const existing = await db | |
| 121 | .select({ id: webhooks.id }) | |
| 122 | .from(webhooks) | |
| 123 | .where( | |
| 124 | and( | |
| 125 | eq(webhooks.repositoryId, repositoryId), | |
| 126 | eq(webhooks.url, integ.webhookUrl), | |
| 127 | eq(webhooks.events, SHADOW_EVENT) | |
| 128 | ) | |
| 129 | ) | |
| 130 | .limit(1); | |
| 131 | if (existing[0]) return existing[0].id; | |
| 132 | ||
| 133 | const [row] = await db | |
| 134 | .insert(webhooks) | |
| 135 | .values({ | |
| 136 | repositoryId, | |
| 137 | url: integ.webhookUrl, | |
| 138 | secret: integ.signingSecret, | |
| 139 | events: SHADOW_EVENT, | |
| 140 | isActive: true, | |
| 141 | }) | |
| 142 | .returning({ id: webhooks.id }); | |
| 143 | return row?.id ?? null; | |
| 144 | } catch (err) { | |
| 145 | console.error("[chat-notifier] ensure shadow webhook failed:", err); | |
| 146 | return null; | |
| 147 | } | |
| 148 | } |