import { env } from "cloudflare:workers"; import { all, first, run } from "../db"; import { randomId, randomToken } from "../crypto"; import { badRequest, conflict, notFoundError } from "../errors"; import { githubAppConfigured } from "./github-app"; import { isGithubRepoName } from "./github-sync"; import { assertOutboundUrl, isAllowedLoopback } from "./outbound"; import { assertResolvesPublic } from "../net/resolve-guard"; import { enqueueDirect, redeliver } from "./queue"; import { requireIntegrationScope } from "./scope"; import type { ResolvedScope, ScopeRef } from "./scope"; import { insertIntegration, loadIntegration, openCredentials, parseConfig, parseEvents, updateConfig } from "./store"; import type { IntegrationConfig, IntegrationRow } from "./store"; import type { EventSummary } from "./summarize"; import { publicUrl } from "../events/emit"; import { DEFAULT_FILTERS, DEFAULT_INTEGRATION_EVENTS } from "~/lib/integrations"; import type { GithubMirrorDirection, IntegrationEvent, IntegrationFilters, IntegrationKind, LinearStateMapping, SlackChannelRoute, } from "~/lib/integrations"; import type { Viewer } from "~/lib/types"; const DELIVERY_LOG_LIMIT = 30; const STALE_JOB_MS = 30 * 60_000; const JOB_LOG_LIMIT = 5; const WEBHOOK_HOSTS: Partial> = { slack: "hooks.slack.com", google_chat: "chat.googleapis.com", }; async function scopedIntegration( ref: ScopeRef, viewer: Viewer | null, id: string, mode: "read" | "write" = "write", ): Promise<{ scope: ResolvedScope; row: IntegrationRow }> { const scope = await requireIntegrationScope(ref, viewer, mode); const row = await loadIntegration(id); if (!row || row.scope_type !== scope.scopeType || row.scope_id !== scope.scopeId) { throw notFoundError("Integration not found"); } return { scope, row }; } export async function listIntegrationsFor(ref: ScopeRef, viewer: Viewer | null) { const scope = await requireIntegrationScope(ref, viewer, "read"); const rows = await all( "SELECT * FROM integrations WHERE scope_type = ? AND scope_id = ? ORDER BY created_at", scope.scopeType, scope.scopeId, ); const integrations = []; for (const row of rows) { const deliveries = await all<{ id: string; direction: string; event: string; status: string; status_code: number | null; error: string | null; duration_ms: number | null; attempt: number; created_at: number; }>( `SELECT id, direction, event, status, status_code, error, duration_ms, attempt, created_at FROM integration_deliveries WHERE integration_id = ? ORDER BY created_at DESC LIMIT ?`, row.id, DELIVERY_LOG_LIMIT, ); const jobs = await all<{ id: string; kind: string; status: string; progress: string; error: string | null; created_at: number; }>( "SELECT id, kind, status, progress, error, created_at FROM integration_jobs WHERE integration_id = ? ORDER BY created_at DESC LIMIT ?", row.id, JOB_LOG_LIMIT, ); const audit = await all<{ id: string; action: string; detail: string; created_at: number; actor: string | null }>( `SELECT a.id, a.action, a.detail, a.created_at, acc.name AS actor FROM integration_audit a LEFT JOIN accounts acc ON acc.id = a.actor_id WHERE a.integration_id = ? ORDER BY a.created_at DESC LIMIT ?`, row.id, AUDIT_LOG_LIMIT, ); const credentials = await openCredentials(row); integrations.push({ audit, id: row.id, kind: row.kind, name: row.name, active: row.active === 1, events: parseEvents(row), deliveryMode: row.delivery_mode, digestMinutes: row.digest_minutes, config: publicConfig(parseConfig(row)), connection: credentials.webhookUrl ? "webhook" : credentials.botToken || credentials.accessToken ? "oauth" : "app", deliveries, jobs, }); } return { scope: { type: scope.scopeType, label: scope.label }, providers: { slackOAuth: Boolean(env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET), slackCommands: Boolean(env.SLACK_SIGNING_SECRET), linear: Boolean(env.LINEAR_CLIENT_ID && env.LINEAR_CLIENT_SECRET), githubApp: githubAppConfigured() && Boolean(env.GITHUB_APP_SLUG), }, slashCommandUrl: publicUrl("/integrations/hooks/slack/commands"), githubWebhookUrl: publicUrl("/integrations/hooks/github"), integrations, }; } /** Webhook-style integrations (Slack incoming webhook, Google Chat, generic). Returns the signing secret once. */ export async function createWebhookIntegration( ref: ScopeRef, viewer: Viewer | null, input: { kind: "slack" | "google_chat" | "webhook"; name: string; url: string; events: IntegrationEvent[]; secret: string | null; }, ): Promise<{ id: string; signingSecret: string }> { const scope = await requireIntegrationScope(ref, viewer); const url = assertOutboundUrl(input.url); if (!isAllowedLoopback(url)) await assertResolvesPublic(url); const requiredHost = WEBHOOK_HOSTS[input.kind]; if (requiredHost && url.hostname !== requiredHost && url.protocol === "https:") { throw badRequest(`${input.kind === "slack" ? "Slack" : "Google Chat"} webhook URLs must be on ${requiredHost}`); } const signingSecret = input.secret ?? randomToken("whsec"); const id = await insertIntegration({ scopeType: scope.scopeType, scopeId: scope.scopeId, kind: input.kind, name: input.name, config: { filters: DEFAULT_FILTERS }, events: input.events.length > 0 ? input.events : DEFAULT_INTEGRATION_EVENTS, credentials: { signingSecret, webhookUrl: url.toString() }, createdBy: (viewer as Viewer).id, }); return { id, signingSecret }; } export type IntegrationUpdate = { id: string; name: string; active: boolean; events: IntegrationEvent[]; deliveryMode: "instant" | "digest"; digestMinutes: number; filters: IntegrationFilters; slackChannels?: SlackChannelRoute[]; linearMapping?: LinearStateMapping; githubRepo?: string; githubMirror?: GithubMirrorDirection; githubRelayWebhooks?: boolean; githubForceMirror?: boolean; }; const AUDIT_LOG_LIMIT = 10; /** The install-time repo lists name every GitHub repo the installer could see; admins only need the selection. */ function publicConfig(config: IntegrationConfig): Omit & { githubCanPush?: boolean; } { const { githubAllowedRepos, githubPushRepos, ...rest } = config; if (!config.githubRepo) return rest; const selected = config.githubRepo.toLowerCase(); return { ...rest, githubCanPush: (githubPushRepos ?? []).includes(selected) && (githubAllowedRepos ?? []).includes(selected), }; } export async function auditIntegration( integrationId: string, actorId: string | null, action: string, detail: Record, ): Promise { await run( "INSERT INTO integration_audit (id, integration_id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?, ?)", randomId(), integrationId, actorId, action, JSON.stringify(detail), Date.now(), ); } export async function updateIntegrationSettings(ref: ScopeRef, viewer: Viewer | null, input: IntegrationUpdate) { const { scope, row } = await scopedIntegration(ref, viewer, input.id); const current = parseConfig(row); const config: IntegrationConfig = { ...current, filters: input.filters }; const audits: Array<[string, Record]> = []; if (row.kind === "slack" && input.slackChannels) config.slackChannels = input.slackChannels; if (row.kind === "linear" && input.linearMapping) config.linearMapping = input.linearMapping; if (row.kind === "github") { if (input.githubRepo !== undefined && input.githubRepo !== current.githubRepo) { if (input.githubRepo && !isGithubRepoName(input.githubRepo)) throw badRequest("Use owner/name for the GitHub repository"); if (input.githubRepo && !(current.githubAllowedRepos ?? []).includes(input.githubRepo.toLowerCase())) { throw badRequest( `You did not have access to ${input.githubRepo} when the GitHub App was installed; reinstall to refresh`, ); } config.githubRepo = input.githubRepo || undefined; } if (input.githubMirror) { if (input.githubMirror !== "none" && scope.scopeType !== "repo") throw badRequest("Mirrors are configured per repository"); if (input.githubMirror !== (current.githubMirror ?? "none")) { audits.push(["mirror.direction", { from: current.githubMirror ?? "none", to: input.githubMirror }]); } config.githubMirror = input.githubMirror; } if (config.githubMirror === "push" && config.githubRepo) { if (!config.githubLogin || !(config.githubPushRepos ?? []).includes(config.githubRepo.toLowerCase())) { throw badRequest( `Push mirroring needs push access to ${config.githubRepo} on GitHub for the installing account; reinstall to refresh`, ); } } if (input.githubRelayWebhooks !== undefined) config.githubRelayWebhooks = input.githubRelayWebhooks; if (input.githubForceMirror !== undefined && input.githubForceMirror !== (current.githubForceMirror ?? false)) { config.githubForceMirror = input.githubForceMirror; audits.push([ input.githubForceMirror ? "force_mirror.enabled" : "force_mirror.disabled", { repo: config.githubRepo ?? null, direction: config.githubMirror ?? "none" }, ]); } } await run( "UPDATE integrations SET name = ?, active = ?, events = ?, delivery_mode = ?, digest_minutes = ?, updated_at = ? WHERE id = ?", input.name, input.active ? 1 : 0, JSON.stringify(input.events), input.deliveryMode, input.digestMinutes, Date.now(), row.id, ); await updateConfig(row.id, config); for (const [action, detail] of audits) await auditIntegration(row.id, (viewer as Viewer).id, action, detail); } export async function deleteIntegrationById(ref: ScopeRef, viewer: Viewer | null, id: string): Promise { const { row } = await scopedIntegration(ref, viewer, id, "read"); await run("DELETE FROM integrations WHERE id = ?", row.id); } export function testSummary(scope: ResolvedScope): EventSummary { return { category: "pull_request", event: "ping", action: "test", repo: scope.label, repoUrl: publicUrl(scope.settingsPath.replace(/\/settings\/integrations$|\/integrations$/, "")), title: "Test event from gitfor", url: publicUrl(scope.settingsPath), actor: null, text: "If you can read this, the integration is connected.", tone: "info", branch: null, draft: false, conclusion: null, fields: [], }; } export async function sendTestEvent(ref: ScopeRef, viewer: Viewer | null, id: string): Promise { const { scope, row } = await scopedIntegration(ref, viewer, id); return enqueueDirect(row, { repoId: scope.scopeType === "repo" ? scope.scopeId : null, event: "ping", payload: { zen: "Keep it quiet.", hook_id: row.id }, summary: testSummary(scope), }); } export async function redeliverDelivery(ref: ScopeRef, viewer: Viewer | null, id: string, deliveryId: string) { const { row } = await scopedIntegration(ref, viewer, id); await redeliver(row, deliveryId); } export async function startGithubJob(ref: ScopeRef, viewer: Viewer | null, id: string, kind: "import" | "mirror") { const { scope, row } = await scopedIntegration(ref, viewer, id); const config = parseConfig(row); if (row.kind !== "github" || scope.scopeType !== "repo") throw badRequest("Not a repository GitHub integration"); if (!config.githubRepo) throw badRequest("Choose a GitHub repository first"); if (kind === "mirror") { if (!config.githubMirror || config.githubMirror === "none") throw badRequest("Mirroring is off"); await enqueueDirect(row, { repoId: scope.scopeId, event: config.githubMirror === "push" ? "mirror.push" : "mirror.pull", payload: {}, summary: null, }); return; } const running = await first<{ id: string }>( `SELECT id FROM integration_jobs WHERE integration_id = ? AND kind = 'github_import' AND status IN ('queued', 'running') AND updated_at > ?`, row.id, Date.now() - STALE_JOB_MS, ); if (running) throw conflict("An import is already running"); const jobId = randomId(); const now = Date.now(); await run( "INSERT INTO integration_jobs (id, integration_id, kind, status, progress, created_at, updated_at) VALUES (?, ?, 'github_import', 'queued', '{}', ?, ?)", jobId, row.id, now, now, ); await env.INTEGRATIONS.send({ kind: "integration.job", jobId, page: 1 }); }