From 105da627ec5026805aa854a70b308cdbe325bbfc Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Fri, 16 Oct 2026 16:39:49 +0200 Subject: [PATCH] Feat/incident command (#40) * chore(deps): bump openstatus api sdk for IncidentService Pin buf.build/gen/go/openstatus/api to BSR commit 404d544bfc59, which ships openstatus.incident.v1. The generated connect module requires go 1.26 and connectrpc.com/connect v1.21.1. The new SDK deprecates the Mumbai region and SMS notifications; their read paths stay for existing resources. Co-Authored-By: Claude Opus 5.5 * fix(cli): surface failed_precondition errors The API now returns failed_precondition for requests that conflict with the current state of a resource, across every service. Show the server message instead of the raw connect error. Co-Authored-By: Claude Opus 5.5 * feat: add incident command Add 'openstatus incident' (aliases inc, incidents) on top of the new IncidentService: list, info, declare (with a short wizard), update, set-status plus mitigate/resolve/reopen/cancel shortcuts, note, link-report/unlink-report, close and delete. Mutations print the incident as JSON with --json and only the ID with -q; otherwise a success line and a next-step hint. Cancelling, closing and deleting ask for confirmation unless -y is passed. Conflicts and permission errors get incident-specific hints. Co-Authored-By: Claude Opus 5.5 * feat(incident): add postmortem subcommands Add 'openstatus incident postmortem' (alias pm) with show, edit and approve. show pages the body with its review status in a terminal and prints the raw markdown when piped, so show and edit round-trip through a file. edit takes --file (or - for stdin) or --content; approve can also close the incident. Co-Authored-By: Claude Opus 5.5 * feat(status-report): link reports to incidents Add 'status-report create --incident-id' to link a new report to an incident in the same step, and show the linked incident in 'info' and in the JSON output of 'info' and 'list'. Co-Authored-By: Claude Opus 5.5 * docs: document incident command Add the incident command to the app help, README and the CLI skill. The skill now tells agents to declare an incident first and publish a linked status report when users are affected. Regenerate the markdown docs and the man page. Co-Authored-By: Claude Opus 5.5 * fix(incident): address review feedback - approve: only report a missing postmortem when the server says so - permission denied: keep the server reason and append the role hint - declare: validate passed flags before opening the wizard - list: ignore ANSI codes when measuring table column widths - read from stdin: tell TTY users to press Ctrl-D - postmortem edit --file: trim one trailing newline like stdin does - status-report create: confirm the incident link from the response - ci: use go.mod for the test workflow Go version Co-Authored-By: Claude Opus 5.5 * fix pr --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/test.yml | 2 +- README.md | 16 +- docs/openstatus-docs.md | 400 +++++- docs/openstatus.1 | 1168 ++++++++++++++++- go.mod | 12 +- go.sum | 20 +- internal/cli/errors.go | 5 + internal/cli/errors_test.go | 89 ++ internal/cmd/app.go | 12 +- internal/cmd/app_test.go | 5 +- internal/incident/errors.go | 86 ++ internal/incident/export_test.go | 28 + internal/incident/incident.go | 534 ++++++++ internal/incident/incident_close.go | 75 ++ .../incident/incident_close_delete_test.go | 107 ++ internal/incident/incident_declare.go | 231 ++++ internal/incident/incident_declare_test.go | 147 +++ internal/incident/incident_delete.go | 80 ++ internal/incident/incident_info.go | 136 ++ internal/incident/incident_info_test.go | 157 +++ internal/incident/incident_link.go | 102 ++ internal/incident/incident_link_test.go | 69 + internal/incident/incident_list.go | 183 +++ internal/incident/incident_list_test.go | 226 ++++ internal/incident/incident_note.go | 98 ++ internal/incident/incident_note_test.go | 89 ++ internal/incident/incident_postmortem.go | 358 +++++ internal/incident/incident_postmortem_test.go | 288 ++++ internal/incident/incident_status.go | 175 +++ internal/incident/incident_status_test.go | 128 ++ internal/incident/incident_test.go | 257 ++++ internal/incident/incident_update.go | 179 +++ internal/incident/incident_update_test.go | 110 ++ internal/incident/incident_wizard.go | 60 + internal/incident/output_test.go | 51 + internal/incident/testhelpers_test.go | 128 ++ internal/monitors/monitors.go | 4 +- internal/notification/notification.go | 2 +- internal/notification/notification_info.go | 2 +- internal/statusreport/export_test.go | 3 + internal/statusreport/statusreport_create.go | 71 +- .../statusreport_incident_test.go | 201 +++ internal/statusreport/statusreport_info.go | 67 +- internal/statusreport/statusreport_list.go | 22 +- internal/terraform/hcl.go | 2 +- internal/terraform/regions.go | 2 +- skills/cli/SKILL.md | 118 +- 47 files changed, 6205 insertions(+), 100 deletions(-) create mode 100644 internal/cli/errors_test.go create mode 100644 internal/incident/errors.go create mode 100644 internal/incident/export_test.go create mode 100644 internal/incident/incident.go create mode 100644 internal/incident/incident_close.go create mode 100644 internal/incident/incident_close_delete_test.go create mode 100644 internal/incident/incident_declare.go create mode 100644 internal/incident/incident_declare_test.go create mode 100644 internal/incident/incident_delete.go create mode 100644 internal/incident/incident_info.go create mode 100644 internal/incident/incident_info_test.go create mode 100644 internal/incident/incident_link.go create mode 100644 internal/incident/incident_link_test.go create mode 100644 internal/incident/incident_list.go create mode 100644 internal/incident/incident_list_test.go create mode 100644 internal/incident/incident_note.go create mode 100644 internal/incident/incident_note_test.go create mode 100644 internal/incident/incident_postmortem.go create mode 100644 internal/incident/incident_postmortem_test.go create mode 100644 internal/incident/incident_status.go create mode 100644 internal/incident/incident_status_test.go create mode 100644 internal/incident/incident_test.go create mode 100644 internal/incident/incident_update.go create mode 100644 internal/incident/incident_update_test.go create mode 100644 internal/incident/incident_wizard.go create mode 100644 internal/incident/output_test.go create mode 100644 internal/incident/testhelpers_test.go create mode 100644 internal/statusreport/export_test.go create mode 100644 internal/statusreport/statusreport_incident_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b164f1c..abcc711 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,6 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '>=1.22.0' + go-version-file: go.mod - name: Run test run: go test -timeout 30s -race -count=1 ./... diff --git a/README.md b/README.md index 6af9ffa..00c3c6f 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,18 @@ openstatus monitors info 123 # Trigger an on-demand check openstatus monitors trigger 123 -# Report an incident +# Declare an incident, then publish a linked public status report +ID=$(openstatus incident declare --title "Checkout API returns 502" --severity major -q) +openstatus status-report create --title "Checkout issues" --status investigating \ + --message "We are investigating failed payments" --page-id 1 --incident-id "$ID" + +# Run the incident: notes, status changes, postmortem +openstatus inc note "$ID" --message "Rolled back the deploy" +openstatus inc resolve "$ID" --note "Error rate back to normal" +openstatus inc postmortem edit "$ID" --file postmortem.md +openstatus inc postmortem approve "$ID" --close -y + +# Post a public status update openstatus status-report create --title "API degradation" --status investigating --page-id 1 # Report an incident with per-component impact @@ -78,7 +89,8 @@ Rate limit: 3 requests per 60 seconds. | `login` / `logout` | | Authenticate with the OpenStatus API | | `whoami` | `w` | Show current workspace info | | `monitors` | `m` | List, inspect, trigger, import, and apply monitors | -| `status-report` | `sr` | Create and manage incident reports | +| `incident` | `inc`, `incidents` | Declare and run incidents (lifecycle, notes, status report link, postmortem) | +| `status-report` | `sr` | Post and manage public status reports | | `maintenance` | `mt` | Schedule and manage maintenance windows | | `status-page` | `sp` | View status pages and components | | `notification` | `n` | View notification channels | diff --git a/docs/openstatus-docs.md b/docs/openstatus-docs.md index 647bfd9..e89ca1a 100644 --- a/docs/openstatus-docs.md +++ b/docs/openstatus-docs.md @@ -2,7 +2,7 @@ ## CLI interface - openstatus -OpenStatus CLI lets you manage your status pages and uptime monitors from the command line. Report and track incidents, define monitors as code, and run on-demand checks. Get started: openstatus login Save your API token openstatus status-report create Report an incident openstatus status-report list View active incidents openstatus maintenance create Schedule a maintenance window openstatus maintenance list View maintenance windows openstatus monitors apply Sync monitors from config openstatus monitors list List your monitors openstatus run Run synthetic tests openstatus pl list List your private locations https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new. +OpenStatus CLI lets you manage your status pages and uptime monitors from the command line. Declare and run incidents, post public status updates, define monitors as code, and run on-demand checks. Get started: openstatus login Save your API token openstatus incident declare Declare an incident openstatus incident list View incidents openstatus status-report create Post a public status update openstatus status-report list View status reports openstatus maintenance create Schedule a maintenance window openstatus maintenance list View maintenance windows openstatus monitors apply Sync monitors from config openstatus monitors list List your monitors openstatus run Run synthetic tests openstatus pl list List your private locations https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new. Manage status pages, monitors, and incidents from the terminal. @@ -339,6 +339,7 @@ The following flags are supported: | `--impact="…"` | Per-component impact, repeatable: --impact = (level: operational, degraded, partial_outage, major_outage). Mutually exclusive with --component-ids | string | | *none* | | `--notify` | Notify subscribers about this status report | bool | `false` | *none* | | `--date="…"` | Date when the event occurred (RFC 3339 format, defaults to now) | string | | *none* | +| `--incident-id="…"` | ID of an incident to link the report to | string | | *none* | ### `status-report update` subcommand @@ -403,6 +404,403 @@ The following flags are supported: | `--impact="…"` | Per-component impact, repeatable: --impact = (level: operational, degraded, partial_outage, major_outage). Can also add new affected components | string | | *none* | | `--notify` | Notify subscribers about this update | bool | `false` | *none* | +### `incident` command (aliases: `inc`, `incidents`) + +Declare and run incidents. + +Incidents are the internal response to an outage: severity, commander, lifecycle (open -> mitigated -> resolved, or canceled), timeline notes, a linked public status report and the postmortem. Use 'openstatus status-report' for the public communication on a status page. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident [ARGUMENTS...] +``` + +### `incident list` subcommand + +List incidents. + +> openstatus incident list +> openstatus inc list --active +> openstatus inc list --status open,mitigated --limit 10 + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident list [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-------------------------------------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--status="…"` | Filter by status, comma-separated (open, mitigated, resolved, canceled) | string | | *none* | +| `--active` | Only incidents that are not closed | bool | `false` | *none* | +| `--closed` | Only closed incidents | bool | `false` | *none* | +| `--limit="…"` | Maximum number of incidents to return (1-100, defaults to 50) | int | `0` | *none* | +| `--offset="…"` | Number of incidents to skip | int | `0` | *none* | + +### `incident info` subcommand + +Get incident details and timeline. + +> openstatus incident info +> openstatus inc info 42 + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident info [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-----------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | + +### `incident declare` subcommand (aliases: `create`) + +Declare a new incident. + +> openstatus incident declare --title "Checkout API returns 502" --severity major +> openstatus inc declare --title "Database down" --severity critical --commander jane@example.com --slack +> ID=$(openstatus inc declare --title "Login errors" --severity minor -q) + +Declare a new incident in the open status. Run without --title or --severity in an interactive terminal to be prompted. In scripts, pass every required flag. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident declare [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|--------------------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--title="…"` | Title of the incident (1-256 characters) | string | | *none* | +| `--severity="…"` | Severity of the incident (critical, major, minor) | string | | *none* | +| `--summary="…"` | Short human summary | string | | *none* | +| `--commander="…"` | Email of the workspace member leading the response | string | | *none* | +| `--started-at="…"` | When the impact began (RFC 3339, defaults to now) | string | | *none* | +| `--status-report-id="…"` | ID of an existing status report to link | string | | *none* | +| `--slack` | Open a dedicated Slack channel when Slack is connected | bool | `false` | *none* | + +### `incident update` subcommand + +Edit an incident's title, severity, summary, commander or start time. + +> openstatus incident update [flags] +> openstatus inc update 42 --severity critical +> openstatus inc update 42 --commander jane@example.com +> openstatus inc update 42 --clear-summary + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident update [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|---------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--title="…"` | New title (1-256 characters) | string | | *none* | +| `--severity="…"` | New severity (critical, major, minor) | string | | *none* | +| `--summary="…"` | New summary | string | | *none* | +| `--clear-summary` | Remove the summary | bool | `false` | *none* | +| `--commander="…"` | Email of the new commander | string | | *none* | +| `--clear-commander` | Unassign the commander | bool | `false` | *none* | +| `--started-at="…"` | New start of impact (RFC 3339) | string | | *none* | + +### `incident set-status` subcommand + +Move an incident to a new status. + +> openstatus incident set-status --status [--note "..."] +> openstatus inc set-status 42 --status mitigated --note "Rolled back the deploy" + +Allowed transitions: open -> mitigated, resolved or canceled; mitigated -> resolved, open or canceled; resolved -> open. Canceled is terminal and closes the incident. A linked status report is not updated: post the public update with 'openstatus status-report add-update'. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident set-status [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-----------------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--status="…"` | Target status (open, mitigated, resolved, canceled) | string | | *none* | +| `--note="…"` | Note recorded on the timeline with the change | string | | *none* | +| `--auto-accept` (`-y`) | Automatically accept the prompt | bool | `false` | *none* | + +### `incident mitigate` subcommand + +Mark an incident as mitigated. + +> openstatus incident mitigate [--note "..."] +> openstatus inc mitigate 42 --note "..." + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident mitigate [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-----------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--note="…"` | Note recorded on the timeline with the change | string | | *none* | + +### `incident resolve` subcommand + +Mark an incident as resolved. + +> openstatus incident resolve [--note "..."] +> openstatus inc resolve 42 --note "..." + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident resolve [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-----------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--note="…"` | Note recorded on the timeline with the change | string | | *none* | + +### `incident reopen` subcommand + +Reopen a mitigated or resolved incident. + +> openstatus incident reopen [--note "..."] +> openstatus inc reopen 42 --note "..." + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident reopen [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-----------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--note="…"` | Note recorded on the timeline with the change | string | | *none* | + +### `incident cancel` subcommand + +Cancel an incident (closes it). + +> openstatus incident cancel [--note "..."] +> openstatus inc cancel 42 --note "..." + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident cancel [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-----------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--note="…"` | Note recorded on the timeline with the change | string | | *none* | +| `--auto-accept` (`-y`) | Automatically accept the prompt | bool | `false` | *none* | + +### `incident note` subcommand + +Add a note to an incident timeline. + +> openstatus incident note --message "Rolled back the deploy" +> echo "multi-line markdown" | openstatus inc note 42 --message - + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident note [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|----------------------------------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--message="…"` (`-m`) | Note text in markdown (1-10000 characters); use - to read from stdin | string | | *none* | + +### `incident link-report` subcommand + +Link a public status report to an incident. + +> openstatus incident link-report +> openstatus inc link-report 42 123 + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident link-report [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-----------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | + +### `incident unlink-report` subcommand + +Remove the link to an incident's status report. + +> openstatus incident unlink-report +> openstatus inc unlink-report 42 + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident unlink-report [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-----------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | + +### `incident close` subcommand + +Close a resolved incident. + +> openstatus incident close +> openstatus inc close 42 --skip-postmortem -y + +Close a resolved incident. A closed incident is read-only except for its postmortem. Requires an approved postmortem, or --skip-postmortem. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident close [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|--------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--skip-postmortem` | Close without an approved postmortem | bool | `false` | *none* | +| `--auto-accept` (`-y`) | Automatically accept the prompt | bool | `false` | *none* | + +### `incident delete` subcommand + +Delete an incident declared by mistake. + +> openstatus incident delete +> openstatus inc delete 42 -y + +Delete an incident declared by mistake. Only allowed while the incident is open and was never mitigated, resolved or closed; cancel it otherwise. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident delete [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|---------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--auto-accept` (`-y`) | Automatically accept the prompt | bool | `false` | *none* | + +### `incident postmortem` subcommand (aliases: `pm`) + +Read, write and approve an incident's postmortem. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident postmortem [ARGUMENTS...] +``` + +### `incident postmortem show` subcommand + +Show the postmortem of an incident. + +> openstatus incident postmortem show +> openstatus inc pm show 42 > postmortem.md + +In a terminal, prints the review status and the markdown body through your pager. When piped, prints only the markdown body, so it can be edited and sent back with 'openstatus inc pm edit --file postmortem.md'. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident postmortem show [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-----------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | + +### `incident postmortem edit` subcommand + +Write or replace the postmortem of a resolved incident. + +> openstatus incident postmortem edit --file postmortem.md +> cat postmortem.md | openstatus inc pm edit 42 --file - +> openstatus inc pm edit 42 --content "## Summary ..." + +Replaces the whole markdown body. An approved postmortem stays approved. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident postmortem edit [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|---------------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--file="…"` | Markdown file to upload; use - to read from stdin | string | | *none* | +| `--content="…"` | Markdown body passed inline | string | | *none* | + +### `incident postmortem approve` subcommand + +Approve the postmortem, optionally closing the incident. + +> openstatus incident postmortem approve +> openstatus inc pm approve 42 --close -y + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] incident postmortem approve [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|---------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--close` | Also close the incident | bool | `false` | *none* | +| `--auto-accept` (`-y`) | Automatically accept the prompt | bool | `false` | *none* | + ### `maintenance` command (aliases: `mt`) Manage maintenance windows. diff --git a/docs/openstatus.1 b/docs/openstatus.1 index b0ed0af..6d52d0b 100644 --- a/docs/openstatus.1 +++ b/docs/openstatus.1 @@ -1,20 +1,22 @@ '\" t -.\" Automatically generated by Pandoc 3.10.1 +.\" Automatically generated by Pandoc 3.12.1 .\" .TH "" "" "" "" .SH OpenStatus CLI .SS CLI interface \- openstatus OpenStatus CLI lets you manage your status pages and uptime monitors from the command line. -Report and track incidents, define monitors as code, and run on\-demand -checks. -Get started: openstatus login Save your API token openstatus -status\-report create Report an incident openstatus status\-report list -View active incidents openstatus maintenance create Schedule a -maintenance window openstatus maintenance list View maintenance windows -openstatus monitors apply Sync monitors from config openstatus monitors -list List your monitors openstatus run Run synthetic tests openstatus pl -list List your private locations https://docs.openstatus.dev | +Declare and run incidents, post public status updates, define monitors +as code, and run on\-demand checks. +Get started: openstatus login Save your API token openstatus incident +declare Declare an incident openstatus incident list View incidents +openstatus status\-report create Post a public status update openstatus +status\-report list View status reports openstatus maintenance create +Schedule a maintenance window openstatus maintenance list View +maintenance windows openstatus monitors apply Sync monitors from config +openstatus monitors list List your monitors openstatus run Run synthetic +tests openstatus pl list List your private locations +https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new. .PP Manage status pages, monitors, and incidents from the terminal. @@ -197,6 +199,9 @@ Creates or updates monitors according to the OpenStatus configuration file. Compares your openstatus.yaml with the current state and applies changes. +DEPRECATED: this command is deprecated. +Prefer managing your monitors with Terraform via `openstatus terraform +generate'. .PP Usage: .IP @@ -393,6 +398,8 @@ monitors.yaml .PP Import all your monitors from your workspace to a YAML file; it will also create a lock file to manage your monitors with `apply'. +DEPRECATED: the monitors.yaml config file is deprecated for import. +Prefer managing monitors declaratively with `openstatus monitors apply'. .PP Usage: .IP @@ -967,6 +974,16 @@ T}@T{ T}@T{ \f[I]none\f[R] T} +T{ +\f[CR]\-\-incident\-id=\(dq\&...\(dq\f[R] +T}@T{ +ID of an incident to link the report to +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} .TE .SS \f[CR]status\-report update\f[R] subcommand Update status report metadata. @@ -1178,6 +1195,1137 @@ T}@T{ \f[I]none\f[R] T} .TE +.SS \f[CR]incident\f[R] command (aliases: \f[CR]inc\f[R], \f[CR]incidents\f[R]) +Declare and run incidents. +.PP +Incidents are the internal response to an outage: severity, commander, +lifecycle (open \-> mitigated \-> resolved, or canceled), timeline +notes, a linked public status report and the postmortem. +Use `openstatus status\-report' for the public communication on a status +page. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident [ARGUMENTS...] +.EE +.SS \f[CR]incident list\f[R] subcommand +List incidents. +.RS +.PP +openstatus incident list openstatus inc list \(enactive openstatus inc +list \(enstatus open,mitigated \(enlimit 10 +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident list [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(13.6n) lw(34.3n) lw(3.8n) cw(7.0n) cw(11.3n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-status=\(dq\&...\(dq\f[R] +T}@T{ +Filter by status, comma\-separated (open, mitigated, resolved, canceled) +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-active\f[R] +T}@T{ +Only incidents that are not closed +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-closed\f[R] +T}@T{ +Only closed incidents +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-limit=\(dq\&...\(dq\f[R] +T}@T{ +Maximum number of incidents to return (1\-100, defaults to 50) +T}@T{ +int +T}@T{ +\f[CR]0\f[R] +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-offset=\(dq\&...\(dq\f[R] +T}@T{ +Number of incidents to skip +T}@T{ +int +T}@T{ +\f[CR]0\f[R] +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]incident info\f[R] subcommand +Get incident details and timeline. +.RS +.PP +openstatus incident info openstatus inc info 42 +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident info [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(19.3n) lw(19.3n) lw(5.3n) cw(10.0n) cw(16.0n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +.TE +.SS \f[CR]incident declare\f[R] subcommand (aliases: \f[CR]create\f[R]) +Declare a new incident. +.RS +.PP +openstatus incident declare \(entitle \(lqCheckout API returns 502\(rq +\(enseverity major openstatus inc declare \(entitle \(lqDatabase +down\(rq \(enseverity critical \(encommander jane\(atexample.com +\(enslack ID=$(openstatus inc declare \(entitle \(lqLogin errors\(rq +\(enseverity minor \-q) +.RE +.PP +Declare a new incident in the open status. +Run without \(entitle or \(enseverity in an interactive terminal to be +prompted. +In scripts, pass every required flag. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident declare [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(15.4n) lw(29.7n) lw(4.2n) cw(8.0n) cw(12.7n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-title=\(dq\&...\(dq\f[R] +T}@T{ +Title of the incident (1\-256 characters) +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-severity=\(dq\&...\(dq\f[R] +T}@T{ +Severity of the incident (critical, major, minor) +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-summary=\(dq\&...\(dq\f[R] +T}@T{ +Short human summary +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-commander=\(dq\&...\(dq\f[R] +T}@T{ +Email of the workspace member leading the response +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-started\-at=\(dq\&...\(dq\f[R] +T}@T{ +When the impact began (RFC 3339, defaults to now) +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-status\-report\-id=\(dq\&...\(dq\f[R] +T}@T{ +ID of an existing status report to link +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-slack\f[R] +T}@T{ +Open a dedicated Slack channel when Slack is connected +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]incident update\f[R] subcommand +Edit an incident\(cqs title, severity, summary, commander or start time. +.RS +.PP +openstatus incident update [flags] openstatus inc update 42 +\(enseverity critical openstatus inc update 42 \(encommander +jane\(atexample.com openstatus inc update 42 \(enclear\-summary +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident update [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(17.7n) lw(23.7n) lw(4.9n) cw(9.1n) cw(14.6n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-title=\(dq\&...\(dq\f[R] +T}@T{ +New title (1\-256 characters) +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-severity=\(dq\&...\(dq\f[R] +T}@T{ +New severity (critical, major, minor) +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-summary=\(dq\&...\(dq\f[R] +T}@T{ +New summary +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-clear\-summary\f[R] +T}@T{ +Remove the summary +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-commander=\(dq\&...\(dq\f[R] +T}@T{ +Email of the new commander +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-clear\-commander\f[R] +T}@T{ +Unassign the commander +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-started\-at=\(dq\&...\(dq\f[R] +T}@T{ +New start of impact (RFC 3339) +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]incident set\-status\f[R] subcommand +Move an incident to a new status. +.RS +.PP +openstatus incident set\-status \(enstatus + [\(ennote \(lq\&...\(rq] openstatus +inc set\-status 42 \(enstatus mitigated \(ennote \(lqRolled back the +deploy\(rq +.RE +.PP +Allowed transitions: open \-> mitigated, resolved or canceled; mitigated +\-> resolved, open or canceled; resolved \-> open. +Canceled is terminal and closes the incident. +A linked status report is not updated: post the public update with +`openstatus status\-report add\-update'. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident set\-status [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(15.7n) lw(28.8n) lw(4.3n) cw(8.1n) cw(13.0n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-status=\(dq\&...\(dq\f[R] +T}@T{ +Target status (open, mitigated, resolved, canceled) +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-note=\(dq\&...\(dq\f[R] +T}@T{ +Note recorded on the timeline with the change +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-auto\-accept\f[R] (\f[CR]\-y\f[R]) +T}@T{ +Automatically accept the prompt +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]incident mitigate\f[R] subcommand +Mark an incident as mitigated. +.RS +.PP +openstatus incident mitigate [\(ennote \(lq\&...\(rq] openstatus inc +mitigate 42 \(ennote \(lq\&...\(rq +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident mitigate [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(16.5n) lw(26.7n) lw(4.6n) cw(8.5n) cw(13.7n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-note=\(dq\&...\(dq\f[R] +T}@T{ +Note recorded on the timeline with the change +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]incident resolve\f[R] subcommand +Mark an incident as resolved. +.RS +.PP +openstatus incident resolve [\(ennote \(lq\&...\(rq] openstatus inc +resolve 42 \(ennote \(lq\&...\(rq +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident resolve [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(16.5n) lw(26.7n) lw(4.6n) cw(8.5n) cw(13.7n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-note=\(dq\&...\(dq\f[R] +T}@T{ +Note recorded on the timeline with the change +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]incident reopen\f[R] subcommand +Reopen a mitigated or resolved incident. +.RS +.PP +openstatus incident reopen [\(ennote \(lq\&...\(rq] openstatus inc +reopen 42 \(ennote \(lq\&...\(rq +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident reopen [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(16.5n) lw(26.7n) lw(4.6n) cw(8.5n) cw(13.7n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-note=\(dq\&...\(dq\f[R] +T}@T{ +Note recorded on the timeline with the change +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]incident cancel\f[R] subcommand +Cancel an incident (closes it). +.RS +.PP +openstatus incident cancel [\(ennote \(lq\&...\(rq] openstatus inc +cancel 42 \(ennote \(lq\&...\(rq +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident cancel [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(16.5n) lw(26.7n) lw(4.6n) cw(8.5n) cw(13.7n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-note=\(dq\&...\(dq\f[R] +T}@T{ +Note recorded on the timeline with the change +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-auto\-accept\f[R] (\f[CR]\-y\f[R]) +T}@T{ +Automatically accept the prompt +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]incident note\f[R] subcommand +Add a note to an incident timeline. +.RS +.PP +openstatus incident note \(enmessage \(lqRolled back the deploy\(rq +echo \(lqmulti\-line markdown\(rq | openstatus inc note 42 \(enmessage +\- +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident note [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(13.9n) lw(33.6n) lw(3.8n) cw(7.2n) cw(11.5n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-message=\(dq\&...\(dq\f[R] (\f[CR]\-m\f[R]) +T}@T{ +Note text in markdown (1\-10000 characters); use \- to read from stdin +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]incident link\-report\f[R] subcommand +Link a public status report to an incident. +.RS +.PP +openstatus incident link\-report openstatus inc link\-report 42 123 +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident link\-report [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(19.3n) lw(19.3n) lw(5.3n) cw(10.0n) cw(16.0n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +.TE +.SS \f[CR]incident unlink\-report\f[R] subcommand +Remove the link to an incident\(cqs status report. +.RS +.PP +openstatus incident unlink\-report openstatus inc unlink\-report 42 +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident unlink\-report [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(19.3n) lw(19.3n) lw(5.3n) cw(10.0n) cw(16.0n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +.TE +.SS \f[CR]incident close\f[R] subcommand +Close a resolved incident. +.RS +.PP +openstatus incident close openstatus inc close 42 \(enskip\-postmortem +\-y +.RE +.PP +Close a resolved incident. +A closed incident is read\-only except for its postmortem. +Requires an approved postmortem, or \(enskip\-postmortem. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident close [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(17.8n) lw(23.3n) lw(4.9n) cw(9.2n) cw(14.7n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-skip\-postmortem\f[R] +T}@T{ +Close without an approved postmortem +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-auto\-accept\f[R] (\f[CR]\-y\f[R]) +T}@T{ +Automatically accept the prompt +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]incident delete\f[R] subcommand +Delete an incident declared by mistake. +.RS +.PP +openstatus incident delete openstatus inc delete 42 \-y +.RE +.PP +Delete an incident declared by mistake. +Only allowed while the incident is open and was never mitigated, +resolved or closed; cancel it otherwise. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident delete [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(18.6n) lw(21.2n) lw(5.1n) cw(9.6n) cw(15.4n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-auto\-accept\f[R] (\f[CR]\-y\f[R]) +T}@T{ +Automatically accept the prompt +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]incident postmortem\f[R] subcommand (aliases: \f[CR]pm\f[R]) +Read, write and approve an incident\(cqs postmortem. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident postmortem [ARGUMENTS...] +.EE +.SS \f[CR]incident postmortem show\f[R] subcommand +Show the postmortem of an incident. +.RS +.PP +openstatus incident postmortem show openstatus inc pm show 42 > +postmortem.md +.RE +.PP +In a terminal, prints the review status and the markdown body through +your pager. +When piped, prints only the markdown body, so it can be edited and sent +back with `openstatus inc pm edit \(enfile postmortem.md'. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident postmortem show [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(19.3n) lw(19.3n) lw(5.3n) cw(10.0n) cw(16.0n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +.TE +.SS \f[CR]incident postmortem edit\f[R] subcommand +Write or replace the postmortem of a resolved incident. +.RS +.PP +openstatus incident postmortem edit \(enfile postmortem.md cat +postmortem.md | openstatus inc pm edit 42 \(enfile \- openstatus inc pm +edit 42 \(encontent \(lq## Summary \&...\(rq +.RE +.PP +Replaces the whole markdown body. +An approved postmortem stays approved. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident postmortem edit [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(16.0n) lw(28.1n) lw(4.4n) cw(8.3n) cw(13.2n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-file=\(dq\&...\(dq\f[R] +T}@T{ +Markdown file to upload; use \- to read from stdin +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-content=\(dq\&...\(dq\f[R] +T}@T{ +Markdown body passed inline +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]incident postmortem approve\f[R] subcommand +Approve the postmortem, optionally closing the incident. +.RS +.PP +openstatus incident postmortem approve openstatus inc pm approve 42 +\(enclose \-y +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] incident postmortem approve [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(18.6n) lw(21.2n) lw(5.1n) cw(9.6n) cw(15.4n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-close\f[R] +T}@T{ +Also close the incident +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-auto\-accept\f[R] (\f[CR]\-y\f[R]) +T}@T{ +Automatically accept the prompt +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +.TE .SS \f[CR]maintenance\f[R] command (aliases: \f[CR]mt\f[R]) Manage maintenance windows. .PP diff --git a/go.mod b/go.mod index 6af5836..22687d1 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ module github.com/openstatusHQ/cli -go 1.25.0 +go 1.26.0 require github.com/urfave/cli/v3 v3.9.0 // direct require ( - buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.20.0-20260826071311-8210bb95208b.1 - buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.12-20260826071311-8210bb95208b.1 - connectrpc.com/connect v1.20.0 + buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.21.1-20261016131942-404d544bfc59.1 + buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.12-20261016131942-404d544bfc59.2 + connectrpc.com/connect v1.21.1 github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/huh v1.0.0 github.com/fatih/color v1.19.0 @@ -30,8 +30,8 @@ require ( ) require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20251209175733-2a1774d88802.1 // indirect - buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.12-20230414000709-087bc8072ce4.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20251209175733-2a1774d88802.2 // indirect + buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.12-20230414000709-087bc8072ce4.2 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect diff --git a/go.sum b/go.sum index e0b0c36..6ee5e3c 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,13 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20251209175733-2a1774d88802.1 h1:fllAS5JX4LI/DuJhHTRJeACHEuVU69STRW/lPPS68/M= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20251209175733-2a1774d88802.1/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= -buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.12-20230414000709-087bc8072ce4.1 h1:vZuF9KSFuaVk8BJ2oIETVL24WFc5ehiY0kdYJMrds+Q= -buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.12-20230414000709-087bc8072ce4.1/go.mod h1:Y6uGzulgNTMSJafTYzhV2xKPPiUnSoE1AStyVM9mBbI= -buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.20.0-20260826071311-8210bb95208b.1 h1:c8+IWCmwEhaEVaqWcQoYPAi33WPfGDce4taD+mll0kM= -buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.20.0-20260826071311-8210bb95208b.1/go.mod h1:X1+npKnwmGjhmqc/f0DB75aqs6IOfEsvakUTV6oDVJY= -buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.12-20260826071311-8210bb95208b.1 h1:UByAofqyXvii0fPFzno5TE0JA7p38ZEZ6YrMv2HcLhA= -buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.12-20260826071311-8210bb95208b.1/go.mod h1:0bDFGY01RucPOrga1Y+2eAfIiDrJVFGxs2bMnIyZOAc= -connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= -connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20251209175733-2a1774d88802.2 h1:27vjNrBDRX9Yylib58IV8zLnzq1ByCwRj14H07MxlFc= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20251209175733-2a1774d88802.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.12-20230414000709-087bc8072ce4.2 h1:ZXEI+qE54FRhRcNAJOpvwU6CLLTgF4Etr87l3qECkcI= +buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.12-20230414000709-087bc8072ce4.2/go.mod h1:Y6uGzulgNTMSJafTYzhV2xKPPiUnSoE1AStyVM9mBbI= +buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.21.1-20261016131942-404d544bfc59.1 h1:d/S2LMlBK8TfKsr1k6vcVGE09Xj12ErhU4H/tn5OZys= +buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.21.1-20261016131942-404d544bfc59.1/go.mod h1:+v6vxbKwdB4wi4R0kk5ZTTaZK5i/yXaxvvQ5rEsPIlU= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.12-20261016131942-404d544bfc59.2 h1:LssxxLbf9zdCvQMmwe+BhXwHg1dnUGH4NT7uaSJWzeI= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.12-20261016131942-404d544bfc59.2/go.mod h1:xoGfyLPYG1IeO8lINiEfzsnXUBtEs413IRBzyg2kaNg= +connectrpc.com/connect v1.21.1 h1:h2kacdlzXpZU2OQFSv98xkjYXNUSr/ptxmGVbS/+CRM= +connectrpc.com/connect v1.21.1/go.mod h1:fhMcVJ5w6r/yTYhnkEbW6+sIF7OZ5Ay7KYgmfebh2ek= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 8900832..bdd2984 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -30,6 +30,11 @@ func FormatError(err error, resource string, id string) error { return fmt.Errorf("rate limited. Wait a moment and try again") case connect.CodeInvalidArgument: return fmt.Errorf("invalid request: %s", connectErr.Message()) + case connect.CodeFailedPrecondition: + if connectErr.Message() == "" { + return errors.New("request conflicts with the current state of the resource") + } + return errors.New(connectErr.Message()) } } diff --git a/internal/cli/errors_test.go b/internal/cli/errors_test.go new file mode 100644 index 0000000..343b773 --- /dev/null +++ b/internal/cli/errors_test.go @@ -0,0 +1,89 @@ +package cli_test + +import ( + "errors" + "strings" + "testing" + + "connectrpc.com/connect" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +func Test_FormatError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + resource string + id string + want string + }{ + { + name: "failed precondition shows server message", + err: connect.NewError(connect.CodeFailedPrecondition, errors.New("incident is closed")), + want: "incident is closed", + }, + { + name: "failed precondition without message", + err: connect.NewError(connect.CodeFailedPrecondition, errors.New("")), + want: "request conflicts with the current state of the resource", + }, + { + name: "unauthenticated", + err: connect.NewError(connect.CodeUnauthenticated, errors.New("nope")), + want: "authentication failed", + }, + { + name: "permission denied", + err: connect.NewError(connect.CodePermissionDenied, errors.New("nope")), + want: "permission denied", + }, + { + name: "not found with id", + err: connect.NewError(connect.CodeNotFound, errors.New("missing")), + resource: "incident", + id: "42", + want: "incident 42 not found. Run 'openstatus incident list' to see available incidents", + }, + { + name: "not found without id", + err: connect.NewError(connect.CodeNotFound, errors.New("missing")), + resource: "incident", + want: "incident not found", + }, + { + name: "rate limited", + err: connect.NewError(connect.CodeResourceExhausted, errors.New("slow down")), + want: "rate limited", + }, + { + name: "invalid argument", + err: connect.NewError(connect.CodeInvalidArgument, errors.New("title is required")), + want: "invalid request: title is required", + }, + { + name: "plain error passes through", + err: errors.New("boom"), + want: "boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := output.FormatError(tt.err, tt.resource, tt.id) + if got == nil || !strings.Contains(got.Error(), tt.want) { + t.Errorf("FormatError() = %v, want it to contain %q", got, tt.want) + } + }) + } + + t.Run("nil error", func(t *testing.T) { + t.Parallel() + if err := output.FormatError(nil, "incident", ""); err != nil { + t.Errorf("expected nil, got %v", err) + } + }) +} diff --git a/internal/cmd/app.go b/internal/cmd/app.go index 1e728bb..587eb10 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -12,6 +12,7 @@ import ( "github.com/openstatusHQ/cli/internal/api" "github.com/openstatusHQ/cli/internal/check" output "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/incident" "github.com/openstatusHQ/cli/internal/login" "github.com/openstatusHQ/cli/internal/maintenance" "github.com/openstatusHQ/cli/internal/monitors" @@ -32,13 +33,15 @@ func NewApp() *cli.Command { EnableShellCompletion: true, Usage: "Manage status pages, monitors, and incidents from the terminal", Description: `OpenStatus CLI lets you manage your status pages and uptime monitors -from the command line. Report and track incidents, define monitors as code, -and run on-demand checks. +from the command line. Declare and run incidents, post public status updates, +define monitors as code, and run on-demand checks. Get started: openstatus login Save your API token - openstatus status-report create Report an incident - openstatus status-report list View active incidents + openstatus incident declare Declare an incident + openstatus incident list View incidents + openstatus status-report create Post a public status update + openstatus status-report list View status reports openstatus maintenance create Schedule a maintenance window openstatus maintenance list View maintenance windows openstatus monitors apply Sync monitors from config @@ -78,6 +81,7 @@ https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, check.CheckCmd(), monitors.MonitorsCmd(), statusreport.StatusReportCmd(), + incident.IncidentCmd(), maintenance.MaintenanceCmd(), statuspage.StatusPageCmd(), notification.NotificationCmd(), diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go index 8c33ac2..c873d3d 100644 --- a/internal/cmd/app_test.go +++ b/internal/cmd/app_test.go @@ -33,14 +33,15 @@ func Test_NewApp(t *testing.T) { t.Run("Has expected commands", func(t *testing.T) { app := cmd.NewApp() - if len(app.Commands) != 12 { - t.Errorf("Expected 12 commands, got %d", len(app.Commands)) + if len(app.Commands) != 13 { + t.Errorf("Expected 13 commands, got %d", len(app.Commands)) } expectedCommands := map[string]bool{ "check": false, "monitors": false, "status-report": false, + "incident": false, "maintenance": false, "status-page": false, "notification": false, diff --git a/internal/incident/errors.go b/internal/incident/errors.go new file mode 100644 index 0000000..52cd82f --- /dev/null +++ b/internal/incident/errors.go @@ -0,0 +1,86 @@ +package incident + +import ( + "errors" + "fmt" + "strings" + + "connectrpc.com/connect" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +type op int + +const ( + opGeneric op = iota + opSetStatus + opDelete + opClose + opApprove + opLink + opDeclare +) + +func formatIncidentError(err error, id string, o op) error { + var connectErr *connect.Error + if !errors.As(err, &connectErr) { + return output.FormatError(err, "incident", id) + } + + msg := strings.TrimSuffix(connectErr.Message(), ".") + + switch connectErr.Code() { + case connect.CodeFailedPrecondition: + if hint := preconditionHint(id, o); hint != "" { + if msg == "" { + return errors.New(hint) + } + return fmt.Errorf("%s. %s", msg, hint) + } + case connect.CodeNotFound: + switch o { + case opApprove: + if strings.Contains(strings.ToLower(msg), "postmortem") { + return fmt.Errorf("no postmortem written yet for incident %s. Write one first: openstatus inc postmortem edit %s --file postmortem.md", id, id) + } + case opLink, opDeclare: + if msg != "" { + return errors.New(msg) + } + } + case connect.CodePermissionDenied: + if hint := permissionHint(o); hint != "" { + if msg == "" { + return fmt.Errorf("permission denied. %s", hint) + } + return fmt.Errorf("permission denied: %s. %s", msg, hint) + } + } + + return output.FormatError(err, "incident", id) +} + +func preconditionHint(id string, o op) string { + switch o { + case opSetStatus: + return fmt.Sprintf("Run 'openstatus inc info %s' to see allowed transitions.", id) + case opDelete: + return fmt.Sprintf("Only incidents that are still open and were never mitigated can be deleted; use 'openstatus inc cancel %s' instead.", id) + case opClose: + return "Close requires a resolved incident and an approved postmortem (or --skip-postmortem)." + default: + return "" + } +} + +func permissionHint(o op) string { + switch o { + case opClose, opApprove: + return "This action requires a workspace owner or admin, or the incident commander. API keys without a creator cannot perform it." + case opDelete: + return "This action requires a workspace owner or admin. API keys without a creator cannot perform it." + default: + return "" + } +} diff --git a/internal/incident/export_test.go b/internal/incident/export_test.go new file mode 100644 index 0000000..ca2882e --- /dev/null +++ b/internal/incident/export_test.go @@ -0,0 +1,28 @@ +package incident + +var ( + SeverityToSDK = severityToSDK + SeverityToString = severityToString + StatusToSDK = statusToSDK + StatusToString = statusToString + ParseStatuses = parseStatuses + EventTypeToString = eventTypeToString + FormatUser = formatUser + ShortUser = shortUser + DisplayStatus = displayStatus + ReadTextArg = readTextArg + NextStepHint = nextStepHint + NeedsConfirmation = needsConfirmation + ListFooter = listFooter + BuildListRequest = buildListRequest + BuildDeclareRequest = buildDeclareRequest + MissingDeclareFlags = missingDeclareFlags + ValidateDeclareFlags = validateProvidedDeclareFlags + BuildUpdateRequest = buildUpdateRequest + BuildSetStatusRequest = buildSetStatusRequest + BuildNoteRequest = buildNoteRequest + InfoRows = infoRows + StatusDoneWord = statusDoneWord + ResolvePostmortemContent = resolvePostmortemContent + WritePostmortem = writePostmortem +) diff --git a/internal/incident/incident.go b/internal/incident/incident.go new file mode 100644 index 0000000..13c15cd --- /dev/null +++ b/internal/incident/incident.go @@ -0,0 +1,534 @@ +package incident + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/mail" + "os" + "regexp" + "strings" + "time" + "unicode/utf8" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/incident/v1/incidentv1connect" + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + "connectrpc.com/connect" + "github.com/fatih/color" + "github.com/mattn/go-isatty" + "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/renderer" + "github.com/olekukonko/tablewriter/tw" + "github.com/urfave/cli/v3" + + "github.com/openstatusHQ/cli/internal/api" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" +) + +func NewIncidentClient(apiKey string) incidentv1connect.IncidentServiceClient { + return NewIncidentClientWithHTTPClient(api.DefaultHTTPClient, apiKey) +} + +func NewIncidentClientWithHTTPClient(httpClient *http.Client, apiKey string) incidentv1connect.IncidentServiceClient { + return incidentv1connect.NewIncidentServiceClient( + httpClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) +} + +func severityToSDK(s string) (incidentv1.IncidentSeverity, error) { + switch s { + case "critical": + return incidentv1.IncidentSeverity_INCIDENT_SEVERITY_CRITICAL, nil + case "major": + return incidentv1.IncidentSeverity_INCIDENT_SEVERITY_MAJOR, nil + case "minor": + return incidentv1.IncidentSeverity_INCIDENT_SEVERITY_MINOR, nil + default: + return incidentv1.IncidentSeverity_INCIDENT_SEVERITY_UNSPECIFIED, + fmt.Errorf("invalid severity %q: must be one of critical, major, minor", s) + } +} + +func severityToString(s incidentv1.IncidentSeverity) string { + switch s { + case incidentv1.IncidentSeverity_INCIDENT_SEVERITY_CRITICAL: + return "critical" + case incidentv1.IncidentSeverity_INCIDENT_SEVERITY_MAJOR: + return "major" + case incidentv1.IncidentSeverity_INCIDENT_SEVERITY_MINOR: + return "minor" + default: + return "unknown" + } +} + +func severityColor(s string) string { + switch s { + case "critical": + return color.New(color.FgRed, color.Bold).Sprint(s) + case "major": + return color.MagentaString(s) + case "minor": + return color.YellowString(s) + default: + return s + } +} + +func statusToSDK(s string) (incidentv1.IncidentStatus, error) { + switch s { + case "open": + return incidentv1.IncidentStatus_INCIDENT_STATUS_OPEN, nil + case "mitigated": + return incidentv1.IncidentStatus_INCIDENT_STATUS_MITIGATED, nil + case "resolved": + return incidentv1.IncidentStatus_INCIDENT_STATUS_RESOLVED, nil + case "canceled": + return incidentv1.IncidentStatus_INCIDENT_STATUS_CANCELED, nil + default: + return incidentv1.IncidentStatus_INCIDENT_STATUS_UNSPECIFIED, + fmt.Errorf("invalid status %q: must be one of open, mitigated, resolved, canceled", s) + } +} + +func statusToString(s incidentv1.IncidentStatus) string { + switch s { + case incidentv1.IncidentStatus_INCIDENT_STATUS_OPEN: + return "open" + case incidentv1.IncidentStatus_INCIDENT_STATUS_MITIGATED: + return "mitigated" + case incidentv1.IncidentStatus_INCIDENT_STATUS_RESOLVED: + return "resolved" + case incidentv1.IncidentStatus_INCIDENT_STATUS_CANCELED: + return "canceled" + default: + return "unknown" + } +} + +func statusColor(s string) string { + switch s { + case "open": + return color.RedString(s) + case "mitigated": + return color.YellowString(s) + case "resolved": + return color.GreenString(s) + case "canceled": + return color.New(color.Faint).Sprint(s) + default: + return s + } +} + +func statusesToStrings(statuses []incidentv1.IncidentStatus) []string { + out := make([]string, 0, len(statuses)) + for _, s := range statuses { + out = append(out, statusToString(s)) + } + return out +} + +func parseStatuses(raw string) ([]incidentv1.IncidentStatus, error) { + if strings.TrimSpace(raw) == "" { + return nil, nil + } + seen := make(map[incidentv1.IncidentStatus]bool) + var out []incidentv1.IncidentStatus + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + return nil, fmt.Errorf("invalid --status %q: empty value in list", raw) + } + s, err := statusToSDK(part) + if err != nil { + return nil, err + } + if seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out, nil +} + +func eventTypeToString(t incidentv1.IncidentEventType) string { + if t == incidentv1.IncidentEventType_INCIDENT_EVENT_TYPE_UNSPECIFIED { + return "event" + } + name := strings.TrimPrefix(t.String(), "INCIDENT_EVENT_TYPE_") + if name == t.String() { + return "event" + } + return strings.ToLower(name) +} + +func statusReportStatusToString(name string) string { + return strings.ToLower(strings.TrimPrefix(name, "STATUS_REPORT_STATUS_")) +} + +func formatUser(u *incidentv1.IncidentUser) string { + if u == nil { + return "unassigned" + } + switch { + case u.GetName() != "" && u.GetEmail() != "": + return fmt.Sprintf("%s <%s>", u.GetName(), u.GetEmail()) + case u.GetEmail() != "": + return u.GetEmail() + case u.GetName() != "": + return u.GetName() + default: + return "unassigned" + } +} + +func shortUser(u *incidentv1.IncidentUser) string { + if u == nil { + return "unassigned" + } + if u.GetName() != "" { + return u.GetName() + } + if u.GetEmail() != "" { + return u.GetEmail() + } + return "unassigned" +} + +func displayStatus(status incidentv1.IncidentStatus, closedAt string) string { + s := statusToString(status) + out := statusColor(s) + if closedAt != "" && s != "canceled" { + out += " (closed)" + } + return out +} + +func validateEmail(flag, v string) error { + addr, err := mail.ParseAddress(v) + if err != nil || addr.Address != v { + return fmt.Errorf("invalid %s %q: must be an email address", flag, v) + } + return nil +} + +func validateTimestamp(flag, v string) error { + if _, err := time.Parse(time.RFC3339, v); err != nil { + return fmt.Errorf("invalid %s %q: must be RFC 3339, e.g. 2026-10-16T10:30:00Z", flag, v) + } + return nil +} + +type userJSON struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type statusReportRefJSON struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + PageID string `json:"page_id,omitempty"` +} + +type incidentSummaryJSON struct { + ID string `json:"id"` + Title string `json:"title"` + Severity string `json:"severity"` + Status string `json:"status"` + Commander *userJSON `json:"commander,omitempty"` + DeclaredAt string `json:"declared_at"` + StartedAt string `json:"started_at"` + ResolvedAt string `json:"resolved_at,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` + StatusReport *statusReportRefJSON `json:"status_report,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type incidentEventJSON struct { + ID string `json:"id"` + Type string `json:"type"` + Message string `json:"message"` + CreatedBy *userJSON `json:"created_by,omitempty"` + CreatedAt string `json:"created_at"` +} + +type incidentDetail struct { + ID string `json:"id"` + Title string `json:"title"` + Severity string `json:"severity"` + Status string `json:"status"` + Summary string `json:"summary,omitempty"` + Commander *userJSON `json:"commander,omitempty"` + DeclaredBy *userJSON `json:"declared_by,omitempty"` + ResolvedBy *userJSON `json:"resolved_by,omitempty"` + DeclaredAt string `json:"declared_at"` + StartedAt string `json:"started_at"` + MitigatedAt string `json:"mitigated_at,omitempty"` + ResolvedAt string `json:"resolved_at,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` + StatusReport *statusReportRefJSON `json:"status_report,omitempty"` + SlackChannelURL string `json:"slack_channel_url,omitempty"` + AllowedTransitions []string `json:"allowed_transitions"` + Deletable bool `json:"deletable"` + Events []incidentEventJSON `json:"events,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toUserJSON(u *incidentv1.IncidentUser) *userJSON { + if u == nil { + return nil + } + return &userJSON{Name: u.GetName(), Email: u.GetEmail()} +} + +func toStatusReportRefJSON(r *incidentv1.IncidentStatusReport) *statusReportRefJSON { + if r == nil { + return nil + } + return &statusReportRefJSON{ + ID: r.GetId(), + Title: r.GetTitle(), + Status: statusReportStatusToString(r.GetStatus().String()), + PageID: r.GetPageId(), + } +} + +func toSummaryJSON(s *incidentv1.IncidentSummary) incidentSummaryJSON { + return incidentSummaryJSON{ + ID: s.GetId(), + Title: s.GetTitle(), + Severity: severityToString(s.GetSeverity()), + Status: statusToString(s.GetStatus()), + Commander: toUserJSON(s.GetCommander()), + DeclaredAt: s.GetDeclaredAt(), + StartedAt: s.GetStartedAt(), + ResolvedAt: s.GetResolvedAt(), + ClosedAt: s.GetClosedAt(), + StatusReport: toStatusReportRefJSON(s.GetStatusReport()), + CreatedAt: s.GetCreatedAt(), + UpdatedAt: s.GetUpdatedAt(), + } +} + +func toEventJSON(e *incidentv1.IncidentEvent) incidentEventJSON { + return incidentEventJSON{ + ID: e.GetId(), + Type: eventTypeToString(e.GetType()), + Message: e.GetMessage(), + CreatedBy: toUserJSON(e.GetCreatedBy()), + CreatedAt: e.GetCreatedAt(), + } +} + +func toDetailJSON(inc *incidentv1.Incident) incidentDetail { + d := incidentDetail{ + ID: inc.GetId(), + Title: inc.GetTitle(), + Severity: severityToString(inc.GetSeverity()), + Status: statusToString(inc.GetStatus()), + Summary: inc.GetSummary(), + Commander: toUserJSON(inc.GetCommander()), + DeclaredBy: toUserJSON(inc.GetDeclaredBy()), + ResolvedBy: toUserJSON(inc.GetResolvedBy()), + DeclaredAt: inc.GetDeclaredAt(), + StartedAt: inc.GetStartedAt(), + MitigatedAt: inc.GetMitigatedAt(), + ResolvedAt: inc.GetResolvedAt(), + ClosedAt: inc.GetClosedAt(), + StatusReport: toStatusReportRefJSON(inc.GetStatusReport()), + SlackChannelURL: inc.GetSlackChannelUrl(), + AllowedTransitions: statusesToStrings(inc.GetAllowedTransitions()), + Deletable: inc.GetDeletable(), + CreatedAt: inc.GetCreatedAt(), + UpdatedAt: inc.GetUpdatedAt(), + } + for _, e := range inc.GetEvents() { + d.Events = append(d.Events, toEventJSON(e)) + } + return d +} + +func readTextArg(value string, stdin io.Reader) (string, error) { + if value != "-" { + return value, nil + } + if f, ok := stdin.(*os.File); ok && (isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())) { + fmt.Fprintln(os.Stderr, "Reading from stdin, press Ctrl-D to finish.") + } + data, err := io.ReadAll(stdin) + if err != nil { + return "", fmt.Errorf("failed to read stdin: %w", err) + } + text := trimTrailingNewline(string(data)) + if strings.TrimSpace(text) == "" { + return "", errors.New("no input received on stdin") + } + return text, nil +} + +func trimTrailingNewline(s string) string { + return strings.TrimSuffix(strings.TrimSuffix(s, "\n"), "\r") +} + +var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// visibleWidth ignores ANSI color codes so colored table cells stay aligned. +func visibleWidth(s string) int { + return utf8.RuneCountInString(ansiEscape.ReplaceAllString(s, "")) +} + +func nextStepHint(inc *incidentv1.Incident) string { + if inc.GetClosedAt() != "" { + return "" + } + id := inc.GetId() + switch inc.GetStatus() { + case incidentv1.IncidentStatus_INCIDENT_STATUS_OPEN: + return fmt.Sprintf("Next: openstatus inc mitigate %s --note \"...\" or openstatus inc resolve %s", id, id) + case incidentv1.IncidentStatus_INCIDENT_STATUS_MITIGATED: + return fmt.Sprintf("Next: openstatus inc resolve %s --note \"...\"", id) + case incidentv1.IncidentStatus_INCIDENT_STATUS_RESOLVED: + return fmt.Sprintf("Write the postmortem: openstatus inc postmortem edit %s --file postmortem.md\nOr close without one: openstatus inc close %s --skip-postmortem", id, id) + default: + return "" + } +} + +func renderIncidentResult(inc *incidentv1.Incident, success string) error { + if output.IsJSONOutput() { + return output.PrintJSON(toDetailJSON(inc)) + } + if output.IsQuiet() { + fmt.Println(inc.GetId()) + return nil + } + fmt.Println(success) + if hint := nextStepHint(inc); hint != "" { + fmt.Println(hint) + } + return nil +} + +func newBlueprintTable() *tablewriter.Table { + return tablewriter.NewTable(os.Stdout, + tablewriter.WithRenderer(renderer.NewBlueprint()), + tablewriter.WithRendition(tw.Rendition{ + Symbols: tw.NewSymbolCustom("custom").WithColumn("="), + Borders: tw.Border{ + Top: tw.Off, + Left: tw.Off, + Right: tw.Off, + Bottom: tw.Off, + }, + Settings: tw.Settings{ + Lines: tw.Lines{ + ShowHeaderLine: tw.Off, + ShowFooterLine: tw.On, + }, + Separators: tw.Separators{ + BetweenRows: tw.Off, + BetweenColumns: tw.On, + }, + }, + }), + tablewriter.WithRowAlignment(tw.AlignLeft), + tablewriter.WithHeaderAlignment(tw.AlignLeft), + ) +} + +func accessTokenFlag() cli.Flag { + return &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + } +} + +func autoAcceptFlag() cli.Flag { + return &cli.BoolFlag{ + Name: "auto-accept", + Usage: "Automatically accept the prompt", + Aliases: []string{"y"}, + } +} + +func requireArg(cmd *cli.Command, index int, name, usage string) (string, error) { + v := cmd.Args().Get(index) + if v == "" { + fmt.Fprintln(os.Stderr, "Usage: "+usage) + return "", fmt.Errorf("%s is required", name) + } + return v, nil +} + +func requireID(cmd *cli.Command, usage string) (string, error) { + return requireArg(cmd, 0, "incident ID", usage) +} + +func confirm(cmd *cli.Command, prompt string) (bool, error) { + if cmd.Bool("auto-accept") { + return true, nil + } + confirmed, err := output.AskForConfirmation(prompt) + if err != nil { + return false, fmt.Errorf("failed to read input: %w", err) + } + return confirmed, nil +} + +type actionFunc func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error + +func withClient(fn actionFunc) cli.ActionFunc { + return func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + if err := fn(ctx, cmd, NewIncidentClient(apiKey)); err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + } +} + +func IncidentCmd() *cli.Command { + return &cli.Command{ + Name: "incident", + Aliases: []string{"inc", "incidents"}, + Usage: "Declare and run incidents", + Description: `Incidents are the internal response to an outage: severity, commander, +lifecycle (open -> mitigated -> resolved, or canceled), timeline notes, a linked +public status report and the postmortem. + +Use 'openstatus status-report' for the public communication on a status page.`, + Commands: []*cli.Command{ + GetIncidentListCmd(), + GetIncidentInfoCmd(), + GetIncidentDeclareCmd(), + GetIncidentUpdateCmd(), + GetIncidentSetStatusCmd(), + newStatusVerbCmd(verbMitigate), + newStatusVerbCmd(verbResolve), + newStatusVerbCmd(verbReopen), + newStatusVerbCmd(verbCancel), + GetIncidentNoteCmd(), + GetIncidentLinkReportCmd(), + GetIncidentUnlinkReportCmd(), + GetIncidentCloseCmd(), + GetIncidentDeleteCmd(), + postmortemCmd(), + }, + } +} diff --git a/internal/incident/incident_close.go b/internal/incident/incident_close.go new file mode 100644 index 0000000..3f45a72 --- /dev/null +++ b/internal/incident/incident_close.go @@ -0,0 +1,75 @@ +package incident + +import ( + "context" + "fmt" + "net/http" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/incident/v1/incidentv1connect" + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + "github.com/urfave/cli/v3" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +const closeUsage = "openstatus incident close [--skip-postmortem] [-y]" + +func CloseIncident(ctx context.Context, client incidentv1connect.IncidentServiceClient, id string, skipPostmortem bool) (*incidentv1.Incident, error) { + req := &incidentv1.CloseIncidentRequest{Id: id} + if skipPostmortem { + req.SetSkipPostmortem(true) + } + resp, err := client.CloseIncident(ctx, req) + if err != nil { + return nil, formatIncidentError(err, id, opClose) + } + return resp.GetIncident(), nil +} + +func CloseIncidentWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id string, skipPostmortem bool) error { + inc, err := CloseIncident(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id, skipPostmortem) + if err != nil { + return err + } + return renderIncidentResult(inc, fmt.Sprintf("Incident %s closed", id)) +} + +func GetIncidentCloseCmd() *cli.Command { + return &cli.Command{ + Name: "close", + Usage: "Close a resolved incident", + UsageText: `openstatus incident close + openstatus inc close 42 --skip-postmortem -y`, + Description: `Close a resolved incident. A closed incident is read-only except for its +postmortem. Requires an approved postmortem, or --skip-postmortem.`, + Flags: []cli.Flag{ + accessTokenFlag(), + &cli.BoolFlag{ + Name: "skip-postmortem", + Usage: "Close without an approved postmortem", + }, + autoAcceptFlag(), + }, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, closeUsage) + if err != nil { + return err + } + confirmed, err := confirm(cmd, fmt.Sprintf("You are about to close incident %s. It becomes read-only except for its postmortem. Continue", id)) + if err != nil { + return err + } + if !confirmed { + return nil + } + + s := output.StartSpinner("Closing incident...") + inc, err := CloseIncident(ctx, client, id, cmd.Bool("skip-postmortem")) + output.StopSpinner(s) + if err != nil { + return err + } + return renderIncidentResult(inc, fmt.Sprintf("Incident %s closed", id)) + }), + } +} diff --git a/internal/incident/incident_close_delete_test.go b/internal/incident/incident_close_delete_test.go new file mode 100644 index 0000000..c16a96d --- /dev/null +++ b/internal/incident/incident_close_delete_test.go @@ -0,0 +1,107 @@ +package incident_test + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + + "github.com/openstatusHQ/cli/internal/incident" +) + +const closedResponse = `{"incident":{"id":"42","status":"INCIDENT_STATUS_RESOLVED","closedAt":"2026-10-16T12:00:00Z"}}` + +func Test_CloseIncident(t *testing.T) { + t.Run("skip postmortem only when set", func(t *testing.T) { + for _, skip := range []bool{false, true} { + srv := newServer(http.StatusOK, closedResponse) + out := captureStdout(t, func() { + if err := incident.CloseIncidentWithHTTPClient(context.Background(), srv.client(), "token", "42", skip); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + req := decodeBody(t, srv.reqBody, &incidentv1.CloseIncidentRequest{}) + if req.HasSkipPostmortem() != skip { + t.Errorf("skip=%v: unexpected request %v", skip, req) + } + if strings.TrimSpace(out) != "Incident 42 closed" { + t.Errorf("unexpected output %q", out) + } + } + }) + + t.Run("refused close explains requirements", func(t *testing.T) { + srv := errorServer(http.StatusBadRequest, "failed_precondition", "postmortem is not approved") + err := incident.CloseIncidentWithHTTPClient(context.Background(), srv.client(), "token", "42", false) + if err == nil || !strings.Contains(err.Error(), "postmortem is not approved. Close requires a resolved incident") { + t.Errorf("unexpected error %v", err) + } + }) + + t.Run("permission denied explains roles", func(t *testing.T) { + srv := errorServer(http.StatusForbidden, "permission_denied", "forbidden") + err := incident.CloseIncidentWithHTTPClient(context.Background(), srv.client(), "token", "42", true) + if err == nil || !strings.Contains(err.Error(), "or the incident commander") { + t.Errorf("unexpected error %v", err) + } + }) +} + +func Test_DeleteIncident(t *testing.T) { + t.Run("deletes", func(t *testing.T) { + srv := newServer(http.StatusOK, `{"success":true}`) + var err error + out := captureStdout(t, func() { + err = incident.DeleteIncidentWithHTTPClient(context.Background(), srv.client(), "token", "42") + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := decodeBody(t, srv.reqBody, &incidentv1.DeleteIncidentRequest{}) + if req.GetId() != "42" { + t.Errorf("unexpected request %v", req) + } + if !strings.Contains(out, "Incident 42 deleted") { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("json output", func(t *testing.T) { + withOutputModes(t, true, false) + srv := newServer(http.StatusOK, `{"success":true}`) + out := captureStdout(t, func() { + if err := incident.DeleteIncidentWithHTTPClient(context.Background(), srv.client(), "token", "42"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var got struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if got.ID != "42" || !got.Deleted { + t.Errorf("unexpected payload %+v", got) + } + }) + + t.Run("refused delete suggests cancel", func(t *testing.T) { + srv := errorServer(http.StatusBadRequest, "failed_precondition", "incident was mitigated") + err := incident.DeleteIncidentWithHTTPClient(context.Background(), srv.client(), "token", "42") + if err == nil || !strings.Contains(err.Error(), "use 'openstatus inc cancel 42' instead") { + t.Errorf("unexpected error %v", err) + } + }) + + t.Run("permission denied explains roles", func(t *testing.T) { + srv := errorServer(http.StatusForbidden, "permission_denied", "forbidden") + err := incident.DeleteIncidentWithHTTPClient(context.Background(), srv.client(), "token", "42") + if err == nil || !strings.Contains(err.Error(), "requires a workspace owner or admin.") { + t.Errorf("unexpected error %v", err) + } + }) +} diff --git a/internal/incident/incident_declare.go b/internal/incident/incident_declare.go new file mode 100644 index 0000000..2cf0c50 --- /dev/null +++ b/internal/incident/incident_declare.go @@ -0,0 +1,231 @@ +package incident + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "unicode/utf8" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/incident/v1/incidentv1connect" + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + "github.com/urfave/cli/v3" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +const ( + maxTitleLen = 256 + maxSummaryLen = 4000 +) + +type DeclareParams struct { + Title string + Severity string + Summary string + Commander string + StartedAt string + StatusReportID string + Slack bool +} + +func validateTitle(title string) error { + if strings.TrimSpace(title) == "" { + return errors.New("title cannot be empty") + } + if utf8.RuneCountInString(title) > maxTitleLen { + return fmt.Errorf("title is too long: maximum %d characters", maxTitleLen) + } + return nil +} + +func validateSummary(summary string) error { + if utf8.RuneCountInString(summary) > maxSummaryLen { + return fmt.Errorf("summary is too long: maximum %d characters", maxSummaryLen) + } + return nil +} + +func missingDeclareFlags(p DeclareParams) []string { + var missing []string + if p.Title == "" { + missing = append(missing, "--title") + } + if p.Severity == "" { + missing = append(missing, "--severity") + } + return missing +} + +// validateProvidedDeclareFlags checks the flags that were passed so the wizard +// does not prompt for the rest before rejecting a bad value. +func validateProvidedDeclareFlags(p DeclareParams) error { + if p.Title != "" { + if err := validateTitle(p.Title); err != nil { + return err + } + } + if p.Severity != "" { + if _, err := severityToSDK(p.Severity); err != nil { + return err + } + } + if err := validateSummary(p.Summary); err != nil { + return err + } + if p.Commander != "" { + if err := validateEmail("--commander", p.Commander); err != nil { + return err + } + } + if p.StartedAt != "" { + if err := validateTimestamp("--started-at", p.StartedAt); err != nil { + return err + } + } + return nil +} + +func buildDeclareRequest(p DeclareParams) (*incidentv1.DeclareIncidentRequest, error) { + if err := validateTitle(p.Title); err != nil { + return nil, err + } + severity, err := severityToSDK(p.Severity) + if err != nil { + return nil, err + } + if err := validateSummary(p.Summary); err != nil { + return nil, err + } + + req := &incidentv1.DeclareIncidentRequest{ + Title: p.Title, + Severity: severity, + } + if p.Summary != "" { + req.SetSummary(p.Summary) + } + if p.Commander != "" { + if err := validateEmail("--commander", p.Commander); err != nil { + return nil, err + } + req.SetCommanderEmail(p.Commander) + } + if p.StartedAt != "" { + if err := validateTimestamp("--started-at", p.StartedAt); err != nil { + return nil, err + } + req.SetStartedAt(p.StartedAt) + } + if p.StatusReportID != "" { + req.SetStatusReportId(p.StatusReportID) + } + if p.Slack { + req.SetOpenSlackChannel(true) + } + return req, nil +} + +func DeclareIncident(ctx context.Context, client incidentv1connect.IncidentServiceClient, p DeclareParams) (*incidentv1.Incident, error) { + req, err := buildDeclareRequest(p) + if err != nil { + return nil, err + } + resp, err := client.DeclareIncident(ctx, req) + if err != nil { + return nil, formatIncidentError(err, "", opDeclare) + } + return resp.GetIncident(), nil +} + +func declaredMessage(inc *incidentv1.Incident) string { + return fmt.Sprintf("Incident declared (ID: %s)", inc.GetId()) +} + +func DeclareIncidentWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, p DeclareParams) error { + inc, err := DeclareIncident(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), p) + if err != nil { + return err + } + return renderIncidentResult(inc, declaredMessage(inc)) +} + +func GetIncidentDeclareCmd() *cli.Command { + return &cli.Command{ + Name: "declare", + Aliases: []string{"create"}, + Usage: "Declare a new incident", + UsageText: `openstatus incident declare --title "Checkout API returns 502" --severity major + openstatus inc declare --title "Database down" --severity critical --commander jane@example.com --slack + ID=$(openstatus inc declare --title "Login errors" --severity minor -q)`, + Description: `Declare a new incident in the open status. + +Run without --title or --severity in an interactive terminal to be prompted. +In scripts, pass every required flag.`, + Flags: []cli.Flag{ + accessTokenFlag(), + &cli.StringFlag{ + Name: "title", + Usage: "Title of the incident (1-256 characters)", + }, + &cli.StringFlag{ + Name: "severity", + Usage: "Severity of the incident (critical, major, minor)", + }, + &cli.StringFlag{ + Name: "summary", + Usage: "Short human summary", + }, + &cli.StringFlag{ + Name: "commander", + Usage: "Email of the workspace member leading the response", + }, + &cli.StringFlag{ + Name: "started-at", + Usage: "When the impact began (RFC 3339, defaults to now)", + }, + &cli.StringFlag{ + Name: "status-report-id", + Usage: "ID of an existing status report to link", + }, + &cli.BoolFlag{ + Name: "slack", + Usage: "Open a dedicated Slack channel when Slack is connected", + }, + }, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + p := DeclareParams{ + Title: cmd.String("title"), + Severity: cmd.String("severity"), + Summary: cmd.String("summary"), + Commander: cmd.String("commander"), + StartedAt: cmd.String("started-at"), + StatusReportID: cmd.String("status-report-id"), + Slack: cmd.Bool("slack"), + } + + if missing := missingDeclareFlags(p); len(missing) > 0 { + if output.IsJSONOutput() || !output.IsStdinTerminal() { + return fmt.Errorf("missing required flags: %s", strings.Join(missing, ", ")) + } + if err := validateProvidedDeclareFlags(p); err != nil { + return err + } + var err error + p, err = runDeclareWizard(p) + if err != nil { + return err + } + } + + s := output.StartSpinner("Declaring incident...") + inc, err := DeclareIncident(ctx, client, p) + output.StopSpinner(s) + if err != nil { + return err + } + return renderIncidentResult(inc, declaredMessage(inc)) + }), + } +} diff --git a/internal/incident/incident_declare_test.go b/internal/incident/incident_declare_test.go new file mode 100644 index 0000000..d128ed9 --- /dev/null +++ b/internal/incident/incident_declare_test.go @@ -0,0 +1,147 @@ +package incident_test + +import ( + "context" + "net/http" + "slices" + "strings" + "testing" + + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + + "github.com/openstatusHQ/cli/internal/incident" +) + +func Test_BuildDeclareRequest(t *testing.T) { + t.Parallel() + + t.Run("minimal request leaves optional fields unset", func(t *testing.T) { + t.Parallel() + req, err := incident.BuildDeclareRequest(incident.DeclareParams{Title: "Checkout 502", Severity: "major"}) + if err != nil { + t.Fatal(err) + } + if req.GetTitle() != "Checkout 502" || req.GetSeverity() != incidentv1.IncidentSeverity_INCIDENT_SEVERITY_MAJOR { + t.Errorf("unexpected request %v", req) + } + if req.HasSummary() || req.HasCommanderEmail() || req.HasStartedAt() || req.HasStatusReportId() || req.HasOpenSlackChannel() { + t.Errorf("expected optional fields unset, got %v", req) + } + }) + + t.Run("all fields", func(t *testing.T) { + t.Parallel() + req, err := incident.BuildDeclareRequest(incident.DeclareParams{ + Title: "Database down", + Severity: "critical", + Summary: "Primary unreachable", + Commander: "jane@example.com", + StartedAt: "2026-10-16T09:55:00Z", + StatusReportID: "123", + Slack: true, + }) + if err != nil { + t.Fatal(err) + } + if req.GetSummary() != "Primary unreachable" || req.GetCommanderEmail() != "jane@example.com" || + req.GetStartedAt() != "2026-10-16T09:55:00Z" || req.GetStatusReportId() != "123" || !req.GetOpenSlackChannel() { + t.Errorf("unexpected request %v", req) + } + }) + + t.Run("validation errors", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + p incident.DeclareParams + want string + }{ + {name: "bad severity", p: incident.DeclareParams{Title: "x", Severity: "high"}, want: "invalid severity"}, + {name: "bad commander", p: incident.DeclareParams{Title: "x", Severity: "minor", Commander: "jane"}, want: "invalid --commander"}, + {name: "named commander", p: incident.DeclareParams{Title: "x", Severity: "minor", Commander: "Jane "}, want: "invalid --commander"}, + {name: "bad started-at", p: incident.DeclareParams{Title: "x", Severity: "minor", StartedAt: "yesterday"}, want: "invalid --started-at"}, + {name: "blank title", p: incident.DeclareParams{Title: " ", Severity: "minor"}, want: "title cannot be empty"}, + {name: "long title", p: incident.DeclareParams{Title: strings.Repeat("a", 257), Severity: "minor"}, want: "title is too long"}, + {name: "long summary", p: incident.DeclareParams{Title: "x", Severity: "minor", Summary: strings.Repeat("a", 4001)}, want: "summary is too long"}, + } + for _, tt := range tests { + if _, err := incident.BuildDeclareRequest(tt.p); err == nil || !strings.Contains(err.Error(), tt.want) { + t.Errorf("%s: got %v, want error containing %q", tt.name, err, tt.want) + } + } + }) +} + +func Test_MissingDeclareFlags(t *testing.T) { + t.Parallel() + + if got := incident.MissingDeclareFlags(incident.DeclareParams{}); !slices.Equal(got, []string{"--title", "--severity"}) { + t.Errorf("got %v", got) + } + if got := incident.MissingDeclareFlags(incident.DeclareParams{Title: "x"}); !slices.Equal(got, []string{"--severity"}) { + t.Errorf("got %v", got) + } + if got := incident.MissingDeclareFlags(incident.DeclareParams{Title: "x", Severity: "minor"}); len(got) != 0 { + t.Errorf("got %v", got) + } +} + +func Test_ValidateDeclareFlags(t *testing.T) { + t.Parallel() + + if err := incident.ValidateDeclareFlags(incident.DeclareParams{}); err != nil { + t.Errorf("empty params should be valid before the wizard, got %v", err) + } + for _, p := range []incident.DeclareParams{ + {Severity: "foo"}, + {Commander: "not-an-email"}, + {StartedAt: "yesterday"}, + {Title: " "}, + } { + if err := incident.ValidateDeclareFlags(p); err == nil { + t.Errorf("expected error for %+v", p) + } + } +} + +func Test_DeclareIncident(t *testing.T) { + t.Run("sends request and prints hint", func(t *testing.T) { + srv := newServer(http.StatusOK, incidentResponse()) + var err error + out := captureStdout(t, func() { + err = incident.DeclareIncidentWithHTTPClient(context.Background(), srv.client(), "token", incident.DeclareParams{Title: "Checkout 502", Severity: "major", Slack: true}) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if srv.procedure() != "DeclareIncident" { + t.Errorf("procedure = %s", srv.procedure()) + } + req := decodeBody(t, srv.reqBody, &incidentv1.DeclareIncidentRequest{}) + if req.GetTitle() != "Checkout 502" || !req.GetOpenSlackChannel() { + t.Errorf("unexpected request %v", req) + } + if !strings.Contains(out, "Incident declared (ID: 42)") || !strings.Contains(out, "openstatus inc mitigate 42") { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("validation error makes no request", func(t *testing.T) { + srv := newServer(http.StatusOK, incidentResponse()) + err := incident.DeclareIncidentWithHTTPClient(context.Background(), srv.client(), "token", incident.DeclareParams{Title: "x", Severity: "minor", StartedAt: "now"}) + if err == nil { + t.Fatal("expected error") + } + if srv.calls != 0 { + t.Errorf("expected no request, got %d", srv.calls) + } + }) + + t.Run("missing status report shows server message", func(t *testing.T) { + srv := errorServer(http.StatusNotFound, "not_found", "status report 999 not found") + err := incident.DeclareIncidentWithHTTPClient(context.Background(), srv.client(), "token", incident.DeclareParams{Title: "x", Severity: "minor", StatusReportID: "999"}) + if err == nil || err.Error() != "status report 999 not found" { + t.Errorf("unexpected error %v", err) + } + }) +} diff --git a/internal/incident/incident_delete.go b/internal/incident/incident_delete.go new file mode 100644 index 0000000..36e0de6 --- /dev/null +++ b/internal/incident/incident_delete.go @@ -0,0 +1,80 @@ +package incident + +import ( + "context" + "fmt" + "net/http" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/incident/v1/incidentv1connect" + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + "github.com/urfave/cli/v3" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +const deleteUsage = "openstatus incident delete [-y]" + +type deleteOutput struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` +} + +func DeleteIncident(ctx context.Context, client incidentv1connect.IncidentServiceClient, id string) error { + if _, err := client.DeleteIncident(ctx, &incidentv1.DeleteIncidentRequest{Id: id}); err != nil { + return formatIncidentError(err, id, opDelete) + } + return nil +} + +func renderDeleted(id string) error { + if output.IsJSONOutput() { + return output.PrintJSON(deleteOutput{ID: id, Deleted: true}) + } + if output.IsQuiet() { + fmt.Println(id) + return nil + } + fmt.Printf("Incident %s deleted\n", id) + fmt.Println("Run 'openstatus inc list' to see remaining incidents") + return nil +} + +func DeleteIncidentWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id string) error { + if err := DeleteIncident(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id); err != nil { + return err + } + return renderDeleted(id) +} + +func GetIncidentDeleteCmd() *cli.Command { + return &cli.Command{ + Name: "delete", + Usage: "Delete an incident declared by mistake", + UsageText: `openstatus incident delete + openstatus inc delete 42 -y`, + Description: `Delete an incident declared by mistake. Only allowed while the incident is +open and was never mitigated, resolved or closed; cancel it otherwise.`, + Flags: []cli.Flag{accessTokenFlag(), autoAcceptFlag()}, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, deleteUsage) + if err != nil { + return err + } + confirmed, err := confirm(cmd, fmt.Sprintf("You are about to delete incident %s, do you want to continue", id)) + if err != nil { + return err + } + if !confirmed { + return nil + } + + s := output.StartSpinner("Deleting incident...") + err = DeleteIncident(ctx, client, id) + output.StopSpinner(s) + if err != nil { + return err + } + return renderDeleted(id) + }), + } +} diff --git a/internal/incident/incident_info.go b/internal/incident/incident_info.go new file mode 100644 index 0000000..660e95e --- /dev/null +++ b/internal/incident/incident_info.go @@ -0,0 +1,136 @@ +package incident + +import ( + "context" + "fmt" + "net/http" + "strings" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/incident/v1/incidentv1connect" + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + "github.com/logrusorgru/aurora/v4" + "github.com/urfave/cli/v3" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +const infoUsage = "openstatus incident info " + +func GetIncidentInfo(ctx context.Context, client incidentv1connect.IncidentServiceClient, id string, s *output.Spinner) error { + resp, err := client.GetIncident(ctx, &incidentv1.GetIncidentRequest{Id: id}) + output.StopSpinner(s) + if err != nil { + return formatIncidentError(err, id, opGeneric) + } + + inc := resp.GetIncident() + + if output.IsJSONOutput() { + return output.PrintJSON(toDetailJSON(inc)) + } + + fmt.Println(aurora.Bold("Incident:")) + tbl := newBlueprintTable() + tbl.Bulk(infoRows(inc)) + tbl.Render() + + printTimeline(inc.GetEvents()) + return nil +} + +func infoRows(inc *incidentv1.Incident) [][]string { + rows := [][]string{ + {"ID", inc.GetId()}, + {"Title", inc.GetTitle()}, + {"Severity", severityColor(severityToString(inc.GetSeverity()))}, + {"Status", displayStatus(inc.GetStatus(), inc.GetClosedAt())}, + } + if inc.HasSummary() { + rows = append(rows, []string{"Summary", inc.GetSummary()}) + } + rows = append(rows, []string{"Commander", formatUser(inc.GetCommander())}) + if inc.HasDeclaredBy() { + rows = append(rows, []string{"Declared by", formatUser(inc.GetDeclaredBy())}) + } + rows = append(rows, + []string{"Declared", output.FormatTimestamp(inc.GetDeclaredAt())}, + []string{"Started", output.FormatTimestamp(inc.GetStartedAt())}, + ) + if inc.HasMitigatedAt() { + rows = append(rows, []string{"Mitigated", output.FormatTimestamp(inc.GetMitigatedAt())}) + } + if inc.HasResolvedAt() { + resolved := output.FormatTimestamp(inc.GetResolvedAt()) + if inc.HasResolvedBy() { + resolved += " by " + formatUser(inc.GetResolvedBy()) + } + rows = append(rows, []string{"Resolved", resolved}) + } + if inc.HasClosedAt() { + rows = append(rows, []string{"Closed", output.FormatTimestamp(inc.GetClosedAt())}) + } + if inc.HasStatusReport() { + r := inc.GetStatusReport() + rows = append(rows, []string{"Status report", fmt.Sprintf("%s %q [%s]", r.GetId(), r.GetTitle(), statusReportStatusToString(r.GetStatus().String()))}) + } + if inc.HasSlackChannelUrl() { + rows = append(rows, []string{"Slack channel", inc.GetSlackChannelUrl()}) + } + transitions := "none" + if t := statusesToStrings(inc.GetAllowedTransitions()); len(t) > 0 { + transitions = strings.Join(t, ", ") + } + deletable := "no" + if inc.GetDeletable() { + deletable = "yes" + } + rows = append(rows, + []string{"Allowed transitions", transitions}, + []string{"Deletable", deletable}, + []string{"Created", output.FormatTimestamp(inc.GetCreatedAt())}, + []string{"Updated", output.FormatTimestamp(inc.GetUpdatedAt())}, + ) + return rows +} + +func printTimeline(events []*incidentv1.IncidentEvent) { + if len(events) == 0 { + fmt.Println("\nNo timeline events") + return + } + + fmt.Println(aurora.Bold("\nTimeline:")) + for _, e := range events { + lines := strings.Split(e.GetMessage(), "\n") + line := fmt.Sprintf(" %s %s %s", output.FormatTimestamp(e.GetCreatedAt()), eventTypeToString(e.GetType()), lines[0]) + if e.HasCreatedBy() { + line += " — " + shortUser(e.GetCreatedBy()) + } + fmt.Println(line) + for _, l := range lines[1:] { + fmt.Println(" " + l) + } + } +} + +func GetIncidentInfoWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id string) error { + return GetIncidentInfo(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id, nil) +} + +func GetIncidentInfoCmd() *cli.Command { + return &cli.Command{ + Name: "info", + Usage: "Get incident details and timeline", + UsageText: `openstatus incident info + openstatus inc info 42`, + Flags: []cli.Flag{accessTokenFlag()}, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, infoUsage) + if err != nil { + return err + } + s := output.StartSpinner("Fetching incident...") + return GetIncidentInfo(ctx, client, id, s) + }), + } +} diff --git a/internal/incident/incident_info_test.go b/internal/incident/incident_info_test.go new file mode 100644 index 0000000..6fc9427 --- /dev/null +++ b/internal/incident/incident_info_test.go @@ -0,0 +1,157 @@ +package incident_test + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + + "github.com/openstatusHQ/cli/internal/incident" +) + +const fullIncidentResponse = `{"incident": { + "id": "42", + "title": "Checkout 502", + "severity": "INCIDENT_SEVERITY_CRITICAL", + "status": "INCIDENT_STATUS_RESOLVED", + "summary": "Payments failing", + "commander": {"email": "jane@example.com", "name": "Jane"}, + "declaredBy": {"email": "bob@example.com", "name": "Bob"}, + "resolvedBy": {"email": "jane@example.com", "name": "Jane"}, + "declaredAt": "2026-10-16T10:00:00Z", + "startedAt": "2026-10-16T09:55:00Z", + "mitigatedAt": "2026-10-16T10:30:00Z", + "resolvedAt": "2026-10-16T11:00:00Z", + "statusReport": {"id": "123", "title": "Checkout issues", "status": "STATUS_REPORT_STATUS_MONITORING", "pageId": "7"}, + "slackChannelUrl": "https://slack.com/app_redirect?channel=C1", + "allowedTransitions": ["INCIDENT_STATUS_OPEN"], + "deletable": false, + "events": [ + {"id": "e3", "type": "INCIDENT_EVENT_TYPE_NOTE", "message": "Root cause found\nBad deploy", "createdBy": {"email": "jane@example.com", "name": "Jane"}, "createdAt": "2026-10-16T11:05:00Z"}, + {"id": "e2", "type": "INCIDENT_EVENT_TYPE_RESOLVED", "message": "Resolved", "createdAt": "2026-10-16T11:00:00Z"}, + {"id": "e1", "type": "INCIDENT_EVENT_TYPE_DECLARED", "message": "Declared", "createdAt": "2026-10-16T10:00:00Z"} + ], + "createdAt": "2026-10-16T10:00:00Z", + "updatedAt": "2026-10-16T11:05:00Z" +}}` + +func Test_GetIncidentInfo(t *testing.T) { + t.Run("renders details and timeline", func(t *testing.T) { + srv := newServer(http.StatusOK, fullIncidentResponse) + var err error + out := captureStdout(t, func() { + err = incident.GetIncidentInfoWithHTTPClient(context.Background(), srv.client(), "token", "42") + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := decodeBody(t, srv.reqBody, &incidentv1.GetIncidentRequest{}) + if req.GetId() != "42" { + t.Errorf("id = %s", req.GetId()) + } + for _, want := range []string{ + "Checkout 502", "critical", "Payments failing", "Jane ", "Bob ", + "2026-10-16 11:00 UTC by Jane", `123 "Checkout issues" [monitoring]`, "slack.com", + "Allowed transitions", "open", "Timeline:", "note Root cause found — Jane", " Bad deploy", "declared Declared", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + if strings.Index(out, "Root cause found") > strings.Index(out, "declared Declared") { + t.Error("expected newest event first") + } + }) + + t.Run("json includes events", func(t *testing.T) { + withOutputModes(t, true, false) + srv := newServer(http.StatusOK, fullIncidentResponse) + out := captureStdout(t, func() { + if err := incident.GetIncidentInfoWithHTTPClient(context.Background(), srv.client(), "token", "42"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var got struct { + ID string `json:"id"` + Severity string `json:"severity"` + AllowedTransitions []string `json:"allowed_transitions"` + Deletable bool `json:"deletable"` + StatusReport struct { + PageID string `json:"page_id"` + } `json:"status_report"` + Events []struct { + Type string `json:"type"` + CreatedBy *struct { + Email string `json:"email"` + } `json:"created_by"` + } `json:"events"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if got.ID != "42" || got.Severity != "critical" || got.Deletable || got.StatusReport.PageID != "7" { + t.Errorf("unexpected payload %+v", got) + } + if len(got.AllowedTransitions) != 1 || got.AllowedTransitions[0] != "open" { + t.Errorf("allowed transitions = %v", got.AllowedTransitions) + } + if len(got.Events) != 3 || got.Events[0].Type != "note" || got.Events[0].CreatedBy == nil || got.Events[1].CreatedBy != nil { + t.Errorf("unexpected events %+v", got.Events) + } + }) + + t.Run("minimal incident", func(t *testing.T) { + srv := newServer(http.StatusOK, `{"incident":{"id":"43","title":"Login","severity":"INCIDENT_SEVERITY_MINOR","status":"INCIDENT_STATUS_OPEN","declaredAt":"2026-10-16T10:00:00Z","startedAt":"2026-10-16T10:00:00Z","deletable":true}}`) + out := captureStdout(t, func() { + if err := incident.GetIncidentInfoWithHTTPClient(context.Background(), srv.client(), "token", "43"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + for _, want := range []string{"unassigned", "No timeline events", "yes"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + for _, notWant := range []string{"Summary", "Status report", "Slack channel", "Mitigated", "Closed"} { + if strings.Contains(out, notWant) { + t.Errorf("output should not contain %q:\n%s", notWant, out) + } + } + }) + + t.Run("not found", func(t *testing.T) { + srv := errorServer(http.StatusNotFound, "not_found", "incident not found") + err := incident.GetIncidentInfoWithHTTPClient(context.Background(), srv.client(), "token", "99") + if err == nil || !strings.Contains(err.Error(), "Run 'openstatus incident list'") { + t.Errorf("unexpected error %v", err) + } + }) +} + +func Test_InfoRows(t *testing.T) { + t.Parallel() + + inc := incidentv1.Incident_builder{ + Id: "42", + Status: incidentv1.IncidentStatus_INCIDENT_STATUS_RESOLVED, + ClosedAt: strPtr("2026-10-16T12:00:00Z"), + }.Build() + + var status, closed, transitions string + for _, row := range incident.InfoRows(inc) { + switch row[0] { + case "Status": + status = row[1] + case "Closed": + closed = row[1] + case "Allowed transitions": + transitions = row[1] + } + } + if status != "resolved (closed)" || closed != "2026-10-16 12:00 UTC" || transitions != "none" { + t.Errorf("status=%q closed=%q transitions=%q", status, closed, transitions) + } +} diff --git a/internal/incident/incident_link.go b/internal/incident/incident_link.go new file mode 100644 index 0000000..7eb1ea3 --- /dev/null +++ b/internal/incident/incident_link.go @@ -0,0 +1,102 @@ +package incident + +import ( + "context" + "fmt" + "net/http" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/incident/v1/incidentv1connect" + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + "github.com/urfave/cli/v3" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +const ( + linkUsage = "openstatus incident link-report " + unlinkUsage = "openstatus incident unlink-report " +) + +func LinkStatusReport(ctx context.Context, client incidentv1connect.IncidentServiceClient, id, reportID string) (*incidentv1.Incident, error) { + resp, err := client.LinkStatusReport(ctx, &incidentv1.LinkStatusReportRequest{Id: id, StatusReportId: reportID}) + if err != nil { + return nil, formatIncidentError(err, id, opLink) + } + return resp.GetIncident(), nil +} + +func UnlinkStatusReport(ctx context.Context, client incidentv1connect.IncidentServiceClient, id string) (*incidentv1.Incident, error) { + resp, err := client.UnlinkStatusReport(ctx, &incidentv1.UnlinkStatusReportRequest{Id: id}) + if err != nil { + return nil, formatIncidentError(err, id, opGeneric) + } + return resp.GetIncident(), nil +} + +func LinkStatusReportWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id, reportID string) error { + inc, err := LinkStatusReport(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id, reportID) + if err != nil { + return err + } + return renderIncidentResult(inc, fmt.Sprintf("Status report %s linked to incident %s", reportID, id)) +} + +func UnlinkStatusReportWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id string) error { + inc, err := UnlinkStatusReport(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id) + if err != nil { + return err + } + return renderIncidentResult(inc, fmt.Sprintf("Status report unlinked from incident %s", id)) +} + +func GetIncidentLinkReportCmd() *cli.Command { + return &cli.Command{ + Name: "link-report", + Usage: "Link a public status report to an incident", + UsageText: `openstatus incident link-report + openstatus inc link-report 42 123`, + Flags: []cli.Flag{accessTokenFlag()}, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, linkUsage) + if err != nil { + return err + } + reportID, err := requireArg(cmd, 1, "status report ID", linkUsage) + if err != nil { + return err + } + + s := output.StartSpinner("Linking status report...") + inc, err := LinkStatusReport(ctx, client, id, reportID) + output.StopSpinner(s) + if err != nil { + return err + } + return renderIncidentResult(inc, fmt.Sprintf("Status report %s linked to incident %s", reportID, id)) + }), + } +} + +func GetIncidentUnlinkReportCmd() *cli.Command { + return &cli.Command{ + Name: "unlink-report", + Usage: "Remove the link to an incident's status report", + UsageText: `openstatus incident unlink-report + openstatus inc unlink-report 42`, + Flags: []cli.Flag{accessTokenFlag()}, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, unlinkUsage) + if err != nil { + return err + } + + s := output.StartSpinner("Unlinking status report...") + inc, err := UnlinkStatusReport(ctx, client, id) + output.StopSpinner(s) + if err != nil { + return err + } + return renderIncidentResult(inc, fmt.Sprintf("Status report unlinked from incident %s", id)) + }), + } +} diff --git a/internal/incident/incident_link_test.go b/internal/incident/incident_link_test.go new file mode 100644 index 0000000..d734082 --- /dev/null +++ b/internal/incident/incident_link_test.go @@ -0,0 +1,69 @@ +package incident_test + +import ( + "context" + "net/http" + "strings" + "testing" + + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + + "github.com/openstatusHQ/cli/internal/incident" +) + +func Test_LinkStatusReport(t *testing.T) { + t.Run("link sends both ids", func(t *testing.T) { + srv := newServer(http.StatusOK, incidentResponse()) + var err error + out := captureStdout(t, func() { + err = incident.LinkStatusReportWithHTTPClient(context.Background(), srv.client(), "token", "42", "123") + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := decodeBody(t, srv.reqBody, &incidentv1.LinkStatusReportRequest{}) + if req.GetId() != "42" || req.GetStatusReportId() != "123" { + t.Errorf("unexpected request %v", req) + } + if !strings.Contains(out, "Status report 123 linked to incident 42") { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("link not found shows server message", func(t *testing.T) { + srv := errorServer(http.StatusNotFound, "not_found", "status report 123 not found") + err := incident.LinkStatusReportWithHTTPClient(context.Background(), srv.client(), "token", "42", "123") + if err == nil || err.Error() != "status report 123 not found" { + t.Errorf("unexpected error %v", err) + } + }) + + t.Run("already linked", func(t *testing.T) { + srv := errorServer(http.StatusBadRequest, "failed_precondition", "status report is already linked to another incident") + err := incident.LinkStatusReportWithHTTPClient(context.Background(), srv.client(), "token", "42", "123") + if err == nil || err.Error() != "status report is already linked to another incident" { + t.Errorf("unexpected error %v", err) + } + }) + + t.Run("unlink", func(t *testing.T) { + srv := newServer(http.StatusOK, incidentResponse()) + var err error + out := captureStdout(t, func() { + err = incident.UnlinkStatusReportWithHTTPClient(context.Background(), srv.client(), "token", "42") + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if srv.procedure() != "UnlinkStatusReport" { + t.Errorf("procedure = %s", srv.procedure()) + } + req := decodeBody(t, srv.reqBody, &incidentv1.UnlinkStatusReportRequest{}) + if req.GetId() != "42" { + t.Errorf("unexpected request %v", req) + } + if !strings.Contains(out, "Status report unlinked from incident 42") { + t.Errorf("unexpected output %q", out) + } + }) +} diff --git a/internal/incident/incident_list.go b/internal/incident/incident_list.go new file mode 100644 index 0000000..4d11e50 --- /dev/null +++ b/internal/incident/incident_list.go @@ -0,0 +1,183 @@ +package incident + +import ( + "context" + "errors" + "fmt" + "net/http" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/incident/v1/incidentv1connect" + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + "github.com/fatih/color" + "github.com/rodaine/table" + "github.com/urfave/cli/v3" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +type ListParams struct { + Statuses string + Active bool + Closed bool + Limit int + Offset int +} + +type listOutput struct { + Incidents []incidentSummaryJSON `json:"incidents"` + TotalSize int32 `json:"total_size"` +} + +func buildListRequest(p ListParams) (*incidentv1.ListIncidentsRequest, error) { + if p.Active && p.Closed { + return nil, errors.New("--active and --closed are mutually exclusive") + } + if p.Limit < 0 || p.Limit > 100 { + return nil, fmt.Errorf("invalid --limit %d: must be between 1 and 100", p.Limit) + } + if p.Offset < 0 { + return nil, fmt.Errorf("invalid --offset %d: must be 0 or more", p.Offset) + } + + statuses, err := parseStatuses(p.Statuses) + if err != nil { + return nil, err + } + + req := &incidentv1.ListIncidentsRequest{} + if len(statuses) > 0 { + req.SetStatuses(statuses) + } + if p.Active { + req.SetClosed(false) + } + if p.Closed { + req.SetClosed(true) + } + if p.Limit > 0 { + req.SetLimit(int32(p.Limit)) + } + if p.Offset > 0 { + req.SetOffset(int32(p.Offset)) + } + return req, nil +} + +func listFooter(offset, shown int, total int32) string { + if shown == 0 || int(total) <= offset+shown { + return "" + } + return fmt.Sprintf("Showing %d-%d of %d — use --offset %d for more", offset+1, offset+shown, total, offset+shown) +} + +func ListIncidents(ctx context.Context, client incidentv1connect.IncidentServiceClient, p ListParams, s *output.Spinner) error { + req, err := buildListRequest(p) + if err != nil { + output.StopSpinner(s) + return err + } + + resp, err := client.ListIncidents(ctx, req) + output.StopSpinner(s) + if err != nil { + return formatIncidentError(err, "", opGeneric) + } + + incidents := resp.GetIncidents() + + if output.IsJSONOutput() { + out := listOutput{ + Incidents: make([]incidentSummaryJSON, 0, len(incidents)), + TotalSize: resp.GetTotalSize(), + } + for _, inc := range incidents { + out.Incidents = append(out.Incidents, toSummaryJSON(inc)) + } + return output.PrintJSON(out) + } + + if len(incidents) == 0 { + fmt.Println("No incidents found") + return nil + } + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("ID", "Title", "Severity", "Status", "Commander", "Declared", "Report") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt).WithWidthFunc(visibleWidth) + + for _, inc := range incidents { + report := "—" + if inc.HasStatusReport() { + report = inc.GetStatusReport().GetId() + } + tbl.AddRow( + inc.GetId(), + inc.GetTitle(), + severityColor(severityToString(inc.GetSeverity())), + displayStatus(inc.GetStatus(), inc.GetClosedAt()), + shortUser(inc.GetCommander()), + output.FormatTimestamp(inc.GetDeclaredAt()), + report, + ) + } + + tbl.Print() + + if footer := listFooter(p.Offset, len(incidents), resp.GetTotalSize()); footer != "" { + fmt.Println(footer) + } + return nil +} + +func ListIncidentsWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, p ListParams) error { + return ListIncidents(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), p, nil) +} + +func GetIncidentListCmd() *cli.Command { + return &cli.Command{ + Name: "list", + Usage: "List incidents", + UsageText: `openstatus incident list + openstatus inc list --active + openstatus inc list --status open,mitigated --limit 10`, + Flags: []cli.Flag{ + accessTokenFlag(), + &cli.StringFlag{ + Name: "status", + Usage: "Filter by status, comma-separated (open, mitigated, resolved, canceled)", + }, + &cli.BoolFlag{ + Name: "active", + Usage: "Only incidents that are not closed", + }, + &cli.BoolFlag{ + Name: "closed", + Usage: "Only closed incidents", + }, + &cli.IntFlag{ + Name: "limit", + Usage: "Maximum number of incidents to return (1-100, defaults to 50)", + }, + &cli.IntFlag{ + Name: "offset", + Usage: "Number of incidents to skip", + }, + }, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + p := ListParams{ + Statuses: cmd.String("status"), + Active: cmd.Bool("active"), + Closed: cmd.Bool("closed"), + Limit: int(cmd.Int("limit")), + Offset: int(cmd.Int("offset")), + } + if cmd.IsSet("limit") && p.Limit < 1 { + return fmt.Errorf("invalid --limit %d: must be between 1 and 100", p.Limit) + } + s := output.StartSpinner("Fetching incidents...") + return ListIncidents(ctx, client, p, s) + }), + } +} diff --git a/internal/incident/incident_list_test.go b/internal/incident/incident_list_test.go new file mode 100644 index 0000000..80e8e27 --- /dev/null +++ b/internal/incident/incident_list_test.go @@ -0,0 +1,226 @@ +package incident_test + +import ( + "context" + "encoding/json" + "net/http" + "slices" + "strings" + "testing" + + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + + "github.com/openstatusHQ/cli/internal/incident" +) + +const listResponse = `{ + "incidents": [ + { + "id": "42", + "title": "Checkout 502", + "severity": "INCIDENT_SEVERITY_MAJOR", + "status": "INCIDENT_STATUS_RESOLVED", + "commander": {"email": "jane@example.com", "name": "Jane"}, + "declaredAt": "2026-10-16T10:00:00Z", + "startedAt": "2026-10-16T09:55:00Z", + "closedAt": "2026-10-16T12:00:00Z", + "statusReport": {"id": "123", "title": "Checkout issues", "status": "STATUS_REPORT_STATUS_RESOLVED"}, + "createdAt": "2026-10-16T10:00:00Z", + "updatedAt": "2026-10-16T12:00:00Z" + }, + { + "id": "43", + "title": "Login errors", + "severity": "INCIDENT_SEVERITY_MINOR", + "status": "INCIDENT_STATUS_OPEN", + "declaredAt": "2026-10-16T11:00:00Z", + "startedAt": "2026-10-16T11:00:00Z", + "createdAt": "2026-10-16T11:00:00Z", + "updatedAt": "2026-10-16T11:00:00Z" + } + ], + "totalSize": 5 +}` + +func Test_BuildListRequest(t *testing.T) { + t.Parallel() + + t.Run("no filters", func(t *testing.T) { + t.Parallel() + req, err := incident.BuildListRequest(incident.ListParams{}) + if err != nil { + t.Fatal(err) + } + if req.HasClosed() || req.HasLimit() || req.HasOffset() || len(req.GetStatuses()) != 0 { + t.Errorf("expected empty request, got %v", req) + } + }) + + t.Run("active sets closed false", func(t *testing.T) { + t.Parallel() + req, err := incident.BuildListRequest(incident.ListParams{Active: true}) + if err != nil { + t.Fatal(err) + } + if !req.HasClosed() || req.GetClosed() { + t.Errorf("expected closed=false, got %v", req) + } + }) + + t.Run("closed sets closed true", func(t *testing.T) { + t.Parallel() + req, err := incident.BuildListRequest(incident.ListParams{Closed: true}) + if err != nil { + t.Fatal(err) + } + if !req.HasClosed() || !req.GetClosed() { + t.Errorf("expected closed=true, got %v", req) + } + }) + + t.Run("active and closed are exclusive", func(t *testing.T) { + t.Parallel() + if _, err := incident.BuildListRequest(incident.ListParams{Active: true, Closed: true}); err == nil { + t.Error("expected error") + } + }) + + t.Run("statuses limit offset", func(t *testing.T) { + t.Parallel() + req, err := incident.BuildListRequest(incident.ListParams{Statuses: "open,mitigated", Limit: 10, Offset: 20}) + if err != nil { + t.Fatal(err) + } + want := []incidentv1.IncidentStatus{ + incidentv1.IncidentStatus_INCIDENT_STATUS_OPEN, + incidentv1.IncidentStatus_INCIDENT_STATUS_MITIGATED, + } + if !slices.Equal(req.GetStatuses(), want) { + t.Errorf("statuses = %v", req.GetStatuses()) + } + if req.GetLimit() != 10 || req.GetOffset() != 20 { + t.Errorf("limit/offset = %d/%d", req.GetLimit(), req.GetOffset()) + } + }) + + t.Run("invalid values", func(t *testing.T) { + t.Parallel() + for _, p := range []incident.ListParams{{Limit: 101}, {Limit: -1}, {Offset: -1}, {Statuses: "bogus"}} { + if _, err := incident.BuildListRequest(p); err == nil { + t.Errorf("expected error for %+v", p) + } + } + }) +} + +func Test_ListFooter(t *testing.T) { + t.Parallel() + + if got := incident.ListFooter(0, 2, 2); got != "" { + t.Errorf("expected no footer, got %q", got) + } + if got := incident.ListFooter(0, 0, 10); got != "" { + t.Errorf("expected no footer for empty page, got %q", got) + } + if got := incident.ListFooter(0, 2, 5); got != "Showing 1-2 of 5 — use --offset 2 for more" { + t.Errorf("got %q", got) + } + if got := incident.ListFooter(2, 2, 5); got != "Showing 3-4 of 5 — use --offset 4 for more" { + t.Errorf("got %q", got) + } +} + +func Test_ListIncidents(t *testing.T) { + t.Run("sends filters and renders table", func(t *testing.T) { + srv := newServer(http.StatusOK, listResponse) + var err error + out := captureStdout(t, func() { + err = incident.ListIncidentsWithHTTPClient(context.Background(), srv.client(), "token", incident.ListParams{Statuses: "resolved", Closed: true}) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if srv.procedure() != "ListIncidents" { + t.Errorf("procedure = %s", srv.procedure()) + } + req := decodeBody(t, srv.reqBody, &incidentv1.ListIncidentsRequest{}) + if !req.GetClosed() || len(req.GetStatuses()) != 1 { + t.Errorf("unexpected request %v", req) + } + for _, want := range []string{"Checkout 502", "resolved (closed)", "Jane", "123", "Login errors", "unassigned", "Showing 1-2 of 5"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + }) + + t.Run("json output", func(t *testing.T) { + withOutputModes(t, true, false) + srv := newServer(http.StatusOK, listResponse) + out := captureStdout(t, func() { + if err := incident.ListIncidentsWithHTTPClient(context.Background(), srv.client(), "token", incident.ListParams{}); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var got struct { + Incidents []struct { + ID string `json:"id"` + Severity string `json:"severity"` + Status string `json:"status"` + ClosedAt string `json:"closed_at"` + StatusReport *struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"status_report"` + } `json:"incidents"` + TotalSize int `json:"total_size"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if got.TotalSize != 5 || len(got.Incidents) != 2 { + t.Fatalf("unexpected payload %+v", got) + } + first := got.Incidents[0] + if first.ID != "42" || first.Severity != "major" || first.Status != "resolved" || first.ClosedAt == "" { + t.Errorf("unexpected first incident %+v", first) + } + if first.StatusReport == nil || first.StatusReport.ID != "123" || first.StatusReport.Status != "resolved" { + t.Errorf("unexpected status report %+v", first.StatusReport) + } + if got.Incidents[1].StatusReport != nil { + t.Error("expected no status report on second incident") + } + }) + + t.Run("empty list", func(t *testing.T) { + srv := newServer(http.StatusOK, `{"incidents":[],"totalSize":0}`) + out := captureStdout(t, func() { + if err := incident.ListIncidentsWithHTTPClient(context.Background(), srv.client(), "token", incident.ListParams{}); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if !strings.Contains(out, "No incidents found") { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("invalid filter makes no request", func(t *testing.T) { + srv := newServer(http.StatusOK, listResponse) + err := incident.ListIncidentsWithHTTPClient(context.Background(), srv.client(), "token", incident.ListParams{Active: true, Closed: true}) + if err == nil { + t.Fatal("expected error") + } + if srv.calls != 0 { + t.Errorf("expected no request, got %d", srv.calls) + } + }) + + t.Run("api error", func(t *testing.T) { + srv := errorServer(http.StatusUnauthorized, "unauthenticated", "bad key") + err := incident.ListIncidentsWithHTTPClient(context.Background(), srv.client(), "token", incident.ListParams{}) + if err == nil || !strings.Contains(err.Error(), "authentication failed") { + t.Errorf("unexpected error %v", err) + } + }) +} diff --git a/internal/incident/incident_note.go b/internal/incident/incident_note.go new file mode 100644 index 0000000..5ea0964 --- /dev/null +++ b/internal/incident/incident_note.go @@ -0,0 +1,98 @@ +package incident + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "strings" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/incident/v1/incidentv1connect" + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + "github.com/urfave/cli/v3" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +const noteUsage = "openstatus incident note --message " + +func buildNoteRequest(id, message string) (*incidentv1.AddIncidentNoteRequest, error) { + if strings.TrimSpace(message) == "" { + return nil, errors.New("note message cannot be empty") + } + if err := validateNote(message); err != nil { + return nil, err + } + return &incidentv1.AddIncidentNoteRequest{Id: id, Message: message}, nil +} + +func AddIncidentNote(ctx context.Context, client incidentv1connect.IncidentServiceClient, id, message string) (*incidentv1.IncidentEvent, error) { + req, err := buildNoteRequest(id, message) + if err != nil { + return nil, err + } + resp, err := client.AddIncidentNote(ctx, req) + if err != nil { + return nil, formatIncidentError(err, id, opGeneric) + } + return resp.GetEvent(), nil +} + +func renderNote(id string, event *incidentv1.IncidentEvent) error { + if output.IsJSONOutput() { + return output.PrintJSON(toEventJSON(event)) + } + if output.IsQuiet() { + fmt.Println(event.GetId()) + return nil + } + fmt.Printf("Note added to incident %s\n", id) + return nil +} + +func AddIncidentNoteWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id, message string) error { + event, err := AddIncidentNote(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id, message) + if err != nil { + return err + } + return renderNote(id, event) +} + +func GetIncidentNoteCmd() *cli.Command { + return &cli.Command{ + Name: "note", + Usage: "Add a note to an incident timeline", + UsageText: `openstatus incident note --message "Rolled back the deploy" + echo "multi-line markdown" | openstatus inc note 42 --message -`, + Flags: []cli.Flag{ + accessTokenFlag(), + &cli.StringFlag{ + Name: "message", + Usage: "Note text in markdown (1-10000 characters); use - to read from stdin", + Aliases: []string{"m"}, + }, + }, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, noteUsage) + if err != nil { + return err + } + if cmd.String("message") == "" { + return errors.New("missing required flags: --message") + } + message, err := readTextArg(cmd.String("message"), os.Stdin) + if err != nil { + return err + } + + s := output.StartSpinner("Adding note...") + event, err := AddIncidentNote(ctx, client, id, message) + output.StopSpinner(s) + if err != nil { + return err + } + return renderNote(id, event) + }), + } +} diff --git a/internal/incident/incident_note_test.go b/internal/incident/incident_note_test.go new file mode 100644 index 0000000..8867dc7 --- /dev/null +++ b/internal/incident/incident_note_test.go @@ -0,0 +1,89 @@ +package incident_test + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + + "github.com/openstatusHQ/cli/internal/incident" +) + +const noteResponse = `{"event":{"id":"e9","type":"INCIDENT_EVENT_TYPE_NOTE","message":"Rolled back","createdAt":"2026-10-16T10:10:00Z"}}` + +func Test_BuildNoteRequest(t *testing.T) { + t.Parallel() + + req, err := incident.BuildNoteRequest("42", "line one\nline two") + if err != nil { + t.Fatal(err) + } + if req.GetId() != "42" || req.GetMessage() != "line one\nline two" { + t.Errorf("unexpected request %v", req) + } + if _, err := incident.BuildNoteRequest("42", " "); err == nil { + t.Error("expected error for blank note") + } + if _, err := incident.BuildNoteRequest("42", strings.Repeat("é", 10001)); err == nil { + t.Error("expected error for long note") + } + if _, err := incident.BuildNoteRequest("42", strings.Repeat("é", 10000)); err != nil { + t.Errorf("10000 characters should be accepted: %v", err) + } +} + +func Test_AddIncidentNote(t *testing.T) { + t.Run("text output", func(t *testing.T) { + srv := newServer(http.StatusOK, noteResponse) + var err error + out := captureStdout(t, func() { + err = incident.AddIncidentNoteWithHTTPClient(context.Background(), srv.client(), "token", "42", "Rolled back") + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := decodeBody(t, srv.reqBody, &incidentv1.AddIncidentNoteRequest{}) + if req.GetMessage() != "Rolled back" { + t.Errorf("message = %q", req.GetMessage()) + } + if strings.TrimSpace(out) != "Note added to incident 42" { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("json output is the event", func(t *testing.T) { + withOutputModes(t, true, false) + srv := newServer(http.StatusOK, noteResponse) + out := captureStdout(t, func() { + if err := incident.AddIncidentNoteWithHTTPClient(context.Background(), srv.client(), "token", "42", "Rolled back"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var got struct { + ID string `json:"id"` + Type string `json:"type"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if got.ID != "e9" || got.Type != "note" { + t.Errorf("unexpected payload %+v", got) + } + }) + + t.Run("quiet prints event id", func(t *testing.T) { + withOutputModes(t, false, true) + srv := newServer(http.StatusOK, noteResponse) + out := captureStdout(t, func() { + if err := incident.AddIncidentNoteWithHTTPClient(context.Background(), srv.client(), "token", "42", "Rolled back"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if strings.TrimSpace(out) != "e9" { + t.Errorf("unexpected output %q", out) + } + }) +} diff --git a/internal/incident/incident_postmortem.go b/internal/incident/incident_postmortem.go new file mode 100644 index 0000000..bec5786 --- /dev/null +++ b/internal/incident/incident_postmortem.go @@ -0,0 +1,358 @@ +package incident + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "unicode/utf8" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/incident/v1/incidentv1connect" + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + "github.com/urfave/cli/v3" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +const ( + maxPostmortemLen = 100000 + pmShowUsage = "openstatus incident postmortem show " + pmEditUsage = "openstatus incident postmortem edit --file | --content " + pmApproveUsage = "openstatus incident postmortem approve [--close] [-y]" +) + +type postmortemJSON struct { + IncidentID string `json:"incident_id"` + Status string `json:"status"` + Content string `json:"content"` + DraftedBy string `json:"drafted_by"` + ApprovedBy *userJSON `json:"approved_by,omitempty"` + ApprovedAt string `json:"approved_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type approveOutput struct { + Postmortem postmortemJSON `json:"postmortem"` + Incident incidentDetail `json:"incident"` +} + +func postmortemStatusToString(s incidentv1.PostmortemStatus) string { + switch s { + case incidentv1.PostmortemStatus_POSTMORTEM_STATUS_DRAFT: + return "draft" + case incidentv1.PostmortemStatus_POSTMORTEM_STATUS_APPROVED: + return "approved" + default: + return "unknown" + } +} + +func postmortemAuthorToString(a incidentv1.PostmortemAuthor) string { + switch a { + case incidentv1.PostmortemAuthor_POSTMORTEM_AUTHOR_AGENT: + return "agent" + case incidentv1.PostmortemAuthor_POSTMORTEM_AUTHOR_USER: + return "user" + default: + return "unknown" + } +} + +func toPostmortemJSON(pm *incidentv1.Postmortem) postmortemJSON { + return postmortemJSON{ + IncidentID: pm.GetIncidentId(), + Status: postmortemStatusToString(pm.GetStatus()), + Content: pm.GetContent(), + DraftedBy: postmortemAuthorToString(pm.GetDraftedBy()), + ApprovedBy: toUserJSON(pm.GetApprovedBy()), + ApprovedAt: pm.GetApprovedAt(), + CreatedAt: pm.GetCreatedAt(), + UpdatedAt: pm.GetUpdatedAt(), + } +} + +func GetPostmortem(ctx context.Context, client incidentv1connect.IncidentServiceClient, id string) (*incidentv1.Postmortem, error) { + resp, err := client.GetPostmortem(ctx, &incidentv1.GetPostmortemRequest{IncidentId: id}) + if err != nil { + return nil, formatIncidentError(err, id, opGeneric) + } + if !resp.HasPostmortem() { + return nil, nil + } + return resp.GetPostmortem(), nil +} + +func writePostmortem(w io.Writer, pm *incidentv1.Postmortem) { + fmt.Fprintf(w, "Status: %s\n", postmortemStatusToString(pm.GetStatus())) + fmt.Fprintf(w, "Drafted by: %s\n", postmortemAuthorToString(pm.GetDraftedBy())) + if pm.HasApprovedBy() { + fmt.Fprintf(w, "Approved: %s by %s\n", output.FormatTimestamp(pm.GetApprovedAt()), formatUser(pm.GetApprovedBy())) + } + fmt.Fprintf(w, "Updated: %s\n\n", output.FormatTimestamp(pm.GetUpdatedAt())) + fmt.Fprintln(w, pm.GetContent()) +} + +func renderPostmortem(id string, pm *incidentv1.Postmortem) error { + if output.IsJSONOutput() { + if pm == nil { + return output.PrintJSON(nil) + } + return output.PrintJSON(toPostmortemJSON(pm)) + } + if pm == nil { + // Piped output is a postmortem body by contract; never let the hint + // end up in a file that 'pm edit --file' would upload. + if !output.IsTerminal() || output.IsQuiet() { + return fmt.Errorf("no postmortem yet for incident %s. Write one: openstatus inc postmortem edit %s --file postmortem.md", id, id) + } + fmt.Printf("No postmortem yet for incident %s\n", id) + fmt.Printf("Write one: openstatus inc postmortem edit %s --file postmortem.md\n", id) + return nil + } + if !output.IsTerminal() { + fmt.Println(pm.GetContent()) + return nil + } + output.WithPager(func(w io.Writer) { + writePostmortem(w, pm) + }) + return nil +} + +func ShowPostmortemWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id string) error { + pm, err := GetPostmortem(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id) + if err != nil { + return err + } + return renderPostmortem(id, pm) +} + +func resolvePostmortemContent(file, content string, fileSet, contentSet bool, stdin io.Reader) (string, error) { + if fileSet == contentSet { + return "", errors.New("pass exactly one of --file or --content") + } + + text := content + if fileSet { + switch file { + case "": + return "", errors.New("--file cannot be empty") + case "-": + var err error + text, err = readTextArg("-", stdin) + if err != nil { + return "", err + } + default: + data, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("read postmortem file %q: %w", file, err) + } + text = trimTrailingNewline(string(data)) + } + } + + if strings.TrimSpace(text) == "" { + return "", errors.New("postmortem content cannot be empty") + } + if utf8.RuneCountInString(text) > maxPostmortemLen { + return "", fmt.Errorf("postmortem is too long: maximum %d characters", maxPostmortemLen) + } + return text, nil +} + +func UpdatePostmortem(ctx context.Context, client incidentv1connect.IncidentServiceClient, id, content string) (*incidentv1.Postmortem, error) { + resp, err := client.UpdatePostmortem(ctx, &incidentv1.UpdatePostmortemRequest{IncidentId: id, Content: content}) + if err != nil { + return nil, formatIncidentError(err, id, opGeneric) + } + return resp.GetPostmortem(), nil +} + +func renderPostmortemSaved(id string, pm *incidentv1.Postmortem) error { + if output.IsJSONOutput() { + return output.PrintJSON(toPostmortemJSON(pm)) + } + if output.IsQuiet() { + fmt.Println(id) + return nil + } + status := postmortemStatusToString(pm.GetStatus()) + fmt.Printf("Postmortem saved for incident %s (%s)\n", id, status) + if status == "draft" { + fmt.Printf("Approve: openstatus inc postmortem approve %s [--close]\n", id) + } + return nil +} + +func UpdatePostmortemWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id, content string) error { + pm, err := UpdatePostmortem(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id, content) + if err != nil { + return err + } + return renderPostmortemSaved(id, pm) +} + +func ApprovePostmortem(ctx context.Context, client incidentv1connect.IncidentServiceClient, id string, closeIncident bool) (*incidentv1.ApprovePostmortemResponse, error) { + req := &incidentv1.ApprovePostmortemRequest{IncidentId: id} + if closeIncident { + req.SetClose(true) + } + resp, err := client.ApprovePostmortem(ctx, req) + if err != nil { + return nil, formatIncidentError(err, id, opApprove) + } + return resp, nil +} + +func renderApproved(id string, resp *incidentv1.ApprovePostmortemResponse) error { + if output.IsJSONOutput() { + return output.PrintJSON(approveOutput{ + Postmortem: toPostmortemJSON(resp.GetPostmortem()), + Incident: toDetailJSON(resp.GetIncident()), + }) + } + if output.IsQuiet() { + fmt.Println(id) + return nil + } + fmt.Printf("Postmortem approved for incident %s\n", id) + if resp.GetIncident().GetClosedAt() != "" { + fmt.Printf("Incident %s closed\n", id) + return nil + } + fmt.Printf("Close the incident: openstatus inc close %s\n", id) + return nil +} + +func ApprovePostmortemWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id string, closeIncident bool) error { + resp, err := ApprovePostmortem(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id, closeIncident) + if err != nil { + return err + } + return renderApproved(id, resp) +} + +func postmortemCmd() *cli.Command { + return &cli.Command{ + Name: "postmortem", + Aliases: []string{"pm"}, + Usage: "Read, write and approve an incident's postmortem", + Commands: []*cli.Command{ + postmortemShowCmd(), + postmortemEditCmd(), + postmortemApproveCmd(), + }, + } +} + +func postmortemShowCmd() *cli.Command { + return &cli.Command{ + Name: "show", + Usage: "Show the postmortem of an incident", + UsageText: `openstatus incident postmortem show + openstatus inc pm show 42 > postmortem.md`, + Description: `In a terminal, prints the review status and the markdown body through your +pager. When piped, prints only the markdown body, so it can be edited and sent +back with 'openstatus inc pm edit --file postmortem.md'.`, + Flags: []cli.Flag{accessTokenFlag()}, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, pmShowUsage) + if err != nil { + return err + } + s := output.StartSpinner("Fetching postmortem...") + pm, err := GetPostmortem(ctx, client, id) + output.StopSpinner(s) + if err != nil { + return err + } + return renderPostmortem(id, pm) + }), + } +} + +func postmortemEditCmd() *cli.Command { + return &cli.Command{ + Name: "edit", + Usage: "Write or replace the postmortem of a resolved incident", + UsageText: `openstatus incident postmortem edit --file postmortem.md + cat postmortem.md | openstatus inc pm edit 42 --file - + openstatus inc pm edit 42 --content "## Summary ..."`, + Description: "Replaces the whole markdown body. An approved postmortem stays approved.", + Flags: []cli.Flag{ + accessTokenFlag(), + &cli.StringFlag{ + Name: "file", + Usage: "Markdown file to upload; use - to read from stdin", + }, + &cli.StringFlag{ + Name: "content", + Usage: "Markdown body passed inline", + }, + }, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, pmEditUsage) + if err != nil { + return err + } + content, err := resolvePostmortemContent(cmd.String("file"), cmd.String("content"), cmd.IsSet("file"), cmd.IsSet("content"), os.Stdin) + if err != nil { + return err + } + + s := output.StartSpinner("Saving postmortem...") + pm, err := UpdatePostmortem(ctx, client, id, content) + output.StopSpinner(s) + if err != nil { + return err + } + return renderPostmortemSaved(id, pm) + }), + } +} + +func postmortemApproveCmd() *cli.Command { + return &cli.Command{ + Name: "approve", + Usage: "Approve the postmortem, optionally closing the incident", + UsageText: `openstatus incident postmortem approve + openstatus inc pm approve 42 --close -y`, + Flags: []cli.Flag{ + accessTokenFlag(), + &cli.BoolFlag{ + Name: "close", + Usage: "Also close the incident", + }, + autoAcceptFlag(), + }, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, pmApproveUsage) + if err != nil { + return err + } + closeIncident := cmd.Bool("close") + if closeIncident { + confirmed, err := confirm(cmd, fmt.Sprintf("You are about to approve the postmortem and close incident %s. It becomes read-only except for its postmortem. Continue", id)) + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + s := output.StartSpinner("Approving postmortem...") + resp, err := ApprovePostmortem(ctx, client, id, closeIncident) + output.StopSpinner(s) + if err != nil { + return err + } + return renderApproved(id, resp) + }), + } +} diff --git a/internal/incident/incident_postmortem_test.go b/internal/incident/incident_postmortem_test.go new file mode 100644 index 0000000..6b95af5 --- /dev/null +++ b/internal/incident/incident_postmortem_test.go @@ -0,0 +1,288 @@ +package incident_test + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + + "github.com/openstatusHQ/cli/internal/incident" +) + +const postmortemResponse = `{"postmortem":{ + "incidentId": "42", + "status": "POSTMORTEM_STATUS_DRAFT", + "content": "# Summary\n\nBad deploy.", + "draftedBy": "POSTMORTEM_AUTHOR_USER", + "createdAt": "2026-10-16T12:00:00Z", + "updatedAt": "2026-10-16T12:30:00Z" +}}` + +func Test_PostmortemCmd(t *testing.T) { + t.Parallel() + + var pm []string + for _, sub := range incident.IncidentCmd().Commands { + if sub.Name != "postmortem" { + continue + } + if !slices.Contains(sub.Aliases, "pm") { + t.Error("expected alias 'pm'") + } + for _, c := range sub.Commands { + pm = append(pm, c.Name) + } + } + if !slices.Equal(pm, []string{"show", "edit", "approve"}) { + t.Errorf("postmortem subcommands = %v", pm) + } +} + +func Test_ShowPostmortem(t *testing.T) { + t.Run("piped output is the raw body", func(t *testing.T) { + srv := newServer(http.StatusOK, postmortemResponse) + var err error + out := captureStdout(t, func() { + err = incident.ShowPostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42") + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := decodeBody(t, srv.reqBody, &incidentv1.GetPostmortemRequest{}) + if req.GetIncidentId() != "42" { + t.Errorf("unexpected request %v", req) + } + if out != "# Summary\n\nBad deploy.\n" { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("none yet when piped fails and keeps stdout empty", func(t *testing.T) { + srv := newServer(http.StatusOK, `{}`) + var err error + out := captureStdout(t, func() { + err = incident.ShowPostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42") + }) + if err == nil || !strings.Contains(err.Error(), "no postmortem yet for incident 42") || !strings.Contains(err.Error(), "postmortem edit 42") { + t.Errorf("unexpected error %v", err) + } + if out != "" { + t.Errorf("expected empty stdout, got %q", out) + } + }) + + t.Run("none yet json is null", func(t *testing.T) { + withOutputModes(t, true, false) + srv := newServer(http.StatusOK, `{}`) + out := captureStdout(t, func() { + if err := incident.ShowPostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if strings.TrimSpace(out) != "null" { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("json", func(t *testing.T) { + withOutputModes(t, true, false) + srv := newServer(http.StatusOK, postmortemResponse) + out := captureStdout(t, func() { + if err := incident.ShowPostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var got struct { + IncidentID string `json:"incident_id"` + Status string `json:"status"` + DraftedBy string `json:"drafted_by"` + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if got.IncidentID != "42" || got.Status != "draft" || got.DraftedBy != "user" || !strings.HasPrefix(got.Content, "# Summary") { + t.Errorf("unexpected payload %+v", got) + } + }) +} + +func Test_ResolvePostmortemContent(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "pm.md") + if err := os.WriteFile(path, []byte("# From file\n"), 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + file string + content string + fileSet bool + contentSet bool + stdin string + want string + wantErr string + }{ + {name: "file", file: path, fileSet: true, want: "# From file"}, + {name: "stdin", file: "-", fileSet: true, stdin: "# From stdin\n", want: "# From stdin"}, + {name: "content", content: "# Inline", contentSet: true, want: "# Inline"}, + {name: "neither", wantErr: "exactly one of --file or --content"}, + {name: "both", file: path, fileSet: true, content: "x", contentSet: true, wantErr: "exactly one of --file or --content"}, + {name: "missing file", file: filepath.Join(dir, "nope.md"), fileSet: true, wantErr: "read postmortem file"}, + {name: "empty content", content: " ", contentSet: true, wantErr: "cannot be empty"}, + {name: "empty file flag", file: "", fileSet: true, wantErr: "--file cannot be empty"}, + {name: "too long", content: strings.Repeat("a", 100001), contentSet: true, wantErr: "too long"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := incident.ResolvePostmortemContent(tt.file, tt.content, tt.fileSet, tt.contentSet, strings.NewReader(tt.stdin)) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("got %v, want error containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func Test_UpdatePostmortem(t *testing.T) { + srv := newServer(http.StatusOK, postmortemResponse) + var err error + out := captureStdout(t, func() { + err = incident.UpdatePostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42", "# Summary") + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := decodeBody(t, srv.reqBody, &incidentv1.UpdatePostmortemRequest{}) + if req.GetIncidentId() != "42" || req.GetContent() != "# Summary" { + t.Errorf("unexpected request %v", req) + } + if !strings.Contains(out, "Postmortem saved for incident 42 (draft)") || !strings.Contains(out, "postmortem approve 42") { + t.Errorf("unexpected output %q", out) + } +} + +func Test_ApprovePostmortem(t *testing.T) { + approved := `{"postmortem":{"incidentId":"42","status":"POSTMORTEM_STATUS_APPROVED","draftedBy":"POSTMORTEM_AUTHOR_AGENT","approvedBy":{"email":"jane@example.com","name":"Jane"}},"incident":` + closedResponse[len(`{"incident":`):] + + t.Run("close sent only when requested", func(t *testing.T) { + for _, closeIncident := range []bool{false, true} { + srv := newServer(http.StatusOK, approved) + out := captureStdout(t, func() { + if err := incident.ApprovePostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42", closeIncident); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + req := decodeBody(t, srv.reqBody, &incidentv1.ApprovePostmortemRequest{}) + if req.HasClose() != closeIncident || req.GetIncidentId() != "42" { + t.Errorf("close=%v: unexpected request %v", closeIncident, req) + } + if !strings.Contains(out, "Postmortem approved for incident 42") || !strings.Contains(out, "Incident 42 closed") { + t.Errorf("unexpected output %q", out) + } + } + }) + + t.Run("json has postmortem and incident", func(t *testing.T) { + withOutputModes(t, true, false) + srv := newServer(http.StatusOK, approved) + out := captureStdout(t, func() { + if err := incident.ApprovePostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42", true); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var got struct { + Postmortem struct { + Status string `json:"status"` + DraftedBy string `json:"drafted_by"` + ApprovedBy struct { + Email string `json:"email"` + } `json:"approved_by"` + } `json:"postmortem"` + Incident struct { + ClosedAt string `json:"closed_at"` + } `json:"incident"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if got.Postmortem.Status != "approved" || got.Postmortem.DraftedBy != "agent" || got.Postmortem.ApprovedBy.Email != "jane@example.com" || got.Incident.ClosedAt == "" { + t.Errorf("unexpected payload %+v", got) + } + }) + + t.Run("not found means no postmortem written", func(t *testing.T) { + srv := errorServer(http.StatusNotFound, "not_found", "postmortem not found") + err := incident.ApprovePostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42", false) + if err == nil || !strings.Contains(err.Error(), "no postmortem written yet for incident 42") { + t.Errorf("unexpected error %v", err) + } + }) + + t.Run("permission denied explains roles", func(t *testing.T) { + srv := errorServer(http.StatusForbidden, "permission_denied", "forbidden") + err := incident.ApprovePostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42", false) + if err == nil || !strings.Contains(err.Error(), "or the incident commander") { + t.Errorf("unexpected error %v", err) + } + }) + + t.Run("missing incident is not reported as missing postmortem", func(t *testing.T) { + srv := errorServer(http.StatusNotFound, "not_found", "incident not found") + err := incident.ApprovePostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42", false) + if err == nil || !strings.Contains(err.Error(), "incident 42 not found") { + t.Errorf("unexpected error %v", err) + } + }) + + t.Run("permission denied keeps the server reason", func(t *testing.T) { + srv := errorServer(http.StatusForbidden, "permission_denied", "api key lacks the incidents scope") + err := incident.ApprovePostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42", false) + if err == nil || !strings.Contains(err.Error(), "api key lacks the incidents scope") || !strings.Contains(err.Error(), "or the incident commander") { + t.Errorf("unexpected error %v", err) + } + }) +} + +func Test_WritePostmortemHeader(t *testing.T) { + t.Parallel() + + approvedAt := "2026-10-16T13:00:00Z" + pm := incidentv1.Postmortem_builder{ + IncidentId: "42", + Status: incidentv1.PostmortemStatus_POSTMORTEM_STATUS_APPROVED, + Content: "# Summary", + DraftedBy: incidentv1.PostmortemAuthor_POSTMORTEM_AUTHOR_AGENT, + ApprovedBy: incidentv1.IncidentUser_builder{Name: "Jane", Email: "jane@example.com"}.Build(), + ApprovedAt: &approvedAt, + UpdatedAt: "2026-10-16T13:00:00Z", + }.Build() + + var buf strings.Builder + incident.WritePostmortem(&buf, pm) + out := buf.String() + for _, want := range []string{"Status: approved", "Drafted by: agent", "Approved: 2026-10-16 13:00 UTC by Jane ", "\n\n# Summary\n"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } +} diff --git a/internal/incident/incident_status.go b/internal/incident/incident_status.go new file mode 100644 index 0000000..f34d998 --- /dev/null +++ b/internal/incident/incident_status.go @@ -0,0 +1,175 @@ +package incident + +import ( + "context" + "errors" + "fmt" + "net/http" + "unicode/utf8" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/incident/v1/incidentv1connect" + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + "github.com/urfave/cli/v3" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +const maxNoteLen = 10000 + +type statusVerb struct { + name string + status string + usage string + done string +} + +var ( + verbMitigate = statusVerb{name: "mitigate", status: "mitigated", usage: "Mark an incident as mitigated", done: "mitigated"} + verbResolve = statusVerb{name: "resolve", status: "resolved", usage: "Mark an incident as resolved", done: "resolved"} + verbReopen = statusVerb{name: "reopen", status: "open", usage: "Reopen a mitigated or resolved incident", done: "reopened"} + verbCancel = statusVerb{name: "cancel", status: "canceled", usage: "Cancel an incident (closes it)", done: "canceled"} +) + +func statusDoneWord(status string) string { + for _, v := range []statusVerb{verbMitigate, verbResolve, verbReopen, verbCancel} { + if v.status == status { + return v.done + } + } + return status +} + +func needsConfirmation(status string, autoAccept bool) bool { + return status == "canceled" && !autoAccept +} + +func validateNote(note string) error { + if utf8.RuneCountInString(note) > maxNoteLen { + return fmt.Errorf("note is too long: maximum %d characters", maxNoteLen) + } + return nil +} + +func buildSetStatusRequest(id, status, note string) (*incidentv1.SetIncidentStatusRequest, error) { + sdkStatus, err := statusToSDK(status) + if err != nil { + return nil, err + } + if err := validateNote(note); err != nil { + return nil, err + } + req := &incidentv1.SetIncidentStatusRequest{Id: id, Status: sdkStatus} + if note != "" { + req.SetNote(note) + } + return req, nil +} + +func SetIncidentStatus(ctx context.Context, client incidentv1connect.IncidentServiceClient, id, status, note string) (*incidentv1.Incident, error) { + req, err := buildSetStatusRequest(id, status, note) + if err != nil { + return nil, err + } + resp, err := client.SetIncidentStatus(ctx, req) + if err != nil { + return nil, formatIncidentError(err, id, opSetStatus) + } + return resp.GetIncident(), nil +} + +func statusChangedMessage(id, status string) string { + return fmt.Sprintf("Incident %s %s", id, statusDoneWord(status)) +} + +func SetIncidentStatusWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id, status, note string) error { + inc, err := SetIncidentStatus(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id, status, note) + if err != nil { + return err + } + return renderIncidentResult(inc, statusChangedMessage(id, status)) +} + +func runSetStatus(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient, id, status string) error { + if _, err := statusToSDK(status); err != nil { + return err + } + if needsConfirmation(status, cmd.Bool("auto-accept")) { + confirmed, err := confirm(cmd, fmt.Sprintf("You are about to cancel incident %s. This closes it and cannot be undone. Continue", id)) + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + s := output.StartSpinner("Updating incident status...") + inc, err := SetIncidentStatus(ctx, client, id, status, cmd.String("note")) + output.StopSpinner(s) + if err != nil { + return err + } + return renderIncidentResult(inc, statusChangedMessage(id, status)) +} + +func noteFlag() cli.Flag { + return &cli.StringFlag{ + Name: "note", + Usage: "Note recorded on the timeline with the change", + } +} + +func GetIncidentSetStatusCmd() *cli.Command { + usage := "openstatus incident set-status --status [--note ...]" + return &cli.Command{ + Name: "set-status", + Usage: "Move an incident to a new status", + UsageText: `openstatus incident set-status --status [--note "..."] + openstatus inc set-status 42 --status mitigated --note "Rolled back the deploy"`, + Description: `Allowed transitions: open -> mitigated, resolved or canceled; mitigated -> +resolved, open or canceled; resolved -> open. Canceled is terminal and closes +the incident. A linked status report is not updated: post the public update with +'openstatus status-report add-update'.`, + Flags: []cli.Flag{ + accessTokenFlag(), + &cli.StringFlag{ + Name: "status", + Usage: "Target status (open, mitigated, resolved, canceled)", + }, + noteFlag(), + autoAcceptFlag(), + }, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, usage) + if err != nil { + return err + } + if cmd.String("status") == "" { + return errors.New("missing required flags: --status") + } + return runSetStatus(ctx, cmd, client, id, cmd.String("status")) + }), + } +} + +func newStatusVerbCmd(v statusVerb) *cli.Command { + usage := fmt.Sprintf("openstatus incident %s [--note ...]", v.name) + flags := []cli.Flag{accessTokenFlag(), noteFlag()} + if v.status == "canceled" { + flags = append(flags, autoAcceptFlag()) + } + return &cli.Command{ + Name: v.name, + Usage: v.usage, + UsageText: fmt.Sprintf(`openstatus incident %s [--note "..."] + openstatus inc %s 42 --note "..."`, v.name, v.name), + Flags: flags, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, usage) + if err != nil { + return err + } + return runSetStatus(ctx, cmd, client, id, v.status) + }), + } +} diff --git a/internal/incident/incident_status_test.go b/internal/incident/incident_status_test.go new file mode 100644 index 0000000..8d5bdb0 --- /dev/null +++ b/internal/incident/incident_status_test.go @@ -0,0 +1,128 @@ +package incident_test + +import ( + "context" + "net/http" + "strings" + "testing" + + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + + "github.com/openstatusHQ/cli/internal/incident" +) + +func Test_BuildSetStatusRequest(t *testing.T) { + t.Parallel() + + tests := []struct { + status string + want incidentv1.IncidentStatus + }{ + {"open", incidentv1.IncidentStatus_INCIDENT_STATUS_OPEN}, + {"mitigated", incidentv1.IncidentStatus_INCIDENT_STATUS_MITIGATED}, + {"resolved", incidentv1.IncidentStatus_INCIDENT_STATUS_RESOLVED}, + {"canceled", incidentv1.IncidentStatus_INCIDENT_STATUS_CANCELED}, + } + for _, tt := range tests { + req, err := incident.BuildSetStatusRequest("42", tt.status, "") + if err != nil { + t.Fatalf("%s: %v", tt.status, err) + } + if req.GetStatus() != tt.want || req.HasNote() { + t.Errorf("%s: unexpected request %v", tt.status, req) + } + } + + req, err := incident.BuildSetStatusRequest("42", "resolved", "Rolled back") + if err != nil { + t.Fatal(err) + } + if req.GetNote() != "Rolled back" { + t.Errorf("note = %q", req.GetNote()) + } + + if _, err := incident.BuildSetStatusRequest("42", "closed", ""); err == nil { + t.Error("expected error for invalid status") + } + if _, err := incident.BuildSetStatusRequest("42", "resolved", strings.Repeat("a", 10001)); err == nil { + t.Error("expected error for long note") + } +} + +func Test_NeedsConfirmation(t *testing.T) { + t.Parallel() + + if !incident.NeedsConfirmation("canceled", false) { + t.Error("cancel without -y needs confirmation") + } + if incident.NeedsConfirmation("canceled", true) { + t.Error("cancel with -y does not need confirmation") + } + for _, s := range []string{"open", "mitigated", "resolved"} { + if incident.NeedsConfirmation(s, false) { + t.Errorf("%s should not need confirmation", s) + } + } +} + +func Test_StatusVerbs(t *testing.T) { + t.Parallel() + + cmd := incident.IncidentCmd() + verbs := map[string]bool{"mitigate": false, "resolve": false, "reopen": false, "cancel": true} + for _, sub := range cmd.Commands { + wantAutoAccept, ok := verbs[sub.Name] + if !ok { + continue + } + hasAutoAccept := false + for _, f := range sub.Flags { + for _, name := range f.Names() { + if name == "auto-accept" { + hasAutoAccept = true + } + } + } + if hasAutoAccept != wantAutoAccept { + t.Errorf("%s: auto-accept flag = %v, want %v", sub.Name, hasAutoAccept, wantAutoAccept) + } + } + + for status, word := range map[string]string{"open": "reopened", "mitigated": "mitigated", "resolved": "resolved", "canceled": "canceled"} { + if got := incident.StatusDoneWord(status); got != word { + t.Errorf("StatusDoneWord(%q) = %q, want %q", status, got, word) + } + } +} + +func Test_SetIncidentStatus(t *testing.T) { + t.Run("sends status and note", func(t *testing.T) { + srv := newServer(http.StatusOK, `{"incident":{"id":"42","status":"INCIDENT_STATUS_RESOLVED"}}`) + var err error + out := captureStdout(t, func() { + err = incident.SetIncidentStatusWithHTTPClient(context.Background(), srv.client(), "token", "42", "resolved", "Fixed") + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if srv.procedure() != "SetIncidentStatus" { + t.Errorf("procedure = %s", srv.procedure()) + } + req := decodeBody(t, srv.reqBody, &incidentv1.SetIncidentStatusRequest{}) + if req.GetStatus() != incidentv1.IncidentStatus_INCIDENT_STATUS_RESOLVED || req.GetNote() != "Fixed" { + t.Errorf("unexpected request %v", req) + } + if !strings.Contains(out, "Incident 42 resolved") || !strings.Contains(out, "postmortem edit 42") { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("refused transition hints at info", func(t *testing.T) { + srv := errorServer(http.StatusBadRequest, "failed_precondition", "cannot move from resolved to mitigated.") + err := incident.SetIncidentStatusWithHTTPClient(context.Background(), srv.client(), "token", "42", "mitigated", "") + want := "cannot move from resolved to mitigated. Run 'openstatus inc info 42' to see allowed transitions." + if err == nil || err.Error() != want { + t.Errorf("got %v, want %q", err, want) + } + }) +} diff --git a/internal/incident/incident_test.go b/internal/incident/incident_test.go new file mode 100644 index 0000000..1f9acfb --- /dev/null +++ b/internal/incident/incident_test.go @@ -0,0 +1,257 @@ +package incident_test + +import ( + "slices" + "strings" + "testing" + + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + + "github.com/openstatusHQ/cli/internal/incident" +) + +func Test_IncidentCmd(t *testing.T) { + t.Parallel() + + cmd := incident.IncidentCmd() + + if cmd.Name != "incident" { + t.Errorf("expected name 'incident', got %s", cmd.Name) + } + if cmd.Usage != "Declare and run incidents" { + t.Errorf("unexpected usage %q", cmd.Usage) + } + for _, alias := range []string{"inc", "incidents"} { + if !slices.Contains(cmd.Aliases, alias) { + t.Errorf("expected alias %q", alias) + } + } + + expected := []string{ + "list", "info", "declare", "update", "set-status", + "mitigate", "resolve", "reopen", "cancel", + "note", "link-report", "unlink-report", "close", "delete", "postmortem", + } + if len(cmd.Commands) != len(expected) { + t.Errorf("expected %d subcommands, got %d", len(expected), len(cmd.Commands)) + } + names := make([]string, 0, len(cmd.Commands)) + for _, sub := range cmd.Commands { + names = append(names, sub.Name) + if sub.Name == "declare" && !slices.Contains(sub.Aliases, "create") { + t.Error("expected declare to have alias 'create'") + } + } + for _, name := range expected { + if !slices.Contains(names, name) { + t.Errorf("expected subcommand %q", name) + } + } +} + +func Test_SeverityConversions(t *testing.T) { + t.Parallel() + + for _, s := range []string{"critical", "major", "minor"} { + sdk, err := incident.SeverityToSDK(s) + if err != nil { + t.Fatalf("SeverityToSDK(%q) error: %v", s, err) + } + if got := incident.SeverityToString(sdk); got != s { + t.Errorf("round trip %q -> %q", s, got) + } + } + if _, err := incident.SeverityToSDK("high"); err == nil || !strings.Contains(err.Error(), "critical, major, minor") { + t.Errorf("expected invalid severity error, got %v", err) + } + if got := incident.SeverityToString(incidentv1.IncidentSeverity_INCIDENT_SEVERITY_UNSPECIFIED); got != "unknown" { + t.Errorf("expected unknown, got %q", got) + } +} + +func Test_StatusConversions(t *testing.T) { + t.Parallel() + + for _, s := range []string{"open", "mitigated", "resolved", "canceled"} { + sdk, err := incident.StatusToSDK(s) + if err != nil { + t.Fatalf("StatusToSDK(%q) error: %v", s, err) + } + if got := incident.StatusToString(sdk); got != s { + t.Errorf("round trip %q -> %q", s, got) + } + } + if _, err := incident.StatusToSDK("closed"); err == nil { + t.Error("expected error for 'closed'") + } + if got := incident.StatusToString(incidentv1.IncidentStatus_INCIDENT_STATUS_UNSPECIFIED); got != "unknown" { + t.Errorf("expected unknown, got %q", got) + } +} + +func Test_ParseStatuses(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + want []incidentv1.IncidentStatus + wantErr bool + }{ + {name: "empty", raw: ""}, + {name: "single", raw: "open", want: []incidentv1.IncidentStatus{incidentv1.IncidentStatus_INCIDENT_STATUS_OPEN}}, + { + name: "list with spaces and duplicates", + raw: "open, mitigated,open", + want: []incidentv1.IncidentStatus{ + incidentv1.IncidentStatus_INCIDENT_STATUS_OPEN, + incidentv1.IncidentStatus_INCIDENT_STATUS_MITIGATED, + }, + }, + {name: "unknown", raw: "open,closed", wantErr: true}, + {name: "empty item", raw: "open,,resolved", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := incident.ParseStatuses(tt.raw) + if (err != nil) != tt.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) + } + if !slices.Equal(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func Test_EventTypeToString(t *testing.T) { + t.Parallel() + + for value, name := range incidentv1.IncidentEventType_name { + got := incident.EventTypeToString(incidentv1.IncidentEventType(value)) + if value == 0 { + if got != "event" { + t.Errorf("unspecified: got %q", got) + } + continue + } + want := strings.ToLower(strings.TrimPrefix(name, "INCIDENT_EVENT_TYPE_")) + if got != want { + t.Errorf("%s: got %q, want %q", name, got, want) + } + } + if got := incident.EventTypeToString(incidentv1.IncidentEventType(999)); got != "event" { + t.Errorf("unknown value: got %q", got) + } +} + +func Test_FormatUser(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + user *incidentv1.IncidentUser + full string + shortName string + }{ + {name: "nil", user: nil, full: "unassigned", shortName: "unassigned"}, + {name: "name and email", user: incidentv1.IncidentUser_builder{Name: "Jane", Email: "jane@example.com"}.Build(), full: "Jane ", shortName: "Jane"}, + {name: "email only", user: incidentv1.IncidentUser_builder{Email: "jane@example.com"}.Build(), full: "jane@example.com", shortName: "jane@example.com"}, + {name: "deleted user", user: incidentv1.IncidentUser_builder{Name: "Deleted user"}.Build(), full: "Deleted user", shortName: "Deleted user"}, + {name: "empty", user: &incidentv1.IncidentUser{}, full: "unassigned", shortName: "unassigned"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := incident.FormatUser(tt.user); got != tt.full { + t.Errorf("FormatUser = %q, want %q", got, tt.full) + } + if got := incident.ShortUser(tt.user); got != tt.shortName { + t.Errorf("ShortUser = %q, want %q", got, tt.shortName) + } + }) + } +} + +func Test_DisplayStatus(t *testing.T) { + t.Parallel() + + if got := incident.DisplayStatus(incidentv1.IncidentStatus_INCIDENT_STATUS_RESOLVED, ""); got != "resolved" { + t.Errorf("got %q", got) + } + if got := incident.DisplayStatus(incidentv1.IncidentStatus_INCIDENT_STATUS_RESOLVED, "2026-10-16T12:00:00Z"); got != "resolved (closed)" { + t.Errorf("got %q", got) + } + if got := incident.DisplayStatus(incidentv1.IncidentStatus_INCIDENT_STATUS_CANCELED, "2026-10-16T12:00:00Z"); got != "canceled" { + t.Errorf("got %q", got) + } +} + +func Test_ReadTextArg(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + stdin string + want string + wantErr bool + }{ + {name: "literal", value: "hello", want: "hello"}, + {name: "stdin", value: "-", stdin: "line one\nline two\n", want: "line one\nline two"}, + {name: "stdin crlf", value: "-", stdin: "hello\r\n", want: "hello"}, + {name: "empty stdin", value: "-", stdin: " \n", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := incident.ReadTextArg(tt.value, strings.NewReader(tt.stdin)) + if (err != nil) != tt.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func Test_NextStepHint(t *testing.T) { + t.Parallel() + + build := func(status incidentv1.IncidentStatus, closedAt *string) *incidentv1.Incident { + return incidentv1.Incident_builder{Id: "42", Status: status, ClosedAt: closedAt}.Build() + } + + tests := []struct { + name string + inc *incidentv1.Incident + want string + }{ + {name: "open", inc: build(incidentv1.IncidentStatus_INCIDENT_STATUS_OPEN, nil), want: "openstatus inc mitigate 42"}, + {name: "mitigated", inc: build(incidentv1.IncidentStatus_INCIDENT_STATUS_MITIGATED, nil), want: "openstatus inc resolve 42"}, + {name: "resolved", inc: build(incidentv1.IncidentStatus_INCIDENT_STATUS_RESOLVED, nil), want: "openstatus inc postmortem edit 42"}, + {name: "closed", inc: build(incidentv1.IncidentStatus_INCIDENT_STATUS_RESOLVED, strPtr("2026-10-16T12:00:00Z")), want: ""}, + {name: "canceled", inc: build(incidentv1.IncidentStatus_INCIDENT_STATUS_CANCELED, strPtr("2026-10-16T12:00:00Z")), want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := incident.NextStepHint(tt.inc) + if tt.want == "" { + if got != "" { + t.Errorf("expected no hint, got %q", got) + } + return + } + if !strings.Contains(got, tt.want) { + t.Errorf("hint %q does not contain %q", got, tt.want) + } + }) + } +} diff --git a/internal/incident/incident_update.go b/internal/incident/incident_update.go new file mode 100644 index 0000000..f8c0179 --- /dev/null +++ b/internal/incident/incident_update.go @@ -0,0 +1,179 @@ +package incident + +import ( + "context" + "errors" + "fmt" + "net/http" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/incident/v1/incidentv1connect" + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + "github.com/urfave/cli/v3" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +const updateUsage = "openstatus incident update [--title ...] [--severity ...] [--summary ... | --clear-summary] [--commander ... | --clear-commander] [--started-at ...]" + +type UpdateParams struct { + Title *string + Severity *string + Summary *string + Commander *string + StartedAt *string + ClearSummary bool + ClearCommander bool +} + +func (p UpdateParams) empty() bool { + return p.Title == nil && p.Severity == nil && p.Summary == nil && p.Commander == nil && + p.StartedAt == nil && !p.ClearSummary && !p.ClearCommander +} + +func buildUpdateRequest(id string, p UpdateParams) (*incidentv1.UpdateIncidentRequest, error) { + if p.empty() { + return nil, errors.New("nothing to update: pass at least one of --title, --severity, --summary, --clear-summary, --commander, --clear-commander, --started-at") + } + if p.Summary != nil && p.ClearSummary { + return nil, errors.New("--summary and --clear-summary are mutually exclusive") + } + if p.Commander != nil && p.ClearCommander { + return nil, errors.New("--commander and --clear-commander are mutually exclusive") + } + + req := &incidentv1.UpdateIncidentRequest{Id: id} + if p.Title != nil { + if err := validateTitle(*p.Title); err != nil { + return nil, err + } + req.SetTitle(*p.Title) + } + if p.Severity != nil { + severity, err := severityToSDK(*p.Severity) + if err != nil { + return nil, err + } + req.SetSeverity(severity) + } + if p.Summary != nil { + if *p.Summary == "" { + return nil, errors.New("--summary cannot be empty; use --clear-summary to remove it") + } + if err := validateSummary(*p.Summary); err != nil { + return nil, err + } + req.Set_Summary(*p.Summary) + } + if p.ClearSummary { + req.SetClearSummary(true) + } + if p.Commander != nil { + if err := validateEmail("--commander", *p.Commander); err != nil { + return nil, err + } + req.SetCommanderEmail(*p.Commander) + } + if p.ClearCommander { + req.SetClearCommander(true) + } + if p.StartedAt != nil { + if err := validateTimestamp("--started-at", *p.StartedAt); err != nil { + return nil, err + } + req.SetStartedAt(*p.StartedAt) + } + return req, nil +} + +func UpdateIncident(ctx context.Context, client incidentv1connect.IncidentServiceClient, id string, p UpdateParams) (*incidentv1.Incident, error) { + req, err := buildUpdateRequest(id, p) + if err != nil { + return nil, err + } + resp, err := client.UpdateIncident(ctx, req) + if err != nil { + return nil, formatIncidentError(err, id, opGeneric) + } + return resp.GetIncident(), nil +} + +func UpdateIncidentWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id string, p UpdateParams) error { + inc, err := UpdateIncident(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id, p) + if err != nil { + return err + } + return renderIncidentResult(inc, fmt.Sprintf("Incident %s updated", id)) +} + +func optionalString(cmd *cli.Command, name string) *string { + if !cmd.IsSet(name) { + return nil + } + v := cmd.String(name) + return &v +} + +func GetIncidentUpdateCmd() *cli.Command { + return &cli.Command{ + Name: "update", + Usage: "Edit an incident's title, severity, summary, commander or start time", + UsageText: `openstatus incident update [flags] + openstatus inc update 42 --severity critical + openstatus inc update 42 --commander jane@example.com + openstatus inc update 42 --clear-summary`, + Flags: []cli.Flag{ + accessTokenFlag(), + &cli.StringFlag{ + Name: "title", + Usage: "New title (1-256 characters)", + }, + &cli.StringFlag{ + Name: "severity", + Usage: "New severity (critical, major, minor)", + }, + &cli.StringFlag{ + Name: "summary", + Usage: "New summary", + }, + &cli.BoolFlag{ + Name: "clear-summary", + Usage: "Remove the summary", + }, + &cli.StringFlag{ + Name: "commander", + Usage: "Email of the new commander", + }, + &cli.BoolFlag{ + Name: "clear-commander", + Usage: "Unassign the commander", + }, + &cli.StringFlag{ + Name: "started-at", + Usage: "New start of impact (RFC 3339)", + }, + }, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, updateUsage) + if err != nil { + return err + } + p := UpdateParams{ + Title: optionalString(cmd, "title"), + Severity: optionalString(cmd, "severity"), + Summary: optionalString(cmd, "summary"), + Commander: optionalString(cmd, "commander"), + StartedAt: optionalString(cmd, "started-at"), + ClearSummary: cmd.Bool("clear-summary"), + ClearCommander: cmd.Bool("clear-commander"), + } + + s := output.StartSpinner("Updating incident...") + inc, err := UpdateIncident(ctx, client, id, p) + output.StopSpinner(s) + if err != nil { + return err + } + return renderIncidentResult(inc, fmt.Sprintf("Incident %s updated", id)) + }), + } +} diff --git a/internal/incident/incident_update_test.go b/internal/incident/incident_update_test.go new file mode 100644 index 0000000..e5a06a4 --- /dev/null +++ b/internal/incident/incident_update_test.go @@ -0,0 +1,110 @@ +package incident_test + +import ( + "context" + "net/http" + "strings" + "testing" + + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + + "github.com/openstatusHQ/cli/internal/incident" +) + +func Test_BuildUpdateRequest(t *testing.T) { + t.Parallel() + + t.Run("only set fields", func(t *testing.T) { + t.Parallel() + req, err := incident.BuildUpdateRequest("42", incident.UpdateParams{Severity: strPtr("critical")}) + if err != nil { + t.Fatal(err) + } + if req.GetId() != "42" || req.GetSeverity() != incidentv1.IncidentSeverity_INCIDENT_SEVERITY_CRITICAL { + t.Errorf("unexpected request %v", req) + } + if req.HasTitle() || req.Has_Summary() || req.HasClearSummary() || req.HasCommanderEmail() || req.HasClearCommander() || req.HasStartedAt() { + t.Errorf("expected only severity set, got %v", req) + } + }) + + t.Run("all fields", func(t *testing.T) { + t.Parallel() + req, err := incident.BuildUpdateRequest("42", incident.UpdateParams{ + Title: strPtr("New title"), + Summary: strPtr("New summary"), + Commander: strPtr("jane@example.com"), + StartedAt: strPtr("2026-10-16T09:00:00+02:00"), + }) + if err != nil { + t.Fatal(err) + } + if req.GetTitle() != "New title" || req.Get_Summary() != "New summary" || req.GetCommanderEmail() != "jane@example.com" || req.GetStartedAt() != "2026-10-16T09:00:00+02:00" { + t.Errorf("unexpected request %v", req) + } + }) + + t.Run("clear flags", func(t *testing.T) { + t.Parallel() + req, err := incident.BuildUpdateRequest("42", incident.UpdateParams{ClearSummary: true, ClearCommander: true}) + if err != nil { + t.Fatal(err) + } + if !req.GetClearSummary() || !req.GetClearCommander() || req.Has_Summary() || req.HasCommanderEmail() { + t.Errorf("unexpected request %v", req) + } + }) + + t.Run("errors", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + p incident.UpdateParams + want string + }{ + {name: "nothing", p: incident.UpdateParams{}, want: "nothing to update"}, + {name: "summary and clear", p: incident.UpdateParams{Summary: strPtr("x"), ClearSummary: true}, want: "--summary and --clear-summary"}, + {name: "commander and clear", p: incident.UpdateParams{Commander: strPtr("a@b.co"), ClearCommander: true}, want: "--commander and --clear-commander"}, + {name: "empty summary", p: incident.UpdateParams{Summary: strPtr("")}, want: "use --clear-summary"}, + {name: "bad severity", p: incident.UpdateParams{Severity: strPtr("low")}, want: "invalid severity"}, + {name: "bad commander", p: incident.UpdateParams{Commander: strPtr("nope")}, want: "invalid --commander"}, + {name: "bad started-at", p: incident.UpdateParams{StartedAt: strPtr("2026-10-16")}, want: "invalid --started-at"}, + {name: "empty title", p: incident.UpdateParams{Title: strPtr("")}, want: "title cannot be empty"}, + } + for _, tt := range tests { + if _, err := incident.BuildUpdateRequest("42", tt.p); err == nil || !strings.Contains(err.Error(), tt.want) { + t.Errorf("%s: got %v, want error containing %q", tt.name, err, tt.want) + } + } + }) +} + +func Test_UpdateIncident(t *testing.T) { + t.Run("sends request", func(t *testing.T) { + srv := newServer(http.StatusOK, incidentResponse()) + var err error + out := captureStdout(t, func() { + err = incident.UpdateIncidentWithHTTPClient(context.Background(), srv.client(), "token", "42", incident.UpdateParams{Title: strPtr("Renamed")}) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := decodeBody(t, srv.reqBody, &incidentv1.UpdateIncidentRequest{}) + if req.GetId() != "42" || req.GetTitle() != "Renamed" { + t.Errorf("unexpected request %v", req) + } + if !strings.Contains(out, "Incident 42 updated") { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("validation error makes no request", func(t *testing.T) { + srv := newServer(http.StatusOK, incidentResponse()) + if err := incident.UpdateIncidentWithHTTPClient(context.Background(), srv.client(), "token", "42", incident.UpdateParams{}); err == nil { + t.Fatal("expected error") + } + if srv.calls != 0 { + t.Errorf("expected no request, got %d", srv.calls) + } + }) +} diff --git a/internal/incident/incident_wizard.go b/internal/incident/incident_wizard.go new file mode 100644 index 0000000..050c028 --- /dev/null +++ b/internal/incident/incident_wizard.go @@ -0,0 +1,60 @@ +package incident + +import ( + "github.com/charmbracelet/huh" + + "github.com/openstatusHQ/cli/internal/wizard" +) + +func runDeclareWizard(prefilled DeclareParams) (DeclareParams, error) { + inputs := prefilled + + var fields []huh.Field + + if inputs.Title == "" { + fields = append(fields, huh.NewInput(). + Title("Title"). + Description("What is broken, e.g. Checkout API returns 502"). + Validate(validateTitle). + Value(&inputs.Title)) + } + + if inputs.Severity == "" { + fields = append(fields, huh.NewSelect[string](). + Title("Severity"). + Options( + huh.NewOption("critical", "critical"), + huh.NewOption("major", "major"), + huh.NewOption("minor", "minor"), + ). + Value(&inputs.Severity)) + } + + if inputs.Summary == "" { + fields = append(fields, huh.NewText(). + Title("Summary"). + Description("Short human summary (optional)"). + Validate(validateSummary). + Value(&inputs.Summary)) + } + + if inputs.Commander == "" { + fields = append(fields, huh.NewInput(). + Title("Commander"). + Description("Email of the member leading the response (optional)"). + Validate(func(s string) error { + if s == "" { + return nil + } + return validateEmail("commander", s) + }). + Value(&inputs.Commander)) + } + + form := huh.NewForm(huh.NewGroup(fields...)).WithTheme(huh.ThemeBase()) + if err := form.Run(); err != nil { + return inputs, wizard.HandleFormError(err) + } + + return inputs, nil +} diff --git a/internal/incident/output_test.go b/internal/incident/output_test.go new file mode 100644 index 0000000..9186753 --- /dev/null +++ b/internal/incident/output_test.go @@ -0,0 +1,51 @@ +package incident_test + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/openstatusHQ/cli/internal/incident" +) + +func Test_MutationOutputModes(t *testing.T) { + t.Run("quiet prints only the id", func(t *testing.T) { + withOutputModes(t, false, true) + srv := newServer(http.StatusOK, incidentResponse()) + out := captureStdout(t, func() { + if err := incident.DeclareIncidentWithHTTPClient(context.Background(), srv.client(), "token", incident.DeclareParams{Title: "x", Severity: "major"}); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if out != "42\n" { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("json prints the incident without events", func(t *testing.T) { + withOutputModes(t, true, false) + srv := newServer(http.StatusOK, incidentResponse()) + out := captureStdout(t, func() { + if err := incident.SetIncidentStatusWithHTTPClient(context.Background(), srv.client(), "token", "42", "mitigated", ""); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var got map[string]json.RawMessage + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + for _, key := range []string{"id", "title", "severity", "status", "commander", "allowed_transitions", "deletable"} { + if _, ok := got[key]; !ok { + t.Errorf("missing key %q in %s", key, out) + } + } + if _, ok := got["events"]; ok { + t.Error("mutation output should not include events") + } + if strings.Contains(out, "Next:") { + t.Error("json output should not include hints") + } + }) +} diff --git a/internal/incident/testhelpers_test.go b/internal/incident/testhelpers_test.go new file mode 100644 index 0000000..d284671 --- /dev/null +++ b/internal/incident/testhelpers_test.go @@ -0,0 +1,128 @@ +package incident_test + +import ( + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/fatih/color" + "github.com/rodaine/table" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +func TestMain(m *testing.M) { + color.NoColor = true + os.Exit(m.Run()) +} + +const incidentJSON = `{ + "id": "42", + "title": "Checkout 502", + "severity": "INCIDENT_SEVERITY_MAJOR", + "status": "INCIDENT_STATUS_OPEN", + "commander": {"email": "jane@example.com", "name": "Jane"}, + "declaredAt": "2026-10-16T10:00:00Z", + "startedAt": "2026-10-16T09:55:00Z", + "allowedTransitions": ["INCIDENT_STATUS_MITIGATED", "INCIDENT_STATUS_RESOLVED", "INCIDENT_STATUS_CANCELED"], + "deletable": true, + "createdAt": "2026-10-16T10:00:00Z", + "updatedAt": "2026-10-16T10:00:00Z" +}` + +func incidentResponse() string { + return `{"incident":` + incidentJSON + `}` +} + +type fakeServer struct { + status int + body string + calls int + path string + reqBody []byte +} + +func newServer(status int, body string) *fakeServer { + return &fakeServer{status: status, body: body} +} + +func errorServer(status int, code, message string) *fakeServer { + return newServer(status, `{"code":"`+code+`","message":"`+message+`"}`) +} + +func (f *fakeServer) RoundTrip(req *http.Request) (*http.Response, error) { + f.calls++ + f.path = req.URL.Path + if req.Body != nil { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + f.reqBody = body + } + return &http.Response{ + StatusCode: f.status, + Body: io.NopCloser(strings.NewReader(f.body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + Request: req, + }, nil +} + +func (f *fakeServer) client() *http.Client { + return &http.Client{Transport: f} +} + +func (f *fakeServer) procedure() string { + return f.path[strings.LastIndex(f.path, "/")+1:] +} + +func decodeBody[T proto.Message](t *testing.T, body []byte, into T) T { + t.Helper() + if len(body) == 0 { + t.Fatal("expected non-empty request body") + } + if err := protojson.Unmarshal(body, into); err != nil { + t.Fatalf("failed to unmarshal request body: %v\nbody: %s", err, string(body)) + } + return into +} + +func captureStdout(t *testing.T, f func()) string { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + original, originalTable := os.Stdout, table.DefaultWriter + os.Stdout, table.DefaultWriter = w, w + defer func() { os.Stdout, table.DefaultWriter = original, originalTable }() + + done := make(chan string, 1) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + + f() + w.Close() + return <-done +} + +func withOutputModes(t *testing.T, jsonMode, quiet bool) { + t.Helper() + output.SetJSONOutput(jsonMode) + output.SetQuietMode(quiet) + t.Cleanup(func() { + output.SetJSONOutput(false) + output.SetQuietMode(false) + }) +} + +func strPtr(s string) *string { + return &s +} diff --git a/internal/monitors/monitors.go b/internal/monitors/monitors.go index 37834e8..9f714df 100644 --- a/internal/monitors/monitors.go +++ b/internal/monitors/monitors.go @@ -90,7 +90,7 @@ func regionToString(r monitorv1.Region) string { return "ams" case monitorv1.Region_REGION_FLY_ARN: return "arn" - case monitorv1.Region_REGION_FLY_BOM: + case monitorv1.Region_REGION_FLY_BOM: //nolint:staticcheck // deprecated values still exist on older resources return "bom" case monitorv1.Region_REGION_FLY_CDG: return "cdg" @@ -231,7 +231,7 @@ func stringToRegion(r config.Region) monitorv1.Region { case config.Arn: return monitorv1.Region_REGION_FLY_ARN case config.BOM: - return monitorv1.Region_REGION_FLY_BOM + return monitorv1.Region_REGION_FLY_BOM //nolint:staticcheck // deprecated values still exist on older resources case config.Cdg: return monitorv1.Region_REGION_FLY_CDG case config.Dfw: diff --git a/internal/notification/notification.go b/internal/notification/notification.go index 10b448a..7ad4bc7 100644 --- a/internal/notification/notification.go +++ b/internal/notification/notification.go @@ -47,7 +47,7 @@ func providerToString(p notificationv1.NotificationProvider) string { return "opsgenie" case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_SLACK: return "slack" - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_SMS: + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_SMS: //nolint:staticcheck // deprecated values still exist on older resources return "sms" case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_TELEGRAM: return "telegram" diff --git a/internal/notification/notification_info.go b/internal/notification/notification_info.go index 75f37f5..500097b 100644 --- a/internal/notification/notification_info.go +++ b/internal/notification/notification_info.go @@ -93,7 +93,7 @@ func extractNotificationData(data *notificationv1.NotificationData) (map[string] return map[string]any{"ntfy": jsonMap}, rows case *notificationv1.NotificationData_Sms: - phone := d.Sms.GetPhoneNumber() + phone := d.Sms.GetPhoneNumber() //nolint:staticcheck // deprecated values still exist on older resources return map[string]any{"sms": map[string]any{"phone_number": phone}}, [][]string{{"Phone Number", phone}} diff --git a/internal/statusreport/export_test.go b/internal/statusreport/export_test.go new file mode 100644 index 0000000..46a9ff7 --- /dev/null +++ b/internal/statusreport/export_test.go @@ -0,0 +1,3 @@ +package statusreport + +var RenderCreated = renderCreated diff --git a/internal/statusreport/statusreport_create.go b/internal/statusreport/statusreport_create.go index c8404b3..2eff38d 100644 --- a/internal/statusreport/statusreport_create.go +++ b/internal/statusreport/statusreport_create.go @@ -2,13 +2,16 @@ package statusreport import ( "context" + "errors" "fmt" "net/http" + "os" "strings" "time" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + "connectrpc.com/connect" "github.com/urfave/cli/v3" "github.com/openstatusHQ/cli/internal/auth" @@ -24,13 +27,22 @@ type CreateStatusReportParams struct { ComponentIDs []string ComponentImpacts []*status_reportv1.ComponentImpact Notify bool + IncidentID string } func CreateStatusReport(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, p CreateStatusReportParams) (string, error) { - sdkStatus, err := statusToSDK(p.Status) + report, err := createStatusReport(ctx, client, p) if err != nil { return "", err } + return report.GetId(), nil +} + +func createStatusReport(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, p CreateStatusReportParams) (*status_reportv1.StatusReport, error) { + sdkStatus, err := statusToSDK(p.Status) + if err != nil { + return nil, err + } req := &status_reportv1.CreateStatusReportRequest{ Title: p.Title, @@ -52,12 +64,30 @@ func CreateStatusReport(ctx context.Context, client status_reportv1connect.Statu req.SetNotify(true) } + if p.IncidentID != "" { + req.SetIncidentId(p.IncidentID) + } + resp, err := client.CreateStatusReport(ctx, req) if err != nil { - return "", output.FormatError(err, "status-report", "") + return nil, formatCreateError(err, p.IncidentID) } - return resp.GetStatusReport().GetId(), nil + return resp.GetStatusReport(), nil +} + +// formatCreateError keeps the server message on NotFound when an incident is +// linked, since it names the missing resource (page or incident); the generic +// mapping would always blame the status report. +func formatCreateError(err error, incidentID string) error { + var connectErr *connect.Error + if incidentID != "" && errors.As(err, &connectErr) && connectErr.Code() == connect.CodeNotFound { + if msg := strings.TrimSuffix(connectErr.Message(), "."); msg != "" { + return errors.New(msg) + } + return fmt.Errorf("incident %s not found. Run 'openstatus incident list' to see available incidents", incidentID) + } + return output.FormatError(err, "status-report", "") } func CreateStatusReportWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, p CreateStatusReportParams) (string, error) { @@ -109,6 +139,10 @@ func GetStatusReportCreateCmd() *cli.Command { Name: "date", Usage: "Date when the event occurred (RFC 3339 format, defaults to now)", }, + &cli.StringFlag{ + Name: "incident-id", + Usage: "ID of an incident to link the report to", + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { apiKey, err := auth.ResolveAccessToken(cmd) @@ -179,7 +213,7 @@ func GetStatusReportCreateCmd() *cli.Command { client := NewStatusReportClient(apiKey) s := output.StartSpinner("Creating status report...") - id, err := CreateStatusReport(ctx, client, CreateStatusReportParams{ + report, err := createStatusReport(ctx, client, CreateStatusReportParams{ Title: inputs.Title, Status: inputs.Status, Message: inputs.Message, @@ -188,16 +222,37 @@ func GetStatusReportCreateCmd() *cli.Command { ComponentIDs: componentIDs, ComponentImpacts: componentImpacts, Notify: inputs.Notify, + IncidentID: cmd.String("incident-id"), }) output.StopSpinner(s) if err != nil { return cli.Exit(err.Error(), 1) } - fmt.Printf("Status report created successfully (ID: %s)\n", id) - fmt.Printf("To add updates, run: openstatus status-report add-update %s --status identified --message '...'\n", id) - fmt.Println("Tip: pass --impact = to update per-component impact.") - return nil + return renderCreated(report, cmd.String("incident-id")) }, } } + +// renderCreated prints the created report. requestedIncidentID is the +// --incident-id the user passed, so a silently dropped link can be flagged. +func renderCreated(report *status_reportv1.StatusReport, requestedIncidentID string) error { + id := report.GetId() + if requestedIncidentID != "" && !report.HasIncidentId() { + fmt.Fprintf(os.Stderr, "Warning: the report was not linked to incident %s. Link it with: openstatus inc link-report %s %s\n", requestedIncidentID, requestedIncidentID, id) + } + if output.IsJSONOutput() { + return output.PrintJSON(toStatusReportDetail(report, affectedOrder(report), currentImpacts(report.GetUpdates()))) + } + if output.IsQuiet() { + fmt.Println(id) + return nil + } + fmt.Printf("Status report created successfully (ID: %s)\n", id) + if report.HasIncidentId() { + fmt.Printf("Linked to incident %s\n", report.GetIncidentId()) + } + fmt.Printf("To add updates, run: openstatus status-report add-update %s --status identified --message '...'\n", id) + fmt.Println("Tip: pass --impact = to update per-component impact.") + return nil +} diff --git a/internal/statusreport/statusreport_incident_test.go b/internal/statusreport/statusreport_incident_test.go new file mode 100644 index 0000000..8255003 --- /dev/null +++ b/internal/statusreport/statusreport_incident_test.go @@ -0,0 +1,201 @@ +package statusreport_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "os" + "testing" + + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/statusreport" +) + +func respondWith(body string, captured *[]byte) *interceptorHTTPClient { + return &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if err := captureRequestBody(req, captured); err != nil { + return nil, err + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(body))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } +} + +func captureJSONStdout(t *testing.T, f func()) []byte { + t.Helper() + + output.SetJSONOutput(true) + defer output.SetJSONOutput(false) + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + original := os.Stdout + os.Stdout = w + defer func() { os.Stdout = original }() + + done := make(chan []byte, 1) + go func() { + b, _ := io.ReadAll(r) + done <- b + }() + + f() + w.Close() + return <-done +} + +func Test_CreateStatusReport_IncidentID(t *testing.T) { + t.Parallel() + + body := `{"statusReport":{"id":"44","title":"DB Down","status":"STATUS_REPORT_STATUS_INVESTIGATING","incidentId":"42"}}` + + for _, incidentID := range []string{"", "42"} { + var captured []byte + _, err := statusreport.CreateStatusReportWithHTTPClient( + context.Background(), respondWith(body, &captured).GetHTTPClient(), "test-token", + statusreport.CreateStatusReportParams{ + Title: "DB Down", + Status: "investigating", + Message: "Looking", + Date: "2026-03-20T10:00:00Z", + PageID: "page-1", + IncidentID: incidentID, + }, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + req := decodeBody(t, captured, &status_reportv1.CreateStatusReportRequest{}) + if req.HasIncidentId() != (incidentID != "") || req.GetIncidentId() != incidentID { + t.Errorf("incidentID=%q: unexpected request %v", incidentID, req) + } + } +} + +func Test_StatusReport_IncidentIDInJSON(t *testing.T) { + t.Run("list", func(t *testing.T) { + body := `{"statusReports":[{"id":"1","title":"Linked","status":"STATUS_REPORT_STATUS_INVESTIGATING","incidentId":"42"},{"id":"2","title":"Unlinked","status":"STATUS_REPORT_STATUS_RESOLVED"}],"totalSize":2}` + var captured []byte + out := captureJSONStdout(t, func() { + if err := statusreport.ListStatusReportsWithHTTPClient(context.Background(), respondWith(body, &captured).GetHTTPClient(), "test-token", "", 0); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var got []map[string]string + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if len(got) != 2 || got[0]["incident_id"] != "42" { + t.Fatalf("unexpected payload %v", got) + } + if _, ok := got[1]["incident_id"]; ok { + t.Error("unlinked report should omit incident_id") + } + }) + + t.Run("info", func(t *testing.T) { + body := `{"statusReport":{"id":"1","title":"Linked","status":"STATUS_REPORT_STATUS_INVESTIGATING","incidentId":"42"}}` + var captured []byte + out := captureJSONStdout(t, func() { + if err := statusreport.GetStatusReportInfoWithHTTPClient(context.Background(), respondWith(body, &captured).GetHTTPClient(), "test-token", "1"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var got struct { + IncidentID string `json:"incident_id"` + } + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if got.IncidentID != "42" { + t.Errorf("incident_id = %q", got.IncidentID) + } + }) +} + +func Test_CreateStatusReport_IncidentNotFound(t *testing.T) { + t.Parallel() + + client := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewReader([]byte(`{"code":"not_found","message":"incident 999 not found"}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } + _, err := statusreport.CreateStatusReportWithHTTPClient( + context.Background(), client.GetHTTPClient(), "test-token", + statusreport.CreateStatusReportParams{ + Title: "DB Down", + Status: "investigating", + Message: "Looking", + Date: "2026-03-20T10:00:00Z", + PageID: "page-1", + IncidentID: "999", + }, + ) + if err == nil || err.Error() != "incident 999 not found" { + t.Errorf("unexpected error %v", err) + } +} + +func Test_RenderCreated_JSON(t *testing.T) { + report := &status_reportv1.StatusReport{} + report.SetId("44") + report.SetTitle("DB Down") + report.SetIncidentId("42") + + var err error + out := captureJSONStdout(t, func() { + err = statusreport.RenderCreated(report, "42") + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got struct { + ID string `json:"id"` + IncidentID string `json:"incident_id"` + } + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out) + } + if got.ID != "44" || got.IncidentID != "42" { + t.Errorf("unexpected output %+v", got) + } +} + +func Test_RenderCreated_Quiet(t *testing.T) { + report := &status_reportv1.StatusReport{} + report.SetId("44") + + output.SetQuietMode(true) + defer output.SetQuietMode(false) + + r, w, _ := os.Pipe() + original := os.Stdout + os.Stdout = w + err := statusreport.RenderCreated(report, "") + w.Close() + os.Stdout = original + out, _ := io.ReadAll(r) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(out) != "44\n" { + t.Errorf("unexpected output %q", out) + } +} diff --git a/internal/statusreport/statusreport_info.go b/internal/statusreport/statusreport_info.go index ba955e4..9579291 100644 --- a/internal/statusreport/statusreport_info.go +++ b/internal/statusreport/statusreport_info.go @@ -25,6 +25,7 @@ type statusReportDetail struct { Status string `json:"status"` Components []string `json:"components,omitempty"` CurrentImpacts []componentImpactJSON `json:"current_impacts,omitempty"` + IncidentID string `json:"incident_id,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Updates []statusReportUpdate `json:"updates,omitempty"` @@ -42,6 +43,39 @@ type componentImpactJSON struct { Impact string `json:"impact"` } +func toStatusReportDetail(report *status_reportv1.StatusReport, affected []string, current map[string]status_reportv1.PageComponentImpact) statusReportDetail { + detail := statusReportDetail{ + ID: report.GetId(), + Title: report.GetTitle(), + Status: statusToString(report.GetStatus()), + Components: report.GetPageComponentIds(), + IncidentID: report.GetIncidentId(), + CreatedAt: report.GetCreatedAt(), + UpdatedAt: report.GetUpdatedAt(), + } + for _, id := range affected { + detail.CurrentImpacts = append(detail.CurrentImpacts, componentImpactJSON{ + ComponentID: id, + Impact: impactToString(current[id]), + }) + } + for _, u := range report.GetUpdates() { + ue := statusReportUpdate{ + Date: u.GetDate(), + Status: statusToString(u.GetStatus()), + Message: u.GetMessage(), + } + for _, ci := range u.GetComponentImpacts() { + ue.Impacts = append(ue.Impacts, componentImpactJSON{ + ComponentID: ci.GetPageComponentId(), + Impact: impactToString(ci.GetImpact()), + }) + } + detail.Updates = append(detail.Updates, ue) + } + return detail +} + func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, reportId string, s *output.Spinner) error { if reportId == "" { output.StopSpinner(s) @@ -65,35 +99,7 @@ func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.Stat current := currentImpacts(report.GetUpdates()) if output.IsJSONOutput() { - detail := statusReportDetail{ - ID: report.GetId(), - Title: report.GetTitle(), - Status: statusToString(report.GetStatus()), - Components: report.GetPageComponentIds(), - CreatedAt: report.GetCreatedAt(), - UpdatedAt: report.GetUpdatedAt(), - } - for _, id := range affected { - detail.CurrentImpacts = append(detail.CurrentImpacts, componentImpactJSON{ - ComponentID: id, - Impact: impactToString(current[id]), - }) - } - for _, u := range report.GetUpdates() { - ue := statusReportUpdate{ - Date: u.GetDate(), - Status: statusToString(u.GetStatus()), - Message: u.GetMessage(), - } - for _, ci := range u.GetComponentImpacts() { - ue.Impacts = append(ue.Impacts, componentImpactJSON{ - ComponentID: ci.GetPageComponentId(), - Impact: impactToString(ci.GetImpact()), - }) - } - detail.Updates = append(detail.Updates, ue) - } - return output.PrintJSON(detail) + return output.PrintJSON(toStatusReportDetail(report, affected, current)) } fmt.Println(aurora.Bold("Status Report:")) @@ -129,6 +135,9 @@ func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.Stat {"Created", output.FormatTimestamp(report.GetCreatedAt())}, {"Updated", output.FormatTimestamp(report.GetUpdatedAt())}, } + if report.HasIncidentId() { + data = append(data, []string{"Incident", fmt.Sprintf("%s (openstatus inc info %s)", report.GetIncidentId(), report.GetIncidentId())}) + } table.Bulk(data) table.Render() diff --git a/internal/statusreport/statusreport_list.go b/internal/statusreport/statusreport_list.go index f970fb4..b7fc2ed 100644 --- a/internal/statusreport/statusreport_list.go +++ b/internal/statusreport/statusreport_list.go @@ -16,11 +16,12 @@ import ( ) type statusReportListEntry struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + IncidentID string `json:"incident_id,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } func ListStatusReports(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, statusFilter string, limit int, s *output.Spinner) error { @@ -52,11 +53,12 @@ func ListStatusReports(ctx context.Context, client status_reportv1connect.Status entries := make([]statusReportListEntry, 0, len(reports)) for _, r := range reports { entries = append(entries, statusReportListEntry{ - ID: r.GetId(), - Title: r.GetTitle(), - Status: statusToString(r.GetStatus()), - CreatedAt: r.GetCreatedAt(), - UpdatedAt: r.GetUpdatedAt(), + ID: r.GetId(), + Title: r.GetTitle(), + Status: statusToString(r.GetStatus()), + IncidentID: r.GetIncidentId(), + CreatedAt: r.GetCreatedAt(), + UpdatedAt: r.GetUpdatedAt(), }) } return output.PrintJSON(entries) diff --git a/internal/terraform/hcl.go b/internal/terraform/hcl.go index 7247600..0449e14 100644 --- a/internal/terraform/hcl.go +++ b/internal/terraform/hcl.go @@ -426,7 +426,7 @@ func (g *Generator) writeNotificationProvider(b *hclwrite.Body, n *notificationv pb.SetAttributeValue("chat_id", cty.StringVal(d.Telegram.GetChatId())) case *notificationv1.NotificationData_Sms: pb := b.AppendNewBlock("sms", nil).Body() - pb.SetAttributeValue("phone_number", cty.StringVal(d.Sms.GetPhoneNumber())) + pb.SetAttributeValue("phone_number", cty.StringVal(d.Sms.GetPhoneNumber())) //nolint:staticcheck // deprecated values still exist on older resources case *notificationv1.NotificationData_Whatsapp: pb := b.AppendNewBlock("whatsapp", nil).Body() pb.SetAttributeValue("phone_number", cty.StringVal(d.Whatsapp.GetPhoneNumber())) diff --git a/internal/terraform/regions.go b/internal/terraform/regions.go index b0500fb..73e2d16 100644 --- a/internal/terraform/regions.go +++ b/internal/terraform/regions.go @@ -11,7 +11,7 @@ func regionToTerraform(r monitorv1.Region) string { return "fly-ams" case monitorv1.Region_REGION_FLY_ARN: return "fly-arn" - case monitorv1.Region_REGION_FLY_BOM: + case monitorv1.Region_REGION_FLY_BOM: //nolint:staticcheck // deprecated values still exist on older resources return "fly-bom" case monitorv1.Region_REGION_FLY_CDG: return "fly-cdg" diff --git a/skills/cli/SKILL.md b/skills/cli/SKILL.md index 113605e..0f8ebfa 100644 --- a/skills/cli/SKILL.md +++ b/skills/cli/SKILL.md @@ -1,14 +1,14 @@ --- name: openstatus-cli description: | - OpenStatus CLI for managing uptime monitors, incident reports, status pages, notifications, maintenance windows, synthetic tests, monitor response logs, and ad-hoc global HTTP checks. Use this skill whenever the user wants to monitor a website or API, set up uptime checks, create or manage monitors, inspect monitor response logs or debug why a check failed, report an incident, update a status page, view notifications, schedule maintenance, run synthetic tests, check latency or availability from around the world, run an ad-hoc speed check, define monitors as code, generate Terraform configuration, export to Terraform, log in or out, or use the openstatus command. Also trigger when the user says "is my site up", "check my endpoint", "speed check", "global latency", "latency from regions", "ad-hoc check", "test from multiple regions", "show monitor logs", "response logs", "why did my monitor fail", "see the failed requests", "create a status report", "set component impact", "monitor this URL", "run uptime tests", "set up monitoring", "our API is down", "schedule maintenance", "maintenance window", "planned downtime", "terraform", "generate terraform", "export to terraform", "infrastructure as code", "list notifications", "notification channels", "log in to openstatus", "save my api token", or mentions openstatus in any context. This skill knows the full CLI — commands, flags, config format, and workflows — so Claude can act without guessing. + OpenStatus CLI for managing uptime monitors, incidents (declare, run, postmortem), public status reports, status pages, notifications, maintenance windows, synthetic tests, monitor response logs, and ad-hoc global HTTP checks. Use this skill whenever the user wants to monitor a website or API, set up uptime checks, create or manage monitors, inspect monitor response logs or debug why a check failed, declare or run an incident, write or approve a postmortem, report an incident publicly, update a status page, view notifications, schedule maintenance, run synthetic tests, check latency or availability from around the world, run an ad-hoc speed check, define monitors as code, generate Terraform configuration, export to Terraform, log in or out, or use the openstatus command. Also trigger when the user says "is my site up", "check my endpoint", "speed check", "global latency", "latency from regions", "ad-hoc check", "test from multiple regions", "show monitor logs", "response logs", "why did my monitor fail", "see the failed requests", "create a status report", "set component impact", "monitor this URL", "run uptime tests", "set up monitoring", "our API is down", "we have an incident", "declare an incident", "open an incident", "incident commander", "mitigate", "resolve the incident", "close the incident", "postmortem", "post-mortem", "schedule maintenance", "maintenance window", "planned downtime", "terraform", "generate terraform", "export to terraform", "infrastructure as code", "list notifications", "notification channels", "log in to openstatus", "save my api token", or mentions openstatus in any context. This skill knows the full CLI — commands, flags, config format, and workflows — so Claude can act without guessing. allowed-tools: - Bash(openstatus *) --- # OpenStatus CLI -Manage uptime monitors, incident reports, status pages, notifications, and maintenance windows from the terminal. The CLI supports monitors-as-code via YAML config files. +Manage uptime monitors, incidents, public status reports, status pages, notifications, and maintenance windows from the terminal. The CLI supports monitors-as-code via YAML config files. Run `openstatus --help` or `openstatus --help` for full option details. @@ -42,12 +42,22 @@ Token resolution order: | Trigger a monitor now | `monitors trigger ` | Run an on-demand check across all regions | | Delete a monitor | `monitors delete ` | Remove a monitor | | Export monitors to YAML | `monitors import` | Pull existing monitors into an `openstatus.yaml` + lock file | -| Create incident report | `status-report create` | Something is broken, notify users | -| Add update to incident | `status-report add-update ` | Post a progress update on an ongoing incident | -| List incidents | `status-report list` | See active/recent incidents | -| Get incident details | `status-report info ` | View full incident timeline | -| Update incident metadata | `status-report update ` | Change title or components | -| Delete incident | `status-report delete ` | Remove a status report | +| Declare an incident | `incident declare` | Something is broken — start the internal response first | +| List incidents | `incident list` | See open/recent incidents (`--active`, `--closed`, `--status`) | +| Get incident details + timeline | `incident info ` | Severity, commander, allowed transitions, timeline | +| Edit an incident | `incident update ` | Change title, severity, summary, commander or start time | +| Change incident status | `incident mitigate/resolve/reopen/cancel ` or `incident set-status --status …` | Move through the lifecycle, optionally with `--note` | +| Add a timeline note | `incident note --message …` | Record what happened or what was tried | +| Link a status report | `incident link-report ` / `unlink-report ` | Connect the public report to the incident | +| Close an incident | `incident close ` | After it is resolved and the postmortem is approved (or `--skip-postmortem`) | +| Delete an incident | `incident delete ` | Only for incidents declared by mistake (still open, never mitigated) | +| Read/write/approve the postmortem | `incident postmortem show/edit/approve ` | After the incident is resolved | +| Create public status report | `status-report create` | Users are affected — publish on the status page (link with `--incident-id`) | +| Add update to status report | `status-report add-update ` | Post a public progress update | +| List status reports | `status-report list` | See active/recent public reports | +| Get status report details | `status-report info ` | View the public update timeline | +| Update status report metadata | `status-report update ` | Change title or components | +| Delete status report | `status-report delete ` | Remove a status report | | List status pages | `status-page list` | See all your status pages | | Get status page details | `status-page info ` | View page config, components, theme | | List notifications | `notification list` | See all notification channels in the workspace | @@ -63,7 +73,7 @@ Token resolution order: | Save API token | `login` | Authenticate — paste your token from the dashboard | | Remove saved token | `logout` | Sign out (deletes the saved token) | -Command aliases: `check` = `c`, `monitors` = `m`, `status-report` = `sr`, `status-page` = `sp`, `notification` = `n`, `maintenance` = `mt`, `terraform` = `tf` (and `terraform generate` = `gen`), `run` = `r`, `whoami` = `w`. `login` and `logout` have no aliases. +Command aliases: `check` = `c`, `monitors` = `m`, `incident` = `inc` / `incidents` (and `incident declare` = `create`, `incident postmortem` = `pm`), `status-report` = `sr`, `status-page` = `sp`, `notification` = `n`, `maintenance` = `mt`, `terraform` = `tf` (and `terraform generate` = `gen`), `run` = `r`, `whoami` = `w`. `login` and `logout` have no aliases. ## Workflows @@ -119,9 +129,94 @@ This is the primary way to manage monitors. Write a YAML config, then let the CL **The apply workflow** compares your `openstatus.yaml` against the lock file and the API, then creates, updates, or deletes monitors to match. Use `--dry-run` to preview, `-y` to skip the confirmation prompt. -### Incident lifecycle +### Running an incident -Use status reports for **unplanned** outages and incidents. +**Incident vs. status report:** an **incident** is the internal response — severity, commander, lifecycle, timeline notes and the postmortem. A **status report** is the public communication on a status page. When something is broken ("our API is down", "we have an incident"), **declare an incident first**; then, if users are affected, publish a status report linked to it with `--incident-id`. Changing the incident status never updates the public report — post public updates with `status-report add-update`. + +Severities: `critical`, `major`, `minor`. Statuses: `open` -> `mitigated` -> `resolved`, or `canceled`. + +| From | Allowed transitions | +|------|---------------------| +| `open` | `mitigated`, `resolved`, `canceled` | +| `mitigated` | `resolved`, `open`, `canceled` | +| `resolved` | `open` | + +`canceled` is terminal and closes the incident. "Closed" is not a status: a closed incident keeps its last status, has a `closed_at` timestamp, and is read-only except for its postmortem. `incident info ` lists the allowed transitions from the current state. + +**1. Declare:** +```bash +openstatus incident declare \ + --title "Checkout API returns 502" \ + --severity major \ + --summary "Payments failing for EU customers" \ + --commander jane@example.com +``` + +Prints `Incident declared (ID: 42)` and the next step. Use `-q` to print only the ID: `ID=$(openstatus inc declare --title "…" --severity major -q)`. + +**`declare` flags:** + +| Flag | Required | Description | +|------|----------|-------------| +| `--title` | yes | 1-256 characters | +| `--severity` | yes | `critical`, `major` or `minor` | +| `--summary` | no | Short human summary | +| `--commander` | no | Email of the workspace member leading the response (emailed when assigned) | +| `--started-at` | no | RFC 3339 start of impact, defaults to now; can be in the past | +| `--status-report-id` | no | Link an existing status report | +| `--slack` | no | Open a dedicated Slack channel when Slack is connected | + +> **Interactive vs. scripted:** without `--title` or `--severity`, `declare` prompts on a terminal; with `--json` or a non-TTY stdin it fails with `missing required flags: …`. Always pass both when scripting. + +**2. Publish the public status report, linked to the incident** (only if users are affected — see [Public status reports](#public-status-reports)): +```bash +openstatus status-report create --title "Checkout issues" --status investigating \ + --message "We are investigating failed payments" --page-id 123 --incident-id 42 +``` +Or link an existing report: `openstatus incident link-report 42 456` (`unlink-report 42` removes it). + +**3. Record what happens and move the status:** +```bash +openstatus incident note 42 --message "Rolled back deploy 2026-10-16.3" +echo "multi-line markdown" | openstatus incident note 42 --message - +openstatus incident mitigate 42 --note "Traffic shifted to the healthy region" +openstatus incident resolve 42 --note "Error rate back to baseline" +openstatus incident reopen 42 --note "Errors are back" +openstatus incident set-status 42 --status resolved # generic form, for scripts +openstatus incident cancel 42 -y # declared in error but already mitigated: closes it +``` + +Edit metadata with `incident update 42 [--title …] [--severity …] [--summary … | --clear-summary] [--commander … | --clear-commander] [--started-at …]`. + +**4. Postmortem (after it is resolved), then close:** +```bash +openstatus incident postmortem edit 42 --file postmortem.md # or --file - (stdin), or --content "…" +openstatus incident postmortem show 42 # piped: raw markdown only +openstatus incident postmortem approve 42 --close -y # approve and close in one step +openstatus incident close 42 --skip-postmortem -y # close without a postmortem +``` + +`postmortem show 42 > pm.md`, edit, then `postmortem edit 42 --file pm.md` round-trips the body. + +**5. Inspect and list:** +```bash +openstatus incident info 42 # details + timeline, newest first +openstatus incident list # everything, newest declared first +openstatus incident list --active # not closed +openstatus incident list --status open,mitigated --limit 10 --offset 10 +``` + +**Rules agents must know:** +- `cancel` (and `set-status --status canceled`), `close`, `delete` and `postmortem approve --close` ask for confirmation; pass `-y` when not on a terminal. +- `delete` only works while the incident is open and was never mitigated; otherwise `cancel` it. +- `close` needs a resolved incident and an approved postmortem, or `--skip-postmortem`. +- `close`, `delete` and `postmortem approve` act as the API key's creator: they need a workspace owner/admin (or the incident commander for close/approve). API keys without a creator get `permission denied`. +- Every command that changes an incident prints the incident as JSON with `--json`, and only its ID with `-q`. +- A refused change (wrong transition, closed incident, report already linked) prints the server's reason plus a hint. + +### Public status reports + +Use status reports to communicate **unplanned** outages publicly on a status page. When there is an incident, link the report to it with `--incident-id`. Status reports follow a progression: `investigating` -> `identified` -> `monitoring` -> `resolved`. These are the only valid status values — the CLI rejects anything else. @@ -162,6 +257,7 @@ To add updates, run: openstatus status-report add-update 456 --status identified | `--component-ids` | no | **Legacy.** Comma-separated component IDs in a single string: `"id1,id2"` — marks them all affected without a specific impact level. Prefer `--impact`. Mutually exclusive with `--impact`. | | `--notify` | no | Send notification to status page subscribers | | `--date` | no | RFC 3339 timestamp (e.g. `2026-03-25T10:00:00Z`), defaults to now (UTC) | +| `--incident-id` | no | Link the report to an incident (fails if the incident is closed or already linked) | The four impact levels are `operational`, `degraded`, `partial_outage`, `major_outage` (`degraded_performance` is also accepted as an alias for `degraded`). Pass `--impact` once per component, e.g. `--impact comp-1=major_outage --impact comp-2=degraded`. -- knotmirror 0.0.1