diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx index 31791cca..8bbdcf0c 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx @@ -29,23 +29,19 @@ import { IncidentStatusReport } from "@/components/incidents/incident-status-rep import { IncidentTimeline } from "@/components/incidents/incident-timeline"; import { ResolveReportDialog } from "@/components/incidents/resolve-report-dialog"; import { severityConfig } from "@/data/managed-incidents.client"; -import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; import { cn } from "@/lib/utils"; export function Client({ id }: { id: number }) { const trpc = useTRPC(); - const enabled = useFeature("incident-management"); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: incident, isError } = useQuery({ ...trpc.incident.get.queryOptions({ id }), - enabled, retry: false, }); - const { data: events } = useQuery({ - ...trpc.incident.listEvents.queryOptions({ id }), - enabled, - }); + const { data: events } = useQuery( + trpc.incident.listEvents.queryOptions({ id }), + ); const [followUp, setFollowUp] = useState<{ note: string } | null>(null); if (isError) { diff --git a/apps/dashboard/src/app/(dashboard)/incidents/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/client.tsx index b67b5609..c756ad36 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/client.tsx @@ -19,29 +19,11 @@ import { import { columns } from "@/components/data-table/managed-incidents/columns"; import { DeclareIncidentButton } from "@/components/incidents/declare-incident-button"; import { DataTable } from "@/components/ui/data-table/data-table"; -import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; export function Client() { const trpc = useTRPC(); - const enabled = useFeature("incident-management"); - const { data: incidents } = useQuery({ - ...trpc.incident.list.queryOptions(), - enabled, - }); - - if (!enabled) { - return ( - - - Incidents are not available yet - - Incident management is rolling out gradually. - - - - ); - } + const { data: incidents } = useQuery(trpc.incident.list.queryOptions()); return ( diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx index adae79f4..8898de37 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx @@ -22,7 +22,6 @@ import { import { getColumns } from "@/components/data-table/incidents/columns"; import { DataTable } from "@/components/ui/data-table/data-table"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; -import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; export default function Page() { @@ -36,11 +35,7 @@ export default function Page() { const { data: monitor } = useQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); - const incidentsEnabled = useFeature("incident-management"); - const columns = useMemo( - () => getColumns({ declare: incidentsEnabled }), - [incidentsEnabled], - ); + const columns = useMemo(() => getColumns({ declare: true }), []); if (!incidents || !monitor) return null; diff --git a/apps/dashboard/src/app/(dashboard)/overview/page.tsx b/apps/dashboard/src/app/(dashboard)/overview/page.tsx index 19bac0ee..bc189fd8 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/page.tsx @@ -31,7 +31,6 @@ import { } from "@/components/metric/metric-card"; import { DataTable } from "@/components/ui/data-table/data-table"; import { buildOverviewData } from "@/data/overview-events.client"; -import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; import { CreateEventButtonGroup } from "./create-event-button-group"; @@ -49,15 +48,10 @@ export default function Page() { trpc.statusReport.list.queryOptions({}), ); const { data: maintenances } = useQuery(trpc.maintenance.list.queryOptions()); - const incidentsEnabled = useFeature("incident-management"); - const { data: openIncidents } = useQuery({ - ...trpc.incident.list.queryOptions({ status: ["open", "mitigated"] }), - enabled: incidentsEnabled, - }); - const columns = useMemo( - () => getColumns({ declare: incidentsEnabled }), - [incidentsEnabled], + const { data: openIncidents } = useQuery( + trpc.incident.list.queryOptions({ status: ["open", "mitigated"] }), ); + const columns = useMemo(() => getColumns({ declare: true }), []); if (!monitors || !pages || !incidents || !statusReports || !maintenances) return null; diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx index d7065ab3..0f4d3e44 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx @@ -17,7 +17,6 @@ import { FormCardTitle, FormCardUpgrade, } from "@/components/forms/form-card"; -import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; const SERVER_URL = @@ -43,9 +42,7 @@ export function SlackIntegrationCard({ const trpc = useTRPC(); const queryClient = useQueryClient(); const isConnected = !!integration; - const incidents = useFeature("incident-management"); - const needsReconnect = - incidents && (integration?.missingScopes.length ?? 0) > 0; + const needsReconnect = (integration?.missingScopes.length ?? 0) > 0; const deleteIntegration = useMutation( trpc.integrationRouter.deleteIntegration.mutationOptions({ diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx index 697a294c..5602c5da 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx @@ -33,7 +33,6 @@ import { impactsEqual, toCreateStatusReportUpdateInput, } from "@/data/status-report-updates.client"; -import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; export default function Page() { @@ -45,13 +44,11 @@ export default function Page() { trpc.statusReport.get.queryOptions({ id: Number.parseInt(reportId) }), ); - const incidentsEnabled = useFeature("incident-management"); - const { data: linkedIncident } = useQuery({ - ...trpc.incident.forStatusReport.queryOptions({ + const { data: linkedIncident } = useQuery( + trpc.incident.forStatusReport.queryOptions({ statusReportId: Number.parseInt(reportId), }), - enabled: incidentsEnabled, - }); + ); const sendStatusReportUpdateMutation = useMutation( trpc.subscriberNotification.statusReport.mutationOptions(), diff --git a/apps/dashboard/src/app/api/chat/route.ts b/apps/dashboard/src/app/api/chat/route.ts index b6ca81a9..bd135dbf 100644 --- a/apps/dashboard/src/app/api/chat/route.ts +++ b/apps/dashboard/src/app/api/chat/route.ts @@ -3,7 +3,6 @@ import { type ChatStoredMessage, storedMessageSchema, } from "@openstatus/db/src/schema"; -import { isFeatureEnabled } from "@openstatus/services"; import { agentTools, buildAgentSystemPrompt, @@ -155,10 +154,6 @@ export async function POST(req: NextRequest) { workspaceName: ctx.workspace.name ?? "Unknown", surface: "dashboard", canNotifySubscribers: ctx.workspace.limits["status-subscribers"] === true, - incidentManagement: isFeatureEnabled( - ctx.workspace, - "incident-management", - ), }), messages: modelMessages, tools, diff --git a/apps/dashboard/src/components/incidents/declare-from-row.tsx b/apps/dashboard/src/components/incidents/declare-from-row.tsx index 6f83f99b..1e1c4021 100644 --- a/apps/dashboard/src/components/incidents/declare-from-row.tsx +++ b/apps/dashboard/src/components/incidents/declare-from-row.tsx @@ -4,7 +4,6 @@ import { Button } from "@openstatus/ui/components/ui/button"; import { useQuery } from "@tanstack/react-query"; import { toLocalInput } from "@/components/forms/incident/form"; -import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; import { @@ -25,13 +24,11 @@ export function DeclareFromRow({ statusReportId?: number; }) { const trpc = useTRPC(); - const enabled = useFeature("incident-management"); // A report can belong to one incident; declaring another would fail on submit. const { data: incidents } = useQuery({ ...trpc.incident.list.queryOptions(), - enabled: enabled && statusReportId !== undefined, + enabled: statusReportId !== undefined, }); - if (!enabled) return null; if (incidents?.some((i) => i.statusReport?.id === statusReportId)) { return null; } diff --git a/apps/dashboard/src/components/nav/app-sidebar.tsx b/apps/dashboard/src/components/nav/app-sidebar.tsx index b330dc7d..fbbdcbb3 100644 --- a/apps/dashboard/src/components/nav/app-sidebar.tsx +++ b/apps/dashboard/src/components/nav/app-sidebar.tsx @@ -25,7 +25,6 @@ import { NavStatusPages } from "@/components/nav/nav-status-pages"; import { NavUser } from "@/components/nav/nav-user"; import { WorkspaceSwitcher } from "@/components/nav/workspace-switcher"; import { NAV } from "@/config/nav"; -import { useFeature } from "@/hooks/use-feature"; import { NavBanner } from "./nav-banner"; import { NavHelp } from "./nav-help"; @@ -41,6 +40,11 @@ const data = { }, overview: [ { name: "Overview", url: NAV.overview.href, icon: NAV.overview.icon }, + { + name: NAV.incidents.label, + url: NAV.incidents.href, + icon: NAV.incidents.icon, + }, { name: "Assistant", url: NAV.chat.href, icon: NAV.chat.icon }, { name: "Agents", url: NAV.agents.href, icon: NAV.agents.icon }, { @@ -59,26 +63,13 @@ const data = { }; export function AppSidebar({ ...props }: React.ComponentProps) { - const incidents = useFeature("incident-management"); - const overview = incidents - ? [ - data.overview[0], - { - name: NAV.incidents.label, - url: NAV.incidents.href, - icon: NAV.incidents.icon, - }, - ...data.overview.slice(1), - ] - : data.overview; - return ( - +
diff --git a/apps/dashboard/src/config/nav.ts b/apps/dashboard/src/config/nav.ts index 55ec063d..0ba8b2e8 100644 --- a/apps/dashboard/src/config/nav.ts +++ b/apps/dashboard/src/config/nav.ts @@ -88,6 +88,7 @@ export const NAV = { // Command-menu "Navigation" group order. export const NAV_MENU_ITEMS: NavItem[] = [ NAV.overview, + NAV.incidents, NAV.monitors, NAV.statusPages, NAV.notifications, diff --git a/apps/server/src/routes/slack/agent.ts b/apps/server/src/routes/slack/agent.ts index 5a3425d3..a255501c 100644 --- a/apps/server/src/routes/slack/agent.ts +++ b/apps/server/src/routes/slack/agent.ts @@ -1,5 +1,5 @@ import type { Workspace } from "@openstatus/db/src/schema/workspaces/validation"; -import { isFeatureEnabled, type ServiceContext } from "@openstatus/services"; +import type { ServiceContext } from "@openstatus/services"; import { stepCountIs, streamText } from "ai"; import type { ModelMessage, Tool } from "ai"; @@ -105,9 +105,7 @@ export async function runAgent( const result = streamText({ model: SLACK_AGENT_MODEL, - system: buildSystemPrompt(workspace.name ?? "Unknown", contextNote, { - incidentManagement: isFeatureEnabled(workspace, "incident-management"), - }), + system: buildSystemPrompt(workspace.name ?? "Unknown", contextNote), messages, tools, stopWhen: stepCountIs(MAX_STEPS), diff --git a/apps/server/src/routes/slack/handler.test.ts b/apps/server/src/routes/slack/handler.test.ts index 425aab02..fc476e2f 100644 --- a/apps/server/src/routes/slack/handler.test.ts +++ b/apps/server/src/routes/slack/handler.test.ts @@ -64,6 +64,17 @@ function signAndPost( }); } +/** Waits until the background turn stops making Slack calls. */ +async function settleCalls(quietMs = 150, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + let seen = -1; + while (Date.now() < deadline) { + if (slackTestState.calls.length === seen) return; + seen = slackTestState.calls.length; + await new Promise((r) => setTimeout(r, quietMs)); + } +} + // Generous: the member gate hits the DB, which is slow under `--parallel`. async function waitForCall(method: string, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; @@ -286,11 +297,11 @@ describe("handleSlackEvent", () => { }; await signAndPost(app, body); - await new Promise((r) => setTimeout(r, 50)); + await settleCalls(); slackTestState.calls = []; await signAndPost(app, body); - await new Promise((r) => setTimeout(r, 50)); + await new Promise((r) => setTimeout(r, 150)); expect(slackTestState.calls.length).toBe(0); }); diff --git a/apps/server/src/routes/slack/handler.ts b/apps/server/src/routes/slack/handler.ts index 30760917..b393ea06 100644 --- a/apps/server/src/routes/slack/handler.ts +++ b/apps/server/src/routes/slack/handler.ts @@ -1,5 +1,4 @@ import { getLogger } from "@logtape/logtape"; -import { isFeatureEnabled } from "@openstatus/services"; import { getIncidentBySlackChannel } from "@openstatus/services/incident"; import { missingSlackScopes, @@ -426,9 +425,7 @@ async function processEvent(body: SlackEvent, config: SlackConfig) { slackUserId: userId, }); if (actor) { - const needsReconnect = - isFeatureEnabled(resolved.workspace, "incident-management") && - missingSlackScopes(resolved.scopes).length > 0; + const needsReconnect = missingSlackScopes(resolved.scopes).length > 0; await publishHomeView(slack, userId, { reconnectUrl: needsReconnect ? `${config.dashboardUrl}/settings/integrations` diff --git a/apps/server/src/routes/slack/home.ts b/apps/server/src/routes/slack/home.ts index 3fc7008e..67f2a009 100644 --- a/apps/server/src/routes/slack/home.ts +++ b/apps/server/src/routes/slack/home.ts @@ -45,7 +45,7 @@ export function buildHomeBlocks( type: "section", text: { type: "mrkdwn", - text: "*Slash commands*\n• `/openstatus subscribe ` — subscribe this channel to a status page\n• `/openstatus unsubscribe ` — unsubscribe this channel\n• `/openstatus subscriptions` — list this channel's subscriptions\n• `/openstatus help` — show these commands", + text: "*Slash commands*\n• `/openstatus subscribe ` — subscribe this channel to a status page\n• `/openstatus unsubscribe ` — unsubscribe this channel\n• `/openstatus subscriptions` — list this channel's subscriptions\n• `/openstatus incident help` — declare and run incidents\n• `/openstatus help` — show these commands", }, }, { diff --git a/apps/server/src/routes/slack/incident-commands.ts b/apps/server/src/routes/slack/incident-commands.ts index 9559d468..38f2e6b8 100644 --- a/apps/server/src/routes/slack/incident-commands.ts +++ b/apps/server/src/routes/slack/incident-commands.ts @@ -1,7 +1,6 @@ import { getLogger } from "@logtape/logtape"; import { type IncidentStatus, - isFeatureEnabled, type ServiceContext, ServiceError, } from "@openstatus/services"; @@ -117,9 +116,6 @@ export async function runIncidentCommand(args: { }): Promise { const { words, teamId, channelId, resolved, actor } = args; const ctx: ServiceContext = { workspace: resolved.workspace, actor }; - if (!isFeatureEnabled(resolved.workspace, "incident-management")) { - return "Incident management isn't available for this workspace yet."; - } const slack = new WebClient(resolved.botToken); const [verb = "help", ...rest] = words; const bound = await getIncidentBySlackChannel({ diff --git a/apps/server/src/routes/slack/registry-runner.test.ts b/apps/server/src/routes/slack/registry-runner.test.ts index bd1c0a57..d0854fad 100644 --- a/apps/server/src/routes/slack/registry-runner.test.ts +++ b/apps/server/src/routes/slack/registry-runner.test.ts @@ -379,16 +379,10 @@ describe("buildSystemPrompt coverage", () => { // buildSlackTools hands the model every registry tool; a tool the prompt // never mentions is one the model won't reach for (or will misuse). test("mentions every tool the Slack agent is given", () => { - const prompt = buildSystemPrompt("Acme Corp", undefined, { - incidentManagement: true, - }); + const prompt = buildSystemPrompt("Acme Corp"); const missing = Object.keys(agentTools).filter( (name) => !new RegExp(`\\b${name}\\b`).test(prompt), ); expect(missing).toEqual([]); }); - - test("leaves incident tools out when the feature is off", () => { - expect(buildSystemPrompt("Acme Corp")).not.toContain("declare_incident"); - }); }); diff --git a/apps/server/src/routes/slack/system-prompt.ts b/apps/server/src/routes/slack/system-prompt.ts index b8b51c97..1ec9452c 100644 --- a/apps/server/src/routes/slack/system-prompt.ts +++ b/apps/server/src/routes/slack/system-prompt.ts @@ -16,7 +16,6 @@ Managed incidents (internal, never published): export function buildSystemPrompt( workspaceName: string, contextNote?: string, - options: { incidentManagement?: boolean } = {}, ): string { // Intentional: a per-call ISO timestamp defeats Anthropic/Gateway // prompt caching, but the agent needs minute-level precision to parse @@ -122,5 +121,5 @@ Maintenance scheduling: - Parse natural language dates into ISO 8601 format. Convert relative dates like "next Friday from 2-3 PM" into proper ISO 8601 timestamps. - If the user doesn't specify a timezone, default to UTC and mention that in your response. - The "from" time must be before the "to" time. -- Write a professional maintenance message describing what will happen during the window.${options.incidentManagement ? INCIDENT_SECTION : ""}${contextNote ? `\n\n${contextNote.trim()}` : ""}`; +- Write a professional maintenance message describing what will happen during the window.${INCIDENT_SECTION}${contextNote ? `\n\n${contextNote.trim()}` : ""}`; } diff --git a/apps/web/src/content/docs.config.ts b/apps/web/src/content/docs.config.ts index efd216b7..f5c029ab 100644 --- a/apps/web/src/content/docs.config.ts +++ b/apps/web/src/content/docs.config.ts @@ -51,6 +51,10 @@ export const docsNav: DocsNavSection[] = [ slug: "concept/status-reports-and-incidents", label: "Understanding Status Reports and Incidents", }, + { + slug: "concept/incident-management", + label: "Understanding Incident Management", + }, { slug: "concept/best-practices-status-page", label: "Building Trust with Status Pages", diff --git a/apps/web/src/content/pages/changelog/incident-management.mdx b/apps/web/src/content/pages/changelog/incident-management.mdx new file mode 100644 index 00000000..ef036007 --- /dev/null +++ b/apps/web/src/content/pages/changelog/incident-management.mdx @@ -0,0 +1,17 @@ +--- +title: "Incident management" +description: "Declare incidents, run them with a commander and a timeline, work them from Slack, and close them with a postmortem." +publishedAt: "2026-09-28" +category: "incidents" +author: "openstatus" +--- + +openstatus now runs the incident loop your team already runs by hand. Declare an incident from the new **Incidents** page — or straight from a downtime row, a status report, or Slack — with a severity and a commander. Everything that happens lands on its timeline: notes, status changes, the status report you linked, and who did what. + +Your users still see only what you publish: link a status report to the incident and post public updates from the same page. When you resolve the incident, openstatus offers to resolve the status report too, with your note as the final message. + +With the [Slack agent](/docs/guides/how-to-setup-slack-agent) connected, declaring an incident opens a channel for it, invites your team and pins a summary. React with 📌 to copy a message onto the timeline, run `/openstatus incident resolve` when it's over, and `/openstatus incident postmortem` to let the agent draft the postmortem from the channel. Existing installs need to **reconnect** once to grant the new permissions. + +Once it's resolved, write the postmortem in the incident's **Postmortem** tab (or have the agent draft it), approve it, and close the incident. + +Incident management is available on every plan; the Slack channel automation and agent drafting come with the Slack agent. Read [Understanding Incident Management](/docs/concept/incident-management) to get started. diff --git a/apps/web/src/content/pages/docs/concept/incident-management.mdx b/apps/web/src/content/pages/docs/concept/incident-management.mdx new file mode 100644 index 00000000..c3b462ff --- /dev/null +++ b/apps/web/src/content/pages/docs/concept/incident-management.mdx @@ -0,0 +1,49 @@ +--- +title: Understanding Incident Management +description: "How openstatus separates a declared incident — your team's internal record with a severity, a commander and a timeline — from monitor downtime and from the status reports your users read." +category: Concepts +--- + +Three different things get called an "incident". openstatus keeps them apart, because each answers a different question: + +| | What it is | Who creates it | Who sees it | +| --- | --- | --- | --- | +| **Incident** | Your team's record of an outage: severity, commander, timeline, postmortem | A person declares it | Your workspace | +| **Downtime** | A monitor failing its checks in enough regions | openstatus, automatically | Your workspace, and your status page if the monitor is on it | +| **Status report** | What you tell your users | A person writes it | Everyone who reads your status page | + +Downtime tells you something is wrong. An incident is how your team responds to it. A status report is how you keep your users informed while you do. None of them requires the others: you can declare an incident nobody's monitor caught, or publish a status report without declaring anything. + +## The incident lifecycle + +``` +declare ──▶ open ──▶ mitigated ──▶ resolved ──▶ closed + │ ▲ + └──── (skip mitigated) ──┘ +``` + +- **Declare** from the **Incidents** page, from a downtime row, from a status report, or from Slack. You pick a title and a severity (`critical`, `major`, `minor`) and a commander — the one person leading the response. Set **Started at** in the past to record an outage after the fact. +- **Mitigated** means the impact stopped but the cause isn't fixed. You can go back to **open** if it wasn't. +- **Resolved** means it's fixed. A resolved incident can still be reopened. +- **Canceled** is for a false alarm or an incident declared by mistake. It closes the incident for good. +- **Closed** is the end: the postmortem is approved (or you chose to close without one). A closed incident only accepts postmortem edits. + +An incident that was never mitigated or resolved can be deleted by an admin or owner. Anything further along is history and stays. + +## The timeline + +Every declared incident keeps a timeline: notes, status changes, severity and commander changes, the status report it's linked to, and the Slack channel it's bound to. Post a note or change the status from the composer at the top of the incident page. A status change can carry a note, which lands on the same timeline entry. + +The timeline is the story of the incident for your team and for the postmortem. It is separate from the audit log (**Settings** > **Audit logs**), which records who changed what across your workspace, for every change — including each timeline entry. + +## Linking a status report + +An incident can be linked to one status report, and a status report to one incident. From the incident page you can create a report or link an existing one, then post public updates without leaving the incident. When you resolve or cancel an incident whose status report is still open, openstatus offers to resolve the report too, with your note as the public message. Nothing is posted until you confirm the text. + +## Postmortem + +Once an incident is resolved, the **Postmortem** tab opens a markdown document with the usual sections: summary, impact, timeline, root cause, what went well, what went wrong, and action items. Write it yourself, or let the agent draft it from the timeline, the linked status report and, if the incident had one, the Slack channel. Approving it (an admin, an owner, or the commander) lets you close the incident. Editing an approved postmortem keeps it approved and is recorded on the timeline. + +## Slack + +With the [Slack agent](/docs/guides/how-to-setup-slack-agent) connected, declaring an incident also opens a channel for it, invites the team, and pins a summary card. Pin a message with 📌 to copy it onto the timeline, and run `/openstatus incident resolve` from the channel when it's over. Slack is optional: everything above works from the dashboard alone. diff --git a/apps/web/src/content/pages/docs/concept/status-reports-and-incidents.mdx b/apps/web/src/content/pages/docs/concept/status-reports-and-incidents.mdx index a3668ecf..e7df4559 100644 --- a/apps/web/src/content/pages/docs/concept/status-reports-and-incidents.mdx +++ b/apps/web/src/content/pages/docs/concept/status-reports-and-incidents.mdx @@ -6,7 +6,7 @@ category: Concepts When something goes wrong, two different things need to happen: your team needs to find out, and your users need to be told. openstatus models these as two separate objects — **incidents** and **status reports** — and adds a third, **maintenance windows**, for disruption you planned ahead of time. -The word "incident" is overloaded. Colloquially it means the whole event — the outage, the scramble, the postmortem. In openstatus it means something narrower: the machine-created record that a monitor is failing. Keeping the two apart is the key to using all three tools well. +The word "incident" is overloaded. Colloquially it means the whole event — the outage, the scramble, the postmortem. On this page it means something narrower: the machine-created record that a monitor is failing, shown as **Downtime** on each monitor in the dashboard. The whole event — the one your team declares, runs and writes a postmortem for — is a declared incident, covered in [Understanding Incident Management](/docs/concept/incident-management). Keeping these apart is the key to using all of the tools well. ``` DETECTION COMMUNICATION diff --git a/apps/web/src/content/pages/docs/guides/how-to-setup-slack-agent.mdx b/apps/web/src/content/pages/docs/guides/how-to-setup-slack-agent.mdx index 59baf9c4..86a3832f 100644 --- a/apps/web/src/content/pages/docs/guides/how-to-setup-slack-agent.mdx +++ b/apps/web/src/content/pages/docs/guides/how-to-setup-slack-agent.mdx @@ -96,6 +96,26 @@ Plan downtime so subscribers are informed in advance. @openstatus schedule a maintenance window for my database next Friday from 2–3 PM. ``` +### 6. Run incidents from Slack + +Declared incidents (see [Understanding Incident Management](/docs/concept/incident-management)) have their own commands: + +- `/openstatus incident declare [--sev critical|major|minor]` — declare one. An approval card appears; once approved, openstatus opens a channel for the incident, invites everyone whose Slack account is linked, and pins a summary card. +- `/openstatus incident note <text>` — add a note to the timeline (in the incident's channel). Or react to any message in the channel with 📌 to copy it onto the timeline. +- `/openstatus incident mitigate|resolve|cancel|reopen [#id] [note]` — change its status. Resolving or canceling an incident whose status report is still open offers to resolve the report too. +- `/openstatus incident postmortem [#id]` — let the agent draft the postmortem from the channel, with a card to approve it and close the incident. +- `/openstatus incident status` and `/openstatus incident list` — where things stand. + +Quiet incidents get a reminder in their channel (or a DM to the commander) after 1 hour for critical, 4 hours for major and 24 hours for minor incidents. + +### Linked accounts + +Only members of your openstatus workspace can use the bot. The first time someone uses it, openstatus links their Slack account by email. If their Slack email differs from their openstatus email, the bot replies with a **Link account** button that signs them in to openstatus to finish linking. You can see and remove your linked accounts under **Settings** > **Integrations**. + +### Reconnect after an update + +Incident channels need more Slack permissions than the original install (creating channels, inviting people, pinning and reacting). If you installed the agent before, **Settings** > **Integrations** and the app's Home tab show a **Reconnect** prompt: click it and approve the new permissions. + ## What you've accomplished - Installed the openstatus Slack agent in your workspace diff --git a/packages/services/package.json b/packages/services/package.json index db5cd30a..acbc1917 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -160,7 +160,7 @@ }, "scripts": { "check": "deno check --sloppy-imports .", - "test": "NODE_ENV=test RESEND_API_KEY=test-key OPENSTATUS_FEATURES=incident-management deno test --parallel -A --no-check --sloppy-imports", + "test": "NODE_ENV=test RESEND_API_KEY=test-key deno test --parallel -A --no-check --sloppy-imports", "tsc": "tsc --noEmit" }, "dependencies": { diff --git a/packages/services/src/__tests__/features.test.ts b/packages/services/src/__tests__/features.test.ts index 8b06f261..581a1c41 100644 --- a/packages/services/src/__tests__/features.test.ts +++ b/packages/services/src/__tests__/features.test.ts @@ -2,36 +2,28 @@ import { expect } from "@std/expect"; import { describe, test } from "@std/testing/bdd"; import { ForbiddenError } from "../errors"; -import { isFeatureEnabled, requireFeature } from "../features"; +import { type Feature, isFeatureEnabled, requireFeature } from "../features"; import type { Workspace } from "../types"; const none = new Set<string>(); +// No feature is gated right now; exercise the mechanism with a stand-in. +const flag = "test-feature" as Feature; describe("isFeatureEnabled", () => { - test("allowlisted workspace", () => { - expect(isFeatureEnabled({ id: 1 }, "incident-management", none)).toBe(true); - }); - - test("other workspace", () => { - expect( - isFeatureEnabled({ id: 987654321 }, "incident-management", none), - ).toBe(false); + test("off without an allowlist entry or env override", () => { + expect(isFeatureEnabled({ id: 1 }, flag, none)).toBe(false); }); test("env override enables it for every workspace", () => { - expect( - isFeatureEnabled( - { id: 987654321 }, - "incident-management", - new Set(["incident-management"]), - ), - ).toBe(true); + expect(isFeatureEnabled({ id: 987654321 }, flag, new Set([flag]))).toBe( + true, + ); }); }); describe("requireFeature", () => { test("throws ForbiddenError when disabled", () => { - // The package test script enables the feature globally; clear it here. + // The package test script may enable features globally; clear it here. const processEnv: Record<string, string | undefined> = process.env; const saved = processEnv.OPENSTATUS_FEATURES; delete processEnv.OPENSTATUS_FEATURES; @@ -40,7 +32,7 @@ describe("requireFeature", () => { expect(() => requireFeature( { workspace, actor: { type: "system", job: "test" } }, - "incident-management", + flag, ), ).toThrow(ForbiddenError); } finally { diff --git a/packages/services/src/agent-tools/__tests__/incident.test.ts b/packages/services/src/agent-tools/__tests__/incident.test.ts index 95db28d0..99eae5e7 100644 --- a/packages/services/src/agent-tools/__tests__/incident.test.ts +++ b/packages/services/src/agent-tools/__tests__/incident.test.ts @@ -74,11 +74,9 @@ describe("incident agent tools", () => { }); }); - test("every incident tool is feature-gated, the rest are not", () => { + test("incident tools are offered to every workspace", () => { for (const tool of Object.values(agentTools)) { - const isIncident = - tool.name.includes("incident") || tool.name.includes("postmortem"); - expect(tool.feature === "incident-management").toBe(isIncident); + expect(isAgentToolAvailable(tool, { id: 987654321 })).toBe(true); } expect(isAgentToolAvailable(listIncidentsTool, { id: 1 })).toBe(true); }); diff --git a/packages/services/src/agent-tools/incident.ts b/packages/services/src/agent-tools/incident.ts index d84916b4..03e6f9ab 100644 --- a/packages/services/src/agent-tools/incident.ts +++ b/packages/services/src/agent-tools/incident.ts @@ -21,8 +21,6 @@ import { } from "../incident"; import type { AgentTool, SummaryLine } from "./types"; -const FEATURE = "incident-management"; - const title = z.string().trim().min(1).max(256); const summary = z.string().trim().min(1).max(4000); const note = z.string().trim().min(1).max(10_000); @@ -73,7 +71,6 @@ export const listIncidentsTool: AgentTool< "List managed incidents in this workspace (declared by the team, not monitor downtime), open ones first.", scope: "read", destructive: false, - feature: FEATURE, inputSchema: ListIncidentsInput, outputSchema: ListIncidentsOutput, async run({ ctx, input }) { @@ -127,7 +124,6 @@ export const getIncidentTool: AgentTool< "Get one managed incident with its timeline (newest first, up to 50 events) and linked status report.", scope: "read", destructive: false, - feature: FEATURE, inputSchema: GetIncidentInput, outputSchema: GetIncidentOutput, async run({ ctx, input }) { @@ -223,7 +219,6 @@ export const declareIncidentTool: AgentTool< "Declare a managed incident: the team's internal record of an outage, with a timeline and a commander. Internal only — nothing is published; use create_status_report for public communication.", scope: "write", destructive: true, - feature: FEATURE, inputSchema: DeclareIncidentInput, outputSchema: IncidentWriteOutput, approval: { @@ -286,7 +281,6 @@ export const updateIncidentTool: AgentTool< "Change a managed incident's title, severity, summary, commander or start time. Internal only.", scope: "write", destructive: true, - feature: FEATURE, inputSchema: UpdateIncidentInput, outputSchema: IncidentWriteOutput, approval: { @@ -339,7 +333,6 @@ export const resolveIncidentTool: AgentTool< "Mark a managed incident resolved. Does not touch its status report: resolve that separately with resolve_status_report if it is still open.", scope: "write", destructive: true, - feature: FEATURE, inputSchema: ResolveIncidentInput, outputSchema: IncidentWriteOutput, approval: { @@ -381,7 +374,6 @@ export const setIncidentStatusTool: AgentTool< "Move a managed incident to mitigated, resolved, canceled (false alarm) or back to open. Canceling closes it for good. Does not touch its status report.", scope: "write", destructive: true, - feature: FEATURE, inputSchema: SetIncidentStatusInput, outputSchema: IncidentWriteOutput, approval: { @@ -423,7 +415,6 @@ export const addIncidentNoteTool: AgentTool< "Append a note to a managed incident's timeline. Internal and append-only.", scope: "write", destructive: false, - feature: FEATURE, inputSchema: AddIncidentNoteInput, outputSchema: AddIncidentNoteOutput, async run({ ctx, input }) { @@ -453,7 +444,6 @@ export const getPostmortemTool: AgentTool< "Read a managed incident's postmortem (markdown), if one was drafted.", scope: "read", destructive: false, - feature: FEATURE, inputSchema: PostmortemInput, outputSchema: GetPostmortemOutput, async run({ ctx, input }) { @@ -492,7 +482,6 @@ export const draftPostmortemTool: AgentTool< "Save a postmortem draft for a resolved managed incident. Build it from get_incident (timeline, linked status report) and the conversation; never invent facts. Replaces an existing draft; an approved postmortem can't be redrafted.", scope: "write", destructive: true, - feature: FEATURE, inputSchema: DraftPostmortemToolInput, outputSchema: DraftPostmortemOutput, approval: { @@ -540,7 +529,6 @@ export const approvePostmortemTool: AgentTool< "Approve a managed incident's postmortem draft and, by default, close the incident. Only an admin, owner or the incident's commander can approve.", scope: "write", destructive: true, - feature: FEATURE, inputSchema: ApprovePostmortemToolInput, outputSchema: ApprovePostmortemOutput, approval: { diff --git a/packages/services/src/agent-tools/prompt.ts b/packages/services/src/agent-tools/prompt.ts index f0e73a5b..aa45d0ac 100644 --- a/packages/services/src/agent-tools/prompt.ts +++ b/packages/services/src/agent-tools/prompt.ts @@ -16,8 +16,6 @@ export type AgentSystemPromptOptions = { * false the model passes `notify: false` and skips the yes/no prompt. */ canNotifySubscribers: boolean; - /** Whether managed-incident tools are offered in this workspace. */ - incidentManagement?: boolean; }; export function buildAgentSystemPrompt(opts: AgentSystemPromptOptions): string { @@ -41,8 +39,7 @@ Exception: after get_doc_page or get_content_page, DO synthesize an answer from const preamble = opts.preamble ? `${opts.preamble}\n\n` : ""; - const incidentSection = opts.incidentManagement - ? ` + const incidentSection = ` Managed incidents (internal): - Three different things are called "incident". A managed incident (list_incidents, get_incident, declare_incident, update_incident, resolve_incident, set_incident_status, add_incident_note) is the team's INTERNAL record: severity, commander, timeline. A status report is PUBLIC communication on a status page. Monitor downtime (activeIncidentCount on monitors) is detected automatically. @@ -52,8 +49,7 @@ Managed incidents (internal): - "the incident is fixed/resolved" with a managed incident in play → resolve_incident; if its linked status report is still open, ask whether to resolve that too (resolve_status_report). - "mitigated", "the bleeding stopped" → set_incident_status mitigated; "false alarm", "declared by mistake" → set_incident_status canceled (this closes it). - Postmortems: get_postmortem reads it; draft_postmortem saves a draft you wrote from get_incident and the conversation (resolved incidents only, never invent facts); approve_postmortem signs it off and by default closes the incident. -- severity: critical = major outage or data loss, major = significant degradation, minor = limited impact. Ask when unclear.` - : ""; +- severity: critical = major outage or data loss, major = significant degradation, minor = limited impact. Ask when unclear.`; // Workspaces without subscriber notify get a different rubric — asking // is wasted friction when the field is a server-side no-op anyway. @@ -117,7 +113,7 @@ Component impact: - Recovery counts as a change: when a component is back to normal before the incident is resolved ("API is back up"), set it to operational in that update. - resolve_status_report clears every remaining impact back to operational automatically — never publish a manual "everything operational" update for that. -Draft → Ask → Confirm rubric (MANDATORY for every write tool${opts.incidentManagement ? " except add_incident_note, which logs immediately" : ""}): +Draft → Ask → Confirm rubric (MANDATORY for every write tool except add_incident_note, which logs immediately): 1. Draft the proposed change (title, status, message, time window, affected components and their impact levels). 2. Show the draft to the user before calling the tool. ${notifyStep} diff --git a/packages/services/src/features.ts b/packages/services/src/features.ts index faedc1ac..894977e9 100644 --- a/packages/services/src/features.ts +++ b/packages/services/src/features.ts @@ -2,9 +2,7 @@ import type { ServiceContext } from "./context"; import { ForbiddenError } from "./errors"; import type { Workspace } from "./types"; -const featureWorkspaces = { - "incident-management": [1, 6850], -} satisfies Record<string, ReadonlyArray<number>>; +const featureWorkspaces = {} satisfies Record<string, ReadonlyArray<number>>; export type Feature = keyof typeof featureWorkspaces; @@ -28,7 +26,8 @@ export function isFeatureEnabled( feature: Feature, envFeatures: Set<string> = featuresFromEnv(), ): boolean { - const ids: ReadonlyArray<number> = featureWorkspaces[feature]; + const allowlists: Record<string, ReadonlyArray<number>> = featureWorkspaces; + const ids = allowlists[feature] ?? []; return ids.includes(workspace.id) || envFeatures.has(feature); } diff --git a/packages/services/src/incident/add-note.ts b/packages/services/src/incident/add-note.ts index d518408c..dfca3cef 100644 --- a/packages/services/src/incident/add-note.ts +++ b/packages/services/src/incident/add-note.ts @@ -6,7 +6,6 @@ import { appendIncidentEvent, assertNotClosed, getIncidentInWorkspace, - requireIncidentFeature, } from "./internal"; import { AddIncidentNoteInput } from "./schemas"; @@ -18,7 +17,6 @@ export async function addIncidentNote(args: { }): Promise<IncidentEvent> { const { ctx } = args; requireScope(ctx, "write"); - requireIncidentFeature(ctx); const input = AddIncidentNoteInput.parse(args.input); return withTransaction(ctx, async (tx) => { diff --git a/packages/services/src/incident/close.ts b/packages/services/src/incident/close.ts index c943302e..9842492e 100644 --- a/packages/services/src/incident/close.ts +++ b/packages/services/src/incident/close.ts @@ -14,7 +14,6 @@ import { appendIncidentEvent, assertNotClosed, getIncidentInWorkspace, - requireIncidentFeature, } from "./internal"; import { CloseIncidentInput } from "./schemas"; @@ -73,7 +72,6 @@ export async function closeIncident(args: { }): Promise<Incident> { const { ctx } = args; requireScope(ctx, "write"); - requireIncidentFeature(ctx); const input = CloseIncidentInput.parse(args.input); return withTransaction(ctx, async (tx) => { diff --git a/packages/services/src/incident/declare.ts b/packages/services/src/incident/declare.ts index 52770e9f..9cd9a095 100644 --- a/packages/services/src/incident/declare.ts +++ b/packages/services/src/incident/declare.ts @@ -15,11 +15,7 @@ import { withTransaction, } from "../context"; import { NotFoundError } from "../errors"; -import { - appendIncidentEvent, - assertMember, - requireIncidentFeature, -} from "./internal"; +import { appendIncidentEvent, assertMember } from "./internal"; import { assertStatusReportLinkable } from "./link-status-report"; import { DeclareIncidentInput } from "./schemas"; @@ -63,7 +59,6 @@ export async function declareIncident(args: { }): Promise<Incident> { const { ctx } = args; requireScope(ctx, "write"); - requireIncidentFeature(ctx); const input = DeclareIncidentInput.parse(args.input); return withTransaction(ctx, async (tx) => { diff --git a/packages/services/src/incident/delete.ts b/packages/services/src/incident/delete.ts index 830f9c71..bf6af968 100644 --- a/packages/services/src/incident/delete.ts +++ b/packages/services/src/incident/delete.ts @@ -6,7 +6,7 @@ import { requireScope } from "../auth"; import { requireRole } from "../auth/require-role"; import { type ServiceContext, withTransaction } from "../context"; import { ConflictError } from "../errors"; -import { getIncidentInWorkspace, requireIncidentFeature } from "./internal"; +import { getIncidentInWorkspace } from "./internal"; import { IncidentIdInput } from "./schemas"; /** @@ -20,7 +20,6 @@ export async function deleteIncident(args: { }): Promise<void> { const { ctx } = args; requireScope(ctx, "write"); - requireIncidentFeature(ctx); const input = IncidentIdInput.parse(args.input); await withTransaction(ctx, async (tx) => { diff --git a/packages/services/src/incident/internal.ts b/packages/services/src/incident/internal.ts index 47f4aa33..e767c026 100644 --- a/packages/services/src/incident/internal.ts +++ b/packages/services/src/incident/internal.ts @@ -12,15 +12,8 @@ import { import { emitAudit } from "../audit"; import { type DB, type ServiceContext, tryGetActorUserId } from "../context"; import { ConflictError, NotFoundError, ValidationError } from "../errors"; -import { requireFeature } from "../features"; import { getMembership } from "../member/membership"; -export const INCIDENT_FEATURE = "incident-management"; - -export function requireIncidentFeature(ctx: ServiceContext): void { - requireFeature(ctx, INCIDENT_FEATURE); -} - export async function getIncidentInWorkspace( tx: DB, workspaceId: number, diff --git a/packages/services/src/incident/link-status-report.ts b/packages/services/src/incident/link-status-report.ts index 633d3b2d..5db22092 100644 --- a/packages/services/src/incident/link-status-report.ts +++ b/packages/services/src/incident/link-status-report.ts @@ -13,7 +13,6 @@ import { appendIncidentEvent, assertNotClosed, getIncidentInWorkspace, - requireIncidentFeature, } from "./internal"; import { IncidentIdInput, LinkIncidentStatusReportInput } from "./schemas"; @@ -94,7 +93,6 @@ export async function linkIncidentStatusReport(args: { }): Promise<Incident> { const { ctx } = args; requireScope(ctx, "write"); - requireIncidentFeature(ctx); const input = LinkIncidentStatusReportInput.parse(args.input); return withTransaction(ctx, async (tx) => { @@ -126,7 +124,6 @@ export async function unlinkIncidentStatusReport(args: { }): Promise<Incident> { const { ctx } = args; requireScope(ctx, "write"); - requireIncidentFeature(ctx); const input = IncidentIdInput.parse(args.input); return withTransaction(ctx, async (tx) => { diff --git a/packages/services/src/incident/list-events.ts b/packages/services/src/incident/list-events.ts index 048ce7f8..e5781a04 100644 --- a/packages/services/src/incident/list-events.ts +++ b/packages/services/src/incident/list-events.ts @@ -2,7 +2,7 @@ import { desc, eq } from "@openstatus/db"; import { incidentEvent } from "@openstatus/db/src/schema"; import { type ServiceContext, getReadDb } from "../context"; -import { getIncidentInWorkspace, requireIncidentFeature } from "./internal"; +import { getIncidentInWorkspace } from "./internal"; import { ListIncidentEventsInput } from "./schemas"; /** The incident's timeline, newest first. */ @@ -11,7 +11,6 @@ export async function listIncidentEvents(args: { input: ListIncidentEventsInput; }) { const { ctx } = args; - requireIncidentFeature(ctx); const input = ListIncidentEventsInput.parse(args.input); const db = getReadDb(ctx); const existing = await getIncidentInWorkspace(db, ctx.workspace.id, input.id); diff --git a/packages/services/src/incident/list.ts b/packages/services/src/incident/list.ts index 48ec4b65..b3023d73 100644 --- a/packages/services/src/incident/list.ts +++ b/packages/services/src/incident/list.ts @@ -2,8 +2,6 @@ import { and, desc, eq, inArray, sql } from "@openstatus/db"; import { incident } from "@openstatus/db/src/schema"; import { type ServiceContext, getReadDb } from "../context"; -import { isFeatureEnabled } from "../features"; -import { INCIDENT_FEATURE, requireIncidentFeature } from "./internal"; import { IncidentIdInput, ListIncidentsInput } from "./schemas"; const userColumns = { @@ -22,7 +20,6 @@ export async function listIncidents(args: { input?: ListIncidentsInput; }) { const { ctx } = args; - requireIncidentFeature(ctx); const input = ListIncidentsInput.parse(args.input ?? {}); const where = and( @@ -46,7 +43,6 @@ export async function getIncident(args: { input: IncidentIdInput; }) { const { ctx } = args; - requireIncidentFeature(ctx); const input = IncidentIdInput.parse(args.input); return getReadDb(ctx).query.incident.findFirst({ where: and( @@ -70,7 +66,6 @@ export async function getIncidentForStatusReport(args: { input: { statusReportId: number }; }) { const { ctx } = args; - requireIncidentFeature(ctx); return getReadDb(ctx) .select({ id: incident.id, title: incident.title, status: incident.status }) .from(incident) @@ -89,7 +84,6 @@ export async function getIncidentBySlackChannel(args: { input: { teamId: string; channelId: string }; }) { const { ctx } = args; - if (!isFeatureEnabled(ctx.workspace, INCIDENT_FEATURE)) return undefined; return getReadDb(ctx).query.incident.findFirst({ where: and( eq(incident.workspaceId, ctx.workspace.id), diff --git a/packages/services/src/incident/postmortem.ts b/packages/services/src/incident/postmortem.ts index 3bc84ead..a32d8830 100644 --- a/packages/services/src/incident/postmortem.ts +++ b/packages/services/src/incident/postmortem.ts @@ -16,11 +16,7 @@ import { } from "../context"; import { ConflictError, NotFoundError } from "../errors"; import { closeIncidentInTx } from "./close"; -import { - appendIncidentEvent, - getIncidentInWorkspace, - requireIncidentFeature, -} from "./internal"; +import { appendIncidentEvent, getIncidentInWorkspace } from "./internal"; import { ApprovePostmortemInput, DraftPostmortemInput, @@ -49,7 +45,6 @@ export async function draftPostmortem(args: { }): Promise<IncidentPostmortem> { const { ctx } = args; requireScope(ctx, "write"); - requireIncidentFeature(ctx); const input = DraftPostmortemInput.parse(args.input); return withTransaction(ctx, async (tx) => { @@ -145,7 +140,6 @@ export async function approvePostmortem(args: { }): Promise<IncidentPostmortem> { const { ctx } = args; requireScope(ctx, "write"); - requireIncidentFeature(ctx); const input = ApprovePostmortemInput.parse(args.input); return withTransaction(ctx, async (tx) => { @@ -199,7 +193,6 @@ export async function getPostmortem(args: { input: IncidentIdInput; }): Promise<IncidentPostmortem | undefined> { const { ctx } = args; - requireIncidentFeature(ctx); const input = IncidentIdInput.parse(args.input); const db = getReadDb(ctx); const row = await getIncidentInWorkspace(db, ctx.workspace.id, input.id); diff --git a/packages/services/src/incident/reminders.ts b/packages/services/src/incident/reminders.ts index 55c98503..bf361545 100644 --- a/packages/services/src/incident/reminders.ts +++ b/packages/services/src/incident/reminders.ts @@ -10,9 +10,7 @@ import { } from "@openstatus/db/src/schema"; import type { DB, ServiceContext } from "../context"; -import { isFeatureEnabled } from "../features"; import { getSlackConnection } from "../integration/slack-connection"; -import { INCIDENT_FEATURE } from "./internal"; import type { SlackClientFactory } from "./slack-flow"; const HOUR = 60 * 60 * 1000; @@ -107,7 +105,6 @@ export async function remindStaleIncidents(args: { const parsed = selectWorkspaceSchema.safeParse(workspaceRow); if (!parsed.success) continue; const ws = parsed.data; - if (!isFeatureEnabled(ws, INCIDENT_FEATURE)) continue; if (!ws.limits["slack-agent"]) continue; const last = await db diff --git a/packages/services/src/incident/set-status.ts b/packages/services/src/incident/set-status.ts index 0d906608..7c838e35 100644 --- a/packages/services/src/incident/set-status.ts +++ b/packages/services/src/incident/set-status.ts @@ -18,7 +18,6 @@ import { appendIncidentEvent, assertTransition, getIncidentInWorkspace, - requireIncidentFeature, } from "./internal"; import { SetIncidentStatusInput } from "./schemas"; @@ -50,7 +49,6 @@ export async function setIncidentStatus(args: { }): Promise<Incident> { const { ctx } = args; requireScope(ctx, "write"); - requireIncidentFeature(ctx); const input = SetIncidentStatusInput.parse(args.input); return withTransaction(ctx, async (tx) => { diff --git a/packages/services/src/incident/slack-channel.ts b/packages/services/src/incident/slack-channel.ts index a6be2f85..0c4a6915 100644 --- a/packages/services/src/incident/slack-channel.ts +++ b/packages/services/src/incident/slack-channel.ts @@ -9,7 +9,6 @@ import { appendIncidentEvent, assertNotClosed, getIncidentInWorkspace, - requireIncidentFeature, } from "./internal"; import { BindIncidentSlackChannelInput, IncidentIdInput } from "./schemas"; @@ -19,7 +18,6 @@ export async function bindIncidentSlackChannel(args: { }): Promise<Incident> { const { ctx } = args; requireScope(ctx, "write"); - requireIncidentFeature(ctx); const input = BindIncidentSlackChannelInput.parse(args.input); return withTransaction(ctx, async (tx) => { @@ -83,18 +81,6 @@ export async function bindIncidentSlackChannel(args: { export async function unbindIncidentSlackChannel(args: { ctx: ServiceContext; input: IncidentIdInput; -}): Promise<Incident> { - requireIncidentFeature(args.ctx); - return clearIncidentSlackChannel(args); -} - -/** - * Unbind without the feature gate: Slack uninstall cleanup must drop every - * binding whatever the workspace's rollout state. - */ -export async function clearIncidentSlackChannel(args: { - ctx: ServiceContext; - input: IncidentIdInput; }): Promise<Incident> { const { ctx } = args; requireScope(ctx, "write"); diff --git a/packages/services/src/incident/slack-flow.ts b/packages/services/src/incident/slack-flow.ts index 78129d12..f2d4492b 100644 --- a/packages/services/src/incident/slack-flow.ts +++ b/packages/services/src/incident/slack-flow.ts @@ -7,13 +7,12 @@ import { } 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 { getIncidentInWorkspace } from "./internal"; import { bindIncidentSlackChannel } from "./slack-channel"; type SlackText = { type: "mrkdwn" | "plain_text"; text: string }; @@ -130,7 +129,6 @@ function errorCode(err: Error | string): string { export async function incidentSlackReady( ctx: ServiceContext, ): Promise<SlackConnection | null> { - 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; diff --git a/packages/services/src/incident/update.ts b/packages/services/src/incident/update.ts index 0d64b863..d897237b 100644 --- a/packages/services/src/incident/update.ts +++ b/packages/services/src/incident/update.ts @@ -9,7 +9,6 @@ import { assertMember, assertNotClosed, getIncidentInWorkspace, - requireIncidentFeature, userDisplayName, } from "./internal"; import { UpdateIncidentInput } from "./schemas"; @@ -21,7 +20,6 @@ export async function updateIncident(args: { }): Promise<Incident> { const { ctx } = args; requireScope(ctx, "write"); - requireIncidentFeature(ctx); const input = UpdateIncidentInput.parse(args.input); return withTransaction(ctx, async (tx) => { diff --git a/packages/services/src/integration/uninstall-slack-agent.ts b/packages/services/src/integration/uninstall-slack-agent.ts index 1119b6fd..2d2bd24f 100644 --- a/packages/services/src/integration/uninstall-slack-agent.ts +++ b/packages/services/src/integration/uninstall-slack-agent.ts @@ -4,7 +4,7 @@ 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 { clearIncidentSlackChannel } from "../incident/slack-channel"; +import { unbindIncidentSlackChannel } from "../incident/slack-channel"; import { parseWorkspaceForContext } from "../page-subscriber/internal"; import { removeSlackTeamSubscribers } from "../page-subscriber/slack"; import { deleteSlackUserMappings } from "../slack-user/internal"; @@ -60,7 +60,7 @@ export async function uninstallSlackAgent(args: { ) .all(); for (const row of bound) { - await clearIncidentSlackChannel({ + await unbindIncidentSlackChannel({ ctx: { ...ctx, db: tx }, input: { id: row.id }, });