Skip to content

Commit 3c735e1

Browse files
committed
fix(cli): curate channel escalate-rule-list with compact default projection
The generated escalate-rule-list command marshaled the full nested EscalateRuleItem record (layers, time_filters, template, ...) for every rule in json/toon mode, so a channel with a few dozen rules produced an output far too large to read or pipe usefully. Shadow the generated twin with a curated command (same path, same positional/--channel-id/--data request surface): structured mode now projects each rule to rule_id,rule_name,status,priority,filters by default with a stderr note, overridable via --fields through the existing fieldproject helpers and bounded to the 16 KiB list budget; table mode renders explicit ID/NAME/STATUS/PRIORITY/UPDATED columns. Regenerate the escalation.md card fence (skilldoc check passes).
1 parent 2e19d39 commit 3c735e1

3 files changed

Lines changed: 259 additions & 2 deletions

File tree

internal/cli/channel.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"fmt"
45
"strconv"
56
"strings"
67

@@ -13,6 +14,7 @@ import (
1314
func newChannelCmd() *cobra.Command {
1415
cmd := newGroupCmd("channel", "Manage channels")
1516
cmd.AddCommand(newChannelListCmd())
17+
cmd.AddCommand(newChannelEscalateRuleListCmd())
1618
return cmd
1719
}
1820

@@ -142,3 +144,78 @@ func enrichChannelNames(ctx *RunContext, rows []channelRow) {
142144
rows[i].CreatorName = personNameByID[rows[i].CreatorID]
143145
}
144146
}
147+
148+
func newChannelEscalateRuleListCmd() *cobra.Command {
149+
var dataJSON, fields string
150+
var fChannelID int64
151+
152+
defaultStructuredFields := []string{"rule_id", "rule_name", "status", "priority", "filters"}
153+
154+
cmd := &cobra.Command{
155+
Use: "escalate-rule-list <channel-id>",
156+
Short: "List escalation rules",
157+
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.",
158+
"Channels", "ChannelEscalateRuleList"),
159+
Args: requireBodyFieldOrExactArg("channel_id", "channel-id"),
160+
Example: ` flashduty channel escalate-rule-list --data '{"channel_id":1001}'`,
161+
RunE: func(cmd *cobra.Command, args []string) error {
162+
return runCommand(cmd, args, func(ctx *RunContext) error {
163+
body, err := genAssembleBody(dataJSON, func(body map[string]any) error {
164+
if err := genFoldPositional(args, body, "channel_id", "int"); err != nil {
165+
return err
166+
}
167+
if cmd.Flags().Changed("channel-id") {
168+
body["channel_id"] = fChannelID
169+
}
170+
return nil
171+
})
172+
if err != nil {
173+
return err
174+
}
175+
req := new(flashduty.ChannelScopedListRequest)
176+
if err := genBindBody(body, req); err != nil {
177+
return err
178+
}
179+
out, _, err := ctx.Client.Channels.ChannelEscalateRuleList(cmdContext(ctx.Cmd), req)
180+
if err != nil {
181+
return err
182+
}
183+
184+
if ctx.Structured() {
185+
selectedFields := defaultStructuredFields
186+
if cmd.Flags().Changed("fields") {
187+
selectedFields = parseStringSlice(fields)
188+
if len(selectedFields) == 0 {
189+
return fmt.Errorf("--fields must name at least one field")
190+
}
191+
} else {
192+
noteDefaultProjection(cmd.ErrOrStderr(), selectedFields)
193+
}
194+
proj, err := projectFields(out.Items, selectedFields)
195+
if err != nil {
196+
return err
197+
}
198+
note, err := boundProjectedOutput(proj, compactListOutputLimit)
199+
if err != nil {
200+
return err
201+
}
202+
noteProjectionShortening(cmd.ErrOrStderr(), note)
203+
return ctx.PrintTotal(proj, nil, len(proj))
204+
}
205+
206+
cols := []output.Column{
207+
{Header: "ID", Field: func(v any) string { return v.(flashduty.EscalateRuleItem).RuleID }},
208+
{Header: "NAME", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.EscalateRuleItem).RuleName }},
209+
{Header: "STATUS", Field: func(v any) string { return v.(flashduty.EscalateRuleItem).Status }},
210+
{Header: "PRIORITY", Field: func(v any) string { return strconv.FormatInt(v.(flashduty.EscalateRuleItem).Priority, 10) }},
211+
{Header: "UPDATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.EscalateRuleItem).UpdatedAt) }},
212+
}
213+
return ctx.PrintTotal(out.Items, cols, len(out.Items))
214+
})
215+
},
216+
}
217+
cmd.Flags().Int64Var(&fChannelID, "channel-id", 0, "Channel to list rules for. (required)")
218+
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.")
219+
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.")
220+
return cmd
221+
}

internal/cli/fieldproject_test.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,3 +919,182 @@ func TestBoundProjectedListErrorNamesLargestFields(t *testing.T) {
919919
t.Fatalf("list overflow error = %q, want it to name the largest fields", err)
920920
}
921921
}
922+
923+
// escalateRuleRow is a full EscalateRuleItem stub payload, including the
924+
// nested layers/time_filters blobs that bloat the full dump — the same shape
925+
// that made the raw generated command's toon output oversized per rule.
926+
func escalateRuleRow() map[string]any {
927+
return map[string]any{
928+
"account_id": 1001,
929+
"aggr_window": 0,
930+
"channel_id": 4201,
931+
"channel_name": "payments",
932+
"created_at": 1712000000,
933+
"deleted_at": 0,
934+
"description": "page the primary on-call, then the team",
935+
"filters": []any{
936+
[]any{map[string]any{"key": "incident_severity", "oper": "IN", "vals": []any{"Critical"}}},
937+
},
938+
"layers": []any{
939+
map[string]any{
940+
"escalate_window": 30,
941+
"max_times": 3,
942+
"notify_step": 5,
943+
"target": map[string]any{"person_ids": []any{101}, "by": map[string]any{"critical": []any{"voice", "sms"}}},
944+
},
945+
},
946+
"priority": 10,
947+
"rule_id": "6621b23f4a2c5e0012ab34d0",
948+
"rule_name": "P1 on-call",
949+
"status": "enabled",
950+
"template_id": "6630c34f5b3d6e0012cd45e1",
951+
"time_filters": []any{
952+
map[string]any{"start": "09:00", "end": "18:00", "repeat": []any{1, 2, 3}},
953+
},
954+
"updated_at": 1712100000,
955+
"updated_by": 101,
956+
}
957+
}
958+
959+
// TestChannelEscalateRuleListStructuredProjection mirrors
960+
// TestIncidentListStructuredDefaultUsesCompactProjection for the curated
961+
// channel escalate-rule-list: json/toon mode must default to the compact
962+
// projection (announced on stderr) instead of dumping the full nested rule
963+
// record, an explicit --fields must override it, and table mode keeps its
964+
// explicit columns without the note.
965+
func TestChannelEscalateRuleListStructuredProjection(t *testing.T) {
966+
t.Run("json default", func(t *testing.T) {
967+
saveAndResetGlobals(t)
968+
stub := newGFStub(t)
969+
stub.data = map[string]any{"items": []any{escalateRuleRow()}}
970+
971+
out, stderrText, err := execCommandSplit("channel", "escalate-rule-list", "4201", "--output-format", "json")
972+
if err != nil {
973+
t.Fatalf("execCommandSplit: %v", err)
974+
}
975+
976+
assertProjectedJSONFields(t, out, []string{"rule_id", "rule_name", "status", "priority", "filters"})
977+
if !strings.Contains(stderrText, "note: rows projected to default compact fields") {
978+
t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText)
979+
}
980+
for _, key := range []string{"layers", "template_id"} {
981+
if strings.Contains(out, key) {
982+
t.Errorf("default json output should not contain full-record key %q, got:\n%s", key, out)
983+
}
984+
}
985+
})
986+
987+
t.Run("toon default", func(t *testing.T) {
988+
saveAndResetGlobals(t)
989+
stub := newGFStub(t)
990+
stub.data = map[string]any{"items": []any{escalateRuleRow()}}
991+
992+
out, stderrText, err := execCommandSplit("channel", "escalate-rule-list", "4201", "--output-format", "toon")
993+
if err != nil {
994+
t.Fatalf("execCommandSplit: %v", err)
995+
}
996+
997+
// Positive keys must come from stdout alone: the stderr note embeds the
998+
// same field names, so a merged capture would satisfy this vacuously.
999+
for _, key := range []string{"rule_id", "rule_name", "status", "priority", "filters"} {
1000+
if !strings.Contains(out, key) {
1001+
t.Errorf("default toon output missing compact key %q, got:\n%s", key, out)
1002+
}
1003+
}
1004+
if !strings.Contains(stderrText, "note: rows projected to default compact fields") {
1005+
t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText)
1006+
}
1007+
for _, key := range []string{"layers", "template_id", "description"} {
1008+
if strings.Contains(out, key) {
1009+
t.Errorf("default toon output should not contain full-record key %q, got:\n%s", key, out)
1010+
}
1011+
}
1012+
})
1013+
1014+
t.Run("explicit fields win", func(t *testing.T) {
1015+
saveAndResetGlobals(t)
1016+
stub := newGFStub(t)
1017+
stub.data = map[string]any{"items": []any{escalateRuleRow()}}
1018+
1019+
out, err := execCommand("channel", "escalate-rule-list", "4201", "--fields", "rule_id,layers", "--output-format", "json")
1020+
if err != nil {
1021+
t.Fatalf("execCommand: %v", err)
1022+
}
1023+
1024+
assertProjectedJSONFields(t, out, []string{"rule_id", "layers"})
1025+
})
1026+
1027+
t.Run("explicit empty fields errors", func(t *testing.T) {
1028+
saveAndResetGlobals(t)
1029+
stub := newGFStub(t)
1030+
stub.data = map[string]any{"items": []any{escalateRuleRow()}}
1031+
1032+
_, err := execCommand("channel", "escalate-rule-list", "4201", "--fields", "", "--output-format", "json")
1033+
if err == nil {
1034+
t.Fatal("expected an error for empty --fields, got nil")
1035+
}
1036+
if !strings.Contains(err.Error(), "--fields") {
1037+
t.Errorf("error should name --fields, got: %v", err)
1038+
}
1039+
})
1040+
1041+
t.Run("unknown field errors", func(t *testing.T) {
1042+
saveAndResetGlobals(t)
1043+
stub := newGFStub(t)
1044+
stub.data = map[string]any{"items": []any{escalateRuleRow()}}
1045+
1046+
_, err := execCommand("channel", "escalate-rule-list", "4201", "--fields", "not_a_field", "--output-format", "json")
1047+
if err == nil {
1048+
t.Fatal("expected an error for an unknown field, got nil")
1049+
}
1050+
if !strings.Contains(err.Error(), "not_a_field") {
1051+
t.Errorf("error should name the bad field, got: %v", err)
1052+
}
1053+
})
1054+
1055+
t.Run("table mode headers without note", func(t *testing.T) {
1056+
saveAndResetGlobals(t)
1057+
stub := newGFStub(t)
1058+
stub.data = map[string]any{"items": []any{escalateRuleRow()}}
1059+
1060+
out, stderrText, err := execCommandSplit("channel", "escalate-rule-list", "4201")
1061+
if err != nil {
1062+
t.Fatalf("execCommandSplit: %v", err)
1063+
}
1064+
for _, h := range []string{"ID", "NAME", "STATUS", "PRIORITY", "UPDATED"} {
1065+
if !strings.Contains(out, h) {
1066+
t.Errorf("table output missing header %q, got:\n%s", h, out)
1067+
}
1068+
}
1069+
if strings.Contains(stderrText, "note: rows projected") {
1070+
t.Errorf("table mode must not print the projection note, got:\n%s", stderrText)
1071+
}
1072+
})
1073+
1074+
t.Run("channel id folding", func(t *testing.T) {
1075+
for _, tc := range []struct {
1076+
name string
1077+
args []string
1078+
want float64
1079+
}{
1080+
{"positional", []string{"channel", "escalate-rule-list", "4201"}, 4201},
1081+
{"flag", []string{"channel", "escalate-rule-list", "--channel-id", "4202"}, 4202},
1082+
} {
1083+
t.Run(tc.name, func(t *testing.T) {
1084+
saveAndResetGlobals(t)
1085+
stub := newGFStub(t)
1086+
stub.data = map[string]any{"items": []any{}}
1087+
1088+
if _, err := execCommand(tc.args...); err != nil {
1089+
t.Fatalf("execCommand: %v", err)
1090+
}
1091+
if stub.lastPath != "/channel/escalate/rule/list" {
1092+
t.Fatalf("expected /channel/escalate/rule/list, got %q", stub.lastPath)
1093+
}
1094+
if stub.lastBody["channel_id"] != tc.want {
1095+
t.Fatalf("channel_id = %#v, want %v", stub.lastBody["channel_id"], tc.want)
1096+
}
1097+
})
1098+
}
1099+
})
1100+
}

skills/flashduty/reference/escalation.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,9 @@ Get escalation rule detail
7474

7575
### escalate-rule-list <channel-id>
7676
List escalation rules
77-
- `<channel-id>` (positional, required) int64 — Channel to list rules for.
78-
- 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<object>); priority (integer); rule_id (string); rule_name (string); status (string); template_id (string); time_filters (array<object>); updated_at (string); updated_by (integer)
77+
- `<channel-id>` (positional, required) int64
78+
- `--fields` string
79+
- 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<object>); priority (integer); rule_id (string); rule_name (string); status (string); template_id (string); time_filters (array<object>); updated_at (string); updated_by (integer)
7980

8081
### escalate-rule-update
8182
Update escalation rule

0 commit comments

Comments
 (0)