Skip to content

Commit bdddc1a

Browse files
committed
feat(cli): add --fields projection to incident/alert list (json/toon)
In structured (json/toon) mode the curated `incident list` / `alert list` commands ignore the compact column set and marshal the full nested SDK record (IncidentInfo/AlertItem with responders/labels/alerts and events/incident blobs). That makes the first list call huge — 20 incidents ~60-70KB, 66 alerts ~83KB — which spills to file and is then re-queried with a narrower jq projection. The first dump is pure waste. Add an additive --fields flag to both list commands: in json/toon mode it projects each row to only the named JSON-tagged fields via one shared reflection helper (projectFields), so the first call is already compact and no jq round-trip is needed. Default behavior (no --fields) is byte-identical to today; --fields is a no-op in table mode. Unknown field names fail fast listing the valid tag names. Out of scope by design: --count/group-by aggregation, nested/dotted paths.
1 parent f884414 commit bdddc1a

4 files changed

Lines changed: 359 additions & 4 deletions

File tree

internal/cli/alert.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,14 @@ func newAlertCmd() *cobra.Command {
2828
}
2929

3030
func newAlertListCmd() *cobra.Command {
31-
var severity, channel, since, until string
31+
var severity, channel, since, until, fields string
3232
var active, recovered, muted bool
3333
var limit, page int
3434

3535
cmd := &cobra.Command{
3636
Use: "list",
3737
Short: "List alerts",
38-
Long: curatedLong("List alerts within a time window, optionally filtered by severity, channel, active/recovered/muted state. No server-side title/text filter — to search by title, pipe --json to jq: 'select(.title|test(\"pat\";\"i\"))'. --limit max 100; --since/--until window must be < 31 days.", "Alerts", "ReadList"),
38+
Long: curatedLong("List alerts within a time window, optionally filtered by severity, channel, active/recovered/muted state. No server-side title/text filter — to search by title, pipe --json to jq: 'select(.title|test(\"pat\";\"i\"))'. In json/toon mode, --fields projects each row to just the named fields (e.g. --fields alert_id,title,alert_severity,created_at) so you get a compact record without piping to jq. --limit max 100; --since/--until window must be < 31 days.", "Alerts", "ReadList"),
3939
RunE: func(cmd *cobra.Command, args []string) error {
4040
return runCommand(cmd, args, func(ctx *RunContext) error {
4141
if active && recovered {
@@ -84,6 +84,14 @@ func newAlertListCmd() *cobra.Command {
8484
return err
8585
}
8686

87+
if fields != "" && ctx.Structured() {
88+
proj, err := projectFields(result.Items, parseStringSlice(fields))
89+
if err != nil {
90+
return err
91+
}
92+
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
93+
}
94+
8795
cols := []output.Column{
8896
{Header: "ID", Field: func(v any) string { return v.(flashduty.AlertItem).AlertID }},
8997
{Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertItem).Title }},
@@ -109,6 +117,7 @@ func newAlertListCmd() *cobra.Command {
109117
cmd.Flags().StringVar(&until, "until", "now", "End time")
110118
cmd.Flags().IntVar(&limit, "limit", 20, "Max results")
111119
cmd.Flags().IntVar(&page, "page", 1, "Page number")
120+
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. alert_id,title,alert_severity,created_at); ignored in table mode. Use to avoid dumping the full nested record.")
112121

113122
return cmd
114123
}

internal/cli/fieldproject.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"sort"
7+
"strings"
8+
)
9+
10+
// projectFields reduces each struct element of items to a map containing only
11+
// the requested fields, matched against the struct's `json` tag (the leading
12+
// component, with any `,omitempty` stripped). It exists so the curated `incident
13+
// list` / `alert list` commands can emit a compact projection in structured
14+
// (json/toon) mode instead of dumping the full nested SDK record — the root
15+
// cause of the oversized list dumps the agent then re-queried with jq.
16+
//
17+
// Only top-level, exported, declared fields are selectable: there are no dotted
18+
// nested paths. The original (typed) field value is preserved in the map so its
19+
// custom MarshalJSON / toon tag behavior (e.g. flashduty.Timestamp) stays
20+
// byte-consistent with the full-dump field. An unknown field name is a fail-fast
21+
// error that lists the valid tag names for the row type.
22+
func projectFields(items any, fields []string) ([]map[string]any, error) {
23+
v := reflect.ValueOf(items)
24+
if v.Kind() != reflect.Slice {
25+
return nil, fmt.Errorf("internal error: projectFields expects a slice, got %T", items)
26+
}
27+
28+
elemType := v.Type().Elem()
29+
for elemType.Kind() == reflect.Ptr {
30+
elemType = elemType.Elem()
31+
}
32+
if elemType.Kind() != reflect.Struct {
33+
return nil, fmt.Errorf("internal error: projectFields expects a slice of structs, got element %s", elemType.Kind())
34+
}
35+
36+
// Map each requested field name to its struct field index. Reject any
37+
// unknown name up front so a typo fails fast rather than silently emitting
38+
// an empty projection.
39+
tagToIndex := jsonTagIndex(elemType)
40+
indexes := make([]int, 0, len(fields))
41+
names := make([]string, 0, len(fields))
42+
for _, f := range fields {
43+
idx, ok := tagToIndex[f]
44+
if !ok {
45+
return nil, fmt.Errorf("unknown field %q; valid fields: %s", f, strings.Join(sortedKeys(tagToIndex), ", "))
46+
}
47+
indexes = append(indexes, idx)
48+
names = append(names, f)
49+
}
50+
51+
out := make([]map[string]any, 0, v.Len())
52+
for i := 0; i < v.Len(); i++ {
53+
elem := v.Index(i)
54+
for elem.Kind() == reflect.Ptr {
55+
elem = elem.Elem()
56+
}
57+
row := make(map[string]any, len(indexes))
58+
for j, idx := range indexes {
59+
row[names[j]] = elem.Field(idx).Interface()
60+
}
61+
out = append(out, row)
62+
}
63+
return out, nil
64+
}
65+
66+
// jsonTagIndex maps each exported field's json tag name (leading component, sans
67+
// `,omitempty`) to its index in the struct. Fields tagged `json:"-"`, untagged
68+
// fields, and embedded/anonymous fields are skipped — only declared, named,
69+
// tagged top-level fields are selectable.
70+
func jsonTagIndex(t reflect.Type) map[string]int {
71+
out := make(map[string]int, t.NumField())
72+
for i := 0; i < t.NumField(); i++ {
73+
field := t.Field(i)
74+
if field.Anonymous || field.PkgPath != "" { // skip embedded and unexported
75+
continue
76+
}
77+
tag := field.Tag.Get("json")
78+
if tag == "" || tag == "-" {
79+
continue
80+
}
81+
name := tag
82+
if comma := strings.IndexByte(name, ','); comma >= 0 {
83+
name = name[:comma]
84+
}
85+
if name == "" {
86+
continue
87+
}
88+
out[name] = i
89+
}
90+
return out
91+
}
92+
93+
func sortedKeys(m map[string]int) []string {
94+
keys := make([]string, 0, len(m))
95+
for k := range m {
96+
keys = append(keys, k)
97+
}
98+
sort.Strings(keys)
99+
return keys
100+
}

internal/cli/fieldproject_test.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// incidentRow / alertRow are multi-field stub payloads with the nested blobs
10+
// (responders/labels/alerts, events/incident/labels) that bloat the full dump.
11+
// The SDK structs carry no `omitempty`, so the full toon/json marshal always
12+
// emits every key — which is exactly what the regression tests assert stays put.
13+
func incidentRow() map[string]any {
14+
return map[string]any{
15+
"incident_id": "inc-1",
16+
"title": "Disk full on db-01",
17+
"incident_severity": "Critical",
18+
"progress": "Triggered",
19+
"start_time": 1712000000,
20+
"description": "root volume at 98%",
21+
"labels": map[string]any{"service": "db", "env": "prod"},
22+
"responders": []map[string]any{
23+
{"person_id": 101, "person_name": "Alice"},
24+
},
25+
}
26+
}
27+
28+
func alertRow() map[string]any {
29+
return map[string]any{
30+
"alert_id": "al-1",
31+
"title": "High CPU on web-02",
32+
"alert_severity": "Warning",
33+
"alert_status": "Triggered",
34+
"created_at": 1712000000,
35+
"description": "cpu > 90% for 5m",
36+
"labels": map[string]any{"host": "web-02"},
37+
"events": []map[string]any{
38+
{"event_id": "ev-1", "event_severity": "Warning"},
39+
},
40+
"incident": map[string]any{"incident_id": "inc-9", "progress": "Processing"},
41+
}
42+
}
43+
44+
// TestFieldsProjectionDefaultUnchanged is the conductor constraint: with NO
45+
// --fields, the structured (toon and json) output must still be the full nested
46+
// record — the nested blobs the proposal deliberately preserves as the default.
47+
func TestFieldsProjectionDefaultUnchanged(t *testing.T) {
48+
cases := []struct {
49+
name string
50+
cmd []string
51+
data map[string]any
52+
format string
53+
mustHave []string // nested keys that must survive in the full dump
54+
}{
55+
{"incident toon", []string{"incident", "list"}, incidentRow(), "toon", []string{"responders", "labels", "description"}},
56+
{"incident json", []string{"incident", "list"}, incidentRow(), "json", []string{"responders", "labels", "description"}},
57+
{"alert toon", []string{"alert", "list"}, alertRow(), "toon", []string{"events", "incident", "labels", "description"}},
58+
{"alert json", []string{"alert", "list"}, alertRow(), "json", []string{"events", "incident", "labels", "description"}},
59+
}
60+
for _, tc := range cases {
61+
t.Run(tc.name, func(t *testing.T) {
62+
saveAndResetGlobals(t)
63+
stub := newGFStub(t)
64+
stub.data = map[string]any{"items": []any{tc.data}, "total": 1}
65+
66+
args := append(append([]string(nil), tc.cmd...), "--output-format", tc.format)
67+
out, err := execCommand(args...)
68+
if err != nil {
69+
t.Fatalf("execCommand: %v", err)
70+
}
71+
for _, key := range tc.mustHave {
72+
if !strings.Contains(out, key) {
73+
t.Errorf("default %s output should contain full-record key %q (shape must be unchanged), got:\n%s", tc.format, key, out)
74+
}
75+
}
76+
})
77+
}
78+
}
79+
80+
// TestFieldsProjectionTOON: --fields in toon mode emits exactly the requested
81+
// keys and drops everything else.
82+
func TestFieldsProjectionTOON(t *testing.T) {
83+
cases := []struct {
84+
name string
85+
cmd []string
86+
data map[string]any
87+
fields string
88+
want []string
89+
dropped []string
90+
}{
91+
{
92+
name: "alert",
93+
cmd: []string{"alert", "list"},
94+
data: alertRow(),
95+
fields: "alert_id,title,alert_severity,created_at",
96+
want: []string{"alert_id", "title", "alert_severity", "created_at"},
97+
dropped: []string{"labels", "events", "description", "incident"},
98+
},
99+
{
100+
name: "incident",
101+
cmd: []string{"incident", "list"},
102+
data: incidentRow(),
103+
fields: "incident_id,title,incident_severity,progress,start_time",
104+
want: []string{"incident_id", "title", "incident_severity", "progress", "start_time"},
105+
dropped: []string{"responders", "labels", "description"},
106+
},
107+
}
108+
for _, tc := range cases {
109+
t.Run(tc.name, func(t *testing.T) {
110+
saveAndResetGlobals(t)
111+
stub := newGFStub(t)
112+
stub.data = map[string]any{"items": []any{tc.data}, "total": 1}
113+
114+
args := append(append([]string(nil), tc.cmd...), "--fields", tc.fields, "--output-format", "toon")
115+
out, err := execCommand(args...)
116+
if err != nil {
117+
t.Fatalf("execCommand: %v", err)
118+
}
119+
for _, key := range tc.want {
120+
if !strings.Contains(out, key) {
121+
t.Errorf("projected toon output missing requested key %q, got:\n%s", key, out)
122+
}
123+
}
124+
for _, key := range tc.dropped {
125+
if strings.Contains(out, key) {
126+
t.Errorf("projected toon output should not contain dropped key %q, got:\n%s", key, out)
127+
}
128+
}
129+
})
130+
}
131+
}
132+
133+
// TestFieldsProjectionJSON: --fields in json mode yields rows with EXACTLY the
134+
// requested keys (asserted structurally via json.Unmarshal).
135+
func TestFieldsProjectionJSON(t *testing.T) {
136+
cases := []struct {
137+
name string
138+
cmd []string
139+
data map[string]any
140+
fields []string
141+
}{
142+
{"alert", []string{"alert", "list"}, alertRow(), []string{"alert_id", "title", "alert_severity", "created_at"}},
143+
{"incident", []string{"incident", "list"}, incidentRow(), []string{"incident_id", "title", "incident_severity", "progress", "start_time"}},
144+
}
145+
for _, tc := range cases {
146+
t.Run(tc.name, func(t *testing.T) {
147+
saveAndResetGlobals(t)
148+
stub := newGFStub(t)
149+
stub.data = map[string]any{"items": []any{tc.data}, "total": 1}
150+
151+
args := append(append([]string(nil), tc.cmd...), "--fields", strings.Join(tc.fields, ","), "--output-format", "json")
152+
out, err := execCommand(args...)
153+
if err != nil {
154+
t.Fatalf("execCommand: %v", err)
155+
}
156+
157+
var rows []map[string]json.RawMessage
158+
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
159+
t.Fatalf("failed to parse projected json: %v\nraw:\n%s", err, out)
160+
}
161+
if len(rows) != 1 {
162+
t.Fatalf("expected 1 projected row, got %d:\n%s", len(rows), out)
163+
}
164+
row := rows[0]
165+
if len(row) != len(tc.fields) {
166+
t.Fatalf("expected exactly %d keys, got %d (%v)", len(tc.fields), len(row), row)
167+
}
168+
for _, f := range tc.fields {
169+
if _, ok := row[f]; !ok {
170+
t.Errorf("projected row missing key %q, got keys %v", f, row)
171+
}
172+
}
173+
})
174+
}
175+
}
176+
177+
// TestFieldsIgnoredInTableMode: --fields is a no-op in the default table view —
178+
// the normal column header is still printed.
179+
func TestFieldsIgnoredInTableMode(t *testing.T) {
180+
cases := []struct {
181+
name string
182+
cmd []string
183+
data map[string]any
184+
fields string
185+
headers []string
186+
}{
187+
{"alert", []string{"alert", "list"}, alertRow(), "alert_id", []string{"ID", "TITLE", "SEVERITY", "STATUS"}},
188+
{"incident", []string{"incident", "list"}, incidentRow(), "incident_id", []string{"ID", "TITLE", "SEVERITY", "PROGRESS"}},
189+
}
190+
for _, tc := range cases {
191+
t.Run(tc.name, func(t *testing.T) {
192+
saveAndResetGlobals(t)
193+
stub := newGFStub(t)
194+
stub.data = map[string]any{"items": []any{tc.data}, "total": 1}
195+
196+
args := append(append([]string(nil), tc.cmd...), "--fields", tc.fields)
197+
out, err := execCommand(args...)
198+
if err != nil {
199+
t.Fatalf("execCommand: %v", err)
200+
}
201+
for _, h := range tc.headers {
202+
if !strings.Contains(out, h) {
203+
t.Errorf("table output should contain header %q (--fields is a no-op in table mode), got:\n%s", h, out)
204+
}
205+
}
206+
})
207+
}
208+
}
209+
210+
// TestFieldsUnknownFieldErrors: a bad field name fails fast with the offending
211+
// name in the message.
212+
func TestFieldsUnknownFieldErrors(t *testing.T) {
213+
cases := []struct {
214+
name string
215+
cmd []string
216+
data map[string]any
217+
}{
218+
{"alert", []string{"alert", "list"}, alertRow()},
219+
{"incident", []string{"incident", "list"}, incidentRow()},
220+
}
221+
for _, tc := range cases {
222+
t.Run(tc.name, func(t *testing.T) {
223+
saveAndResetGlobals(t)
224+
stub := newGFStub(t)
225+
stub.data = map[string]any{"items": []any{tc.data}, "total": 1}
226+
227+
args := append(append([]string(nil), tc.cmd...), "--fields", "not_a_field", "--output-format", "json")
228+
_, err := execCommand(args...)
229+
if err == nil {
230+
t.Fatal("expected an error for an unknown field, got nil")
231+
}
232+
if !strings.Contains(err.Error(), "not_a_field") {
233+
t.Errorf("error should name the bad field %q, got: %v", "not_a_field", err)
234+
}
235+
})
236+
}
237+
}

0 commit comments

Comments
 (0)