From 3d8587b42569e3ea7b70fcb84b673b096d0efbde Mon Sep 17 00:00:00 2001 From: Quentin Rousseau Date: Fri, 10 Jul 2026 10:24:44 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20[PRF-2820]=20add=20--escala?= =?UTF-8?q?tion-policy-id=20to=20services=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows attaching/detaching escalation policies on services via CLI, closing a gap that forced CI/CD pipelines to fall back to raw API calls. Empty string sends JSON null to detach; non-empty attaches. Also displays escalation policy ID in `services get` detail view. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/api/client.go | 90 ++++++++++++++++---------- internal/api/client_mutations_test.go | 56 ++++++++++++++++ internal/cmd/services/cmd_test.go | 50 ++++++++++++++ internal/cmd/services/get.go | 3 + internal/cmd/services/services_test.go | 20 +++--- internal/cmd/services/update.go | 12 +++- 6 files changed, 188 insertions(+), 43 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 7979202..7cc5a4b 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -199,15 +199,16 @@ type AlertsResult struct { // Service represents a Rootly service type Service struct { - ID string - Name string - Slug string - Description string - Color string - CreatedAt time.Time - UpdatedAt time.Time - OwnerTeamName string // Populated from included owner_group relationship - DetailLoaded bool + ID string + Name string + Slug string + Description string + Color string + EscalationPolicyID string + CreatedAt time.Time + UpdatedAt time.Time + OwnerTeamName string // Populated from included owner_group relationship + DetailLoaded bool // Raw API response body for JSON/YAML passthrough RawBody []byte } @@ -1958,12 +1959,13 @@ func (c *Client) ListServicesCLI(ctx context.Context, page, pageSize int, sort s Data []struct { ID string `json:"id"` Attributes struct { - Name string `json:"name"` - Slug string `json:"slug"` - Description *string `json:"description"` - Color *string `json:"color"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description"` + Color *string `json:"color"` + EscalationPolicyID *string `json:"escalation_policy_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } `json:"attributes"` } `json:"data"` Meta struct { @@ -1993,6 +1995,9 @@ func (c *Client) ListServicesCLI(ctx context.Context, page, pageSize int, sort s if item.Attributes.Color != nil { service.Color = *item.Attributes.Color } + if item.Attributes.EscalationPolicyID != nil { + service.EscalationPolicyID = *item.Attributes.EscalationPolicyID + } services = append(services, service) } @@ -2054,12 +2059,13 @@ func (c *Client) GetServiceByID(ctx context.Context, id string) (*Service, error Data struct { ID string `json:"id"` Attributes struct { - Name string `json:"name"` - Slug string `json:"slug"` - Description *string `json:"description"` - Color *string `json:"color"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description"` + Color *string `json:"color"` + EscalationPolicyID *string `json:"escalation_policy_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } `json:"attributes"` Relationships struct { OwnerGroup struct { @@ -2096,6 +2102,9 @@ func (c *Client) GetServiceByID(ctx context.Context, id string) (*Service, error if response.Data.Attributes.Color != nil { service.Color = *response.Data.Attributes.Color } + if response.Data.Attributes.EscalationPolicyID != nil { + service.EscalationPolicyID = *response.Data.Attributes.EscalationPolicyID + } // Parse owner_group from included relationships if response.Data.Relationships.OwnerGroup.Data != nil { @@ -2176,12 +2185,13 @@ func (c *Client) CreateService(ctx context.Context, name string, opts map[string Data struct { ID string `json:"id"` Attributes struct { - Name string `json:"name"` - Slug string `json:"slug"` - Description *string `json:"description"` - Color *string `json:"color"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description"` + Color *string `json:"color"` + EscalationPolicyID *string `json:"escalation_policy_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } `json:"attributes"` } `json:"data"` } @@ -2203,6 +2213,9 @@ func (c *Client) CreateService(ctx context.Context, name string, opts map[string if response.Data.Attributes.Color != nil { service.Color = *response.Data.Attributes.Color } + if response.Data.Attributes.EscalationPolicyID != nil { + service.EscalationPolicyID = *response.Data.Attributes.EscalationPolicyID + } service.RawBody = body return service, nil @@ -2221,6 +2234,13 @@ func (c *Client) UpdateService(ctx context.Context, id string, opts map[string]s if color, ok := opts["color"]; ok { attributes["color"] = color } + if epID, ok := opts["escalation_policy_id"]; ok { + if epID == "" { + attributes["escalation_policy_id"] = nil + } else { + attributes["escalation_policy_id"] = epID + } + } requestBody := map[string]interface{}{ "data": map[string]interface{}{ @@ -2276,12 +2296,13 @@ func (c *Client) UpdateService(ctx context.Context, id string, opts map[string]s Data struct { ID string `json:"id"` Attributes struct { - Name string `json:"name"` - Slug string `json:"slug"` - Description *string `json:"description"` - Color *string `json:"color"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description"` + Color *string `json:"color"` + EscalationPolicyID *string `json:"escalation_policy_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } `json:"attributes"` } `json:"data"` } @@ -2303,6 +2324,9 @@ func (c *Client) UpdateService(ctx context.Context, id string, opts map[string]s if response.Data.Attributes.Color != nil { service.Color = *response.Data.Attributes.Color } + if response.Data.Attributes.EscalationPolicyID != nil { + service.EscalationPolicyID = *response.Data.Attributes.EscalationPolicyID + } service.RawBody = body return service, nil diff --git a/internal/api/client_mutations_test.go b/internal/api/client_mutations_test.go index fcbd82a..2d60751 100644 --- a/internal/api/client_mutations_test.go +++ b/internal/api/client_mutations_test.go @@ -601,6 +601,62 @@ func TestUpdateServiceServerError(t *testing.T) { } } +func TestUpdateServiceEscalationPolicyID(t *testing.T) { + cases := []struct { + name string + optVal string + respEPID interface{} + wantEPID string + wantReqVal interface{} + }{ + {"attach", "ep-123", "ep-123", "ep-123", "ep-123"}, + {"detach", "", nil, "", nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var receivedBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &receivedBody) + + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(http.StatusOK) + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "id": "svc-1", + "attributes": map[string]interface{}{ + "name": "My Service", + "slug": "my-service", + "escalation_policy_id": tc.respEPID, + "created_at": "2025-06-15T10:00:00Z", + "updated_at": "2025-06-15T14:00:00Z", + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := newTestClient(t, server.URL) + svc, err := client.UpdateService(context.Background(), "svc-1", map[string]string{ + "escalation_policy_id": tc.optVal, + }) + if err != nil { + t.Fatalf("UpdateService returned error: %v", err) + } + if svc.EscalationPolicyID != tc.wantEPID { + t.Errorf("EscalationPolicyID = %q, want %q", svc.EscalationPolicyID, tc.wantEPID) + } + + data := receivedBody["data"].(map[string]interface{}) + attrs := data["attributes"].(map[string]interface{}) + if attrs["escalation_policy_id"] != tc.wantReqVal { + t.Errorf("request escalation_policy_id = %v, want %v", attrs["escalation_policy_id"], tc.wantReqVal) + } + }) + } +} + // --- CreateTeam --- func TestCreateTeam(t *testing.T) { diff --git a/internal/cmd/services/cmd_test.go b/internal/cmd/services/cmd_test.go index d2fe6ed..f853af5 100644 --- a/internal/cmd/services/cmd_test.go +++ b/internal/cmd/services/cmd_test.go @@ -315,6 +315,56 @@ func TestRunUpdateNoFlags(t *testing.T) { } } +func TestRunUpdateEscalationPolicyID(t *testing.T) { + cases := []struct { + name string + flagVal string + respEPID string + }{ + {"attach", "ep-123", `"ep-123"`}, + {"detach", "", "null"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.Write([]byte(`{ + "data": { + "id": "svc-1", + "attributes": { + "name": "API Gateway", + "slug": "api-gateway", + "escalation_policy_id": ` + tc.respEPID + `, + "created_at": "2025-06-15T10:00:00Z", + "updated_at": "2025-06-15T12:00:00Z" + } + } + }`)) + }) + + viper.Set("format", "table") + + cmd := newTestCmd() + cmd.Flags().String("name", "", "") + cmd.Flags().String("description", "", "") + cmd.Flags().String("color", "", "") + cmd.Flags().String("escalation-policy-id", "", "") + cmd.Flags().Set("escalation-policy-id", tc.flagVal) + + output := captureStdout(t, func() { + err := runUpdate(cmd, []string{"api-gateway"}) + if err != nil { + t.Fatalf("runUpdate error: %v", err) + } + }) + + if output == "" { + t.Error("expected non-empty output") + } + }) + } +} + // --- confirmDelete tests --- func TestConfirmDeleteSkip(t *testing.T) { diff --git a/internal/cmd/services/get.go b/internal/cmd/services/get.go index 8872366..5397fb9 100644 --- a/internal/cmd/services/get.go +++ b/internal/cmd/services/get.go @@ -86,6 +86,9 @@ func serviceDetailRows(svc *api.Service) [][]string { if svc.Color != "" { rows = append(rows, []string{"Color", svc.Color}) } + if svc.EscalationPolicyID != "" { + rows = append(rows, []string{"Escalation Policy", svc.EscalationPolicyID}) + } if svc.OwnerTeamName != "" { rows = append(rows, []string{"Owner Team", svc.OwnerTeamName}) } diff --git a/internal/cmd/services/services_test.go b/internal/cmd/services/services_test.go index 7c2b6c8..5314793 100644 --- a/internal/cmd/services/services_test.go +++ b/internal/cmd/services/services_test.go @@ -34,14 +34,15 @@ func TestTruncateString(t *testing.T) { func TestServiceDetailRows(t *testing.T) { now := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC) svc := &api.Service{ - ID: "svc-123", - Name: "api-gateway", - Slug: "api-gateway", - Description: "Main API gateway", - Color: "#FF5733", - OwnerTeamName: "Platform", - CreatedAt: now, - UpdatedAt: now, + ID: "svc-123", + Name: "api-gateway", + Slug: "api-gateway", + Description: "Main API gateway", + Color: "#FF5733", + EscalationPolicyID: "ep-456", + OwnerTeamName: "Platform", + CreatedAt: now, + UpdatedAt: now, } rows := serviceDetailRows(svc) @@ -59,6 +60,9 @@ func TestServiceDetailRows(t *testing.T) { if findRow(rows, "Description") != "Main API gateway" { t.Errorf("Description = %q, want %q", findRow(rows, "Description"), "Main API gateway") } + if findRow(rows, "Escalation Policy") != "ep-456" { + t.Errorf("Escalation Policy = %q, want %q", findRow(rows, "Escalation Policy"), "ep-456") + } if findRow(rows, "Owner Team") != "Platform" { t.Errorf("Owner Team = %q, want %q", findRow(rows, "Owner Team"), "Platform") } diff --git a/internal/cmd/services/update.go b/internal/cmd/services/update.go index dc73286..ba8f988 100644 --- a/internal/cmd/services/update.go +++ b/internal/cmd/services/update.go @@ -23,8 +23,11 @@ var updateCmd = &cobra.Command{ --description="Updated: Main API gateway" \ --color="#00FF00" - # Update multiple fields - rootly services update api-gateway --name="New Name" --color="#FF0000"`, + # Attach escalation policy + rootly services update api-gateway --escalation-policy-id="ep-123" + + # Detach escalation policy + rootly services update api-gateway --escalation-policy-id=""`, Args: cobra.ExactArgs(1), RunE: runUpdate, } @@ -33,6 +36,7 @@ func init() { updateCmd.Flags().String("name", "", "Updated service name") updateCmd.Flags().String("description", "", "Updated description") updateCmd.Flags().String("color", "", "Updated color (hex format, e.g., #FF5733)") + updateCmd.Flags().String("escalation-policy-id", "", `Escalation policy ID (use "" to detach)`) // Register with parent command ServicesCmd.AddCommand(updateCmd) @@ -65,6 +69,10 @@ func runUpdate(cmd *cobra.Command, args []string) error { } opts["color"] = color } + if cmd.Flags().Changed("escalation-policy-id") { + epID, _ := cmd.Flags().GetString("escalation-policy-id") + opts["escalation_policy_id"] = epID + } // If opts is empty (no flags changed), return error if len(opts) == 0 {