diff --git a/apps/dashboard/src/components/forms/incident/form.tsx b/apps/dashboard/src/components/forms/incident/form.tsx index f8640933..568318be 100644 --- a/apps/dashboard/src/components/forms/incident/form.tsx +++ b/apps/dashboard/src/components/forms/incident/form.tsx @@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { incidentSeverity } from "@openstatus/db/src/schema/incidents/constants"; +import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, FormControl, @@ -29,6 +30,7 @@ import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { Link } from "@/components/common/link"; import { FormCardContent, FormCardSeparator, @@ -52,6 +54,7 @@ const schema = z.object({ "Start time cannot be in the future.", ), statusReportId: z.string(), + openSlackChannel: z.boolean(), }); export type FormValues = z.infer; @@ -63,6 +66,7 @@ export type DeclareIncidentValues = { commanderId: number | null; startedAt: Date; statusReportId?: number; + openSlackChannel: boolean; }; export function toLocalInput(date: Date): string { @@ -72,11 +76,14 @@ export function toLocalInput(date: Date): string { export function FormDeclareIncident({ defaultValues, onSubmit, + slack, className, ...props }: Omit, "onSubmit" | "defaultValues"> & { defaultValues?: Partial; onSubmit: (values: DeclareIncidentValues) => Promise; + /** `ready`: connected, on the plan, fully scoped. */ + slack: "ready" | "reconnect" | "disconnected"; }) { const trpc = useTRPC(); const { data: user } = useQuery(trpc.user.get.queryOptions()); @@ -95,6 +102,7 @@ export function FormDeclareIncident({ commanderId: user ? String(user.id) : NONE, startedAt: toLocalInput(new Date()), statusReportId: NONE, + openSlackChannel: slack === "ready", ...defaultValues, }, }); @@ -121,6 +129,7 @@ export function FormDeclareIncident({ values.statusReportId === NONE ? undefined : Number(values.statusReportId), + openSlackChannel: slack === "ready" && values.openSlackChannel, }); toast.promise(promise, { loading: "Declaring...", @@ -277,6 +286,38 @@ export function FormDeclareIncident({ )} /> + + + {slack === "ready" ? ( + ( + + + + field.onChange(checked === true) + } + /> + + + Open a Slack channel and invite the team + + + )} + /> + ) : ( +

+ {slack === "reconnect" + ? "Reconnect Slack in " + : "Connect Slack in "} + Settings → Integrations{" "} + to open a channel for each incident. +

+ )} +
); diff --git a/apps/dashboard/src/components/forms/incident/sheet.tsx b/apps/dashboard/src/components/forms/incident/sheet.tsx index c501cc2e..972dce17 100644 --- a/apps/dashboard/src/components/forms/incident/sheet.tsx +++ b/apps/dashboard/src/components/forms/incident/sheet.tsx @@ -25,11 +25,13 @@ export function FormSheetDeclareIncident({ defaultValues, onSubmit, footer, + slack, }: { children: React.ReactNode; defaultValues?: Partial; onSubmit: (values: DeclareIncidentValues) => Promise; footer?: React.ReactNode; + slack: "ready" | "reconnect" | "disconnected"; }) { const [open, setOpen] = useState(false); @@ -50,6 +52,7 @@ export function FormSheetDeclareIncident({ id="declare-incident-form" className="my-4" defaultValues={defaultValues} + slack={slack} onSubmit={async (values) => { await onSubmit(values); setOpen(false); diff --git a/apps/dashboard/src/components/incidents/declare-incident-button.tsx b/apps/dashboard/src/components/incidents/declare-incident-button.tsx index 7fc72b4f..3f591232 100644 --- a/apps/dashboard/src/components/incidents/declare-incident-button.tsx +++ b/apps/dashboard/src/components/incidents/declare-incident-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import type { FormValues } from "@/components/forms/incident/form"; @@ -27,6 +27,17 @@ export function DeclareIncidentButton({ const trpc = useTRPC(); const router = useRouter(); const queryClient = useQueryClient(); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const { data: integrations } = useQuery( + trpc.integrationRouter.list.queryOptions(), + ); + const slackIntegration = integrations?.find((i) => i.name === "slack-agent"); + const slack = + !slackIntegration || !workspace?.limits["slack-agent"] + ? "disconnected" + : slackIntegration.missingScopes.length > 0 + ? "reconnect" + : "ready"; const declare = useMutation( trpc.incident.declare.mutationOptions({ onSuccess: (incident) => { @@ -42,6 +53,7 @@ export function DeclareIncidentButton({ { await declare.mutateAsync({ ...values, source }); }} diff --git a/apps/server/src/routes/slack/incident-slack.ts b/apps/server/src/routes/slack/incident-slack.ts new file mode 100644 index 00000000..8272baa2 --- /dev/null +++ b/apps/server/src/routes/slack/incident-slack.ts @@ -0,0 +1,120 @@ +import { getLogger } from "@logtape/logtape"; +import { ServiceError, type ServiceContext } from "@openstatus/services"; +import { + announceIncidentChange, + bindIncidentSlackChannel, + openIncidentSlackChannel, + type SlackClientFactory, +} from "@openstatus/services/incident"; +import { WebClient } from "@slack/web-api"; +import { z } from "zod"; + +import type { SlackConfig } from "./config"; +import { requireSlackMember, slackAgentAllowed } from "./require-slack-member"; +import type { SlackWorkspace } from "./workspace-resolver"; + +const logger = getLogger(["api-server", "slack", "incident"]); + +export const slackClientFor: SlackClientFactory = (token) => + new WebClient(token); + +export const INCIDENT_BIND_ACTION_PREFIX = "incident_bind_"; + +const incidentOutput = z.object({ id: z.number().int(), status: z.string() }); +const incidentInput = z.object({ note: z.string().optional() }); + +function who(ctx: ServiceContext): string { + return ctx.actor.type === "slack" ? `<@${ctx.actor.slackUserId}>` : "Someone"; +} + +/** Slack side effects of an approved incident tool call. Best effort. */ +export async function afterIncidentTool(args: { + ctx: ServiceContext; + toolName: string; + input: object; + output: object; + config: SlackConfig; +}): Promise { + const { ctx, toolName, config } = args; + const output = incidentOutput.safeParse(args.output); + if (!output.success) return; + const incidentId = output.data.id; + try { + if (toolName === "declare_incident") { + const result = await openIncidentSlackChannel({ + ctx, + incidentId, + clientFor: slackClientFor, + dashboardUrl: config.dashboardUrl, + }); + logger.info("slack incident channel", { incidentId, ...result }); + return; + } + if (toolName === "resolve_incident" || toolName === "update_incident") { + const note = incidentInput.safeParse(args.input).data?.note; + const text = + toolName === "resolve_incident" + ? `${who(ctx)} marked the incident *resolved*.${note ? `\n>${note}` : ""}` + : `${who(ctx)} updated the incident.`; + await announceIncidentChange({ + ctx, + incidentId, + text, + clientFor: slackClientFor, + dashboardUrl: config.dashboardUrl, + }); + } + } catch (error) { + logger.warn("slack incident follow-up failed", { error, incidentId }); + } +} + +/** "Link this channel": the fallback when binding failed during declare. */ +export async function bindChannelFromButton(args: { + resolved: SlackWorkspace; + teamId: string; + slackUserId: string; + channelId: string; + messageTs: string; + incidentId: number; +}): Promise { + const { resolved, teamId, slackUserId, channelId, messageTs, incidentId } = + args; + const slack = new WebClient(resolved.botToken); + if (!slackAgentAllowed(resolved.workspace)) return; + const actor = await requireSlackMember({ + workspace: resolved.workspace, + teamId, + slackUserId, + slack, + }); + if (!actor) { + await slack.chat.postEphemeral({ + channel: channelId, + user: slackUserId, + text: "Link your openstatus account first: mention @openstatus to get the link.", + }); + return; + } + try { + await bindIncidentSlackChannel({ + ctx: { workspace: resolved.workspace, actor }, + input: { id: incidentId, teamId, channelId }, + }); + await slack.chat.update({ + channel: channelId, + ts: messageTs, + text: ":white_check_mark: This channel is now linked to the incident.", + blocks: [], + }); + } catch (error) { + await slack.chat.postEphemeral({ + channel: channelId, + user: slackUserId, + text: + error instanceof ServiceError + ? `:x: ${error.message}` + : ":x: Could not link this channel. Please try again.", + }); + } +} diff --git a/apps/server/src/routes/slack/interactions.test.ts b/apps/server/src/routes/slack/interactions.test.ts index 09b9109c..f425b02e 100644 --- a/apps/server/src/routes/slack/interactions.test.ts +++ b/apps/server/src/routes/slack/interactions.test.ts @@ -1,13 +1,20 @@ import crypto from "node:crypto"; import { and, db, eq } from "@openstatus/db"; -import { auditLog } from "@openstatus/db/src/schema"; +import { + auditLog, + incident, + incidentEvent, + selectWorkspaceSchema, + workspace as workspaceTable, +} from "@openstatus/db/src/schema"; import { addUserToWorkspace, createPage, createTestWorkspace, createUser, } from "@openstatus/db/src/test/factories"; +import { declareIncident } from "@openstatus/services/incident"; import { beforeEach, describe, expect, test } from "@openstatus/test-utils"; import { Hono } from "hono"; @@ -564,3 +571,53 @@ describe("registry-runner execution paths", () => { expect(errCall).toBeDefined(); }); }); + +describe("link this channel button", () => { + const app = createTestApp(); + + beforeEach(() => { + configureSlackDoubles(); + redisStore.clear(); + }); + + test("binds the channel to the incident as the clicking member", async () => { + const workspace = selectWorkspaceSchema.parse( + await db.query.workspace.findFirst({ where: eq(workspaceTable.id, 1) }), + ); + const created = await declareIncident({ + ctx: { workspace, actor: { type: "system", job: "test" } }, + input: { title: "Bind me", severity: "minor" }, + }); + const channelId = `C_BIND_${crypto.randomUUID()}`; + try { + const res = await signAndPost(app, { + type: "block_actions", + user: { id: "U_OWNER" }, + channel: { id: channelId }, + message: { ts: "9.9" }, + team: { id: "T_KNOWN" }, + actions: [ + { action_id: `incident_bind_${created.id}`, value: channelId }, + ], + }); + expect(res.status).toBe(200); + const row = await db + .select() + .from(incident) + .where(eq(incident.id, created.id)) + .get(); + expect(row?.slackChannelId).toBe(channelId); + expect( + slackTestState.calls.some( + (c) => + c.method === "update" && String(c.args.text).includes("linked"), + ), + ).toBe(true); + } finally { + await db + .delete(incidentEvent) + .where(eq(incidentEvent.incidentId, created.id)); + await db.delete(incident).where(eq(incident.id, created.id)); + } + }); +}); diff --git a/apps/server/src/routes/slack/interactions.ts b/apps/server/src/routes/slack/interactions.ts index 62bb1d2d..b1ee7253 100644 --- a/apps/server/src/routes/slack/interactions.ts +++ b/apps/server/src/routes/slack/interactions.ts @@ -13,6 +13,11 @@ import { import type { SlackConfig, SlackEnv } from "./config"; import { consume, get } from "./confirmation-store"; import type { PendingAction } from "./confirmation-store"; +import { + afterIncidentTool, + bindChannelFromButton, + INCIDENT_BIND_ACTION_PREFIX, +} from "./incident-slack"; import { renderToolResult } from "./presenters"; import { executeRegistryAction, getRegistryTool } from "./registry-runner"; import { @@ -36,6 +41,25 @@ interface SlackInteractionPayload { actions: Array<{ action_id: string; value?: string }>; } +async function processIncidentBind(payload: SlackInteractionPayload) { + const action = payload.actions[0]; + const incidentId = Number( + action.action_id.slice(INCIDENT_BIND_ACTION_PREFIX.length), + ); + const teamId = payload.team?.id; + if (!teamId || !Number.isInteger(incidentId)) return; + const resolved = await resolveWorkspace(teamId); + if (!resolved) return; + await bindChannelFromButton({ + resolved, + teamId, + slackUserId: payload.user.id, + channelId: action.value ?? payload.channel.id, + messageTs: payload.message.ts, + incidentId, + }); +} + export function handleSlackInteraction(c: Context) { const payload = c.get("slackBody") as SlackInteractionPayload; const config = c.get("slackConfig"); @@ -44,6 +68,13 @@ export function handleSlackInteraction(c: Context) { return c.json({ ok: true }); } + if (payload.actions[0].action_id.startsWith(INCIDENT_BIND_ACTION_PREFIX)) { + runInBackground("incident-bind", () => processIncidentBind(payload), { + teamId: payload.team?.id, + }); + return c.json({ ok: true }); + } + const parsed = parseActionId(payload.actions[0].action_id); if (!parsed) return c.json({ ok: true }); @@ -181,6 +212,7 @@ async function processInteraction( channelId, messageTs, actor, + config, }); } catch (err) { logger.error("slack action execution error", { @@ -205,8 +237,9 @@ async function runAndPresent(args: { channelId: string; messageTs: string; actor: SlackActor; + config: SlackConfig; }) { - const { pending, flag, slack, channelId, messageTs, actor } = args; + const { pending, flag, slack, channelId, messageTs, actor, config } = args; const tool = getRegistryTool(pending.payload.toolName); if (!tool) { throw new Error( @@ -249,6 +282,20 @@ async function runAndPresent(args: { text, blocks: [], }); + + if ( + typeof output === "object" && + output !== null && + typeof input === "object" && + input !== null + ) { + runInBackground( + "incident-follow-up", + () => + afterIncidentTool({ ctx, toolName: tool.name, input, output, config }), + { toolName: tool.name }, + ); + } } function errorMessage(err: unknown): string { diff --git a/packages/api/package.json b/packages/api/package.json index d8edbe8d..a0d52342 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -42,6 +42,7 @@ "@openstatus/tinybird": "workspace:*", "@openstatus/upstash": "workspace:*", "@openstatus/utils": "workspace:*", + "@slack/web-api": "catalog:", "@t3-oss/env-core": "catalog:", "@tanstack/react-query": "catalog:", "@trpc/client": "catalog:", diff --git a/packages/api/src/router/incident.ts b/packages/api/src/router/incident.ts index 4745cac3..9db64c32 100644 --- a/packages/api/src/router/incident.ts +++ b/packages/api/src/router/incident.ts @@ -11,22 +11,28 @@ import { UpdateIncidentInput, addIncidentNote, allowedTransitions, + announceIncidentChange, + announceInChannel, bindIncidentSlackChannel, closeIncident, declareIncident, deleteIncident, + displayName, getIncident, getIncidentForStatusReport, isDeletable, linkIncidentStatusReport, listIncidentEvents, listIncidents, + openIncidentSlackChannel, setIncidentStatus, unbindIncidentSlackChannel, unlinkIncidentStatusReport, updateIncident, } from "@openstatus/services/incident"; +import { WebClient } from "@slack/web-api"; import { TRPCError } from "@trpc/server"; +import { after } from "next/server.js"; import { z } from "zod"; import { toServiceCtx, toTRPCError } from "../service-adapter"; @@ -37,11 +43,49 @@ const DASHBOARD_URL = ? "https://app.openstatus.dev" : "http://localhost:3001"; -/** Best effort: a failed email never fails the mutation that assigned them. */ -async function notifyCommander( - ctx: Parameters[0], +const clientFor = (token: string) => new WebClient(token); + +type AuthedCtx = Parameters[0]; + +/** Slack follow-ups run after the response; the incident is already saved. */ +function afterResponse(task: () => Promise) { + const run = () => + task().catch((err) => console.warn("incident slack follow-up failed", err)); + try { + after(run); + } catch { + void run(); + } +} + +function announce( + ctx: AuthedCtx, incidentId: number, + text: string, + archive = false, ) { + afterResponse(() => + announceIncidentChange({ + ctx: toServiceCtx(ctx), + incidentId, + text, + clientFor, + dashboardUrl: DASHBOARD_URL, + archive, + }), + ); +} + +function actorName(ctx: AuthedCtx): string { + return ctx.user.name || ctx.user.email || "A teammate"; +} + +function quote(note: string | undefined): string { + return note ? `\n>${note.replaceAll("\n", "\n>")}` : ""; +} + +/** Best effort: a failed email never fails the mutation that assigned them. */ +async function notifyCommander(ctx: AuthedCtx, incidentId: number) { try { const row = await getIncident({ ctx: toServiceCtx(ctx), @@ -54,7 +98,7 @@ async function notifyCommander( incidentTitle: row.title, severity: row.severity, workspaceName: ctx.workspace.name ?? ctx.workspace.slug, - assignedBy: ctx.user.name || ctx.user.email || "A teammate", + assignedBy: actorName(ctx), url: `${DASHBOARD_URL}/incidents/${row.id}`, idempotencyKey: `incident-commander:${row.id}:${commander.id}:${row.updatedAt.getTime()}`, }); @@ -130,11 +174,27 @@ export const incidentRouter = createTRPCRouter({ declare: protectedProcedure .meta({ track: Events.DeclareManagedIncident, trackProps: ["severity"] }) - .input(DeclareIncidentInput) + .input( + DeclareIncidentInput.extend({ openSlackChannel: z.boolean().optional() }), + ) .mutation(async ({ ctx, input }) => { try { - const row = await declareIncident({ ctx: toServiceCtx(ctx), input }); + const { openSlackChannel, ...declare } = input; + const row = await declareIncident({ + ctx: toServiceCtx(ctx), + input: declare, + }); if (row.commanderId !== null) await notifyCommander(ctx, row.id); + if (openSlackChannel) { + afterResponse(async () => { + await openIncidentSlackChannel({ + ctx: toServiceCtx(ctx), + incidentId: row.id, + clientFor, + dashboardUrl: DASHBOARD_URL, + }); + }); + } return row; } catch (err) { toTRPCError(err); @@ -151,12 +211,28 @@ export const incidentRouter = createTRPCRouter({ input: { id: input.id }, }); const row = await updateIncident({ ctx: toServiceCtx(ctx), input }); - if ( - row.commanderId !== null && - row.commanderId !== before?.commanderId - ) { + const commanderChanged = row.commanderId !== before?.commanderId; + if (row.commanderId !== null && commanderChanged) { await notifyCommander(ctx, row.id); } + const changes: string[] = []; + if (before && row.severity !== before.severity) { + changes.push(`severity is now *${row.severity}*`); + } + if (before && commanderChanged) { + const after = await getIncident({ + ctx: toServiceCtx(ctx), + input: { id: row.id }, + }); + changes.push( + after?.commander + ? `${displayName(after.commander)} is now commander` + : "there is no commander", + ); + } + if (changes.length) { + announce(ctx, row.id, `${actorName(ctx)}: ${changes.join(", ")}.`); + } return row; } catch (err) { toTRPCError(err); @@ -168,7 +244,14 @@ export const incidentRouter = createTRPCRouter({ .input(SetIncidentStatusInput) .mutation(async ({ ctx, input }) => { try { - return await setIncidentStatus({ ctx: toServiceCtx(ctx), input }); + const row = await setIncidentStatus({ ctx: toServiceCtx(ctx), input }); + announce( + ctx, + row.id, + `${actorName(ctx)} marked the incident *${row.status}*.${quote(input.note)}`, + row.status === "canceled", + ); + return row; } catch (err) { toTRPCError(err); } @@ -243,7 +326,9 @@ export const incidentRouter = createTRPCRouter({ .input(IncidentIdInput) .mutation(async ({ ctx, input }) => { try { - return await closeIncident({ ctx: toServiceCtx(ctx), input }); + const row = await closeIncident({ ctx: toServiceCtx(ctx), input }); + announce(ctx, row.id, `${actorName(ctx)} closed the incident.`, true); + return row; } catch (err) { toTRPCError(err); } @@ -254,7 +339,20 @@ export const incidentRouter = createTRPCRouter({ .input(IncidentIdInput) .mutation(async ({ ctx, input }) => { try { + const before = await getIncident({ ctx: toServiceCtx(ctx), input }); await deleteIncident({ ctx: toServiceCtx(ctx), input }); + if (before?.slackChannelId) { + afterResponse(() => + announceInChannel({ + ctx: toServiceCtx(ctx), + incident: before, + text: `${actorName(ctx)} deleted this incident: it was declared by mistake.`, + clientFor, + dashboardUrl: DASHBOARD_URL, + archive: true, + }), + ); + } } catch (err) { toTRPCError(err); } diff --git a/packages/services/src/incident/__tests__/slack-flow.test.ts b/packages/services/src/incident/__tests__/slack-flow.test.ts new file mode 100644 index 00000000..e3d0882d --- /dev/null +++ b/packages/services/src/incident/__tests__/slack-flow.test.ts @@ -0,0 +1,282 @@ +import { integration } from "@openstatus/db/src/schema"; +import { + addUserToWorkspace, + createUser, +} from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + makeUserCtx, + withTestTransaction, +} from "../../../test/helpers"; +import type { DB, ServiceContext } from "../../context"; +import { SLACK_BOT_SCOPES } from "../../integration/slack-scopes"; +import type { Workspace } from "../../types"; +import { + announceIncidentChange, + declareIncident, + getIncident, + incidentChannelName, + listIncidentEvents, + openIncidentSlackChannel, + type SlackIncidentClient, +} from "../index"; + +type Call = { method: string; args: Record }; + +function fakeSlack( + opts: { + takenNames?: string[]; + emails?: Record; + } = {}, +) { + const calls: Call[] = []; + const record = (method: string, args: Call["args"]) => + calls.push({ method, args }); + const client: SlackIncidentClient = { + conversations: { + create: async (args) => { + record("create", args); + if (opts.takenNames?.includes(args.name)) { + throw Object.assign(new Error("An API error occurred: name_taken"), { + data: { error: "name_taken" }, + }); + } + return { ok: true, channel: { id: "C_NEW", name: args.name } }; + }, + invite: async (args) => { + record("invite", args); + return { ok: true }; + }, + setTopic: async (args) => { + record("setTopic", args); + return { ok: true }; + }, + archive: async (args) => { + record("archive", args); + return { ok: true }; + }, + }, + chat: { + postMessage: async (args) => { + record("postMessage", args); + return { ok: true, ts: "1.1" }; + }, + }, + pins: { + add: async (args) => { + record("pins.add", args); + return { ok: true }; + }, + }, + users: { + lookupByEmail: async (args) => { + record("lookupByEmail", args); + const id = opts.emails?.[args.email]; + if (!id) throw new Error("users_not_found"); + return { ok: true, user: { id } }; + }, + }, + }; + return { client, calls }; +} + +let workspace: Workspace; +let ownerId: number; +let memberEmail: string; + +beforeAll(async () => { + const fixture = await createWorkspaceFixture("team"); + workspace = fixture.workspace; + ownerId = fixture.userId; + const member = await createUser(); + memberEmail = member.email as string; + await addUserToWorkspace(member.id, workspace.id, "member"); +}); + +async function connectSlack(tx: DB, teamId: string, scopes: string) { + await tx.insert(integration).values({ + name: "slack-agent", + workspaceId: workspace.id, + externalId: teamId, + credential: { botToken: "xoxb-test", botUserId: "UBOT" }, + data: { teamId, scopes }, + }); +} + +const ctxFor = (tx: DB): ServiceContext => ({ + ...makeUserCtx(workspace, { userId: ownerId }), + db: tx, +}); + +describe("incidentChannelName", () => { + const declaredAt = new Date("2026-09-28T23:30:00Z"); + test("UTC date, slug, suffix on retry", () => { + expect(incidentChannelName({ title: "API is DOWN!", declaredAt })).toBe( + "inc-2026-09-28-api-is-down", + ); + expect(incidentChannelName({ title: "API is DOWN!", declaredAt }, 3)).toBe( + "inc-2026-09-28-api-is-down-3", + ); + }); + + test("stays within 80 chars and falls back when nothing is left", () => { + const long = incidentChannelName({ title: "x".repeat(200), declaredAt }, 2); + expect(long.length).toBeLessThanOrEqual(80); + expect(long.endsWith("-2")).toBe(true); + expect(incidentChannelName({ title: "🔥🔥", declaredAt })).toBe( + "inc-2026-09-28-incident", + ); + }); +}); + +describe("openIncidentSlackChannel", () => { + test("creates, links members by email, invites, pins and binds", async () => { + await withTestTransaction(async (tx) => { + await connectSlack(tx, "T_FLOW", SLACK_BOT_SCOPES.join(",")); + const ctx = ctxFor(tx); + const inc = await declareIncident({ + ctx, + input: { title: "Checkout down", severity: "critical" }, + }); + const name = incidentChannelName(inc); + const { client, calls } = fakeSlack({ + takenNames: [name], + emails: { [memberEmail]: "U_MEMBER" }, + }); + + const result = await openIncidentSlackChannel({ + ctx, + incidentId: inc.id, + clientFor: () => client, + dashboardUrl: "https://app.test", + }); + + expect(result).toEqual({ status: "bound", channelId: "C_NEW" }); + const creates = calls.filter((c) => c.method === "create"); + expect(creates.map((c) => c.args.name)).toEqual([name, `${name}-2`]); + const invite = calls.find((c) => c.method === "invite"); + expect(String(invite?.args.users)).toContain("U_MEMBER"); + expect(calls.some((c) => c.method === "pins.add")).toBe(true); + + const bound = await getIncident({ ctx, input: { id: inc.id } }); + expect(bound?.slackChannelId).toBe("C_NEW"); + expect(bound?.slackTeamId).toBe("T_FLOW"); + const events = await listIncidentEvents({ ctx, input: { id: inc.id } }); + expect(events[0].type).toBe("slack_channel_bound"); + }); + }); + + test("skips an install that is missing scopes", async () => { + await withTestTransaction(async (tx) => { + await connectSlack(tx, "T_OLD", "chat:write"); + const ctx = ctxFor(tx); + const inc = await declareIncident({ + ctx, + input: { title: "x", severity: "minor" }, + }); + const { client, calls } = fakeSlack(); + const result = await openIncidentSlackChannel({ + ctx, + incidentId: inc.id, + clientFor: () => client, + dashboardUrl: "https://app.test", + }); + expect(result.status).toBe("skipped"); + expect(calls).toHaveLength(0); + }); + }); + + test("a channel create failure leaves the incident intact", async () => { + await withTestTransaction(async (tx) => { + await connectSlack(tx, "T_FAIL", SLACK_BOT_SCOPES.join(",")); + const ctx = ctxFor(tx); + const inc = await declareIncident({ + ctx, + input: { title: "x", severity: "minor" }, + }); + const { client } = fakeSlack(); + client.conversations.create = async () => { + throw Object.assign(new Error("restricted_action"), { + data: { error: "restricted_action" }, + }); + }; + const result = await openIncidentSlackChannel({ + ctx, + incidentId: inc.id, + clientFor: () => client, + dashboardUrl: "https://app.test", + }); + expect(result).toEqual({ status: "failed", error: "restricted_action" }); + expect( + (await getIncident({ ctx, input: { id: inc.id } }))?.slackChannelId, + ).toBeNull(); + }); + }); + + test("a bind failure posts the link-this-channel card", async () => { + await withTestTransaction(async (tx) => { + await connectSlack(tx, "T_BIND", SLACK_BOT_SCOPES.join(",")); + const ctx = ctxFor(tx); + const first = await declareIncident({ + ctx, + input: { title: "first", severity: "minor" }, + }); + const second = await declareIncident({ + ctx, + input: { title: "second", severity: "minor" }, + }); + const { client } = fakeSlack(); + await openIncidentSlackChannel({ + ctx, + incidentId: first.id, + clientFor: () => client, + dashboardUrl: "https://app.test", + }); + const { client: again, calls } = fakeSlack(); + const result = await openIncidentSlackChannel({ + ctx, + incidentId: second.id, + clientFor: () => again, + dashboardUrl: "https://app.test", + }); + expect(result.status).toBe("unbound"); + const card = calls.findLast((c) => c.method === "postMessage"); + expect(JSON.stringify(card?.args.blocks)).toContain( + `incident_bind_${second.id}`, + ); + }); + }); +}); + +describe("announceIncidentChange", () => { + test("posts, refreshes the topic, archives on request", async () => { + await withTestTransaction(async (tx) => { + await connectSlack(tx, "T_ANN", SLACK_BOT_SCOPES.join(",")); + const ctx = ctxFor(tx); + const inc = await declareIncident({ + ctx, + input: { title: "x", severity: "minor" }, + }); + const { client } = fakeSlack(); + await openIncidentSlackChannel({ + ctx, + incidentId: inc.id, + clientFor: () => client, + dashboardUrl: "https://app.test", + }); + const { client: later, calls } = fakeSlack(); + await announceIncidentChange({ + ctx, + incidentId: inc.id, + text: "Resolved", + clientFor: () => later, + dashboardUrl: "https://app.test", + archive: true, + }); + expect(calls.map((c) => c.method)).toEqual(["postMessage", "archive"]); + }); + }); +}); diff --git a/packages/services/src/incident/index.ts b/packages/services/src/incident/index.ts index baa31dbc..ef866417 100644 --- a/packages/services/src/incident/index.ts +++ b/packages/services/src/incident/index.ts @@ -27,4 +27,16 @@ export { bindIncidentSlackChannel, unbindIncidentSlackChannel, } from "./slack-channel"; +export { + announceIncidentChange, + announceInChannel, + headerBlocks, + incidentChannelName, + incidentSlackReady, + type OpenChannelResult, + openIncidentSlackChannel, + type SlackClientFactory, + type SlackIncidentBlock, + type SlackIncidentClient, +} from "./slack-flow"; export { updateIncident } from "./update"; diff --git a/packages/services/src/incident/slack-flow.ts b/packages/services/src/incident/slack-flow.ts new file mode 100644 index 00000000..6e18032a --- /dev/null +++ b/packages/services/src/incident/slack-flow.ts @@ -0,0 +1,379 @@ +import { and, eq, isNull } from "@openstatus/db"; +import { + type Incident, + slackUser, + user, + usersToWorkspaces, +} from "@openstatus/db/src/schema"; + +import { type ServiceContext, getReadDb } from "../context"; +import { isFeatureEnabled } from "../features"; +import { + type SlackConnection, + getSlackConnection, +} from "../integration/slack-connection"; +import { createSlackUserMapping } from "../slack-user/create"; +import { getIncidentInWorkspace, INCIDENT_FEATURE } from "./internal"; +import { bindIncidentSlackChannel } from "./slack-channel"; + +type SlackText = { type: "mrkdwn" | "plain_text"; text: string }; +export type SlackIncidentBlock = + | { type: "header"; text: SlackText } + | { type: "section"; text: SlackText } + | { type: "context"; elements: SlackText[] } + | { + type: "actions"; + elements: { + type: "button"; + text: SlackText; + url?: string; + action_id: string; + value?: string; + style?: "primary" | "danger"; + }[]; + }; + +type SlackResult = { ok?: boolean; error?: string }; + +/** The Slack Web API calls the incident channel flow makes. `WebClient` fits. */ +export type SlackIncidentClient = { + conversations: { + create(args: { + name: string; + is_private?: boolean; + }): Promise; + invite(args: { + channel: string; + users: string; + force?: boolean; + }): Promise; + setTopic(args: { channel: string; topic: string }): Promise; + archive(args: { channel: string }): Promise; + }; + chat: { + postMessage(args: { + channel: string; + text: string; + blocks?: SlackIncidentBlock[]; + }): Promise; + }; + pins: { + add(args: { channel: string; timestamp: string }): Promise; + }; + users: { + lookupByEmail(args: { + email: string; + }): Promise; + }; +}; + +export type SlackClientFactory = (botToken: string) => SlackIncidentClient; + +const CHANNEL_NAME_MAX = 80; + +/** `inc-YYYY-MM-DD-`, UTC date, within Slack's 80 chars and charset. */ +export function incidentChannelName( + incident: Pick, + attempt = 1, +): string { + const date = incident.declaredAt.toISOString().slice(0, 10); + const suffix = attempt > 1 ? `-${attempt}` : ""; + const prefix = `inc-${date}-`; + const slug = incident.title + .toLowerCase() + .normalize("NFKD") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, CHANNEL_NAME_MAX - prefix.length - suffix.length) + .replace(/-+$/g, ""); + return `${prefix}${slug || "incident"}${suffix}`; +} + +export function incidentTopic( + incident: Pick, + url: string, +): string { + return `${incident.severity.toUpperCase()} · ${incident.status} · ${url}`; +} + +function errorCode(err: Error | string): string { + if (typeof err === "string") return err; + const data = "data" in err ? err.data : undefined; + if (typeof data === "object" && data !== null && "error" in data) { + return String(data.error); + } + return err.message; +} + +/** Channel automation needs the feature, the plan, and a fully-scoped install. */ +export async function incidentSlackReady( + ctx: ServiceContext, +): Promise { + if (!isFeatureEnabled(ctx.workspace, INCIDENT_FEATURE)) return null; + if (!ctx.workspace.limits["slack-agent"]) return null; + const connection = await getSlackConnection({ ctx }); + if (!connection || connection.missingScopes.length > 0) return null; + return connection; +} + +/** Link every member whose openstatus email has a Slack account in the team. */ +async function autoMapMembers( + ctx: ServiceContext, + client: SlackIncidentClient, + teamId: string, +): Promise { + const db = getReadDb(ctx); + const members = await db + .select({ id: user.id, email: user.email }) + .from(usersToWorkspaces) + .innerJoin(user, eq(user.id, usersToWorkspaces.userId)) + .where( + and( + eq(usersToWorkspaces.workspaceId, ctx.workspace.id), + isNull(user.deletedAt), + ), + ) + .all(); + const linked = await db + .select({ userId: slackUser.userId, slackUserId: slackUser.slackUserId }) + .from(slackUser) + .where( + and( + eq(slackUser.workspaceId, ctx.workspace.id), + eq(slackUser.slackTeamId, teamId), + ), + ) + .all(); + const slackIds = new Map(linked.map((l) => [l.userId, l.slackUserId])); + const system: ServiceContext = { + ...ctx, + actor: { type: "system", job: "slack-incident-channel" }, + }; + + for (const member of members) { + if (slackIds.has(member.id) || !member.email) continue; + try { + const res = await client.users.lookupByEmail({ email: member.email }); + const slackUserId = res.user?.id; + if (!slackUserId) continue; + await createSlackUserMapping({ + ctx: system, + input: { teamId, slackUserId, userId: member.id }, + }); + slackIds.set(member.id, slackUserId); + } catch { + // users_not_found: no Slack account with this email. + } + } + return [...slackIds.values()]; +} + +export type OpenChannelResult = + | { status: "skipped" } + | { status: "bound"; channelId: string } + | { status: "unbound"; channelId: string; error: string } + | { status: "failed"; error: string }; + +/** + * Creates the incident's channel after the declare has committed: a Slack + * failure never loses the incident. Order: create (retrying `name_taken`), + * link members by email, invite everyone linked, topic, pinned header card, + * bind. If binding fails twice the channel gets a "link this channel" card. + */ +export async function openIncidentSlackChannel(args: { + ctx: ServiceContext; + incidentId: number; + clientFor: SlackClientFactory; + dashboardUrl: string; +}): Promise { + const { ctx, incidentId, clientFor, dashboardUrl } = args; + const connection = await incidentSlackReady(ctx); + if (!connection) return { status: "skipped" }; + const incident = await getIncidentInWorkspace( + getReadDb(ctx), + ctx.workspace.id, + incidentId, + ); + if (incident.slackChannelId || incident.closedAt) + return { status: "skipped" }; + + const client = clientFor(connection.botToken); + const url = `${dashboardUrl}/incidents/${incident.id}`; + + let channelId: string | undefined; + for (let attempt = 1; attempt <= 10 && !channelId; attempt++) { + try { + const res = await client.conversations.create({ + name: incidentChannelName(incident, attempt), + }); + channelId = res.channel?.id; + } catch (err) { + const code = errorCode(err instanceof Error ? err : String(err)); + if (code !== "name_taken") return { status: "failed", error: code }; + } + } + if (!channelId) return { status: "failed", error: "name_taken" }; + + try { + const slackUserIds = await autoMapMembers(ctx, client, connection.teamId); + const invitees = slackUserIds.filter((id) => id !== connection.botUserId); + for (let i = 0; i < invitees.length; i += 1000) { + await client.conversations + .invite({ + channel: channelId, + users: invitees.slice(i, i + 1000).join(","), + force: true, + }) + .catch(() => undefined); + } + await client.conversations + .setTopic({ channel: channelId, topic: incidentTopic(incident, url) }) + .catch(() => undefined); + const header = await client.chat.postMessage({ + channel: channelId, + text: `Incident: ${incident.title}`, + blocks: headerBlocks(incident, url), + }); + if (header.ts) { + await client.pins + .add({ channel: channelId, timestamp: header.ts }) + .catch(() => undefined); + } + } catch { + // Invites, topic and card are cosmetic; binding is what matters. + } + + const system: ServiceContext = { + ...ctx, + actor: { type: "system", job: "slack-incident-channel" }, + }; + let lastError = ""; + for (let attempt = 0; attempt < 2; attempt++) { + try { + await bindIncidentSlackChannel({ + ctx: system, + input: { id: incident.id, teamId: connection.teamId, channelId }, + }); + return { status: "bound", channelId }; + } catch (err) { + lastError = errorCode(err instanceof Error ? err : String(err)); + } + } + + await client.chat + .postMessage({ + channel: channelId, + text: "This channel was created for an incident but could not be linked to it.", + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `:warning: This channel was created for *${incident.title}* but could not be linked to it.`, + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Link this channel" }, + action_id: `incident_bind_${incident.id}`, + value: channelId, + style: "primary", + }, + ], + }, + ], + }) + .catch(() => undefined); + return { status: "unbound", channelId, error: lastError }; +} + +export function headerBlocks( + incident: Pick, + url: string, +): SlackIncidentBlock[] { + return [ + { type: "header", text: { type: "plain_text", text: incident.title } }, + { + type: "section", + text: { + type: "mrkdwn", + text: `*Severity:* ${incident.severity} *Status:* ${incident.status}${incident.summary ? `\n${incident.summary}` : ""}`, + }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `<${url}|Open in openstatus> · Pin a message with :pushpin: to add it to the timeline.`, + }, + ], + }, + ]; +} + +/** Topic refresh and a one-line message in the bound channel. Best effort. */ +export async function announceIncidentChange(args: { + ctx: ServiceContext; + incidentId: number; + text: string; + clientFor: SlackClientFactory; + dashboardUrl: string; + archive?: boolean; +}): Promise { + const { ctx, incidentId, text, clientFor, dashboardUrl } = args; + try { + const incident = await getIncidentInWorkspace( + getReadDb(ctx), + ctx.workspace.id, + incidentId, + ); + if (!incident.slackChannelId) return; + await announceInChannel({ + ctx, + incident, + text, + clientFor, + dashboardUrl, + archive: args.archive, + }); + } catch { + // The incident is the record; the channel is a courtesy. + } +} + +/** Same, for an incident row already in hand (e.g. just before deleting it). */ +export async function announceInChannel(args: { + ctx: ServiceContext; + incident: Pick< + Incident, + "id" | "severity" | "status" | "slackChannelId" | "slackTeamId" + >; + text: string; + clientFor: SlackClientFactory; + dashboardUrl: string; + archive?: boolean; +}): Promise { + const { ctx, incident, text, clientFor, dashboardUrl } = args; + const channel = incident.slackChannelId; + if (!channel) return; + try { + const connection = await getSlackConnection({ ctx }); + if (!connection || connection.teamId !== incident.slackTeamId) return; + const client = clientFor(connection.botToken); + const url = `${dashboardUrl}/incidents/${incident.id}`; + await client.chat.postMessage({ channel, text }).catch(() => undefined); + if (args.archive) { + await client.conversations.archive({ channel }).catch(() => undefined); + return; + } + await client.conversations + .setTopic({ channel, topic: incidentTopic(incident, url) }) + .catch(() => undefined); + } catch { + // Best effort. + } +} diff --git a/packages/services/src/integration/__tests__/uninstall-slack-agent.test.ts b/packages/services/src/integration/__tests__/uninstall-slack-agent.test.ts index 862c87f9..8ee38937 100644 --- a/packages/services/src/integration/__tests__/uninstall-slack-agent.test.ts +++ b/packages/services/src/integration/__tests__/uninstall-slack-agent.test.ts @@ -1,10 +1,15 @@ import { and, eq } from "@openstatus/db"; import { + incident, integration, pageSubscriber, slackUser, } from "@openstatus/db/src/schema"; -import { createPage, createSlackUser } from "@openstatus/db/src/test/factories"; +import { + createIncident, + createPage, + createSlackUser, +} from "@openstatus/db/src/test/factories"; import { expect } from "@std/expect"; import { describe, test } from "@std/testing/bdd"; @@ -67,6 +72,11 @@ describe("uninstallSlackTeam", () => { { slackTeamId: teamId }, tx, ); + const bound = await createIncident( + team.workspace.id, + { slackTeamId: teamId, slackChannelId: "C_BOUND" }, + tx, + ); const sub = await createSlackSubscriber({ input: { pageId: page.id, teamId, channelId: "C_UNINSTALL" }, db: tx, @@ -100,6 +110,12 @@ describe("uninstallSlackTeam", () => { .get(); expect(subscriber).toBeDefined(); expect(subscriber?.unsubscribedAt).not.toBeNull(); + const unbound = await tx + .select() + .from(incident) + .where(eq(incident.id, bound.id)) + .get(); + expect(unbound?.slackChannelId).toBeNull(); await expectAuditRow({ workspaceId: team.workspace.id, diff --git a/packages/services/src/integration/index.ts b/packages/services/src/integration/index.ts index b1593aef..52629449 100644 --- a/packages/services/src/integration/index.ts +++ b/packages/services/src/integration/index.ts @@ -1,5 +1,6 @@ export { deleteIntegration } from "./delete"; export { installSlackAgent } from "./install-slack-agent"; +export { getSlackConnection, type SlackConnection } from "./slack-connection"; export { missingSlackScopes, SLACK_BOT_SCOPES } from "./slack-scopes"; export { uninstallSlackAgent, diff --git a/packages/services/src/integration/slack-connection.ts b/packages/services/src/integration/slack-connection.ts new file mode 100644 index 00000000..ed573b21 --- /dev/null +++ b/packages/services/src/integration/slack-connection.ts @@ -0,0 +1,51 @@ +import { and, desc, eq } from "@openstatus/db"; +import { integration } from "@openstatus/db/src/schema"; +import { z } from "zod"; + +import { type ServiceContext, getReadDb } from "../context"; +import { missingSlackScopes } from "./slack-scopes"; + +const credentialSchema = z.object({ + botToken: z.string().min(1), + botUserId: z.string().optional(), +}); +const dataSchema = z.object({ scopes: z.string().optional() }); + +export type SlackConnection = { + teamId: string; + botToken: string; + botUserId: string | null; + missingScopes: string[]; +}; + +/** The workspace's Slack agent install, bot token included. Server-side only. */ +export async function getSlackConnection(args: { + ctx: ServiceContext; +}): Promise { + const row = await getReadDb(args.ctx) + .select({ + externalId: integration.externalId, + credential: integration.credential, + data: integration.data, + }) + .from(integration) + .where( + and( + eq(integration.name, "slack-agent"), + eq(integration.workspaceId, args.ctx.workspace.id), + ), + ) + .orderBy(desc(integration.updatedAt)) + .get(); + if (!row) return null; + const credential = credentialSchema.safeParse(row.credential); + if (!credential.success) return null; + return { + teamId: row.externalId, + botToken: credential.data.botToken, + botUserId: credential.data.botUserId ?? null, + missingScopes: missingSlackScopes( + dataSchema.safeParse(row.data).data?.scopes, + ), + }; +} diff --git a/packages/services/src/integration/uninstall-slack-agent.ts b/packages/services/src/integration/uninstall-slack-agent.ts index 32fdfe16..2d2bd24f 100644 --- a/packages/services/src/integration/uninstall-slack-agent.ts +++ b/packages/services/src/integration/uninstall-slack-agent.ts @@ -1,9 +1,10 @@ import { and, db as defaultDb, eq, inArray } from "@openstatus/db"; -import { integration, workspace } from "@openstatus/db/src/schema"; +import { incident, integration, workspace } from "@openstatus/db/src/schema"; import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type DB, type ServiceContext, withTransaction } from "../context"; +import { unbindIncidentSlackChannel } from "../incident/slack-channel"; import { parseWorkspaceForContext } from "../page-subscriber/internal"; import { removeSlackTeamSubscribers } from "../page-subscriber/slack"; import { deleteSlackUserMappings } from "../slack-user/internal"; @@ -47,7 +48,23 @@ export async function uninstallSlackAgent(args: { ctx, where: { slackTeamId: input.teamId }, }); - // TODO(incident/15-slack-channel): unbind every incident bound to this team. + + const bound = await tx + .select({ id: incident.id }) + .from(incident) + .where( + and( + eq(incident.workspaceId, ctx.workspace.id), + eq(incident.slackTeamId, input.teamId), + ), + ) + .all(); + for (const row of bound) { + await unbindIncidentSlackChannel({ + ctx: { ...ctx, db: tx }, + input: { id: row.id }, + }); + } }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c88bfd1e..30564427 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2049,6 +2049,9 @@ importers: '@openstatus/utils': specifier: workspace:* version: link:../utils + '@slack/web-api': + specifier: 'catalog:' + version: 8.1.1 '@t3-oss/env-core': specifier: 'catalog:' version: 0.13.11(typescript@7.0.2)(zod@4.6.5)