diff --git a/internal/cli/channel.go b/internal/cli/channel.go index bfdd0fe..a2472f6 100644 --- a/internal/cli/channel.go +++ b/internal/cli/channel.go @@ -1,6 +1,7 @@ package cli import ( + "fmt" "strconv" "strings" @@ -13,6 +14,7 @@ import ( func newChannelCmd() *cobra.Command { cmd := newGroupCmd("channel", "Manage channels") cmd.AddCommand(newChannelListCmd()) + cmd.AddCommand(newChannelEscalateRuleListCmd()) return cmd } @@ -142,3 +144,78 @@ func enrichChannelNames(ctx *RunContext, rows []channelRow) { rows[i].CreatorName = personNameByID[rows[i].CreatorID] } } + +func newChannelEscalateRuleListCmd() *cobra.Command { + var dataJSON, fields string + var fChannelID int64 + + defaultStructuredFields := []string{"rule_id", "rule_name", "status", "priority", "filters"} + + cmd := &cobra.Command{ + Use: "escalate-rule-list ", + Short: "List escalation rules", + Long: curatedLong("List all escalation rules for a channel. In json/toon mode, rows default to the compact fields rule_id,rule_name,status,priority,filters; pass --fields to choose a different projection.", + "Channels", "ChannelEscalateRuleList"), + Args: requireBodyFieldOrExactArg("channel_id", "channel-id"), + Example: ` flashduty channel escalate-rule-list --data '{"channel_id":1001}'`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + body, err := genAssembleBody(dataJSON, func(body map[string]any) error { + if err := genFoldPositional(args, body, "channel_id", "int"); err != nil { + return err + } + if cmd.Flags().Changed("channel-id") { + body["channel_id"] = fChannelID + } + return nil + }) + if err != nil { + return err + } + req := new(flashduty.ChannelScopedListRequest) + if err := genBindBody(body, req); err != nil { + return err + } + out, _, err := ctx.Client.Channels.ChannelEscalateRuleList(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + + if ctx.Structured() { + selectedFields := defaultStructuredFields + if cmd.Flags().Changed("fields") { + selectedFields = parseStringSlice(fields) + if len(selectedFields) == 0 { + return fmt.Errorf("--fields must name at least one field") + } + } else { + noteDefaultProjection(cmd.ErrOrStderr(), selectedFields) + } + proj, err := projectFields(out.Items, selectedFields) + if err != nil { + return err + } + note, err := boundProjectedOutput(proj, compactListOutputLimit) + if err != nil { + return err + } + noteProjectionShortening(cmd.ErrOrStderr(), note) + return ctx.PrintTotal(proj, nil, len(proj)) + } + + cols := []output.Column{ + {Header: "ID", Field: func(v any) string { return v.(flashduty.EscalateRuleItem).RuleID }}, + {Header: "NAME", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.EscalateRuleItem).RuleName }}, + {Header: "STATUS", Field: func(v any) string { return v.(flashduty.EscalateRuleItem).Status }}, + {Header: "PRIORITY", Field: func(v any) string { return strconv.FormatInt(v.(flashduty.EscalateRuleItem).Priority, 10) }}, + {Header: "UPDATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.EscalateRuleItem).UpdatedAt) }}, + } + return ctx.PrintTotal(out.Items, cols, len(out.Items)) + }) + }, + } + cmd.Flags().Int64Var(&fChannelID, "channel-id", 0, "Channel to list rules for. (required)") + cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.") + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. rule_id,rule_name,status,priority); ignored in table mode. Use to avoid dumping the full nested record.") + return cmd +} diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index f3a3e38..9124f2f 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -924,6 +924,185 @@ func TestBoundProjectedListErrorNamesLargestFields(t *testing.T) { } } +// escalateRuleRow is a full EscalateRuleItem stub payload, including the +// nested layers/time_filters blobs that bloat the full dump — the same shape +// that made the raw generated command's toon output oversized per rule. +func escalateRuleRow() map[string]any { + return map[string]any{ + "account_id": 1001, + "aggr_window": 0, + "channel_id": 4201, + "channel_name": "payments", + "created_at": 1712000000, + "deleted_at": 0, + "description": "page the primary on-call, then the team", + "filters": []any{ + []any{map[string]any{"key": "incident_severity", "oper": "IN", "vals": []any{"Critical"}}}, + }, + "layers": []any{ + map[string]any{ + "escalate_window": 30, + "max_times": 3, + "notify_step": 5, + "target": map[string]any{"person_ids": []any{101}, "by": map[string]any{"critical": []any{"voice", "sms"}}}, + }, + }, + "priority": 10, + "rule_id": "6621b23f4a2c5e0012ab34d0", + "rule_name": "P1 on-call", + "status": "enabled", + "template_id": "6630c34f5b3d6e0012cd45e1", + "time_filters": []any{ + map[string]any{"start": "09:00", "end": "18:00", "repeat": []any{1, 2, 3}}, + }, + "updated_at": 1712100000, + "updated_by": 101, + } +} + +// TestChannelEscalateRuleListStructuredProjection mirrors +// TestIncidentListStructuredDefaultUsesCompactProjection for the curated +// channel escalate-rule-list: json/toon mode must default to the compact +// projection (announced on stderr) instead of dumping the full nested rule +// record, an explicit --fields must override it, and table mode keeps its +// explicit columns without the note. +func TestChannelEscalateRuleListStructuredProjection(t *testing.T) { + t.Run("json default", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{escalateRuleRow()}} + + out, stderrText, err := execCommandSplit("channel", "escalate-rule-list", "4201", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + + assertProjectedJSONFields(t, out, []string{"rule_id", "rule_name", "status", "priority", "filters"}) + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } + for _, key := range []string{"layers", "template_id"} { + if strings.Contains(out, key) { + t.Errorf("default json output should not contain full-record key %q, got:\n%s", key, out) + } + } + }) + + t.Run("toon default", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{escalateRuleRow()}} + + out, stderrText, err := execCommandSplit("channel", "escalate-rule-list", "4201", "--output-format", "toon") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + + // Positive keys must come from stdout alone: the stderr note embeds the + // same field names, so a merged capture would satisfy this vacuously. + for _, key := range []string{"rule_id", "rule_name", "status", "priority", "filters"} { + if !strings.Contains(out, key) { + t.Errorf("default toon output missing compact key %q, got:\n%s", key, out) + } + } + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } + for _, key := range []string{"layers", "template_id", "description"} { + if strings.Contains(out, key) { + t.Errorf("default toon output should not contain full-record key %q, got:\n%s", key, out) + } + } + }) + + t.Run("explicit fields win", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{escalateRuleRow()}} + + out, err := execCommand("channel", "escalate-rule-list", "4201", "--fields", "rule_id,layers", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + + assertProjectedJSONFields(t, out, []string{"rule_id", "layers"}) + }) + + t.Run("explicit empty fields errors", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{escalateRuleRow()}} + + _, err := execCommand("channel", "escalate-rule-list", "4201", "--fields", "", "--output-format", "json") + if err == nil { + t.Fatal("expected an error for empty --fields, got nil") + } + if !strings.Contains(err.Error(), "--fields") { + t.Errorf("error should name --fields, got: %v", err) + } + }) + + t.Run("unknown field errors", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{escalateRuleRow()}} + + _, err := execCommand("channel", "escalate-rule-list", "4201", "--fields", "not_a_field", "--output-format", "json") + if err == nil { + t.Fatal("expected an error for an unknown field, got nil") + } + if !strings.Contains(err.Error(), "not_a_field") { + t.Errorf("error should name the bad field, got: %v", err) + } + }) + + t.Run("table mode headers without note", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{escalateRuleRow()}} + + out, stderrText, err := execCommandSplit("channel", "escalate-rule-list", "4201") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + for _, h := range []string{"ID", "NAME", "STATUS", "PRIORITY", "UPDATED"} { + if !strings.Contains(out, h) { + t.Errorf("table output missing header %q, got:\n%s", h, out) + } + } + if strings.Contains(stderrText, "note: rows projected") { + t.Errorf("table mode must not print the projection note, got:\n%s", stderrText) + } + }) + + t.Run("channel id folding", func(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want float64 + }{ + {"positional", []string{"channel", "escalate-rule-list", "4201"}, 4201}, + {"flag", []string{"channel", "escalate-rule-list", "--channel-id", "4202"}, 4202}, + } { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{}} + + if _, err := execCommand(tc.args...); err != nil { + t.Fatalf("execCommand: %v", err) + } + if stub.lastPath != "/channel/escalate/rule/list" { + t.Fatalf("expected /channel/escalate/rule/list, got %q", stub.lastPath) + } + if stub.lastBody["channel_id"] != tc.want { + t.Fatalf("channel_id = %#v, want %v", stub.lastBody["channel_id"], tc.want) + } + }) + } + }) +} + // TestBoundProjectedListNeverShortensIdentifierFields pins the identifier // exemption: keys ending in _id/_key carry values a consumer matches, // filters, or passes back verbatim (a jq exact-match over --json output, a diff --git a/skills/flashduty/reference/escalation.md b/skills/flashduty/reference/escalation.md index 99ffcb6..ac768a8 100644 --- a/skills/flashduty/reference/escalation.md +++ b/skills/flashduty/reference/escalation.md @@ -74,8 +74,9 @@ Get escalation rule detail ### escalate-rule-list List escalation rules -- `` (positional, required) int64 — Channel to list rules for. -- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — items fields: account_id (integer); aggr_window (integer); channel_id (integer); channel_name (string); created_at (string); deleted_at (string); description (string); filters (object); layers (array); priority (integer); rule_id (string); rule_name (string); status (string); template_id (string); time_filters (array); updated_at (string); updated_by (integer) +- `` (positional, required) int64 +- `--fields` string +- response: TOP-LEVEL array — pipe `--json | jq '.[]'` (NOT `.items[]`) — fields: account_id (integer); aggr_window (integer); channel_id (integer); channel_name (string); created_at (string); deleted_at (string); description (string); filters (object); layers (array); priority (integer); rule_id (string); rule_name (string); status (string); template_id (string); time_filters (array); updated_at (string); updated_by (integer) ### escalate-rule-update Update escalation rule