diff --git a/apps/server/src/libs/test/doubles/slack-web-api.mock.ts b/apps/server/src/libs/test/doubles/slack-web-api.mock.ts index dc301130a..d59a83756 100644 --- a/apps/server/src/libs/test/doubles/slack-web-api.mock.ts +++ b/apps/server/src/libs/test/doubles/slack-web-api.mock.ts @@ -123,5 +123,9 @@ export class WebClient { s.calls.push({ method: "views.open", args }); return Promise.resolve({ ok: true }); }, + update: (args: Record) => { + s.calls.push({ method: "views.update", args }); + return Promise.resolve({ ok: true }); + }, }; } diff --git a/apps/server/src/routes/slack/incident-card.ts b/apps/server/src/routes/slack/incident-card.ts new file mode 100644 index 000000000..687af6124 --- /dev/null +++ b/apps/server/src/routes/slack/incident-card.ts @@ -0,0 +1,865 @@ +import { getLogger } from "@logtape/logtape"; +import { + type ServiceContext, + ServiceError, + withTransaction, +} from "@openstatus/services"; +import { + addIncidentNote, + allowedTransitions, + announceIncidentChange, + escapeMrkdwn, + getIncident, + headerBlocks, + setIncidentStatus, + updateIncident, +} from "@openstatus/services/incident"; +import { listPages } from "@openstatus/services/page"; +import { listPageComponents } from "@openstatus/services/page-component"; +import { notifyStatusReport } from "@openstatus/services/status-report"; +import { type ModalView, WebClient } from "@slack/web-api"; +import { z } from "zod"; + +import { runInBackground } from "./background"; +import type { SlackConfig } from "./config"; +import { trackSlackIncident } from "./incident-analytics"; +import { + gate, + noticeModal, + type StateValues, + type ViewSubmissionPayload, + type ViewSubmissionResponse, +} from "./incident-modal"; +import { afterIncidentTool, quote, who } from "./incident-slack"; +import { renderToolResult } from "./presenters"; +import { executeRegistryAction, getRegistryTool } from "./registry-runner"; + +const logger = getLogger(["api-server", "slack", "incident-card"]); + +export const UPDATE_STATUS_CALLBACK = "incident_card_update_status"; +export const STATUS_PAGE_CALLBACK = "incident_card_status_page"; +/** The status page select; picking a page reloads its components. */ +export const STATUS_PAGE_PAGE_ACTION = "incident_card_page"; + +const UPDATE_STATUS_TITLE = "Update incident"; +const STATUS_PAGE_TITLE = "Update status page"; +const GONE = "This incident no longer exists."; +const NOTE_MAX = 3000; +const TITLE_MAX = 256; +// Slack caps option text at 75 chars and a select at 100 options. +const OPTION_TEXT_MAX = 75; +const OPTIONS_MAX = 100; + +const INCIDENT_STATUSES = { + open: "Open — still investigating", + mitigated: "Mitigated — impact stopped, not fixed yet", + resolved: "Resolved — fixed", + canceled: "Canceled — false alarm, closes the incident", +} as const; +type IncidentStatus = keyof typeof INCIDENT_STATUSES; + +const SEVERITIES = { + critical: "Critical — major outage or data loss", + major: "Major — significant degradation", + minor: "Minor — limited impact", +} as const; +type Severity = keyof typeof SEVERITIES; + +const REPORT_STATUSES = { + investigating: "Investigating", + identified: "Identified", + monitoring: "Monitoring", + resolved: "Resolved", +} as const; +type ReportStatus = keyof typeof REPORT_STATUSES; + +const IMPACTS = { + degraded_performance: "Degraded performance", + partial_outage: "Partial outage", + major_outage: "Major outage", +} as const; +type Impact = keyof typeof IMPACTS; + +const metadataSchema = z.object({ + incidentId: z.number().int(), + channelId: z.string().optional(), + messageTs: z.string().optional(), + statusReportId: z.number().int().optional(), +}); +type Metadata = z.infer; + +function readMetadata(raw: string | undefined): Metadata | undefined { + try { + return metadataSchema.safeParse(JSON.parse(raw || "{}")).data; + } catch { + return undefined; + } +} + +function option(value: string, label: string) { + return { + text: { + type: "plain_text" as const, + text: label.slice(0, OPTION_TEXT_MAX), + }, + value, + }; +} + +function options(labels: Record, keys?: K[]) { + return (keys ?? (Object.keys(labels) as K[])).map((k) => + option(k, labels[k]), + ); +} + +function pick( + labels: Record, + value: string | undefined, +): K | undefined { + return value !== undefined && value in labels ? (value as K) : undefined; +} + +type Incident = NonNullable>>; + +export function buildUpdateStatusModal( + incident: Incident, + meta: Metadata, +): ModalView { + if (incident.closedAt) { + return noticeModal( + "This incident is closed.", + undefined, + UPDATE_STATUS_TITLE, + ); + } + const statuses = [incident.status, ...allowedTransitions(incident)]; + return { + type: "modal", + callback_id: UPDATE_STATUS_CALLBACK, + private_metadata: JSON.stringify(meta), + title: { type: "plain_text", text: UPDATE_STATUS_TITLE }, + submit: { type: "plain_text", text: "Update" }, + close: { type: "plain_text", text: "Cancel" }, + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: `*${escapeMrkdwn(incident.title)}*` }, + }, + { + type: "input", + block_id: "status", + label: { type: "plain_text", text: "Status" }, + element: { + type: "static_select", + action_id: "value", + options: options(INCIDENT_STATUSES, statuses), + initial_option: option( + incident.status, + INCIDENT_STATUSES[incident.status], + ), + }, + }, + { + type: "input", + block_id: "severity", + label: { type: "plain_text", text: "Severity" }, + element: { + type: "static_select", + action_id: "value", + options: options(SEVERITIES), + initial_option: option( + incident.severity, + SEVERITIES[incident.severity], + ), + }, + }, + { + type: "input", + block_id: "note", + optional: true, + label: { type: "plain_text", text: "Update" }, + hint: { + type: "plain_text", + text: "Posted in the channel and added to the timeline.", + }, + element: { + type: "plain_text_input", + action_id: "value", + multiline: true, + max_length: NOTE_MAX, + placeholder: { type: "plain_text", text: "What changed?" }, + }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "Internal only: use *Update status page* to tell your users.", + }, + ], + }, + ], + }; +} + +type PageOption = { id: number; title: string }; +type ComponentOption = { id: number; name: string }; + +const notifyBlock = { + type: "input" as const, + block_id: "notify", + optional: true, + label: { type: "plain_text" as const, text: "Subscribers" }, + element: { + type: "checkboxes" as const, + action_id: "value", + options: [option("notify", "Notify subscribers")], + }, +}; + +function messageBlock(placeholder: string) { + return { + type: "input" as const, + block_id: "message", + label: { type: "plain_text" as const, text: "Message" }, + element: { + type: "plain_text_input" as const, + action_id: "value", + multiline: true, + max_length: NOTE_MAX, + placeholder: { type: "plain_text" as const, text: placeholder }, + }, + }; +} + +/** Adds an update to the incident's linked report. */ +export function buildStatusReportUpdateModal( + report: NonNullable, + meta: Metadata, +): ModalView { + return { + type: "modal", + callback_id: STATUS_PAGE_CALLBACK, + private_metadata: JSON.stringify({ ...meta, statusReportId: report.id }), + title: { type: "plain_text", text: STATUS_PAGE_TITLE }, + submit: { type: "plain_text", text: "Publish" }, + close: { type: "plain_text", text: "Cancel" }, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `*${escapeMrkdwn(report.title)}* · ${report.status}`, + }, + }, + { + type: "input", + block_id: "status", + label: { type: "plain_text", text: "Status" }, + element: { + type: "static_select", + action_id: "value", + options: options(REPORT_STATUSES), + initial_option: option(report.status, REPORT_STATUSES[report.status]), + }, + }, + messageBlock("We have identified the cause and are rolling out a fix."), + notifyBlock, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: ":warning: Public: shown on your status page.", + }, + ], + }, + ], + }; +} + +/** Creates a status report linked to the incident. */ +export function buildStatusReportCreateModal(args: { + incident: Incident; + pages: PageOption[]; + pageId: number; + components: ComponentOption[]; + meta: Metadata; +}): ModalView { + const { incident, pages, pageId, components, meta } = args; + const page = pages.find((p) => p.id === pageId) ?? pages[0]; + const componentBlocks = components.length + ? [ + { + type: "input" as const, + // Keyed by page so a page switch drops the previous page's picks. + block_id: `components_${page.id}`, + optional: true, + label: { type: "plain_text" as const, text: "Affected components" }, + element: { + type: "multi_static_select" as const, + action_id: "value", + placeholder: { + type: "plain_text" as const, + text: "Pick components", + }, + options: components + .slice(0, OPTIONS_MAX) + .map((c) => option(String(c.id), c.name)), + }, + }, + { + type: "input" as const, + block_id: "impact", + optional: true, + label: { type: "plain_text" as const, text: "Impact" }, + hint: { + type: "plain_text" as const, + text: "Applied to every affected component.", + }, + element: { + type: "static_select" as const, + action_id: "value", + options: options(IMPACTS), + initial_option: option("partial_outage", IMPACTS.partial_outage), + }, + }, + ] + : []; + return { + type: "modal", + callback_id: STATUS_PAGE_CALLBACK, + private_metadata: JSON.stringify(meta), + title: { type: "plain_text", text: STATUS_PAGE_TITLE }, + submit: { type: "plain_text", text: "Publish" }, + close: { type: "plain_text", text: "Cancel" }, + blocks: [ + { + type: "input", + block_id: "page", + dispatch_action: true, + label: { type: "plain_text", text: "Status page" }, + element: { + type: "static_select", + action_id: STATUS_PAGE_PAGE_ACTION, + options: pages + .slice(0, OPTIONS_MAX) + .map((p) => option(String(p.id), p.title)), + initial_option: option(String(page.id), page.title), + }, + }, + { + type: "input", + block_id: "title", + label: { type: "plain_text", text: "Title" }, + element: { + type: "plain_text_input", + action_id: "value", + max_length: TITLE_MAX, + initial_value: incident.title.slice(0, TITLE_MAX), + }, + }, + { + type: "input", + block_id: "status", + label: { type: "plain_text", text: "Status" }, + element: { + type: "static_select", + action_id: "value", + options: options(REPORT_STATUSES, [ + "investigating", + "identified", + "monitoring", + ]), + initial_option: option( + "investigating", + REPORT_STATUSES.investigating, + ), + }, + }, + ...componentBlocks, + messageBlock("We are investigating elevated error rates."), + notifyBlock, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: ":warning: Public: published on your status page and linked to this incident.", + }, + ], + }, + ], + }; +} + +async function statusPageModal( + ctx: ServiceContext, + incident: Incident, + meta: Metadata, + pageId?: number, +): Promise { + if (incident.statusReport) { + return buildStatusReportUpdateModal(incident.statusReport, meta); + } + if (incident.closedAt) { + return noticeModal( + "This incident is closed.", + undefined, + STATUS_PAGE_TITLE, + ); + } + const pages = await listPages({ ctx, input: { order: "asc" } }); + if (pages.length === 0) { + return noticeModal( + "Create a status page in openstatus first.", + undefined, + STATUS_PAGE_TITLE, + ); + } + const page = pages.find((p) => p.id === pageId) ?? pages[0]; + const components = await listPageComponents({ + ctx, + input: { pageId: page.id, order: "asc" }, + }); + return buildStatusReportCreateModal({ + incident, + pages: pages.map((p) => ({ id: p.id, title: p.title })), + pageId: page.id, + components: components.map((c) => ({ id: c.id, name: c.name })), + meta, + }); +} + +export type IncidentCardAction = "update_status" | "status_page"; + +/** Opens a card button's modal; awaited, since `trigger_id` lives 3s. */ +export async function openIncidentCardModal(args: { + action: IncidentCardAction; + teamId: string; + slackUserId: string; + triggerId: string; + incidentId: number; + channelId?: string; + messageTs?: string; + config: SlackConfig; +}): Promise { + const title = + args.action === "update_status" ? UPDATE_STATUS_TITLE : STATUS_PAGE_TITLE; + const checked = await gate(args.teamId, args.slackUserId, args.config, title); + if (!checked.ok) { + if (!checked.botToken) return; + await new WebClient(checked.botToken).views.open({ + trigger_id: args.triggerId, + view: checked.view, + }); + return; + } + const ctx: ServiceContext = { + workspace: checked.resolved.workspace, + actor: checked.actor, + }; + const meta: Metadata = { + incidentId: args.incidentId, + channelId: args.channelId, + messageTs: args.messageTs, + }; + const incident = await getIncident({ ctx, input: { id: args.incidentId } }); + const view = !incident + ? noticeModal(GONE, undefined, title) + : args.action === "update_status" + ? buildUpdateStatusModal(incident, meta) + : await statusPageModal(ctx, incident, meta); + await new WebClient(checked.resolved.botToken).views.open({ + trigger_id: args.triggerId, + view, + }); +} + +/** A new status page was picked: reload the form with its components. */ +export async function changeStatusPagePage(args: { + teamId: string; + slackUserId: string; + viewId: string; + hash?: string; + privateMetadata?: string; + pageId: number; + config: SlackConfig; +}): Promise { + const meta = readMetadata(args.privateMetadata); + if (!meta) return; + const checked = await gate( + args.teamId, + args.slackUserId, + args.config, + STATUS_PAGE_TITLE, + ); + if (!checked.ok) return; + const ctx: ServiceContext = { + workspace: checked.resolved.workspace, + actor: checked.actor, + }; + const incident = await getIncident({ ctx, input: { id: meta.incidentId } }); + if (!incident) return; + await new WebClient(checked.resolved.botToken).views.update({ + view_id: args.viewId, + ...(args.hash ? { hash: args.hash } : {}), + view: await statusPageModal(ctx, incident, meta, args.pageId), + }); +} + +type Submitted = { + ctx: ServiceContext; + slack: WebClient; + meta: Metadata; + teamId: string; + values: StateValues; +}; + +async function readSubmission( + payload: ViewSubmissionPayload, + config: SlackConfig, + title: string, +): Promise> { + const teamId = payload.team?.id ?? payload.user.team_id; + const meta = readMetadata(payload.view.private_metadata); + if (!teamId || !meta) { + return { + response_action: "update", + view: noticeModal("Could not read this request.", undefined, title), + }; + } + const checked = await gate(teamId, payload.user.id, config, title); + if (!checked.ok) return { response_action: "update", view: checked.view }; + return { + ctx: { workspace: checked.resolved.workspace, actor: checked.actor }, + slack: new WebClient(checked.resolved.botToken), + meta, + teamId, + values: payload.view.state.values, + }; +} + +function errorText(error: unknown): string { + return error instanceof ServiceError + ? error.message + : "Something went wrong. Please try again."; +} + +/** Redraws the pinned card the button was clicked on. Best effort. */ +async function refreshCard( + submitted: Submitted, + config: SlackConfig, +): Promise { + const { ctx, slack, meta } = submitted; + if (!meta.channelId || !meta.messageTs) return; + const incident = await getIncident({ ctx, input: { id: meta.incidentId } }); + if (!incident) return; + await slack.chat + .update({ + channel: meta.channelId, + ts: meta.messageTs, + text: `Incident: ${incident.title}`, + blocks: headerBlocks( + incident, + `${config.dashboardUrl}/incidents/${incident.id}`, + ), + }) + .catch((error) => + logger.warn("slack incident card refresh failed", { + error, + incidentId: incident.id, + }), + ); +} + +export async function submitUpdateStatus( + payload: ViewSubmissionPayload, + config: SlackConfig, +): Promise { + const submitted = await readSubmission(payload, config, UPDATE_STATUS_TITLE); + if (!("ctx" in submitted)) return submitted; + const { ctx, meta, values } = submitted; + + const status = pick( + INCIDENT_STATUSES, + values.status?.value?.selected_option?.value, + ); + if (!status) + return { response_action: "errors", errors: { status: "Pick a status." } }; + const severity = pick( + SEVERITIES, + values.severity?.value?.selected_option?.value, + ); + if (!severity) { + return { + response_action: "errors", + errors: { severity: "Pick a severity." }, + }; + } + const note = values.note?.value?.value?.trim() || undefined; + + const incident = await getIncident({ ctx, input: { id: meta.incidentId } }); + if (!incident) { + return { + response_action: "update", + view: noticeModal(GONE, undefined, UPDATE_STATUS_TITLE), + }; + } + const statusChanged = status !== incident.status; + const severityChanged = severity !== incident.severity; + if (!statusChanged && !severityChanged && !note) { + return { + response_action: "errors", + errors: { note: "Change the status or severity, or write an update." }, + }; + } + + try { + // One transaction: a refused transition must not leave the severity changed. + await withTransaction(ctx, async (tx) => { + const txCtx: ServiceContext = { ...ctx, db: tx }; + if (severityChanged) { + await updateIncident({ + ctx: txCtx, + input: { id: incident.id, severity }, + }); + } + if (statusChanged) { + await setIncidentStatus({ + ctx: txCtx, + input: { id: incident.id, status, note }, + }); + } else if (note) { + await addIncidentNote({ + ctx: txCtx, + input: { id: incident.id, message: note }, + }); + } + }); + } catch (error) { + if (!(error instanceof ServiceError)) { + logger.error("slack incident update failed", { + error, + incidentId: incident.id, + }); + } + return { response_action: "errors", errors: { status: errorText(error) } }; + } + + runInBackground( + "incident-card-status", + () => + afterUpdateStatus({ + submitted, + config, + incidentId: incident.id, + status: statusChanged ? status : undefined, + severity: severityChanged ? severity : undefined, + note, + }), + { incidentId: incident.id }, + ); + return null; +} + +async function afterUpdateStatus(args: { + submitted: Submitted; + config: SlackConfig; + incidentId: number; + status?: IncidentStatus; + severity?: Severity; + note?: string; +}): Promise { + const { submitted, config, incidentId, status, severity, note } = args; + const { ctx, slack, meta, teamId } = submitted; + // Before the follow-up: a canceled incident's channel gets archived. + await refreshCard(submitted, config); + if (severity || !status) { + if (!status && note) trackSlackIncident(ctx, "note"); + const text = severity + ? `${who(ctx)} changed the severity to *${severity}*.${status ? "" : quote(note)}` + : `${who(ctx)} posted an update.${quote(note)}`; + await announceIncidentChange({ + ctx, + incidentId, + text, + clientFor: (token) => new WebClient(token), + dashboardUrl: config.dashboardUrl, + }); + } + if (status && meta.channelId) { + await afterIncidentTool({ + ctx, + toolName: "set_incident_status", + input: { note }, + output: { id: incidentId, status }, + config, + slack, + teamId, + channelId: meta.channelId, + }); + } +} + +type StatusPageWrite = { + toolName: + | "create_status_report" + | "add_status_report_update" + | "resolve_status_report"; + input: object; +}; + +function readStatusPageWrite( + meta: Metadata, + values: StateValues, + incidentId: number, +): StatusPageWrite | { error: Record } { + const message = values.message?.value?.value?.trim(); + if (!message) + return { error: { message: "Write a message for your users." } }; + const status = pick( + REPORT_STATUSES, + values.status?.value?.selected_option?.value, + ); + if (!status) return { error: { status: "Pick a status." } }; + + if (meta.statusReportId !== undefined) { + return status === "resolved" + ? { + toolName: "resolve_status_report", + input: { statusReportId: meta.statusReportId, message }, + } + : { + toolName: "add_status_report_update", + input: { statusReportId: meta.statusReportId, status, message }, + }; + } + + const pageId = Number( + values.page?.[STATUS_PAGE_PAGE_ACTION]?.selected_option?.value, + ); + if (!Number.isInteger(pageId)) + return { error: { page: "Pick a status page." } }; + const title = values.title?.value?.value?.trim(); + if (!title) return { error: { title: "Give the report a title." } }; + const componentsKey = Object.keys(values).find((k) => + k.startsWith("components_"), + ); + const componentIds = ( + (componentsKey && values[componentsKey]?.value?.selected_options) || + [] + ).map((o) => Number(o.value)); + const impact: Impact = + pick(IMPACTS, values.impact?.value?.selected_option?.value) ?? + "partial_outage"; + return { + toolName: "create_status_report", + input: { + title, + status, + message, + pageId, + pageComponentIds: componentIds, + ...(componentIds.length + ? { + componentImpacts: componentIds.map((pageComponentId) => ({ + pageComponentId, + impact, + })), + } + : {}), + incidentId, + }, + }; +} + +const writtenUpdate = z.union([ + z.object({ initialUpdateId: z.number().int() }), + z.object({ statusReportUpdateId: z.number().int() }), +]); + +async function notifySubscribers( + ctx: ServiceContext, + output: unknown, +): Promise { + const parsed = writtenUpdate.safeParse(output); + if (!parsed.success) return false; + const statusReportUpdateId = + "initialUpdateId" in parsed.data + ? parsed.data.initialUpdateId + : parsed.data.statusReportUpdateId; + try { + await notifyStatusReport({ ctx, input: { statusReportUpdateId } }); + return true; + } catch (error) { + logger.warn("slack status page notify failed", { + error, + statusReportUpdateId, + }); + return false; + } +} + +export async function submitStatusPage( + payload: ViewSubmissionPayload, + config: SlackConfig, +): Promise { + const submitted = await readSubmission(payload, config, STATUS_PAGE_TITLE); + if (!("ctx" in submitted)) return submitted; + const { ctx, meta, values } = submitted; + + const write = readStatusPageWrite(meta, values, meta.incidentId); + if ("error" in write) { + return { response_action: "errors", errors: write.error }; + } + const notify = (values.notify?.value?.selected_options ?? []).some( + (o) => o.value === "notify", + ); + const tool = getRegistryTool(write.toolName); + if (!tool) throw new Error(`slack: "${write.toolName}" tool missing`); + + let result: { input: unknown; output: unknown }; + try { + result = await executeRegistryAction({ + tool, + ctx, + draftInput: write.input, + // Subscriber fan-out can outlast Slack's 3s window: it runs after the ack. + flags: { notify: false }, + }); + } catch (error) { + if (!(error instanceof ServiceError)) { + logger.error("slack status page update failed", { + error, + incidentId: meta.incidentId, + toolName: write.toolName, + }); + } + return { response_action: "errors", errors: { message: errorText(error) } }; + } + + runInBackground( + "incident-card-status-page", + async () => { + const notified = notify && (await notifySubscribers(ctx, result.output)); + const rendered = await renderToolResult({ + tool, + ctx, + input: result.input, + output: result.output, + notify: notified, + }); + await announceIncidentChange({ + ctx, + incidentId: meta.incidentId, + text: `${who(ctx)} updated the status page.\n${rendered}`, + clientFor: (token) => new WebClient(token), + dashboardUrl: config.dashboardUrl, + }); + }, + { incidentId: meta.incidentId, toolName: write.toolName }, + ); + return null; +} diff --git a/apps/server/src/routes/slack/incident-modal.ts b/apps/server/src/routes/slack/incident-modal.ts index 1a3dd5853..530e96744 100644 --- a/apps/server/src/routes/slack/incident-modal.ts +++ b/apps/server/src/routes/slack/incident-modal.ts @@ -146,36 +146,41 @@ export function buildDeclareIncidentModal(args: { }; } -function noticeModal(text: string, blocks?: Block[]): View { +export function noticeModal( + text: string, + blocks?: Block[], + title = "Declare an incident", +): View { return { type: "modal", - title: { type: "plain_text", text: "Declare an incident" }, + title: { type: "plain_text", text: title }, close: { type: "plain_text", text: "Close" }, blocks: blocks ?? [{ type: "section", text: { type: "mrkdwn", text } }], }; } -type Gate = +export type Gate = | { ok: true; resolved: SlackWorkspace; actor: SlackActor } | { ok: false; view: View; botToken?: string }; -/** Everything the modal needs before it can declare; a notice view otherwise. */ -async function gate( +/** Everything a modal needs before it can write; a notice view otherwise. */ +export async function gate( teamId: string, slackUserId: string, config: SlackConfig, + title?: string, ): Promise { const resolved = await resolveWorkspace(teamId); if (!resolved) { return { ok: false, - view: noticeModal(NOT_CONNECTED), + view: noticeModal(NOT_CONNECTED, undefined, title), }; } if (!slackAgentAllowed(resolved.workspace)) { return { ok: false, - view: noticeModal(planRequiredMessage(config).text), + view: noticeModal(planRequiredMessage(config).text, undefined, title), botToken: resolved.botToken, }; } @@ -193,7 +198,7 @@ async function gate( }); return { ok: false, - view: noticeModal(LINK_ACCOUNT_TEXT, buildLinkAccountBlocks(url)), + view: noticeModal(LINK_ACCOUNT_TEXT, buildLinkAccountBlocks(url), title), botToken: resolved.botToken, }; } @@ -232,7 +237,7 @@ export async function openDeclareIncidentModal(args: { }); } -type StateValues = Record< +export type StateValues = Record< string, Record< string, @@ -240,6 +245,7 @@ type StateValues = Record< value?: string | null; selected_option?: { value: string } | null; selected_user?: string | null; + selected_options?: { value: string }[] | null; } > >; diff --git a/apps/server/src/routes/slack/incident-slack.ts b/apps/server/src/routes/slack/incident-slack.ts index e220979f3..2b190936a 100644 --- a/apps/server/src/routes/slack/incident-slack.ts +++ b/apps/server/src/routes/slack/incident-slack.ts @@ -30,11 +30,11 @@ 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 { +export function who(ctx: ServiceContext): string { return ctx.actor.type === "slack" ? `<@${ctx.actor.slackUserId}>` : "Someone"; } -function quote(note: string | undefined): string { +export function quote(note: string | undefined): string { return note ? `\n>${escapeMrkdwn(note).replaceAll("\n", "\n>")}` : ""; } @@ -61,7 +61,7 @@ export async function afterIncidentTool(args: { slack: WebClient; teamId: string; channelId: string; - threadTs: string; + threadTs?: string; }): Promise { const { ctx, toolName, config } = args; if (toolName === "approve_postmortem") { @@ -167,7 +167,7 @@ async function offerStatusReportResolve(args: { slack: WebClient; teamId: string; channelId: string; - threadTs: string; + threadTs?: string; statusReportId: number; note: string | undefined; status: string; diff --git a/apps/server/src/routes/slack/interactions.test.ts b/apps/server/src/routes/slack/interactions.test.ts index b3aaa9545..47da1e463 100644 --- a/apps/server/src/routes/slack/interactions.test.ts +++ b/apps/server/src/routes/slack/interactions.test.ts @@ -7,6 +7,8 @@ import { incidentEvent, selectWorkspaceSchema, slackUser, + statusReport, + statusReportUpdate, user, usersToWorkspaces, workspace as workspaceTable, @@ -1179,3 +1181,257 @@ describe("add to incident timeline shortcut", () => { expect(await notes()).toHaveLength(0); }); }); + +describe("pinned incident card buttons", () => { + const app = createTestApp(); + let workspace: ReturnType; + let incidentId: number; + + beforeEach(async () => { + configureSlackDoubles(); + redisStore.clear(); + 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: `Card ${crypto.randomUUID()}`, severity: "major" }, + }); + incidentId = created.id; + }); + + afterEach(async () => { + const row = await db + .select({ statusReportId: incident.statusReportId }) + .from(incident) + .where(eq(incident.id, incidentId)) + .get(); + await deleteIncidentEvents(incidentId); + await db + .delete(auditLog) + .where( + and( + eq(auditLog.entityType, "incident"), + eq(auditLog.entityId, String(incidentId)), + ), + ); + await db.delete(incident).where(eq(incident.id, incidentId)); + if (row?.statusReportId) { + await db + .delete(statusReportUpdate) + .where(eq(statusReportUpdate.statusReportId, row.statusReportId)); + await db + .delete(statusReport) + .where(eq(statusReport.id, row.statusReportId)); + } + }); + + function click(actionId: string) { + return signAndPost(app, { + type: "block_actions", + trigger_id: "trig-card", + team: { id: "T_KNOWN" }, + user: { id: "U_OWNER" }, + channel: { id: "C_INC" }, + message: { ts: "77.7" }, + actions: [{ action_id: actionId, value: String(incidentId) }], + }); + } + + function submit( + callbackId: string, + values: Record, + extraMeta: Record = {}, + ) { + return signAndPost(app, { + type: "view_submission", + team: { id: "T_KNOWN" }, + user: { id: "U_OWNER" }, + view: { + callback_id: callbackId, + private_metadata: JSON.stringify({ + incidentId, + channelId: "C_INC", + messageTs: "77.7", + ...extraMeta, + }), + state: { values }, + }, + }); + } + + function openedView() { + const open = slackTestState.calls.find((c) => c.method === "views.open"); + return open?.args.view as { + callback_id?: string; + private_metadata?: string; + blocks: unknown[]; + }; + } + + test("Update status opens a form offering only allowed transitions", async () => { + await click("incident_card_update_status"); + const view = openedView(); + expect(view.callback_id).toBe("incident_card_update_status"); + expect(JSON.parse(view.private_metadata ?? "{}")).toEqual({ + incidentId, + channelId: "C_INC", + messageTs: "77.7", + }); + const blocks = JSON.stringify(view.blocks); + for (const status of ["open", "mitigated", "resolved", "canceled"]) { + expect(blocks).toContain(`"value":"${status}"`); + } + }); + + test("submitting a new status moves the incident and redraws the card", async () => { + const res = await submit("incident_card_update_status", { + status: { value: { selected_option: { value: "mitigated" } } }, + severity: { value: { selected_option: { value: "critical" } } }, + note: { value: { value: "Rolled back" } }, + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe(""); + const row = await db + .select() + .from(incident) + .where(eq(incident.id, incidentId)) + .get(); + expect(row?.status).toBe("mitigated"); + expect(row?.severity).toBe("critical"); + const card = slackTestState.calls.find( + (c) => c.method === "update" && c.args.ts === "77.7", + ); + expect(card?.args.channel).toBe("C_INC"); + expect(JSON.stringify(card?.args.blocks)).toContain("*Status:* mitigated"); + }); + + test("submitting without any change is rejected on the form", async () => { + const res = await submit("incident_card_update_status", { + status: { value: { selected_option: { value: "open" } } }, + severity: { value: { selected_option: { value: "major" } } }, + }); + expect(await res.json()).toEqual({ + response_action: "errors", + errors: { note: "Change the status or severity, or write an update." }, + }); + }); + + test("Update status page offers to create a report when none is linked", async () => { + await click("incident_card_status_page"); + const view = openedView(); + expect(view.callback_id).toBe("incident_card_status_page"); + expect(JSON.stringify(view.blocks)).toContain('"block_id":"page"'); + }); + + test("publishing creates a status report linked to the incident", async () => { + const res = await submit("incident_card_status_page", { + page: { incident_card_page: { selected_option: { value: "1" } } }, + title: { value: { value: "Elevated errors" } }, + status: { value: { selected_option: { value: "investigating" } } }, + message: { value: { value: "We are looking into it." } }, + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe(""); + const row = await db + .select() + .from(incident) + .where(eq(incident.id, incidentId)) + .get(); + expect(row?.statusReportId).not.toBeNull(); + const report = await db + .select() + .from(statusReport) + .where(eq(statusReport.id, row?.statusReportId ?? -1)) + .get(); + expect(report?.title).toBe("Elevated errors"); + expect(report?.pageId).toBe(1); + }); + + test("with a linked report, the button adds an update to it", async () => { + await submit("incident_card_status_page", { + page: { incident_card_page: { selected_option: { value: "1" } } }, + title: { value: { value: "Elevated errors" } }, + status: { value: { selected_option: { value: "investigating" } } }, + message: { value: { value: "We are looking into it." } }, + }); + slackTestState.calls = []; + await click("incident_card_status_page"); + const view = openedView(); + const meta = JSON.parse(view.private_metadata ?? "{}"); + expect(meta.statusReportId).toBeGreaterThan(0); + expect(JSON.stringify(view.blocks)).not.toContain('"block_id":"page"'); + + const res = await submit( + "incident_card_status_page", + { + status: { value: { selected_option: { value: "identified" } } }, + message: { value: { value: "Found the bad deploy." } }, + }, + { statusReportId: meta.statusReportId }, + ); + expect(await res.text()).toBe(""); + const report = await db + .select() + .from(statusReport) + .where(eq(statusReport.id, meta.statusReportId)) + .get(); + expect(report?.status).toBe("identified"); + const updates = await db + .select() + .from(statusReportUpdate) + .where(eq(statusReportUpdate.statusReportId, meta.statusReportId)) + .all(); + expect(updates.map((u) => u.message)).toContain("Found the bad deploy."); + }); + + test("picking another status page redraws the form for that page", async () => { + const res = await signAndPost(app, { + type: "block_actions", + team: { id: "T_KNOWN" }, + user: { id: "U_OWNER" }, + view: { + id: "V_FORM", + hash: "h1", + private_metadata: JSON.stringify({ incidentId }), + }, + actions: [ + { action_id: "incident_card_page", selected_option: { value: "1" } }, + ], + }); + expect(res.status).toBe(200); + const update = slackTestState.calls.find( + (c) => c.method === "views.update", + ); + expect(update?.args.view_id).toBe("V_FORM"); + expect(update?.args.hash).toBe("h1"); + const view = update?.args.view as { + callback_id?: string; + blocks: unknown[]; + }; + expect(view.callback_id).toBe("incident_card_status_page"); + expect(JSON.stringify(view.blocks)).toContain( + '"initial_option":{"text":{"type":"plain_text","text":', + ); + }); + + test("a refused status change leaves the severity untouched", async () => { + await db + .update(incident) + .set({ status: "resolved", resolvedAt: new Date() }) + .where(eq(incident.id, incidentId)); + const res = await submit("incident_card_update_status", { + status: { value: { selected_option: { value: "mitigated" } } }, + severity: { value: { selected_option: { value: "critical" } } }, + }); + const body = await res.json(); + expect(body.response_action).toBe("errors"); + expect(body.errors.status).toBeDefined(); + const row = await db + .select() + .from(incident) + .where(eq(incident.id, incidentId)) + .get(); + expect(row?.severity).toBe("major"); + }); +}); diff --git a/apps/server/src/routes/slack/interactions.ts b/apps/server/src/routes/slack/interactions.ts index fef7dab75..ac1d021c1 100644 --- a/apps/server/src/routes/slack/interactions.ts +++ b/apps/server/src/routes/slack/interactions.ts @@ -1,5 +1,6 @@ import { getLogger } from "@logtape/logtape"; import { ServiceError } from "@openstatus/services"; +import { INCIDENT_CARD_ACTIONS } from "@openstatus/services/incident"; import { WebClient } from "@slack/web-api"; import type { Context } from "hono"; @@ -14,6 +15,16 @@ import type { SlackConfig, SlackEnv } from "./config"; import { consume, get } from "./confirmation-store"; import type { PendingAction } from "./confirmation-store"; import { OPEN_DECLARE_INCIDENT_ACTION } from "./home"; +import { + changeStatusPagePage, + type IncidentCardAction, + openIncidentCardModal, + STATUS_PAGE_CALLBACK, + STATUS_PAGE_PAGE_ACTION, + submitStatusPage, + submitUpdateStatus, + UPDATE_STATUS_CALLBACK, +} from "./incident-card"; import { ADD_TO_TIMELINE_CALLBACK, handleAddToTimeline, @@ -159,6 +170,90 @@ async function handleShortcut( return c.body(null, 200); } +const CARD_BUTTONS: Record = { + [INCIDENT_CARD_ACTIONS.updateStatus]: "update_status", + [INCIDENT_CARD_ACTIONS.statusPage]: "status_page", +}; + +/** Each form's submit, and the field an unexpected failure is shown on. */ +const MODAL_SUBMITS: Record< + string, + { + submit: ( + payload: ViewSubmissionPayload, + config: SlackConfig, + ) => Promise; + errorField: string; + } +> = { + [DECLARE_INCIDENT_CALLBACK]: { + submit: submitDeclareIncident, + errorField: "title", + }, + [UPDATE_STATUS_CALLBACK]: { + submit: submitUpdateStatus, + errorField: "status", + }, + [STATUS_PAGE_CALLBACK]: { submit: submitStatusPage, errorField: "message" }, +}; + +interface SlackViewActionPayload { + team?: { id: string }; + user: { id: string; team_id?: string }; + trigger_id?: string; + channel?: { id: string }; + message?: { ts: string }; + view?: { id: string; hash?: string; private_metadata?: string }; + actions: Array<{ + action_id: string; + value?: string; + selected_option?: { value: string }; + }>; +} + +/** Card buttons, awaited since `trigger_id` lives 3s, and in-modal selects. */ +async function handleIncidentCardAction( + payload: SlackViewActionPayload, + config: SlackConfig, +): Promise { + const teamId = payload.team?.id ?? payload.user.team_id; + const action = payload.actions[0]; + if (!teamId) return; + if (action.action_id === STATUS_PAGE_PAGE_ACTION) { + const pageId = Number(action.selected_option?.value); + const view = payload.view; + if (!view || !Number.isInteger(pageId)) return; + // No trigger_id to keep alive: ack now, redraw the form after. + runInBackground( + "incident-card-page", + () => + changeStatusPagePage({ + teamId, + slackUserId: payload.user.id, + viewId: view.id, + hash: view.hash, + privateMetadata: view.private_metadata, + pageId, + config, + }), + { teamId }, + ); + return; + } + const incidentId = Number(action.value); + if (!payload.trigger_id || !Number.isInteger(incidentId)) return; + await openIncidentCardModal({ + action: CARD_BUTTONS[action.action_id], + teamId, + slackUserId: payload.user.id, + triggerId: payload.trigger_id, + incidentId, + channelId: payload.channel?.id, + messageTs: payload.message?.ts, + config, + }); +} + export async function handleSlackInteraction(c: Context) { const body = c.get("slackBody") as { type?: string }; const config = c.get("slackConfig"); @@ -168,18 +263,21 @@ export async function handleSlackInteraction(c: Context) { } if (body.type === "view_submission") { const submission = body as ViewSubmissionPayload; - if (submission.view?.callback_id !== DECLARE_INCIDENT_CALLBACK) { - return c.body(null, 200); - } - const response = await submitDeclareIncident(submission, config).catch( - (error): ViewSubmissionResponse => { - logger.error("slack declare modal submit failed", { error }); + const callbackId = submission.view?.callback_id; + const modal = callbackId ? MODAL_SUBMITS[callbackId] : undefined; + if (!modal) return c.body(null, 200); + // A field error keeps the form open with what the user typed. + const response = await modal + .submit(submission, config) + .catch((error): ViewSubmissionResponse => { + logger.error("slack modal submit failed", { error, callbackId }); return { response_action: "errors", - errors: { title: "Something went wrong. Please try again." }, + errors: { + [modal.errorField]: "Something went wrong. Please try again.", + }, }; - }, - ); + }); return response ? c.json(response) : c.body(null, 200); } @@ -190,6 +288,19 @@ export async function handleSlackInteraction(c: Context) { callback_id: DECLARE_INCIDENT_CALLBACK, }); } + const actionId = payload.actions?.[0]?.action_id; + if ( + actionId && + (actionId in CARD_BUTTONS || actionId === STATUS_PAGE_PAGE_ACTION) + ) { + await handleIncidentCardAction( + body as SlackViewActionPayload, + config, + ).catch((error) => + logger.error("slack incident card action failed", { error, actionId }), + ); + return c.json({ ok: true }); + } if (payload.type !== "block_actions" || !payload.actions?.length) { return c.json({ ok: true }); } diff --git a/apps/web/src/content/mdx-components/demo/incident-slack.tsx b/apps/web/src/content/mdx-components/demo/incident-slack.tsx index e08b11603..3612fabbd 100644 --- a/apps/web/src/content/mdx-components/demo/incident-slack.tsx +++ b/apps/web/src/content/mdx-components/demo/incident-slack.tsx @@ -169,9 +169,20 @@ export function IncidentChannelDemo() { Open +
+ + + +
- Open in openstatus · Add a message to - the timeline: ⋯ → Add to incident timeline, or react 📌. + Add a message to the timeline: ⋯ → Add to incident timeline, + or react 📌.
diff --git a/apps/web/src/content/pages/docs/reference/incident-management.mdx b/apps/web/src/content/pages/docs/reference/incident-management.mdx index e7c732281..0047e54a7 100644 --- a/apps/web/src/content/pages/docs/reference/incident-management.mdx +++ b/apps/web/src/content/pages/docs/reference/incident-management.mdx @@ -80,6 +80,7 @@ Requires the [Slack agent](/docs/guides/how-to-setup-slack-agent), which is avai Declaring an incident, from Slack or the dashboard, opens a channel named `inc-YYYY-MM-DD-` (UTC date). The declarer is invited, the topic shows severity, status and a link to the incident, and the incident card is pinned. +- Use the pinned card's buttons. **Update status** changes the status or severity, or posts an update to the channel and timeline. **Update status page** publishes a status report linked to the incident, or adds an update to the one already linked. You can notify subscribers from the same form. The card redraws with the new status after the change. - Add a message to the timeline from its ⋯ menu (**Add to incident timeline**) or by reacting with 📌 (`:pushpin:`). It becomes a timeline note, attributed to its author and linked back to Slack. Thread replies work too. The bot confirms with ✅. - Status changes are announced in the channel and the topic is kept in sync. - The channel is archived when the incident is closed or canceled. diff --git a/packages/services/src/incident/__tests__/slack-flow.test.ts b/packages/services/src/incident/__tests__/slack-flow.test.ts index 2ab5f30b5..d5647d7ff 100644 --- a/packages/services/src/incident/__tests__/slack-flow.test.ts +++ b/packages/services/src/incident/__tests__/slack-flow.test.ts @@ -20,6 +20,7 @@ import { declareIncident, getIncident, headerBlocks, + INCIDENT_CARD_ACTIONS, incidentChannelName, listIncidentEvents, openIncidentSlackChannel, @@ -350,6 +351,8 @@ describe("headerBlocks", () => { test("escapes mrkdwn and keeps within Block Kit limits", () => { const blocks = headerBlocks( { + id: 1, + closedAt: null, title: "t".repeat(200), severity: "major", status: "open", @@ -368,4 +371,24 @@ describe("headerBlocks", () => { expect(section.text.text).toContain("<!channel>"); expect(section.text.text).not.toMatch(/&[a-z]*…$/); }); + + test("offers status and status page buttons until the incident closes", () => { + const base = { + id: 7, + title: "API down", + severity: "major" as const, + status: "open" as const, + summary: null, + }; + const actionIds = (closedAt: Date | null) => + headerBlocks({ ...base, closedAt }, "https://app.test/incidents/7") + .flatMap((b) => (b.type === "actions" ? b.elements : [])) + .map((e) => e.action_id); + expect(actionIds(null)).toEqual([ + INCIDENT_CARD_ACTIONS.updateStatus, + INCIDENT_CARD_ACTIONS.statusPage, + INCIDENT_CARD_ACTIONS.open, + ]); + expect(actionIds(new Date())).toEqual([INCIDENT_CARD_ACTIONS.open]); + }); }); diff --git a/packages/services/src/incident/index.ts b/packages/services/src/incident/index.ts index 4bb247c87..cf9e954e6 100644 --- a/packages/services/src/incident/index.ts +++ b/packages/services/src/incident/index.ts @@ -63,6 +63,7 @@ export { announceInChannel, escapeMrkdwn, headerBlocks, + INCIDENT_CARD_ACTIONS, incidentChannelName, incidentSlackReady, type OpenChannelResult, diff --git a/packages/services/src/incident/slack-flow.ts b/packages/services/src/incident/slack-flow.ts index 2f90c6cd1..0b17efc43 100644 --- a/packages/services/src/incident/slack-flow.ts +++ b/packages/services/src/incident/slack-flow.ts @@ -305,14 +305,31 @@ export async function openIncidentSlackChannel(args: { return { status: "unbound", channelId, error: lastError }; } +/** Action ids of the pinned card's buttons; the value is the incident id. */ +export const INCIDENT_CARD_ACTIONS = { + updateStatus: "incident_card_update_status", + statusPage: "incident_card_status_page", + open: "incident_card_open", +} as const; + export function headerBlocks( - incident: Pick, + incident: Pick< + Incident, + "id" | "title" | "severity" | "status" | "summary" | "closedAt" + >, url: string, ): SlackIncidentBlock[] { const status = `*Severity:* ${incident.severity} *Status:* ${incident.status}`; const summary = incident.summary ? `\n${escapeTruncated(incident.summary, SECTION_TEXT_MAX - status.length - 1)}` : ""; + const value = String(incident.id); + const open = { + type: "button" as const, + text: { type: "plain_text" as const, text: "Open in openstatus" }, + url, + action_id: INCIDENT_CARD_ACTIONS.open, + }; return [ { type: "header", @@ -322,12 +339,32 @@ export function headerBlocks( }, }, { type: "section", text: { type: "mrkdwn", text: `${status}${summary}` } }, + { + type: "actions", + elements: incident.closedAt + ? [open] + : [ + { + type: "button", + text: { type: "plain_text", text: "Update status" }, + action_id: INCIDENT_CARD_ACTIONS.updateStatus, + value, + }, + { + type: "button", + text: { type: "plain_text", text: "Update status page" }, + action_id: INCIDENT_CARD_ACTIONS.statusPage, + value, + }, + open, + ], + }, { type: "context", elements: [ { type: "mrkdwn", - text: `<${url}|Open in openstatus> · Add a message to the timeline: ⋯ → *Add to incident timeline*, or react :pushpin:.`, + text: "Add a message to the timeline: ⋯ → *Add to incident timeline*, or react :pushpin:.", }, ], },