diff --git a/internal/api/client.go b/internal/api/client.go index 553154e..b78cb56 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -285,6 +285,61 @@ 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 + 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 type KeyValue struct { Key string @@ -1521,6 +1576,312 @@ 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{}) (body []byte, statusCode int, err 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 err := validateStatusPagePagination(page, pageSize); err != nil { + return nil, err + } + 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 err := validateStatusPagePagination(page, pageSize); err != nil { + return nil, err + } + 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 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"` + 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 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": attributes, + }, + } + 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 + } + 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": 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.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) + } + 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..1718071 --- /dev/null +++ b/internal/api/client_status_pages_test.go @@ -0,0 +1,170 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +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) + } + 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", + "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{"name": "Public", "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 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" { + 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) + 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 || attributes["started_at"] != "2026-08-12T11:30:00Z" { + 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." + 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, 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["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/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..8478a67 --- /dev/null +++ b/internal/cmd/statuspages/cmd_test.go @@ -0,0 +1,183 @@ +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("name", "", "") + 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, "") + cmd.Flags().String("started-at", "", "") + + 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().String("started-at", "", "") + + 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.", "") + + 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 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.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..aa388a9 --- /dev/null +++ b/internal/cmd/statuspages/events_mutations.go @@ -0,0 +1,153 @@ +package statuspages + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/rootlyhq/rootly-cli/internal/api" + "github.com/rootlyhq/rootly-cli/internal/printer" +) + +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."`, + 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."`, + 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.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().String("started-at", "", "Updated event start time in RFC3339 format") + + eventsResolveCmd.Flags().String("message", "", "Resolution message (required)") + _ = eventsResolveCmd.MarkFlagRequired("message") + + eventsCmd.AddCommand(eventsCreateCmd, eventsUpdateCmd, eventsResolveCmd) +} + +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") + startedAt, err := parseStartedAt(cmd) + if err != nil { + return err + } + 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) + } + 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") + opts.Status = &status + } + if cmd.Flags().Changed("message") { + message, _ := cmd.Flags().GetString("message") + opts.Message = &message + } + 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.StartedAt == 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") + event, err := apiClient.UpdateStatusPageEventCLI(cmd.Context(), args[0], api.StatusPageEventOpts{ + Status: &status, Message: &message, + }) + if err != nil { + return fmt.Errorf("failed to resolve status-page event: %w", err) + } + 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) + } + 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..13d5bef --- /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 --name=public --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("name", "", "Filter by title (partial match)") + 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") + name, _ := cmd.Flags().GetString("name") + slug, _ := cmd.Flags().GetString("slug") + filters := make(map[string]string) + if name != "" { + filters["name"] = name + } + 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) +}