Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions internal/cli/channel.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"fmt"
"strconv"
"strings"

Expand All @@ -13,6 +14,7 @@ import (
func newChannelCmd() *cobra.Command {
cmd := newGroupCmd("channel", "Manage channels")
cmd.AddCommand(newChannelListCmd())
cmd.AddCommand(newChannelEscalateRuleListCmd())
return cmd
}

Expand Down Expand Up @@ -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 <channel-id>",
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
}
179 changes: 179 additions & 0 deletions internal/cli/fieldproject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions skills/flashduty/reference/escalation.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,9 @@ Get escalation rule detail

### escalate-rule-list <channel-id>
List escalation rules
- `<channel-id>` (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<object>); priority (integer); rule_id (string); rule_name (string); status (string); template_id (string); time_filters (array<object>); updated_at (string); updated_by (integer)
- `<channel-id>` (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<object>); priority (integer); rule_id (string); rule_name (string); status (string); template_id (string); time_filters (array<object>); updated_at (string); updated_by (integer)

### escalate-rule-update
Update escalation rule
Expand Down