Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ List a status page's templates with `rootly status-pages templates list --status

`status-pages events create` and `status-pages events update` accept incident statuses `investigating`, `identified`, `monitoring`, and `resolved`, plus scheduled-maintenance statuses `scheduled`, `in_progress`, and `completed`. For `status-pages events resolve`, `--status` defaults to `resolved`; use `--status=completed` to complete scheduled maintenance.

Resolving an incident does not resolve its status-page events. After `incidents update`, `incidents create` or `alerts resolve --resolve-incidents` leaves an incident resolved, the CLI warns on stderr about any events still open and prints the `rootly status-pages events resolve <event-id>` command to run. The check is best-effort and never changes the command's output or exit code.

### Escalation

`rootly alerts escalate <alert-id>` escalates an alert using its current policy. Optionally pass `--escalation-policy=<uuid>` and/or `--level=<n>` to select a policy or escalation level; levels must be at least 1. The API's error title is shown when escalation cannot proceed.
Expand Down
7 changes: 6 additions & 1 deletion internal/cmd/alerts/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import (
"os"

"github.com/spf13/cobra"

"github.com/rootlyhq/rootly-cli/internal/statuspagewarn"
)

var resolveCmd = &cobra.Command{
Use: "resolve <alert-id>",
Short: "Resolve an alert",
Long: "Mark an alert as resolved with optional resolution message.\n\nThis calls the dedicated resolve action endpoint. Optionally resolves related incidents.",
Long: "Mark an alert as resolved with optional resolution message.\n\nThis calls the dedicated resolve action endpoint. Optionally resolves related incidents.\n\nResolving incidents does not resolve their status page events. If any are still open, a warning on stderr lists them with the `rootly status-pages events resolve` command.",
Example: ` # Basic resolve
rootly alerts resolve ALR-123

Expand Down Expand Up @@ -52,5 +54,8 @@ func runResolve(cmd *cobra.Command, args []string) error {

// Print success message to stdout
_, _ = fmt.Fprintf(os.Stdout, "Resolved alert %s\n", alertID)
if resolveIncidents {
statuspagewarn.AlertIncidents(cmd.Context(), apiClient, alertID, os.Stderr)
}
return nil
}
85 changes: 85 additions & 0 deletions internal/cmd/alerts/statuspagewarn_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package alerts

import (
"net/http"
"os"
"strings"
"testing"
)

// captureStderr redirects os.Stderr for the duration of fn, returning captured output.
func captureStderr(t *testing.T, fn func()) string {
t.Helper()
origStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("failed to create pipe: %v", err)
}
os.Stderr = w

fn()

w.Close()
os.Stderr = origStderr

buf := make([]byte, 65536)
n, _ := r.Read(buf)
r.Close()
return string(buf[:n])
}

func resolveWithStatusPageServer(t *testing.T, resolveIncidents bool) (stdout, stderr string, alertGets int) {
t.Helper()
setupTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.api+json")
switch {
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/alerts/"):
alertGets++
_, _ = w.Write([]byte(`{"data": {"id": "alert-1", "attributes": {"short_id": "ALR-42", "incidents": [
{"id": "inc-resolved", "attributes": {"status": "resolved"}},
{"id": "inc-started", "attributes": {"status": "started"}}
]}}}`))
case strings.Contains(r.URL.Path, "/inc-resolved/status-page-events"):
_, _ = w.Write([]byte(`{"data": [{"id": "ev-1", "attributes": {"status": "investigating", "status_page_id": "page-1"}}], "meta": {"current_page": 1, "total_pages": 1}}`))
case strings.Contains(r.URL.Path, "/inc-started/status-page-events"):
t.Errorf("events requested for an incident that is not resolved")
default:
w.WriteHeader(http.StatusOK)
}
})

cmd := newTestCmd()
cmd.Flags().String("message", "", "")
cmd.Flags().Bool("resolve-incidents", false, "")
if resolveIncidents {
_ = cmd.Flags().Set("resolve-incidents", "true")
}

stderr = captureStderr(t, func() {
stdout = captureStdout(t, func() {
if err := runResolve(cmd, []string{"ALR-42"}); err != nil {
t.Fatalf("runResolve error: %v", err)
}
})
})
return stdout, stderr, alertGets
}

func TestRunResolveWithIncidentsWarnsAboutOpenStatusPageEvents(t *testing.T) {
stdout, stderr, _ := resolveWithStatusPageServer(t, true)

if !strings.Contains(stderr, "inc-resolved") || !strings.Contains(stderr, "rootly status-pages events resolve ev-1") {
t.Errorf("expected warning for the resolved incident on stderr, got: %s", stderr)
}
if stdout != "Resolved alert ALR-42\n" {
t.Errorf("stdout should only hold the success message, got: %q", stdout)
}
}

func TestRunResolveWithoutIncidentsDoesNotCheckStatusPages(t *testing.T) {
_, stderr, alertGets := resolveWithStatusPageServer(t, false)

if stderr != "" || alertGets != 0 {
t.Errorf("expected no warning and no alert lookup, got %q and %d lookups", stderr, alertGets)
}
}
2 changes: 2 additions & 0 deletions internal/cmd/incidents/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/spf13/viper"

"github.com/rootlyhq/rootly-cli/internal/printer"
"github.com/rootlyhq/rootly-cli/internal/statuspagewarn"
)

var createCmd = &cobra.Command{
Expand Down Expand Up @@ -148,6 +149,7 @@ func runCreate(cmd *cobra.Command, args []string) error {

// Print success message to stderr
fmt.Fprintf(os.Stderr, "Created incident %s\n", incident.SequentialID)
statuspagewarn.Incident(cmd.Context(), apiClient, incident.ID, incident.Status, os.Stderr)

// Print incident to stdout using configured format
format := viper.GetString("format")
Expand Down
148 changes: 148 additions & 0 deletions internal/cmd/incidents/statuspagewarn_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package incidents

import (
"net/http"
"os"
"strings"
"testing"

"github.com/spf13/cobra"
"github.com/spf13/viper"
)

const resolvedIncidentBody = `{"data": {"id": "inc-1", "attributes": {"sequential_id": 42, "title": "DB outage", "status": "resolved", "kind": "normal", "created_at": "2025-06-15T10:00:00Z"}}}`

const openStatusPageEvents = `{"data": [{"id": "ev-1", "attributes": {"event": "Investigating", "status": "investigating", "status_page_id": "page-1"}}], "meta": {"current_page": 1, "total_pages": 1}}`

// captureStderr redirects os.Stderr for the duration of fn, returning captured output.
func captureStderr(t *testing.T, fn func()) string {
t.Helper()
origStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("failed to create pipe: %v", err)
}
os.Stderr = w

fn()

w.Close()
os.Stderr = origStderr

buf := make([]byte, 65536)
n, _ := r.Read(buf)
r.Close()
return string(buf[:n])
}

// statusPageWarnServer serves the incident write and the status-page events lookup.
func statusPageWarnServer(t *testing.T, incidentBody string, eventsStatus int, eventsRequests *int) {
t.Helper()
setupTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.api+json")
if strings.HasSuffix(r.URL.Path, "/status-page-events") {
*eventsRequests++
w.WriteHeader(eventsStatus)
_, _ = w.Write([]byte(openStatusPageEvents))
return
}
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
}
_, _ = w.Write([]byte(incidentBody))
})
}

func newStatusCmd(title, status string) *cobra.Command {
cmd := newTestCmd()
for _, name := range []string{"summary", "severity", "status", "kind", "scheduled-for", "scheduled-until"} {
cmd.Flags().String(name, "", "")
}
cmd.Flags().String("title", title, "")
cmd.Flags().StringArray("field", nil, "")
for _, name := range []string{"services", "types", "functionalities", "environments", "teams", "causes"} {
cmd.Flags().StringSlice(name, nil, "")
}
_ = cmd.Flags().Set("status", status)
return cmd
}

func TestRunUpdateResolvedWarnsAboutOpenStatusPageEvents(t *testing.T) {
var eventsRequests int
statusPageWarnServer(t, resolvedIncidentBody, http.StatusOK, &eventsRequests)
viper.Set("format", "json")

var stdout string
stderr := captureStderr(t, func() {
stdout = captureStdout(t, func() {
if err := runUpdate(newStatusCmd("", "resolved"), []string{"INC-42"}); err != nil {
t.Fatalf("runUpdate returned error: %v", err)
}
})
})

if !strings.Contains(stderr, "rootly status-pages events resolve ev-1") || !strings.Contains(stderr, "page-1") || !strings.Contains(stderr, "investigating") {
t.Errorf("expected status page warning on stderr, got: %s", stderr)
}
if strings.Contains(stdout, "status-pages") || !strings.Contains(stdout, `"inc-1"`) {
t.Errorf("stdout should hold only the incident JSON, got: %s", stdout)
}
}

func TestRunUpdateNotResolvedDoesNotCheckStatusPages(t *testing.T) {
var eventsRequests int
statusPageWarnServer(t, getResponse(), http.StatusOK, &eventsRequests)
viper.Set("format", "json")

stderr := captureStderr(t, func() {
captureStdout(t, func() {
if err := runUpdate(newStatusCmd("", "mitigated"), []string{"INC-42"}); err != nil {
t.Fatalf("runUpdate returned error: %v", err)
}
})
})

if strings.Contains(stderr, "status-pages") || eventsRequests != 0 {
t.Errorf("expected no warning and no events request, got %q and %d requests", stderr, eventsRequests)
}
}

func TestRunUpdateStatusPageLookupFailureIsIgnored(t *testing.T) {
var eventsRequests int
statusPageWarnServer(t, resolvedIncidentBody, http.StatusInternalServerError, &eventsRequests)
viper.Set("format", "json")

var stdout string
stderr := captureStderr(t, func() {
stdout = captureStdout(t, func() {
if err := runUpdate(newStatusCmd("", "resolved"), []string{"INC-42"}); err != nil {
t.Fatalf("runUpdate must succeed when the events lookup fails: %v", err)
}
})
})

if eventsRequests != 1 || strings.Contains(stderr, "status-pages") {
t.Errorf("expected one failed lookup and no warning, got %d requests and %q", eventsRequests, stderr)
}
if !strings.Contains(stdout, `"inc-1"`) {
t.Errorf("expected incident JSON on stdout, got: %s", stdout)
}
}

func TestRunCreateResolvedWarnsAboutOpenStatusPageEvents(t *testing.T) {
var eventsRequests int
statusPageWarnServer(t, strings.Replace(createResponse(), `"started"`, `"resolved"`, 1), http.StatusOK, &eventsRequests)
viper.Set("format", "json")

stderr := captureStderr(t, func() {
captureStdout(t, func() {
if err := runCreate(newStatusCmd("New incident", "resolved"), nil); err != nil {
t.Fatalf("runCreate returned error: %v", err)
}
})
})

if !strings.Contains(stderr, "rootly status-pages events resolve ev-1") {
t.Errorf("expected status page warning on stderr, got: %s", stderr)
}
}
4 changes: 3 additions & 1 deletion internal/cmd/incidents/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import (

"github.com/rootlyhq/rootly-cli/internal/api"
"github.com/rootlyhq/rootly-cli/internal/printer"
"github.com/rootlyhq/rootly-cli/internal/statuspagewarn"
)

var updateCmd = &cobra.Command{
Use: "update <incident-id>",
Short: "Update an incident",
Long: "Update an existing Rootly incident's attributes.",
Long: "Update an existing Rootly incident's attributes.\n\nResolving an incident does not resolve its status page events. If any are still open, a warning on stderr lists them with the `rootly status-pages events resolve` command.",
Example: ` # Update status
rootly incidents update INC-123 --status=mitigated

Expand Down Expand Up @@ -165,6 +166,7 @@ func runUpdate(cmd *cobra.Command, args []string) error {

// Print success message to stderr
fmt.Fprintf(os.Stderr, "Updated incident %s\n", displayID)
statuspagewarn.Incident(cmd.Context(), apiClient, incident.ID, incident.Status, os.Stderr)

// Print incident to stdout using configured format
format := viper.GetString("format")
Expand Down
Loading
Loading