From b366befc289218e65d154e592e017fec426e74c8 Mon Sep 17 00:00:00 2001 From: Quentin Rousseau Date: Wed, 12 Aug 2026 20:26:54 -0700 Subject: [PATCH 1/4] add status page event commands --- internal/api/client.go | 318 +++++++++++++++++++ internal/api/client_status_pages_test.go | 138 ++++++++ internal/cmd/root.go | 3 +- internal/cmd/statuspages/cmd_test.go | 184 +++++++++++ internal/cmd/statuspages/events.go | 12 + internal/cmd/statuspages/events_list.go | 60 ++++ internal/cmd/statuspages/events_mutations.go | 150 +++++++++ internal/cmd/statuspages/list.go | 72 +++++ internal/cmd/statuspages/statuspages.go | 32 ++ internal/cmd/statuspages_register.go | 7 + 10 files changed, 975 insertions(+), 1 deletion(-) create mode 100644 internal/api/client_status_pages_test.go create mode 100644 internal/cmd/statuspages/cmd_test.go create mode 100644 internal/cmd/statuspages/events.go create mode 100644 internal/cmd/statuspages/events_list.go create mode 100644 internal/cmd/statuspages/events_mutations.go create mode 100644 internal/cmd/statuspages/list.go create mode 100644 internal/cmd/statuspages/statuspages.go create mode 100644 internal/cmd/statuspages_register.go diff --git a/internal/api/client.go b/internal/api/client.go index 553154e..c7c3bba 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -285,6 +285,51 @@ type WorkflowRunOpts struct { CheckConditions *bool } +// StatusPage represents a Rootly status page. +type StatusPage struct { + ID string + Title string + Slug string + Description string + Enabled bool + Public bool + CreatedAt time.Time +} + +// StatusPagesResult contains status pages and pagination info. +type StatusPagesResult struct { + StatusPages []StatusPage + Pagination PaginationInfo + RawBody []byte +} + +// StatusPageEvent represents a public incident update on a status page. +type StatusPageEvent struct { + ID string + Event string + Status string + StatusPageID string + NotifySubscribers bool + StartedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time + RawBody []byte `json:"-"` +} + +// StatusPageEventsResult contains incident status-page events and pagination info. +type StatusPageEventsResult struct { + Events []StatusPageEvent + Pagination PaginationInfo + RawBody []byte +} + +// StatusPageEventOpts contains fields that can be changed on an event. +type StatusPageEventOpts struct { + Message *string + Status *string + NotifySubscribers *bool +} + // KeyValue represents a key-value pair for pulse labels and refs type KeyValue struct { Key string @@ -1521,6 +1566,279 @@ func (c *Client) RunWorkflowCLI(ctx context.Context, workflowID string, opts Wor return run, nil } +func (c *Client) doJSONAPIRequest(ctx context.Context, method, path string, requestBody interface{}) ([]byte, int, error) { + var bodyReader io.Reader = http.NoBody + if requestBody != nil { + bodyBytes, err := json.Marshal(requestBody) + if err != nil { + return nil, 0, fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = strings.NewReader(string(bodyBytes)) + } + req, err := http.NewRequestWithContext(ctx, method, c.endpoint+path, bodyReader) + if err != nil { + return nil, 0, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/vnd.api+json") + + response, err := c.httpClient.Do(req) + if err != nil { + return nil, 0, err + } + defer func() { _ = response.Body.Close() }() + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, response.StatusCode, fmt.Errorf("failed to read response: %w", err) + } + return body, response.StatusCode, nil +} + +// ListStatusPagesCLI lists configured status pages. +func (c *Client) ListStatusPagesCLI(ctx context.Context, page, pageSize int, sort string, filters map[string]string) (*StatusPagesResult, error) { + if pageSize == 0 { + pageSize = 25 + } + if pageSize > 100 { + pageSize = 100 + } + path := fmt.Sprintf("/v1/status-pages?page[number]=%d&page[size]=%d", page, pageSize) + if sort != "" { + path += "&sort=" + neturl.QueryEscape(sort) + } + for key, value := range filters { + path += fmt.Sprintf("&filter[%s]=%s", key, neturl.QueryEscape(value)) + } + body, statusCode, err := c.doJSONAPIRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to list status pages: %w", err) + } + if statusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("invalid API token") + } + if statusCode == http.StatusForbidden { + return nil, fmt.Errorf("access denied: API key lacks 'read status pages' permission") + } + if statusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", statusCode) + } + + var response struct { + Data []struct { + ID string `json:"id"` + Attributes struct { + Title string `json:"title"` + Slug *string `json:"slug"` + Description *string `json:"description"` + Enabled *bool `json:"enabled"` + Public *bool `json:"public"` + CreatedAt string `json:"created_at"` + } `json:"attributes"` + } `json:"data"` + Meta struct { + CurrentPage int `json:"current_page"` + NextPage *int `json:"next_page"` + PrevPage *int `json:"prev_page"` + TotalCount int `json:"total_count"` + TotalPages int `json:"total_pages"` + } `json:"meta"` + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + pages := make([]StatusPage, 0, len(response.Data)) + for _, item := range response.Data { + page := StatusPage{ID: item.ID, Title: item.Attributes.Title} + if item.Attributes.Slug != nil { + page.Slug = *item.Attributes.Slug + } + if item.Attributes.Description != nil { + page.Description = *item.Attributes.Description + } + if item.Attributes.Enabled != nil { + page.Enabled = *item.Attributes.Enabled + } + if item.Attributes.Public != nil { + page.Public = *item.Attributes.Public + } + page.CreatedAt, _ = time.Parse(time.RFC3339, item.Attributes.CreatedAt) + pages = append(pages, page) + } + currentPage := response.Meta.CurrentPage + if currentPage == 0 { + currentPage = page + } + return &StatusPagesResult{ + StatusPages: pages, + Pagination: PaginationInfo{ + CurrentPage: currentPage, + TotalPages: response.Meta.TotalPages, + TotalCount: response.Meta.TotalCount, + HasNext: response.Meta.NextPage != nil, + HasPrev: response.Meta.PrevPage != nil, + }, + RawBody: body, + }, nil +} + +// ListStatusPageEventsCLI lists status-page events for an incident. +func (c *Client) ListStatusPageEventsCLI(ctx context.Context, incidentID string, page, pageSize int) (*StatusPageEventsResult, error) { + if pageSize == 0 { + pageSize = 25 + } + if pageSize > 100 { + pageSize = 100 + } + path := fmt.Sprintf("/v1/incidents/%s/status-page-events?page[number]=%d&page[size]=%d", neturl.PathEscape(incidentID), page, pageSize) + body, statusCode, err := c.doJSONAPIRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to list status-page events: %w", err) + } + if statusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("invalid API token") + } + if statusCode == http.StatusForbidden { + return nil, fmt.Errorf("access denied: API key lacks 'read status page events' permission") + } + if statusCode == http.StatusNotFound { + return nil, fmt.Errorf("incident not found: %s", incidentID) + } + if statusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", statusCode) + } + return parseStatusPageEvents(body, page) +} + +func parseStatusPageEvents(body []byte, requestedPage int) (*StatusPageEventsResult, error) { + var response struct { + Data []statusPageEventResponseData `json:"data"` + Meta struct { + CurrentPage int `json:"current_page"` + NextPage *int `json:"next_page"` + PrevPage *int `json:"prev_page"` + TotalCount int `json:"total_count"` + TotalPages int `json:"total_pages"` + } `json:"meta"` + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + events := make([]StatusPageEvent, 0, len(response.Data)) + for _, data := range response.Data { + events = append(events, parseStatusPageEvent(data, nil)) + } + currentPage := response.Meta.CurrentPage + if currentPage == 0 { + currentPage = requestedPage + } + return &StatusPageEventsResult{ + Events: events, + Pagination: PaginationInfo{ + CurrentPage: currentPage, + TotalPages: response.Meta.TotalPages, + TotalCount: response.Meta.TotalCount, + HasNext: response.Meta.NextPage != nil, + HasPrev: response.Meta.PrevPage != nil, + }, + RawBody: body, + }, nil +} + +type statusPageEventResponseData struct { + ID string `json:"id"` + Attributes struct { + Event string `json:"event"` + Status *string `json:"status"` + StatusPageID *string `json:"status_page_id"` + NotifySubscribers *bool `json:"notify_subscribers"` + StartedAt string `json:"started_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } `json:"attributes"` +} + +func parseStatusPageEvent(data statusPageEventResponseData, rawBody []byte) StatusPageEvent { + event := StatusPageEvent{ID: data.ID, Event: data.Attributes.Event, RawBody: rawBody} + if data.Attributes.Status != nil { + event.Status = *data.Attributes.Status + } + if data.Attributes.StatusPageID != nil { + event.StatusPageID = *data.Attributes.StatusPageID + } + if data.Attributes.NotifySubscribers != nil { + event.NotifySubscribers = *data.Attributes.NotifySubscribers + } + event.StartedAt, _ = time.Parse(time.RFC3339, data.Attributes.StartedAt) + event.CreatedAt, _ = time.Parse(time.RFC3339, data.Attributes.CreatedAt) + event.UpdatedAt, _ = time.Parse(time.RFC3339, data.Attributes.UpdatedAt) + return event +} + +// CreateStatusPageEventCLI creates a status-page event for an incident. +func (c *Client) CreateStatusPageEventCLI(ctx context.Context, incidentID, statusPageID, status, message string, notifySubscribers bool) (*StatusPageEvent, error) { + requestBody := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "incident_status_page_events", + "attributes": map[string]interface{}{ + "status_page_id": statusPageID, + "status": status, + "event": message, + "notify_subscribers": notifySubscribers, + }, + }, + } + path := fmt.Sprintf("/v1/incidents/%s/status-page-events", neturl.PathEscape(incidentID)) + return c.mutateStatusPageEvent(ctx, http.MethodPost, path, requestBody) +} + +// UpdateStatusPageEventCLI updates a status-page event. +func (c *Client) UpdateStatusPageEventCLI(ctx context.Context, eventID string, opts StatusPageEventOpts) (*StatusPageEvent, error) { + attributes := make(map[string]interface{}) + if opts.Message != nil { + attributes["event"] = *opts.Message + } + if opts.Status != nil { + attributes["status"] = *opts.Status + } + if opts.NotifySubscribers != nil { + attributes["notify_subscribers"] = *opts.NotifySubscribers + } + requestBody := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "incident_status_page_events", + "attributes": attributes, + }, + } + return c.mutateStatusPageEvent(ctx, http.MethodPut, "/v1/status-page-events/"+neturl.PathEscape(eventID), requestBody) +} + +func (c *Client) mutateStatusPageEvent(ctx context.Context, method, path string, requestBody interface{}) (*StatusPageEvent, error) { + body, statusCode, err := c.doJSONAPIRequest(ctx, method, path, requestBody) + if err != nil { + return nil, fmt.Errorf("failed to save status-page event: %w", err) + } + if statusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("invalid API token") + } + if statusCode == http.StatusForbidden { + return nil, fmt.Errorf("access denied: API key lacks permission to update status pages") + } + if statusCode == http.StatusNotFound { + return nil, fmt.Errorf("incident, status page, or event not found") + } + if statusCode != http.StatusOK && statusCode != http.StatusCreated { + return nil, fmt.Errorf("API returned status %d", statusCode) + } + var response struct { + Data statusPageEventResponseData `json:"data"` + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + event := parseStatusPageEvent(response.Data, body) + return &event, nil +} + // alertResponseData represents the structure of alert data from the API response type alertResponseData struct { ID string `json:"id"` diff --git a/internal/api/client_status_pages_test.go b/internal/api/client_status_pages_test.go new file mode 100644 index 0000000..88f0d0d --- /dev/null +++ b/internal/api/client_status_pages_test.go @@ -0,0 +1,138 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func statusPageEventResponse() string { + return `{ + "data": { + "id": "event-1", + "attributes": { + "event": "We are investigating.", + "status": "investigating", + "status_page_id": "page-1", + "notify_subscribers": true, + "started_at": "2026-08-12T12:00:00Z", + "created_at": "2026-08-12T12:00:00Z", + "updated_at": "2026-08-12T12:00:00Z" + } + } + }` +} + +func TestListStatusPagesCLI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/status-pages" { + t.Errorf("path = %s, want /v1/status-pages", r.URL.Path) + } + if got := r.URL.Query().Get("filter[slug]"); got != "public-status" { + t.Errorf("slug filter = %q, want public-status", got) + } + _, _ = w.Write([]byte(`{ + "data": [{"id": "page-1", "attributes": { + "title": "Public Status", "slug": "public-status", "description": "Customer updates", + "enabled": true, "public": true, "created_at": "2026-08-12T12:00:00Z" + }}], + "meta": {"current_page": 1, "total_pages": 1, "total_count": 1} + }`)) + })) + defer server.Close() + + client := newTestClient(t, server.URL) + result, err := client.ListStatusPagesCLI(context.Background(), 1, 25, "-created_at", map[string]string{"slug": "public-status"}) + if err != nil { + t.Fatalf("ListStatusPagesCLI returned error: %v", err) + } + if len(result.StatusPages) != 1 || result.StatusPages[0].Slug != "public-status" { + t.Fatalf("status pages = %+v, want public-status", result.StatusPages) + } + if !result.StatusPages[0].Public || !result.StatusPages[0].Enabled { + t.Error("status page should be public and enabled") + } +} + +func TestListStatusPageEventsCLI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/incidents/42/status-page-events" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{ + "data": [{"id": "event-1", "attributes": { + "event": "Monitoring", "status": "monitoring", "status_page_id": "page-1", + "started_at": "2026-08-12T12:00:00Z", "created_at": "2026-08-12T12:00:00Z", "updated_at": "2026-08-12T12:30:00Z" + }}], + "meta": {"current_page": 1, "total_pages": 1, "total_count": 1} + }`)) + })) + defer server.Close() + + client := newTestClient(t, server.URL) + result, err := client.ListStatusPageEventsCLI(context.Background(), "42", 1, 25) + if err != nil { + t.Fatalf("ListStatusPageEventsCLI returned error: %v", err) + } + if len(result.Events) != 1 || result.Events[0].Status != "monitoring" { + t.Fatalf("events = %+v, want monitoring event", result.Events) + } +} + +func TestCreateStatusPageEventCLI(t *testing.T) { + var requestBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/incidents/42/status-page-events" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &requestBody) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(statusPageEventResponse())) + })) + defer server.Close() + + client := newTestClient(t, server.URL) + event, err := client.CreateStatusPageEventCLI(context.Background(), "42", "page-1", "investigating", "We are investigating.", true) + if err != nil { + t.Fatalf("CreateStatusPageEventCLI returned error: %v", err) + } + attributes := requestBody["data"].(map[string]interface{})["attributes"].(map[string]interface{}) + if attributes["status_page_id"] != "page-1" || attributes["status"] != "investigating" || attributes["notify_subscribers"] != true { + t.Errorf("unexpected attributes: %+v", attributes) + } + if event.ID != "event-1" { + t.Errorf("event ID = %q, want event-1", event.ID) + } +} + +func TestUpdateStatusPageEventCLI(t *testing.T) { + var requestBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/v1/status-page-events/event-1" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &requestBody) + _, _ = w.Write([]byte(statusPageEventResponse())) + })) + defer server.Close() + + status := "resolved" + message := "Resolved." + notify := true + client := newTestClient(t, server.URL) + _, err := client.UpdateStatusPageEventCLI(context.Background(), "event-1", StatusPageEventOpts{ + Status: &status, Message: &message, NotifySubscribers: ¬ify, + }) + if err != nil { + t.Fatalf("UpdateStatusPageEventCLI returned error: %v", err) + } + attributes := requestBody["data"].(map[string]interface{})["attributes"].(map[string]interface{}) + if attributes["status"] != "resolved" || attributes["event"] != "Resolved." || attributes["notify_subscribers"] != true { + t.Errorf("unexpected attributes: %+v", attributes) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d231f84..8e5ae14 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -37,11 +37,12 @@ Start here (for AI agents): rootly services list --format=json List services as JSON rootly teams list --format=json List teams as JSON rootly workflows list --format=json List workflows as JSON + rootly status-pages list --format=json List status pages as JSON rootly oncall who --format=json Who is on-call right now rootly pulse create "msg" --source=ci Send a deployment pulse Discovery: run "rootly --help" to see available verbs and flags. - Resources: incidents, alerts, services, teams, workflows, oncall, pulse`, + Resources: incidents, alerts, services, teams, workflows, status-pages, oncall, pulse`, Example: ` # List incidents rootly incidents list diff --git a/internal/cmd/statuspages/cmd_test.go b/internal/cmd/statuspages/cmd_test.go new file mode 100644 index 0000000..035fbf4 --- /dev/null +++ b/internal/cmd/statuspages/cmd_test.go @@ -0,0 +1,184 @@ +package statuspages + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func newTestCmd() *cobra.Command { + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + return cmd +} + +func setupTestServer(t *testing.T, handler http.HandlerFunc) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + viper.Set("api_key", "test-token") + viper.Set("api_host", server.URL) + t.Cleanup(viper.Reset) +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + original := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + fn() + _ = w.Close() + os.Stdout = original + output, _ := io.ReadAll(r) + _ = r.Close() + return string(output) +} + +func statusPageEventResponse() string { + return `{ + "data": {"id": "event-1", "attributes": { + "event": "We are investigating.", "status": "investigating", "status_page_id": "page-1", + "notify_subscribers": true, "started_at": "2026-08-12T12:00:00Z", + "created_at": "2026-08-12T12:00:00Z", "updated_at": "2026-08-12T12:00:00Z" + }} + }` +} + +func TestRunList(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "data": [{"id": "page-1", "attributes": { + "title": "Public Status", "slug": "public-status", "enabled": true, "public": true, + "created_at": "2026-08-12T12:00:00Z" + }}], + "meta": {"current_page": 1, "total_pages": 1, "total_count": 1} + }`)) + }) + viper.Set("format", "table") + cmd := newTestCmd() + cmd.Flags().Int("page", 1, "") + cmd.Flags().Int("page-size", 25, "") + cmd.Flags().String("sort", "-created_at", "") + cmd.Flags().String("search", "", "") + cmd.Flags().String("slug", "", "") + + output := captureStdout(t, func() { + if err := runList(cmd, nil); err != nil { + t.Fatalf("runList returned error: %v", err) + } + }) + if !strings.Contains(output, "Public Status") { + t.Errorf("expected status page in output, got: %s", output) + } +} + +func TestRunEventsListNormalizesIncidentID(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/incidents/42/status-page-events" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{ + "data": [{"id": "event-1", "attributes": { + "event": "Monitoring", "status": "monitoring", "status_page_id": "page-1", + "started_at": "2026-08-12T12:00:00Z", "created_at": "2026-08-12T12:00:00Z", "updated_at": "2026-08-12T12:30:00Z" + }}], + "meta": {"current_page": 1, "total_pages": 1, "total_count": 1} + }`)) + }) + viper.Set("format", "table") + cmd := newTestCmd() + cmd.Flags().Int("page", 1, "") + cmd.Flags().Int("page-size", 25, "") + + output := captureStdout(t, func() { + if err := runEventsList(cmd, []string{"INC-42"}); err != nil { + t.Fatalf("runEventsList returned error: %v", err) + } + }) + if !strings.Contains(output, "Monitoring") { + t.Errorf("expected event in output, got: %s", output) + } +} + +func TestRunEventsCreate(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/incidents/42/status-page-events" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(statusPageEventResponse())) + }) + viper.Set("format", "json") + cmd := newTestCmd() + cmd.Flags().String("status-page", "page-1", "") + cmd.Flags().String("status", "investigating", "") + cmd.Flags().String("message", "We are investigating.", "") + cmd.Flags().Bool("notify-subscribers", true, "") + + output := captureStdout(t, func() { + if err := runEventsCreate(cmd, []string{"INC-42"}); err != nil { + t.Fatalf("runEventsCreate returned error: %v", err) + } + }) + if !strings.Contains(output, "event-1") { + t.Errorf("expected event response, got: %s", output) + } +} + +func TestRunEventsUpdateRequiresChange(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("API should not be called") + }) + cmd := newTestCmd() + cmd.Flags().String("status", "", "") + cmd.Flags().String("message", "", "") + cmd.Flags().Bool("notify-subscribers", false, "") + + err := runEventsUpdate(cmd, []string{"event-1"}) + if err == nil || !strings.Contains(err.Error(), "at least one field") { + t.Fatalf("error = %v, want at least one field", err) + } +} + +func TestRunEventsResolveSetsResolvedStatus(t *testing.T) { + var requestBody map[string]interface{} + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &requestBody) + _, _ = w.Write([]byte(statusPageEventResponse())) + }) + viper.Set("format", "json") + cmd := newTestCmd() + cmd.Flags().String("message", "Resolved.", "") + cmd.Flags().Bool("notify-subscribers", true, "") + + captureStdout(t, func() { + if err := runEventsResolve(cmd, []string{"event-1"}); err != nil { + t.Fatalf("runEventsResolve returned error: %v", err) + } + }) + attributes := requestBody["data"].(map[string]interface{})["attributes"].(map[string]interface{}) + if attributes["status"] != "resolved" || attributes["event"] != "Resolved." { + t.Errorf("unexpected resolve attributes: %+v", attributes) + } +} + +func TestValidateEventStatus(t *testing.T) { + if err := validateEventStatus("monitoring"); err != nil { + t.Fatalf("monitoring should be valid: %v", err) + } + if err := validateEventStatus("unknown"); err == nil { + t.Fatal("unknown status should be rejected") + } +} diff --git a/internal/cmd/statuspages/events.go b/internal/cmd/statuspages/events.go new file mode 100644 index 0000000..351f942 --- /dev/null +++ b/internal/cmd/statuspages/events.go @@ -0,0 +1,12 @@ +package statuspages + +import "github.com/spf13/cobra" + +var eventsCmd = &cobra.Command{ + Use: "events", + Short: "Manage incident status-page events", +} + +func init() { + StatusPagesCmd.AddCommand(eventsCmd) +} diff --git a/internal/cmd/statuspages/events_list.go b/internal/cmd/statuspages/events_list.go new file mode 100644 index 0000000..498d9b2 --- /dev/null +++ b/internal/cmd/statuspages/events_list.go @@ -0,0 +1,60 @@ +package statuspages + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/rootlyhq/rootly-cli/internal/api" + "github.com/rootlyhq/rootly-cli/internal/printer" + "github.com/rootlyhq/rootly-cli/internal/timeformat" +) + +var eventsListCmd = &cobra.Command{ + Use: "list ", + Short: "List status-page events for an incident", + Example: ` rootly status-pages events list INC-123`, + Args: cobra.ExactArgs(1), + RunE: runEventsList, +} + +func init() { + eventsListCmd.Flags().Int("page", 1, "Page number") + eventsListCmd.Flags().Int("page-size", 25, "Results per page (max 100)") + eventsCmd.AddCommand(eventsListCmd) +} + +func runEventsList(cmd *cobra.Command, args []string) error { + apiClient, err := getAPIClient() + if err != nil { + return err + } + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + incidentID := api.NormalizeIncidentID(args[0]) + result, err := apiClient.ListStatusPageEventsCLI(cmd.Context(), incidentID, page, pageSize) + if err != nil { + return fmt.Errorf("failed to list status-page events: %w", err) + } + format := viper.GetString("format") + p, err := printer.NewPrinter(format) + if err != nil { + return err + } + if format == "json" || format == "yaml" { + return p.PrintRawJSON(result.RawBody, os.Stdout) + } + rows := make([][]string, 0, len(result.Events)) + for _, event := range result.Events { + rows = append(rows, []string{event.ID, event.StatusPageID, event.Status, event.Event, timeformat.FormatTime(event.UpdatedAt)}) + } + if err := p.PrintList([]string{"ID", "Status Page", "Status", "Message", "Updated"}, rows, os.Stdout); err != nil { + return fmt.Errorf("failed to print output: %w", err) + } + if result.Pagination.TotalPages > 1 { + fmt.Fprintf(os.Stderr, "\nPage %d of %d (%d total events)\n", result.Pagination.CurrentPage, result.Pagination.TotalPages, result.Pagination.TotalCount) + } + return nil +} diff --git a/internal/cmd/statuspages/events_mutations.go b/internal/cmd/statuspages/events_mutations.go new file mode 100644 index 0000000..ee16e7b --- /dev/null +++ b/internal/cmd/statuspages/events_mutations.go @@ -0,0 +1,150 @@ +package statuspages + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/rootlyhq/rootly-cli/internal/api" + "github.com/rootlyhq/rootly-cli/internal/printer" +) + +var validEventStatuses = map[string]bool{ + "completed": true, "identified": true, "in_progress": true, + "investigating": true, "monitoring": true, "resolved": true, "scheduled": true, +} + +var eventsCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a status-page event for an incident", + Example: ` rootly status-pages events create INC-123 --status-page= --status=investigating --message="We are investigating."`, + Args: cobra.ExactArgs(1), + RunE: runEventsCreate, +} + +var eventsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a status-page event", + Example: ` rootly status-pages events update --status=monitoring --message="A fix has been applied." --notify-subscribers`, + Args: cobra.ExactArgs(1), + RunE: runEventsUpdate, +} + +var eventsResolveCmd = &cobra.Command{ + Use: "resolve ", + Short: "Resolve a status-page event", + Example: ` rootly status-pages events resolve --message="The incident has been resolved." --notify-subscribers`, + Args: cobra.ExactArgs(1), + RunE: runEventsResolve, +} + +func init() { + eventsCreateCmd.Flags().String("status-page", "", "Status page ID (required)") + eventsCreateCmd.Flags().String("status", "", "Event status (required)") + eventsCreateCmd.Flags().String("message", "", "Public update message (required)") + eventsCreateCmd.Flags().Bool("notify-subscribers", false, "Notify status-page subscribers") + _ = eventsCreateCmd.MarkFlagRequired("status-page") + _ = eventsCreateCmd.MarkFlagRequired("status") + _ = eventsCreateCmd.MarkFlagRequired("message") + + eventsUpdateCmd.Flags().String("status", "", "Updated event status") + eventsUpdateCmd.Flags().String("message", "", "Updated public message") + eventsUpdateCmd.Flags().Bool("notify-subscribers", false, "Notify status-page subscribers") + + eventsResolveCmd.Flags().String("message", "", "Resolution message (required)") + eventsResolveCmd.Flags().Bool("notify-subscribers", false, "Notify status-page subscribers") + _ = eventsResolveCmd.MarkFlagRequired("message") + + eventsCmd.AddCommand(eventsCreateCmd, eventsUpdateCmd, eventsResolveCmd) +} + +func validateEventStatus(status string) error { + if !validEventStatuses[status] { + return fmt.Errorf("invalid status %q: expected completed, identified, in_progress, investigating, monitoring, resolved, or scheduled", status) + } + return nil +} + +func runEventsCreate(cmd *cobra.Command, args []string) error { + apiClient, err := getAPIClient() + if err != nil { + return err + } + statusPageID, _ := cmd.Flags().GetString("status-page") + status, _ := cmd.Flags().GetString("status") + message, _ := cmd.Flags().GetString("message") + notify, _ := cmd.Flags().GetBool("notify-subscribers") + if err := validateEventStatus(status); err != nil { + return err + } + event, err := apiClient.CreateStatusPageEventCLI(cmd.Context(), api.NormalizeIncidentID(args[0]), statusPageID, status, message, notify) + if err != nil { + return fmt.Errorf("failed to create status-page event: %w", err) + } + return printSavedEvent(event, "Created") +} + +func runEventsUpdate(cmd *cobra.Command, args []string) error { + apiClient, err := getAPIClient() + if err != nil { + return err + } + opts := api.StatusPageEventOpts{} + if cmd.Flags().Changed("status") { + status, _ := cmd.Flags().GetString("status") + if err := validateEventStatus(status); err != nil { + return err + } + opts.Status = &status + } + if cmd.Flags().Changed("message") { + message, _ := cmd.Flags().GetString("message") + opts.Message = &message + } + if cmd.Flags().Changed("notify-subscribers") { + notify, _ := cmd.Flags().GetBool("notify-subscribers") + opts.NotifySubscribers = ¬ify + } + if opts.Status == nil && opts.Message == nil && opts.NotifySubscribers == nil { + return fmt.Errorf("at least one field must be specified for update") + } + event, err := apiClient.UpdateStatusPageEventCLI(cmd.Context(), args[0], opts) + if err != nil { + return fmt.Errorf("failed to update status-page event: %w", err) + } + return printSavedEvent(event, "Updated") +} + +func runEventsResolve(cmd *cobra.Command, args []string) error { + apiClient, err := getAPIClient() + if err != nil { + return err + } + status := "resolved" + message, _ := cmd.Flags().GetString("message") + notify, _ := cmd.Flags().GetBool("notify-subscribers") + event, err := apiClient.UpdateStatusPageEventCLI(cmd.Context(), args[0], api.StatusPageEventOpts{ + Status: &status, Message: &message, NotifySubscribers: ¬ify, + }) + if err != nil { + return fmt.Errorf("failed to resolve status-page event: %w", err) + } + return printSavedEvent(event, "Resolved") +} + +func printSavedEvent(event *api.StatusPageEvent, verb string) error { + if !viper.GetBool("quiet") { + fmt.Fprintf(os.Stderr, "%s status-page event %s\n", verb, event.ID) + } + format := viper.GetString("format") + p, err := printer.NewPrinter(format) + if err != nil { + return err + } + if format == "json" || format == "yaml" { + return p.PrintRawJSON(event.RawBody, os.Stdout) + } + return p.PrintObj(event, os.Stdout) +} diff --git a/internal/cmd/statuspages/list.go b/internal/cmd/statuspages/list.go new file mode 100644 index 0000000..d78477d --- /dev/null +++ b/internal/cmd/statuspages/list.go @@ -0,0 +1,72 @@ +package statuspages + +import ( + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/rootlyhq/rootly-cli/internal/printer" + "github.com/rootlyhq/rootly-cli/internal/timeformat" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List status pages", + Example: ` rootly status-pages list + rootly status-pages list --slug=public-status --format=json`, + RunE: runList, +} + +func init() { + listCmd.Flags().Int("page", 1, "Page number") + listCmd.Flags().Int("page-size", 25, "Results per page (max 100)") + listCmd.Flags().String("sort", "-created_at", "Sort order") + listCmd.Flags().String("search", "", "Filter by title or description") + listCmd.Flags().String("slug", "", "Filter by slug") + StatusPagesCmd.AddCommand(listCmd) +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, err := getAPIClient() + if err != nil { + return err + } + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + sort, _ := cmd.Flags().GetString("sort") + search, _ := cmd.Flags().GetString("search") + slug, _ := cmd.Flags().GetString("slug") + filters := make(map[string]string) + if search != "" { + filters["search"] = search + } + if slug != "" { + filters["slug"] = slug + } + result, err := apiClient.ListStatusPagesCLI(cmd.Context(), page, pageSize, sort, filters) + if err != nil { + return fmt.Errorf("failed to list status pages: %w", err) + } + format := viper.GetString("format") + p, err := printer.NewPrinter(format) + if err != nil { + return err + } + if format == "json" || format == "yaml" { + return p.PrintRawJSON(result.RawBody, os.Stdout) + } + rows := make([][]string, 0, len(result.StatusPages)) + for _, page := range result.StatusPages { + rows = append(rows, []string{page.ID, page.Title, page.Slug, strconv.FormatBool(page.Public), strconv.FormatBool(page.Enabled), timeformat.FormatTime(page.CreatedAt)}) + } + if err := p.PrintList([]string{"ID", "Title", "Slug", "Public", "Enabled", "Created"}, rows, os.Stdout); err != nil { + return fmt.Errorf("failed to print output: %w", err) + } + if result.Pagination.TotalPages > 1 { + fmt.Fprintf(os.Stderr, "\nPage %d of %d (%d total status pages)\n", result.Pagination.CurrentPage, result.Pagination.TotalPages, result.Pagination.TotalCount) + } + return nil +} diff --git a/internal/cmd/statuspages/statuspages.go b/internal/cmd/statuspages/statuspages.go new file mode 100644 index 0000000..6d4dad1 --- /dev/null +++ b/internal/cmd/statuspages/statuspages.go @@ -0,0 +1,32 @@ +package statuspages + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/rootlyhq/rootly-cli/internal/api" + "github.com/rootlyhq/rootly-cli/internal/config" + "github.com/rootlyhq/rootly-cli/internal/oauth" +) + +// StatusPagesCmd is the parent command for status-page operations. +var StatusPagesCmd = &cobra.Command{ + Use: "status-pages", + Aliases: []string{"status-page"}, + Short: "Manage status-page incident updates", + Long: "List Rootly status pages and manage their incident events.", +} + +func getAPIClient() (*api.Client, error) { + token := viper.GetString("api_key") + if token == "" && !oauth.HasTokens() { + return nil, fmt.Errorf("authentication required: run 'rootly login' or set ROOTLY_API_KEY") + } + endpoint := viper.GetString("api_host") + if endpoint == "" { + endpoint = config.DefaultEndpoint + } + return api.NewClient(&config.Config{APIKey: token, Endpoint: endpoint, Debug: viper.GetBool("debug")}) +} diff --git a/internal/cmd/statuspages_register.go b/internal/cmd/statuspages_register.go new file mode 100644 index 0000000..515f776 --- /dev/null +++ b/internal/cmd/statuspages_register.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/rootlyhq/rootly-cli/internal/cmd/statuspages" + +func init() { + rootCmd.AddCommand(statuspages.StatusPagesCmd) +} From f64e6f62f4e75b0ee72b31dd113503a20fa90fa5 Mon Sep 17 00:00:00 2001 From: Quentin Rousseau Date: Wed, 12 Aug 2026 20:29:15 -0700 Subject: [PATCH 2/4] satisfy lint for API helper --- internal/api/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index c7c3bba..564c265 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -1566,7 +1566,7 @@ func (c *Client) RunWorkflowCLI(ctx context.Context, workflowID string, opts Wor return run, nil } -func (c *Client) doJSONAPIRequest(ctx context.Context, method, path string, requestBody interface{}) ([]byte, int, error) { +func (c *Client) doJSONAPIRequest(ctx context.Context, method, path string, requestBody interface{}) (body []byte, statusCode int, err error) { var bodyReader io.Reader = http.NoBody if requestBody != nil { bodyBytes, err := json.Marshal(requestBody) @@ -1587,7 +1587,7 @@ func (c *Client) doJSONAPIRequest(ctx context.Context, method, path string, requ return nil, 0, err } defer func() { _ = response.Body.Close() }() - body, err := io.ReadAll(response.Body) + body, err = io.ReadAll(response.Body) if err != nil { return nil, response.StatusCode, fmt.Errorf("failed to read response: %w", err) } From 852c34a43a32e33f21de6fa5465834af9dd6728f Mon Sep 17 00:00:00 2001 From: Quentin Rousseau Date: Wed, 12 Aug 2026 20:40:46 -0700 Subject: [PATCH 3/4] align status page commands with API contract --- internal/api/client.go | 43 ++++++++++++--- internal/api/client_status_pages_test.go | 34 +++++++++--- internal/cmd/statuspages/cmd_test.go | 17 +++--- internal/cmd/statuspages/events_mutations.go | 57 ++++++++++---------- internal/cmd/statuspages/list.go | 10 ++-- 5 files changed, 106 insertions(+), 55 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 564c265..c7c8568 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -328,6 +328,16 @@ type StatusPageEventOpts struct { Message *string Status *string NotifySubscribers *bool + StartedAt *time.Time +} + +// CreateStatusPageEventOpts contains fields for a new incident status-page event. +type CreateStatusPageEventOpts struct { + StatusPageID string + Status string + Message string + NotifySubscribers bool + StartedAt *time.Time } // KeyValue represents a key-value pair for pulse labels and refs @@ -1775,16 +1785,20 @@ func parseStatusPageEvent(data statusPageEventResponseData, rawBody []byte) Stat } // CreateStatusPageEventCLI creates a status-page event for an incident. -func (c *Client) CreateStatusPageEventCLI(ctx context.Context, incidentID, statusPageID, status, message string, notifySubscribers bool) (*StatusPageEvent, error) { +func (c *Client) CreateStatusPageEventCLI(ctx context.Context, incidentID string, opts CreateStatusPageEventOpts) (*StatusPageEvent, error) { + attributes := map[string]interface{}{ + "status_page_id": opts.StatusPageID, + "status": opts.Status, + "event": opts.Message, + "notify_subscribers": opts.NotifySubscribers, + } + if opts.StartedAt != nil { + attributes["started_at"] = opts.StartedAt.Format(time.RFC3339) + } requestBody := map[string]interface{}{ "data": map[string]interface{}{ - "type": "incident_status_page_events", - "attributes": map[string]interface{}{ - "status_page_id": statusPageID, - "status": status, - "event": message, - "notify_subscribers": notifySubscribers, - }, + "type": "incident_status_page_events", + "attributes": attributes, }, } path := fmt.Sprintf("/v1/incidents/%s/status-page-events", neturl.PathEscape(incidentID)) @@ -1803,6 +1817,9 @@ func (c *Client) UpdateStatusPageEventCLI(ctx context.Context, eventID string, o if opts.NotifySubscribers != nil { attributes["notify_subscribers"] = *opts.NotifySubscribers } + if opts.StartedAt != nil { + attributes["started_at"] = opts.StartedAt.Format(time.RFC3339) + } requestBody := map[string]interface{}{ "data": map[string]interface{}{ "type": "incident_status_page_events", @@ -1826,6 +1843,16 @@ func (c *Client) mutateStatusPageEvent(ctx context.Context, method, path string, if statusCode == http.StatusNotFound { return nil, fmt.Errorf("incident, status page, or event not found") } + if statusCode == http.StatusUnprocessableEntity { + var response struct { + Errors []struct { + Title string `json:"title"` + } `json:"errors"` + } + if json.Unmarshal(body, &response) == nil && len(response.Errors) > 0 && response.Errors[0].Title != "" { + return nil, fmt.Errorf("API rejected status-page event: %s", response.Errors[0].Title) + } + } if statusCode != http.StatusOK && statusCode != http.StatusCreated { return nil, fmt.Errorf("API returned status %d", statusCode) } diff --git a/internal/api/client_status_pages_test.go b/internal/api/client_status_pages_test.go index 88f0d0d..ba6fcf4 100644 --- a/internal/api/client_status_pages_test.go +++ b/internal/api/client_status_pages_test.go @@ -6,7 +6,9 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" + "time" ) func statusPageEventResponse() string { @@ -34,6 +36,9 @@ func TestListStatusPagesCLI(t *testing.T) { if got := r.URL.Query().Get("filter[slug]"); got != "public-status" { t.Errorf("slug filter = %q, want public-status", got) } + if got := r.URL.Query().Get("filter[name]"); got != "Public" { + t.Errorf("name filter = %q, want Public", got) + } _, _ = w.Write([]byte(`{ "data": [{"id": "page-1", "attributes": { "title": "Public Status", "slug": "public-status", "description": "Customer updates", @@ -45,7 +50,7 @@ func TestListStatusPagesCLI(t *testing.T) { defer server.Close() client := newTestClient(t, server.URL) - result, err := client.ListStatusPagesCLI(context.Background(), 1, 25, "-created_at", map[string]string{"slug": "public-status"}) + result, err := client.ListStatusPagesCLI(context.Background(), 1, 25, "-created_at", map[string]string{"name": "Public", "slug": "public-status"}) if err != nil { t.Fatalf("ListStatusPagesCLI returned error: %v", err) } @@ -96,12 +101,15 @@ func TestCreateStatusPageEventCLI(t *testing.T) { defer server.Close() client := newTestClient(t, server.URL) - event, err := client.CreateStatusPageEventCLI(context.Background(), "42", "page-1", "investigating", "We are investigating.", true) + startedAt := time.Date(2026, time.August, 12, 11, 30, 0, 0, time.UTC) + event, err := client.CreateStatusPageEventCLI(context.Background(), "42", CreateStatusPageEventOpts{ + StatusPageID: "page-1", Status: "investigating", Message: "We are investigating.", NotifySubscribers: true, StartedAt: &startedAt, + }) if err != nil { t.Fatalf("CreateStatusPageEventCLI returned error: %v", err) } attributes := requestBody["data"].(map[string]interface{})["attributes"].(map[string]interface{}) - if attributes["status_page_id"] != "page-1" || attributes["status"] != "investigating" || attributes["notify_subscribers"] != true { + if attributes["status_page_id"] != "page-1" || attributes["status"] != "investigating" || attributes["notify_subscribers"] != true || attributes["started_at"] != "2026-08-12T11:30:00Z" { t.Errorf("unexpected attributes: %+v", attributes) } if event.ID != "event-1" { @@ -123,16 +131,30 @@ func TestUpdateStatusPageEventCLI(t *testing.T) { status := "resolved" message := "Resolved." - notify := true + startedAt := time.Date(2026, time.August, 12, 12, 15, 0, 0, time.UTC) client := newTestClient(t, server.URL) _, err := client.UpdateStatusPageEventCLI(context.Background(), "event-1", StatusPageEventOpts{ - Status: &status, Message: &message, NotifySubscribers: ¬ify, + Status: &status, Message: &message, StartedAt: &startedAt, }) if err != nil { t.Fatalf("UpdateStatusPageEventCLI returned error: %v", err) } attributes := requestBody["data"].(map[string]interface{})["attributes"].(map[string]interface{}) - if attributes["status"] != "resolved" || attributes["event"] != "Resolved." || attributes["notify_subscribers"] != true { + if attributes["status"] != "resolved" || attributes["event"] != "Resolved." || attributes["started_at"] != "2026-08-12T12:15:00Z" { t.Errorf("unexpected attributes: %+v", attributes) } } + +func TestStatusPageEventValidationError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"errors":[{"title":"Status is not valid for this incident","status":"422"}]}`)) + })) + defer server.Close() + + client := newTestClient(t, server.URL) + _, err := client.UpdateStatusPageEventCLI(context.Background(), "event-1", StatusPageEventOpts{}) + if err == nil || !strings.Contains(err.Error(), "Status is not valid for this incident") { + t.Fatalf("error = %v, want server validation title", err) + } +} diff --git a/internal/cmd/statuspages/cmd_test.go b/internal/cmd/statuspages/cmd_test.go index 035fbf4..8478a67 100644 --- a/internal/cmd/statuspages/cmd_test.go +++ b/internal/cmd/statuspages/cmd_test.go @@ -70,7 +70,7 @@ func TestRunList(t *testing.T) { cmd.Flags().Int("page", 1, "") cmd.Flags().Int("page-size", 25, "") cmd.Flags().String("sort", "-created_at", "") - cmd.Flags().String("search", "", "") + cmd.Flags().String("name", "", "") cmd.Flags().String("slug", "", "") output := captureStdout(t, func() { @@ -125,6 +125,7 @@ func TestRunEventsCreate(t *testing.T) { cmd.Flags().String("status", "investigating", "") cmd.Flags().String("message", "We are investigating.", "") cmd.Flags().Bool("notify-subscribers", true, "") + cmd.Flags().String("started-at", "", "") output := captureStdout(t, func() { if err := runEventsCreate(cmd, []string{"INC-42"}); err != nil { @@ -143,7 +144,7 @@ func TestRunEventsUpdateRequiresChange(t *testing.T) { cmd := newTestCmd() cmd.Flags().String("status", "", "") cmd.Flags().String("message", "", "") - cmd.Flags().Bool("notify-subscribers", false, "") + cmd.Flags().String("started-at", "", "") err := runEventsUpdate(cmd, []string{"event-1"}) if err == nil || !strings.Contains(err.Error(), "at least one field") { @@ -161,7 +162,6 @@ func TestRunEventsResolveSetsResolvedStatus(t *testing.T) { viper.Set("format", "json") cmd := newTestCmd() cmd.Flags().String("message", "Resolved.", "") - cmd.Flags().Bool("notify-subscribers", true, "") captureStdout(t, func() { if err := runEventsResolve(cmd, []string{"event-1"}); err != nil { @@ -174,11 +174,10 @@ func TestRunEventsResolveSetsResolvedStatus(t *testing.T) { } } -func TestValidateEventStatus(t *testing.T) { - if err := validateEventStatus("monitoring"); err != nil { - t.Fatalf("monitoring should be valid: %v", err) - } - if err := validateEventStatus("unknown"); err == nil { - t.Fatal("unknown status should be rejected") +func TestParseStartedAtRejectsInvalidTimestamp(t *testing.T) { + cmd := newTestCmd() + cmd.Flags().String("started-at", "tomorrow", "") + if _, err := parseStartedAt(cmd); err == nil || !strings.Contains(err.Error(), "RFC3339") { + t.Fatalf("error = %v, want RFC3339 validation error", err) } } diff --git a/internal/cmd/statuspages/events_mutations.go b/internal/cmd/statuspages/events_mutations.go index ee16e7b..aa388a9 100644 --- a/internal/cmd/statuspages/events_mutations.go +++ b/internal/cmd/statuspages/events_mutations.go @@ -3,6 +3,7 @@ package statuspages import ( "fmt" "os" + "time" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -11,11 +12,6 @@ import ( "github.com/rootlyhq/rootly-cli/internal/printer" ) -var validEventStatuses = map[string]bool{ - "completed": true, "identified": true, "in_progress": true, - "investigating": true, "monitoring": true, "resolved": true, "scheduled": true, -} - var eventsCreateCmd = &cobra.Command{ Use: "create ", Short: "Create a status-page event for an incident", @@ -27,7 +23,7 @@ var eventsCreateCmd = &cobra.Command{ var eventsUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update a status-page event", - Example: ` rootly status-pages events update --status=monitoring --message="A fix has been applied." --notify-subscribers`, + Example: ` rootly status-pages events update --status=monitoring --message="A fix has been applied."`, Args: cobra.ExactArgs(1), RunE: runEventsUpdate, } @@ -35,7 +31,7 @@ var eventsUpdateCmd = &cobra.Command{ var eventsResolveCmd = &cobra.Command{ Use: "resolve ", Short: "Resolve a status-page event", - Example: ` rootly status-pages events resolve --message="The incident has been resolved." --notify-subscribers`, + Example: ` rootly status-pages events resolve --message="The incident has been resolved."`, Args: cobra.ExactArgs(1), RunE: runEventsResolve, } @@ -45,28 +41,21 @@ func init() { eventsCreateCmd.Flags().String("status", "", "Event status (required)") eventsCreateCmd.Flags().String("message", "", "Public update message (required)") eventsCreateCmd.Flags().Bool("notify-subscribers", false, "Notify status-page subscribers") + eventsCreateCmd.Flags().String("started-at", "", "Event start time in RFC3339 format") _ = eventsCreateCmd.MarkFlagRequired("status-page") _ = eventsCreateCmd.MarkFlagRequired("status") _ = eventsCreateCmd.MarkFlagRequired("message") eventsUpdateCmd.Flags().String("status", "", "Updated event status") eventsUpdateCmd.Flags().String("message", "", "Updated public message") - eventsUpdateCmd.Flags().Bool("notify-subscribers", false, "Notify status-page subscribers") + eventsUpdateCmd.Flags().String("started-at", "", "Updated event start time in RFC3339 format") eventsResolveCmd.Flags().String("message", "", "Resolution message (required)") - eventsResolveCmd.Flags().Bool("notify-subscribers", false, "Notify status-page subscribers") _ = eventsResolveCmd.MarkFlagRequired("message") eventsCmd.AddCommand(eventsCreateCmd, eventsUpdateCmd, eventsResolveCmd) } -func validateEventStatus(status string) error { - if !validEventStatuses[status] { - return fmt.Errorf("invalid status %q: expected completed, identified, in_progress, investigating, monitoring, resolved, or scheduled", status) - } - return nil -} - func runEventsCreate(cmd *cobra.Command, args []string) error { apiClient, err := getAPIClient() if err != nil { @@ -76,10 +65,13 @@ func runEventsCreate(cmd *cobra.Command, args []string) error { status, _ := cmd.Flags().GetString("status") message, _ := cmd.Flags().GetString("message") notify, _ := cmd.Flags().GetBool("notify-subscribers") - if err := validateEventStatus(status); err != nil { + startedAt, err := parseStartedAt(cmd) + if err != nil { return err } - event, err := apiClient.CreateStatusPageEventCLI(cmd.Context(), api.NormalizeIncidentID(args[0]), statusPageID, status, message, notify) + event, err := apiClient.CreateStatusPageEventCLI(cmd.Context(), api.NormalizeIncidentID(args[0]), api.CreateStatusPageEventOpts{ + StatusPageID: statusPageID, Status: status, Message: message, NotifySubscribers: notify, StartedAt: startedAt, + }) if err != nil { return fmt.Errorf("failed to create status-page event: %w", err) } @@ -94,20 +86,20 @@ func runEventsUpdate(cmd *cobra.Command, args []string) error { opts := api.StatusPageEventOpts{} if cmd.Flags().Changed("status") { status, _ := cmd.Flags().GetString("status") - if err := validateEventStatus(status); err != nil { - return err - } opts.Status = &status } if cmd.Flags().Changed("message") { message, _ := cmd.Flags().GetString("message") opts.Message = &message } - if cmd.Flags().Changed("notify-subscribers") { - notify, _ := cmd.Flags().GetBool("notify-subscribers") - opts.NotifySubscribers = ¬ify + if cmd.Flags().Changed("started-at") { + startedAt, err := parseStartedAt(cmd) + if err != nil { + return err + } + opts.StartedAt = startedAt } - if opts.Status == nil && opts.Message == nil && opts.NotifySubscribers == nil { + if opts.Status == nil && opts.Message == nil && opts.StartedAt == nil { return fmt.Errorf("at least one field must be specified for update") } event, err := apiClient.UpdateStatusPageEventCLI(cmd.Context(), args[0], opts) @@ -124,9 +116,8 @@ func runEventsResolve(cmd *cobra.Command, args []string) error { } status := "resolved" message, _ := cmd.Flags().GetString("message") - notify, _ := cmd.Flags().GetBool("notify-subscribers") event, err := apiClient.UpdateStatusPageEventCLI(cmd.Context(), args[0], api.StatusPageEventOpts{ - Status: &status, Message: &message, NotifySubscribers: ¬ify, + Status: &status, Message: &message, }) if err != nil { return fmt.Errorf("failed to resolve status-page event: %w", err) @@ -134,6 +125,18 @@ func runEventsResolve(cmd *cobra.Command, args []string) error { return printSavedEvent(event, "Resolved") } +func parseStartedAt(cmd *cobra.Command) (*time.Time, error) { + value, _ := cmd.Flags().GetString("started-at") + if value == "" { + return nil, nil + } + startedAt, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil, fmt.Errorf("invalid --started-at %q: expected RFC3339 timestamp", value) + } + return &startedAt, nil +} + func printSavedEvent(event *api.StatusPageEvent, verb string) error { if !viper.GetBool("quiet") { fmt.Fprintf(os.Stderr, "%s status-page event %s\n", verb, event.ID) diff --git a/internal/cmd/statuspages/list.go b/internal/cmd/statuspages/list.go index d78477d..13d5bef 100644 --- a/internal/cmd/statuspages/list.go +++ b/internal/cmd/statuspages/list.go @@ -16,7 +16,7 @@ var listCmd = &cobra.Command{ Use: "list", Short: "List status pages", Example: ` rootly status-pages list - rootly status-pages list --slug=public-status --format=json`, + rootly status-pages list --name=public --format=json`, RunE: runList, } @@ -24,7 +24,7 @@ func init() { listCmd.Flags().Int("page", 1, "Page number") listCmd.Flags().Int("page-size", 25, "Results per page (max 100)") listCmd.Flags().String("sort", "-created_at", "Sort order") - listCmd.Flags().String("search", "", "Filter by title or description") + listCmd.Flags().String("name", "", "Filter by title (partial match)") listCmd.Flags().String("slug", "", "Filter by slug") StatusPagesCmd.AddCommand(listCmd) } @@ -37,11 +37,11 @@ func runList(cmd *cobra.Command, args []string) error { page, _ := cmd.Flags().GetInt("page") pageSize, _ := cmd.Flags().GetInt("page-size") sort, _ := cmd.Flags().GetString("sort") - search, _ := cmd.Flags().GetString("search") + name, _ := cmd.Flags().GetString("name") slug, _ := cmd.Flags().GetString("slug") filters := make(map[string]string) - if search != "" { - filters["search"] = search + if name != "" { + filters["name"] = name } if slug != "" { filters["slug"] = slug From c1461a10ed5b7fefe9d6575ece1d399064994363 Mon Sep 17 00:00:00 2001 From: Quentin Rousseau Date: Wed, 12 Aug 2026 20:45:40 -0700 Subject: [PATCH 4/4] validate status page pagination --- internal/api/client.go | 16 ++++++++++++++++ internal/api/client_status_pages_test.go | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/internal/api/client.go b/internal/api/client.go index c7c8568..b78cb56 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -1606,6 +1606,9 @@ func (c *Client) doJSONAPIRequest(ctx context.Context, method, path string, requ // ListStatusPagesCLI lists configured status pages. func (c *Client) ListStatusPagesCLI(ctx context.Context, page, pageSize int, sort string, filters map[string]string) (*StatusPagesResult, error) { + if err := validateStatusPagePagination(page, pageSize); err != nil { + return nil, err + } if pageSize == 0 { pageSize = 25 } @@ -1693,6 +1696,9 @@ func (c *Client) ListStatusPagesCLI(ctx context.Context, page, pageSize int, sor // ListStatusPageEventsCLI lists status-page events for an incident. func (c *Client) ListStatusPageEventsCLI(ctx context.Context, incidentID string, page, pageSize int) (*StatusPageEventsResult, error) { + if err := validateStatusPagePagination(page, pageSize); err != nil { + return nil, err + } if pageSize == 0 { pageSize = 25 } @@ -1719,6 +1725,16 @@ func (c *Client) ListStatusPageEventsCLI(ctx context.Context, incidentID string, return parseStatusPageEvents(body, page) } +func validateStatusPagePagination(page, pageSize int) error { + if page < 1 { + return fmt.Errorf("page must be at least 1") + } + if pageSize < 0 { + return fmt.Errorf("page size must not be negative") + } + return nil +} + func parseStatusPageEvents(body []byte, requestedPage int) (*StatusPageEventsResult, error) { var response struct { Data []statusPageEventResponseData `json:"data"` diff --git a/internal/api/client_status_pages_test.go b/internal/api/client_status_pages_test.go index ba6fcf4..1718071 100644 --- a/internal/api/client_status_pages_test.go +++ b/internal/api/client_status_pages_test.go @@ -62,6 +62,16 @@ func TestListStatusPagesCLI(t *testing.T) { } } +func TestStatusPageListsRejectNegativePagination(t *testing.T) { + client := &Client{} + if _, err := client.ListStatusPagesCLI(context.Background(), -1, 25, "", nil); err == nil || !strings.Contains(err.Error(), "page must be at least 1") { + t.Fatalf("status-page list error = %v, want minimum page validation", err) + } + if _, err := client.ListStatusPageEventsCLI(context.Background(), "42", 1, -1); err == nil || !strings.Contains(err.Error(), "page size must not be negative") { + t.Fatalf("event list error = %v, want non-negative page-size validation", err) + } +} + func TestListStatusPageEventsCLI(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/incidents/42/status-page-events" {