From ce5428f5b779e144e558a43560716a04677a3cca Mon Sep 17 00:00:00 2001 From: shuangyu Date: Tue, 23 Jun 2026 17:17:21 +0800 Subject: [PATCH] feat(cli): add --fields projection to incident/alert list (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * fix(skilldoc): regenerate incident/alert cards for --fields flag The --fields projection flag added to `incident list` / `alert list` changed the cobra command tree, leaving the generated card fences stale. CI 'Check skill command-cards' (go run ./internal/cmd/skilldoc check) failed on reference/{alert,incident}.md. Regenerated via make gen-cards. --- internal/cli/alert.go | 13 +- internal/cli/fieldproject.go | 100 +++++++++++ internal/cli/fieldproject_test.go | 237 +++++++++++++++++++++++++ internal/cli/incident.go | 13 +- skills/flashduty/reference/alert.md | 1 + skills/flashduty/reference/incident.md | 1 + 6 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 internal/cli/fieldproject.go create mode 100644 internal/cli/fieldproject_test.go diff --git a/internal/cli/alert.go b/internal/cli/alert.go index 6f21258..f8cbabd 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -28,14 +28,14 @@ func newAlertCmd() *cobra.Command { } func newAlertListCmd() *cobra.Command { - var severity, channel, since, until string + var severity, channel, since, until, fields string var active, recovered, muted bool var limit, page int cmd := &cobra.Command{ Use: "list", Short: "List alerts", - 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"), + 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"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { if active && recovered { @@ -84,6 +84,14 @@ func newAlertListCmd() *cobra.Command { return err } + if fields != "" && ctx.Structured() { + proj, err := projectFields(result.Items, parseStringSlice(fields)) + if err != nil { + return err + } + return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total)) + } + cols := []output.Column{ {Header: "ID", Field: func(v any) string { return v.(flashduty.AlertItem).AlertID }}, {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertItem).Title }}, @@ -109,6 +117,7 @@ func newAlertListCmd() *cobra.Command { cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results") cmd.Flags().IntVar(&page, "page", 1, "Page number") + 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.") return cmd } diff --git a/internal/cli/fieldproject.go b/internal/cli/fieldproject.go new file mode 100644 index 0000000..e421397 --- /dev/null +++ b/internal/cli/fieldproject.go @@ -0,0 +1,100 @@ +package cli + +import ( + "fmt" + "reflect" + "sort" + "strings" +) + +// projectFields reduces each struct element of items to a map containing only +// the requested fields, matched against the struct's `json` tag (the leading +// component, with any `,omitempty` stripped). It exists so the curated `incident +// list` / `alert list` commands can emit a compact projection in structured +// (json/toon) mode instead of dumping the full nested SDK record — the root +// cause of the oversized list dumps the agent then re-queried with jq. +// +// Only top-level, exported, declared fields are selectable: there are no dotted +// nested paths. The original (typed) field value is preserved in the map so its +// custom MarshalJSON / toon tag behavior (e.g. flashduty.Timestamp) stays +// byte-consistent with the full-dump field. An unknown field name is a fail-fast +// error that lists the valid tag names for the row type. +func projectFields(items any, fields []string) ([]map[string]any, error) { + v := reflect.ValueOf(items) + if v.Kind() != reflect.Slice { + return nil, fmt.Errorf("internal error: projectFields expects a slice, got %T", items) + } + + elemType := v.Type().Elem() + for elemType.Kind() == reflect.Ptr { + elemType = elemType.Elem() + } + if elemType.Kind() != reflect.Struct { + return nil, fmt.Errorf("internal error: projectFields expects a slice of structs, got element %s", elemType.Kind()) + } + + // Map each requested field name to its struct field index. Reject any + // unknown name up front so a typo fails fast rather than silently emitting + // an empty projection. + tagToIndex := jsonTagIndex(elemType) + indexes := make([]int, 0, len(fields)) + names := make([]string, 0, len(fields)) + for _, f := range fields { + idx, ok := tagToIndex[f] + if !ok { + return nil, fmt.Errorf("unknown field %q; valid fields: %s", f, strings.Join(sortedKeys(tagToIndex), ", ")) + } + indexes = append(indexes, idx) + names = append(names, f) + } + + out := make([]map[string]any, 0, v.Len()) + for i := 0; i < v.Len(); i++ { + elem := v.Index(i) + for elem.Kind() == reflect.Ptr { + elem = elem.Elem() + } + row := make(map[string]any, len(indexes)) + for j, idx := range indexes { + row[names[j]] = elem.Field(idx).Interface() + } + out = append(out, row) + } + return out, nil +} + +// jsonTagIndex maps each exported field's json tag name (leading component, sans +// `,omitempty`) to its index in the struct. Fields tagged `json:"-"`, untagged +// fields, and embedded/anonymous fields are skipped — only declared, named, +// tagged top-level fields are selectable. +func jsonTagIndex(t reflect.Type) map[string]int { + out := make(map[string]int, t.NumField()) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.Anonymous || field.PkgPath != "" { // skip embedded and unexported + continue + } + tag := field.Tag.Get("json") + if tag == "" || tag == "-" { + continue + } + name := tag + if comma := strings.IndexByte(name, ','); comma >= 0 { + name = name[:comma] + } + if name == "" { + continue + } + out[name] = i + } + return out +} + +func sortedKeys(m map[string]int) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go new file mode 100644 index 0000000..215fdc0 --- /dev/null +++ b/internal/cli/fieldproject_test.go @@ -0,0 +1,237 @@ +package cli + +import ( + "encoding/json" + "strings" + "testing" +) + +// incidentRow / alertRow are multi-field stub payloads with the nested blobs +// (responders/labels/alerts, events/incident/labels) that bloat the full dump. +// The SDK structs carry no `omitempty`, so the full toon/json marshal always +// emits every key — which is exactly what the regression tests assert stays put. +func incidentRow() map[string]any { + return map[string]any{ + "incident_id": "inc-1", + "title": "Disk full on db-01", + "incident_severity": "Critical", + "progress": "Triggered", + "start_time": 1712000000, + "description": "root volume at 98%", + "labels": map[string]any{"service": "db", "env": "prod"}, + "responders": []map[string]any{ + {"person_id": 101, "person_name": "Alice"}, + }, + } +} + +func alertRow() map[string]any { + return map[string]any{ + "alert_id": "al-1", + "title": "High CPU on web-02", + "alert_severity": "Warning", + "alert_status": "Triggered", + "created_at": 1712000000, + "description": "cpu > 90% for 5m", + "labels": map[string]any{"host": "web-02"}, + "events": []map[string]any{ + {"event_id": "ev-1", "event_severity": "Warning"}, + }, + "incident": map[string]any{"incident_id": "inc-9", "progress": "Processing"}, + } +} + +// TestFieldsProjectionDefaultUnchanged is the conductor constraint: with NO +// --fields, the structured (toon and json) output must still be the full nested +// record — the nested blobs the proposal deliberately preserves as the default. +func TestFieldsProjectionDefaultUnchanged(t *testing.T) { + cases := []struct { + name string + cmd []string + data map[string]any + format string + mustHave []string // nested keys that must survive in the full dump + }{ + {"incident toon", []string{"incident", "list"}, incidentRow(), "toon", []string{"responders", "labels", "description"}}, + {"incident json", []string{"incident", "list"}, incidentRow(), "json", []string{"responders", "labels", "description"}}, + {"alert toon", []string{"alert", "list"}, alertRow(), "toon", []string{"events", "incident", "labels", "description"}}, + {"alert json", []string{"alert", "list"}, alertRow(), "json", []string{"events", "incident", "labels", "description"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{tc.data}, "total": 1} + + args := append(append([]string(nil), tc.cmd...), "--output-format", tc.format) + out, err := execCommand(args...) + if err != nil { + t.Fatalf("execCommand: %v", err) + } + for _, key := range tc.mustHave { + if !strings.Contains(out, key) { + t.Errorf("default %s output should contain full-record key %q (shape must be unchanged), got:\n%s", tc.format, key, out) + } + } + }) + } +} + +// TestFieldsProjectionTOON: --fields in toon mode emits exactly the requested +// keys and drops everything else. +func TestFieldsProjectionTOON(t *testing.T) { + cases := []struct { + name string + cmd []string + data map[string]any + fields string + want []string + dropped []string + }{ + { + name: "alert", + cmd: []string{"alert", "list"}, + data: alertRow(), + fields: "alert_id,title,alert_severity,created_at", + want: []string{"alert_id", "title", "alert_severity", "created_at"}, + dropped: []string{"labels", "events", "description", "incident"}, + }, + { + name: "incident", + cmd: []string{"incident", "list"}, + data: incidentRow(), + fields: "incident_id,title,incident_severity,progress,start_time", + want: []string{"incident_id", "title", "incident_severity", "progress", "start_time"}, + dropped: []string{"responders", "labels", "description"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{tc.data}, "total": 1} + + args := append(append([]string(nil), tc.cmd...), "--fields", tc.fields, "--output-format", "toon") + out, err := execCommand(args...) + if err != nil { + t.Fatalf("execCommand: %v", err) + } + for _, key := range tc.want { + if !strings.Contains(out, key) { + t.Errorf("projected toon output missing requested key %q, got:\n%s", key, out) + } + } + for _, key := range tc.dropped { + if strings.Contains(out, key) { + t.Errorf("projected toon output should not contain dropped key %q, got:\n%s", key, out) + } + } + }) + } +} + +// TestFieldsProjectionJSON: --fields in json mode yields rows with EXACTLY the +// requested keys (asserted structurally via json.Unmarshal). +func TestFieldsProjectionJSON(t *testing.T) { + cases := []struct { + name string + cmd []string + data map[string]any + fields []string + }{ + {"alert", []string{"alert", "list"}, alertRow(), []string{"alert_id", "title", "alert_severity", "created_at"}}, + {"incident", []string{"incident", "list"}, incidentRow(), []string{"incident_id", "title", "incident_severity", "progress", "start_time"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{tc.data}, "total": 1} + + args := append(append([]string(nil), tc.cmd...), "--fields", strings.Join(tc.fields, ","), "--output-format", "json") + out, err := execCommand(args...) + if err != nil { + t.Fatalf("execCommand: %v", err) + } + + var rows []map[string]json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil { + t.Fatalf("failed to parse projected json: %v\nraw:\n%s", err, out) + } + if len(rows) != 1 { + t.Fatalf("expected 1 projected row, got %d:\n%s", len(rows), out) + } + row := rows[0] + if len(row) != len(tc.fields) { + t.Fatalf("expected exactly %d keys, got %d (%v)", len(tc.fields), len(row), row) + } + for _, f := range tc.fields { + if _, ok := row[f]; !ok { + t.Errorf("projected row missing key %q, got keys %v", f, row) + } + } + }) + } +} + +// TestFieldsIgnoredInTableMode: --fields is a no-op in the default table view — +// the normal column header is still printed. +func TestFieldsIgnoredInTableMode(t *testing.T) { + cases := []struct { + name string + cmd []string + data map[string]any + fields string + headers []string + }{ + {"alert", []string{"alert", "list"}, alertRow(), "alert_id", []string{"ID", "TITLE", "SEVERITY", "STATUS"}}, + {"incident", []string{"incident", "list"}, incidentRow(), "incident_id", []string{"ID", "TITLE", "SEVERITY", "PROGRESS"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{tc.data}, "total": 1} + + args := append(append([]string(nil), tc.cmd...), "--fields", tc.fields) + out, err := execCommand(args...) + if err != nil { + t.Fatalf("execCommand: %v", err) + } + for _, h := range tc.headers { + if !strings.Contains(out, h) { + t.Errorf("table output should contain header %q (--fields is a no-op in table mode), got:\n%s", h, out) + } + } + }) + } +} + +// TestFieldsUnknownFieldErrors: a bad field name fails fast with the offending +// name in the message. +func TestFieldsUnknownFieldErrors(t *testing.T) { + cases := []struct { + name string + cmd []string + data map[string]any + }{ + {"alert", []string{"alert", "list"}, alertRow()}, + {"incident", []string{"incident", "list"}, incidentRow()}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{tc.data}, "total": 1} + + args := append(append([]string(nil), tc.cmd...), "--fields", "not_a_field", "--output-format", "json") + _, err := execCommand(args...) + 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 %q, got: %v", "not_a_field", err) + } + }) + } +} diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 2da3f92..948aa8a 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -73,14 +73,14 @@ func pastIncidentColumns() []output.Column { } func newIncidentListCmd() *cobra.Command { - var progress, severity, query, since, until, nums string + var progress, severity, query, since, until, nums, fields string var channelID int64 var limit, page int cmd := &cobra.Command{ Use: "list", Short: "List incidents", - Long: curatedLong("List incidents matching the given filters. The --since/--until window must be < 31 days; --limit max is 100.\n\nSee also: fduty insight for aggregated metrics (MTTA, MTTR, noise reduction) instead of paginating raw incidents.", "Incidents", "List"), + Long: curatedLong("List incidents matching the given filters. The --since/--until window must be < 31 days; --limit max is 100. In json/toon mode, --fields projects each row to just the named fields (e.g. --fields incident_id,title,incident_severity,progress,start_time) so you get a compact record without piping to jq.\n\nSee also: fduty insight for aggregated metrics (MTTA, MTTR, noise reduction) instead of paginating raw incidents.", "Incidents", "List"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) @@ -113,6 +113,14 @@ func newIncidentListCmd() *cobra.Command { return err } + if fields != "" && ctx.Structured() { + proj, err := projectFields(result.Items, parseStringSlice(fields)) + if err != nil { + return err + } + return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total)) + } + return ctx.PrintList(result.Items, incidentColumns(), len(result.Items), page, int(result.Total)) }) }, @@ -131,6 +139,7 @@ func newIncidentListCmd() *cobra.Command { cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") cmd.Flags().IntVar(&page, "page", 1, "Page number") + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,start_time); ignored in table mode. Use to avoid dumping the full nested record.") return cmd } diff --git a/skills/flashduty/reference/alert.md b/skills/flashduty/reference/alert.md index 569573b..6c63ec8 100644 --- a/skills/flashduty/reference/alert.md +++ b/skills/flashduty/reference/alert.md @@ -74,6 +74,7 @@ Get alert detail List alerts - `--active` bool - `--channel` string +- `--fields` string - `--limit` int - `--muted` bool - `--page` int diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 3913105..743794b 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -185,6 +185,7 @@ Get incident detail ### list List incidents - `--channel-id` int64 +- `--fields` string - `--limit` int - `--nums` string - `--page` int