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