diff --git a/internal/api/client.go b/internal/api/client.go index 82ecd84..6c102cf 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -1075,7 +1075,7 @@ func (c *Client) GetIncidentByID(ctx context.Context, id string) (*Incident, err } // CreateIncident creates a new incident using raw HTTP POST. -func (c *Client) CreateIncident(ctx context.Context, title string, opts map[string]string) (*Incident, error) { +func (c *Client) CreateIncident(ctx context.Context, title string, opts map[string]interface{}) (*Incident, error) { // Build JSON:API request body requestBody := map[string]interface{}{ "data": map[string]interface{}{ @@ -1097,6 +1097,11 @@ func (c *Client) CreateIncident(ctx context.Context, title string, opts map[stri if status, ok := opts["status"]; ok { attributes["status"] = status } + for _, key := range []string{"service_ids", "incident_type_ids", "functionality_ids", "environment_ids", "group_ids", "cause_ids"} { + if value, ok := opts[key]; ok { + attributes[key] = value + } + } bodyBytes, err := json.Marshal(requestBody) if err != nil { @@ -1150,7 +1155,7 @@ func (c *Client) CreateIncident(ctx context.Context, title string, opts map[stri } // UpdateIncident updates an incident using raw HTTP PUT. -func (c *Client) UpdateIncident(ctx context.Context, id string, opts map[string]string) (*Incident, error) { +func (c *Client) UpdateIncident(ctx context.Context, id string, opts map[string]interface{}) (*Incident, error) { // Build JSON:API request body with only changed attributes attributes := make(map[string]interface{}) if title, ok := opts["title"]; ok { @@ -1165,6 +1170,11 @@ func (c *Client) UpdateIncident(ctx context.Context, id string, opts map[string] if status, ok := opts["status"]; ok { attributes["status"] = status } + for _, key := range []string{"service_ids", "incident_type_ids", "functionality_ids", "environment_ids", "group_ids", "cause_ids"} { + if value, ok := opts[key]; ok { + attributes[key] = value + } + } requestBody := map[string]interface{}{ "data": map[string]interface{}{ diff --git a/internal/api/client_http_test.go b/internal/api/client_http_test.go index b37153b..afae8c8 100644 --- a/internal/api/client_http_test.go +++ b/internal/api/client_http_test.go @@ -256,6 +256,26 @@ func TestCreateIncident(t *testing.T) { t.Errorf("expected POST, got %s", r.Method) } + var requestBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + data := requestBody["data"].(map[string]interface{}) + attrs := data["attributes"].(map[string]interface{}) + for key, want := range map[string]string{ + "service_ids": "api-gateway", + "incident_type_ids": "customer-impacting", + "functionality_ids": "checkout", + "environment_ids": "production", + "group_ids": "platform", + "cause_ids": "deployment", + } { + values := attrs[key].([]interface{}) + if len(values) == 0 || values[0] != want { + t.Errorf("request %s = %v, want first value %s", key, values, want) + } + } + w.Header().Set("Content-Type", "application/vnd.api+json") w.WriteHeader(http.StatusCreated) resp := map[string]interface{}{ @@ -275,9 +295,15 @@ func TestCreateIncident(t *testing.T) { defer server.Close() client := newTestClient(t, server.URL) - inc, err := client.CreateIncident(context.Background(), "New Incident", map[string]string{ - "summary": "Test summary", - "status": "started", + inc, err := client.CreateIncident(context.Background(), "New Incident", map[string]interface{}{ + "summary": "Test summary", + "status": "started", + "service_ids": []string{"api-gateway", "payments"}, + "incident_type_ids": []string{"customer-impacting"}, + "functionality_ids": []string{"checkout"}, + "environment_ids": []string{"production"}, + "group_ids": []string{"platform"}, + "cause_ids": []string{"deployment"}, }) if err != nil { t.Fatalf("CreateIncident returned error: %v", err) diff --git a/internal/api/client_mutations_test.go b/internal/api/client_mutations_test.go index 2d60751..b6011d9 100644 --- a/internal/api/client_mutations_test.go +++ b/internal/api/client_mutations_test.go @@ -43,9 +43,15 @@ func TestUpdateIncident(t *testing.T) { defer server.Close() client := newTestClient(t, server.URL) - inc, err := client.UpdateIncident(context.Background(), "inc-1", map[string]string{ - "title": "Updated Title", - "status": "mitigated", + inc, err := client.UpdateIncident(context.Background(), "inc-1", map[string]interface{}{ + "title": "Updated Title", + "status": "mitigated", + "service_ids": []string{"api-gateway", "payments"}, + "incident_type_ids": []string{"customer-impacting"}, + "functionality_ids": []string{"checkout"}, + "environment_ids": []string{"production"}, + "group_ids": []string{"platform"}, + "cause_ids": []string{"deployment"}, }) if err != nil { t.Fatalf("UpdateIncident returned error: %v", err) @@ -70,6 +76,25 @@ func TestUpdateIncident(t *testing.T) { if attrs["status"] != "mitigated" { t.Errorf("request status = %q, want %q", attrs["status"], "mitigated") } + serviceIDs := attrs["service_ids"].([]interface{}) + if len(serviceIDs) != 2 || serviceIDs[0] != "api-gateway" || serviceIDs[1] != "payments" { + t.Errorf("request service_ids = %v, want [api-gateway payments]", serviceIDs) + } + incidentTypeIDs := attrs["incident_type_ids"].([]interface{}) + if len(incidentTypeIDs) != 1 || incidentTypeIDs[0] != "customer-impacting" { + t.Errorf("request incident_type_ids = %v, want [customer-impacting]", incidentTypeIDs) + } + for key, want := range map[string]string{ + "functionality_ids": "checkout", + "environment_ids": "production", + "group_ids": "platform", + "cause_ids": "deployment", + } { + values := attrs[key].([]interface{}) + if len(values) != 1 || values[0] != want { + t.Errorf("request %s = %v, want [%s]", key, values, want) + } + } } func TestUpdateIncidentNotFound(t *testing.T) { @@ -79,7 +104,7 @@ func TestUpdateIncidentNotFound(t *testing.T) { defer server.Close() client := newTestClient(t, server.URL) - _, err := client.UpdateIncident(context.Background(), "nonexistent", map[string]string{"title": "x"}) + _, err := client.UpdateIncident(context.Background(), "nonexistent", map[string]interface{}{"title": "x"}) if err == nil { t.Fatal("expected error for 404") } @@ -95,7 +120,7 @@ func TestUpdateIncidentForbidden(t *testing.T) { defer server.Close() client := newTestClient(t, server.URL) - _, err := client.UpdateIncident(context.Background(), "inc-1", map[string]string{"title": "x"}) + _, err := client.UpdateIncident(context.Background(), "inc-1", map[string]interface{}{"title": "x"}) if err == nil { t.Fatal("expected error for 403") } diff --git a/internal/cmd/incidents/cmd_test.go b/internal/cmd/incidents/cmd_test.go index ea42d0e..876c716 100644 --- a/internal/cmd/incidents/cmd_test.go +++ b/internal/cmd/incidents/cmd_test.go @@ -2,6 +2,7 @@ package incidents import ( "context" + "encoding/json" "net/http" "net/http/httptest" "os" @@ -287,6 +288,12 @@ func TestRunCreateTable(t *testing.T) { cmd.Flags().String("summary", "Test summary", "") cmd.Flags().String("severity", "", "") cmd.Flags().String("status", "started", "") + cmd.Flags().StringSlice("services", []string{"api-gateway", "payments"}, "") + cmd.Flags().StringSlice("types", []string{"customer-impacting"}, "") + cmd.Flags().StringSlice("functionalities", []string{"checkout"}, "") + cmd.Flags().StringSlice("environments", []string{"production"}, "") + cmd.Flags().StringSlice("teams", []string{"platform"}, "") + cmd.Flags().StringSlice("causes", []string{"deployment"}, "") output := captureStdout(t, func() { err := runCreate(cmd, nil) @@ -315,6 +322,12 @@ func TestRunCreateJSON(t *testing.T) { cmd.Flags().String("summary", "", "") cmd.Flags().String("severity", "", "") cmd.Flags().String("status", "", "") + cmd.Flags().StringSlice("services", nil, "") + cmd.Flags().StringSlice("types", nil, "") + cmd.Flags().StringSlice("functionalities", nil, "") + cmd.Flags().StringSlice("environments", nil, "") + cmd.Flags().StringSlice("teams", nil, "") + cmd.Flags().StringSlice("causes", nil, "") output := captureStdout(t, func() { err := runCreate(cmd, nil) @@ -344,6 +357,12 @@ func TestRunUpdateTable(t *testing.T) { cmd.Flags().String("summary", "", "") cmd.Flags().String("severity", "", "") cmd.Flags().String("status", "", "") + cmd.Flags().StringSlice("services", nil, "") + cmd.Flags().StringSlice("types", nil, "") + cmd.Flags().StringSlice("functionalities", nil, "") + cmd.Flags().StringSlice("environments", nil, "") + cmd.Flags().StringSlice("teams", nil, "") + cmd.Flags().StringSlice("causes", nil, "") // Simulate the user passing --status=mitigated cmd.Flags().Set("status", "mitigated") @@ -370,6 +389,12 @@ func TestRunUpdateNoFlags(t *testing.T) { cmd.Flags().String("summary", "", "") cmd.Flags().String("severity", "", "") cmd.Flags().String("status", "", "") + cmd.Flags().StringSlice("services", nil, "") + cmd.Flags().StringSlice("types", nil, "") + cmd.Flags().StringSlice("functionalities", nil, "") + cmd.Flags().StringSlice("environments", nil, "") + cmd.Flags().StringSlice("teams", nil, "") + cmd.Flags().StringSlice("causes", nil, "") err := runUpdate(cmd, []string{"INC-42"}) if err == nil { @@ -380,6 +405,48 @@ func TestRunUpdateNoFlags(t *testing.T) { } } +func TestRunUpdateCanClearAssociations(t *testing.T) { + var attributes map[string]interface{} + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var requestBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + attributes = requestBody["data"].(map[string]interface{})["attributes"].(map[string]interface{}) + w.Header().Set("Content-Type", "application/vnd.api+json") + _, _ = w.Write([]byte(getResponse())) + }) + viper.Set("format", "table") + + cmd := newTestCmd() + cmd.Flags().String("title", "", "") + cmd.Flags().String("summary", "", "") + cmd.Flags().String("severity", "", "") + cmd.Flags().String("status", "", "") + cmd.Flags().StringSlice("services", nil, "") + cmd.Flags().StringSlice("types", nil, "") + cmd.Flags().StringSlice("functionalities", nil, "") + cmd.Flags().StringSlice("environments", nil, "") + cmd.Flags().StringSlice("teams", nil, "") + cmd.Flags().StringSlice("causes", nil, "") + if err := cmd.Flags().Set("services", ""); err != nil { + t.Fatalf("failed to set empty services: %v", err) + } + + captureStdout(t, func() { + if err := runUpdate(cmd, []string{"INC-42"}); err != nil { + t.Fatalf("runUpdate returned error: %v", err) + } + }) + services, ok := attributes["service_ids"].([]interface{}) + if !ok || len(services) != 0 { + t.Errorf("service_ids = %#v, want an explicitly empty array", attributes["service_ids"]) + } + if _, ok := attributes["incident_type_ids"]; ok { + t.Error("incident_type_ids should be absent when --types is omitted") + } +} + func TestRunListNoToken(t *testing.T) { viper.Reset() defer viper.Reset() diff --git a/internal/cmd/incidents/create.go b/internal/cmd/incidents/create.go index 896de9b..4251548 100644 --- a/internal/cmd/incidents/create.go +++ b/internal/cmd/incidents/create.go @@ -22,6 +22,11 @@ var createCmd = &cobra.Command{ --title="API degradation" \ --summary="Response times elevated above 2s p99" \ --severity=sev0 \ + --services=api-gateway,payments \ + --types=customer-impacting \ + --functionalities=checkout \ + --environments=production \ + --teams=platform \ --status=started # Create and output as JSON @@ -34,6 +39,12 @@ func init() { createCmd.Flags().String("summary", "", "Incident summary/description") createCmd.Flags().String("severity", "", "Severity ID") createCmd.Flags().String("status", "", "Initial status (started, mitigated, resolved)") + createCmd.Flags().StringSlice("services", nil, "Service slugs/IDs, comma-separated") + createCmd.Flags().StringSlice("types", nil, "Incident type slugs/IDs, comma-separated") + createCmd.Flags().StringSlice("functionalities", nil, "Functionality slugs/IDs, comma-separated") + createCmd.Flags().StringSlice("environments", nil, "Environment slugs/IDs, comma-separated") + createCmd.Flags().StringSlice("teams", nil, "Team slugs/IDs, comma-separated") + createCmd.Flags().StringSlice("causes", nil, "Cause slugs/IDs, comma-separated") // Mark title as required _ = createCmd.MarkFlagRequired("title") @@ -54,9 +65,15 @@ func runCreate(cmd *cobra.Command, args []string) error { summary, _ := cmd.Flags().GetString("summary") severity, _ := cmd.Flags().GetString("severity") status, _ := cmd.Flags().GetString("status") + services, _ := cmd.Flags().GetStringSlice("services") + incidentTypes, _ := cmd.Flags().GetStringSlice("types") + functionalities, _ := cmd.Flags().GetStringSlice("functionalities") + environments, _ := cmd.Flags().GetStringSlice("environments") + teams, _ := cmd.Flags().GetStringSlice("teams") + causes, _ := cmd.Flags().GetStringSlice("causes") // Build opts map - only add keys where the flag was provided - opts := make(map[string]string) + opts := make(map[string]interface{}) if summary != "" { opts["summary"] = summary } @@ -66,6 +83,24 @@ func runCreate(cmd *cobra.Command, args []string) error { if status != "" { opts["status"] = status } + if len(services) > 0 { + opts["service_ids"] = services + } + if len(incidentTypes) > 0 { + opts["incident_type_ids"] = incidentTypes + } + if len(functionalities) > 0 { + opts["functionality_ids"] = functionalities + } + if len(environments) > 0 { + opts["environment_ids"] = environments + } + if len(teams) > 0 { + opts["group_ids"] = teams + } + if len(causes) > 0 { + opts["cause_ids"] = causes + } // Call API incident, err := apiClient.CreateIncident(cmd.Context(), title, opts) diff --git a/internal/cmd/incidents/update.go b/internal/cmd/incidents/update.go index 7b7d530..1c73cd9 100644 --- a/internal/cmd/incidents/update.go +++ b/internal/cmd/incidents/update.go @@ -24,7 +24,15 @@ var updateCmd = &cobra.Command{ --summary="Root cause identified: connection pool exhaustion" # Update severity - rootly incidents update INC-123 --severity=sev1`, + rootly incidents update INC-123 --severity=sev1 + + # Update attached services and incident types + rootly incidents update INC-123 \ + --services=api-gateway,payments \ + --types=customer-impacting \ + --functionalities=checkout \ + --environments=production \ + --teams=platform`, Args: cobra.ExactArgs(1), RunE: runUpdate, } @@ -34,6 +42,12 @@ func init() { updateCmd.Flags().String("summary", "", "Updated summary") updateCmd.Flags().String("severity", "", "Updated severity ID") updateCmd.Flags().String("status", "", "Updated status (started, mitigated, resolved, closed, cancelled)") + updateCmd.Flags().StringSlice("services", nil, "Updated service slugs/IDs, comma-separated") + updateCmd.Flags().StringSlice("types", nil, "Updated incident type slugs/IDs, comma-separated") + updateCmd.Flags().StringSlice("functionalities", nil, "Updated functionality slugs/IDs, comma-separated") + updateCmd.Flags().StringSlice("environments", nil, "Updated environment slugs/IDs, comma-separated") + updateCmd.Flags().StringSlice("teams", nil, "Updated team slugs/IDs, comma-separated") + updateCmd.Flags().StringSlice("causes", nil, "Updated cause slugs/IDs, comma-separated") // Register with parent command IncidentsCmd.AddCommand(updateCmd) @@ -50,7 +64,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { } // Build opts map using cmd.Flags().Changed() - ONLY include fields the user explicitly set - opts := make(map[string]string) + opts := make(map[string]interface{}) if cmd.Flags().Changed("title") { title, _ := cmd.Flags().GetString("title") opts["title"] = title @@ -67,6 +81,30 @@ func runUpdate(cmd *cobra.Command, args []string) error { status, _ := cmd.Flags().GetString("status") opts["status"] = status } + if cmd.Flags().Changed("services") { + services, _ := cmd.Flags().GetStringSlice("services") + opts["service_ids"] = services + } + if cmd.Flags().Changed("types") { + incidentTypes, _ := cmd.Flags().GetStringSlice("types") + opts["incident_type_ids"] = incidentTypes + } + if cmd.Flags().Changed("functionalities") { + functionalities, _ := cmd.Flags().GetStringSlice("functionalities") + opts["functionality_ids"] = functionalities + } + if cmd.Flags().Changed("environments") { + environments, _ := cmd.Flags().GetStringSlice("environments") + opts["environment_ids"] = environments + } + if cmd.Flags().Changed("teams") { + teams, _ := cmd.Flags().GetStringSlice("teams") + opts["group_ids"] = teams + } + if cmd.Flags().Changed("causes") { + causes, _ := cmd.Flags().GetStringSlice("causes") + opts["cause_ids"] = causes + } // If opts is empty (no flags changed), return error if len(opts) == 0 {