Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
chat-notifier.ts4.6 KB · 148 lines
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;
  }
}