From 32a3ae951760c87fd9e14e63cc13a7e54d50d297 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 1 Jul 2026 21:46:19 -0700 Subject: [PATCH] fix: compact incident list structured output --- internal/cli/fieldproject_test.go | 116 ++++++++++++++++++++----- internal/cli/incident.go | 14 ++- internal/cli/incident_test.go | 13 +++ skills/flashduty/reference/incident.md | 7 +- 4 files changed, 124 insertions(+), 26 deletions(-) diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index 215fdc0..6dc4535 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -17,6 +17,7 @@ func incidentRow() map[string]any { "incident_severity": "Critical", "progress": "Triggered", "start_time": 1712000000, + "channel_id": 12345, "description": "root volume at 98%", "labels": map[string]any{"service": "db", "env": "prod"}, "responders": []map[string]any{ @@ -41,10 +42,77 @@ func alertRow() map[string]any { } } -// 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) { +// TestIncidentListStructuredDefaultUsesCompactProjection is the default agent +// path: incident list in json/toon mode must not dump the full nested SDK row +// when --fields is omitted, while an explicit --fields still wins. +func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) { + t.Run("json default", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1} + + out, err := execCommand("incident", "list", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + + assertProjectedJSONFields(t, out, []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"}) + }) + + t.Run("toon default", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1} + + out, err := execCommand("incident", "list", "--output-format", "toon") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + + for _, key := range []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"} { + if !strings.Contains(out, key) { + t.Errorf("default toon output missing compact key %q, got:\n%s", key, out) + } + } + for _, key := range []string{"responders", "labels", "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{incidentRow()}, "total": 1} + + out, err := execCommand("incident", "list", "--fields", "incident_id,title", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + + assertProjectedJSONFields(t, out, []string{"incident_id", "title"}) + }) + + t.Run("explicit empty fields errors", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1} + + _, err := execCommand("incident", "list", "--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) + } + }) +} + +// TestAlertFieldsProjectionDefaultUnchanged is the conductor constraint for the +// sibling command: with NO --fields, alert list structured output still emits +// the full nested record. The compact default is incident-list-only. +func TestAlertFieldsProjectionDefaultUnchanged(t *testing.T) { cases := []struct { name string cmd []string @@ -52,8 +120,6 @@ func TestFieldsProjectionDefaultUnchanged(t *testing.T) { 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"}}, } @@ -77,6 +143,27 @@ func TestFieldsProjectionDefaultUnchanged(t *testing.T) { } } +func assertProjectedJSONFields(t *testing.T, out string, fields []string) { + t.Helper() + + 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(fields) { + t.Fatalf("expected exactly %d keys, got %d (%v)", len(fields), len(row), row) + } + for _, f := range fields { + if _, ok := row[f]; !ok { + t.Errorf("projected row missing key %q, got keys %v", f, row) + } + } +} + // TestFieldsProjectionTOON: --fields in toon mode emits exactly the requested // keys and drops everything else. func TestFieldsProjectionTOON(t *testing.T) { @@ -154,22 +241,7 @@ func TestFieldsProjectionJSON(t *testing.T) { 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) - } - } + assertProjectedJSONFields(t, out, tc.fields) }) } } diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 948aa8a..56d521b 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -76,11 +76,12 @@ func newIncidentListCmd() *cobra.Command { var progress, severity, query, since, until, nums, fields string var channelID int64 var limit, page int + defaultStructuredFields := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"} 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. 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"), + Long: curatedLong("List incidents matching the given filters. The --since/--until window must be < 31 days; --limit max is 100. In json/toon mode, rows default to the compact fields incident_id,title,incident_severity,progress,start_time,channel_id; pass --fields to choose a different projection.\n\nSee also: fduty insight for aggregated metrics (MTTA, MTTR, noise reduction), fduty insight incident-list for metric-rich filtered incident rows, and fduty insight incident-export for CSV incident exports.", "Incidents", "List"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) @@ -113,8 +114,15 @@ func newIncidentListCmd() *cobra.Command { return err } - if fields != "" && ctx.Structured() { - proj, err := projectFields(result.Items, parseStringSlice(fields)) + 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") + } + } + proj, err := projectFields(result.Items, selectedFields) if err != nil { return err } diff --git a/internal/cli/incident_test.go b/internal/cli/incident_test.go index 61156a7..76e4e36 100644 --- a/internal/cli/incident_test.go +++ b/internal/cli/incident_test.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strings" "testing" ) @@ -47,3 +48,15 @@ func TestCommandIncidentListChannelIDFlag(t *testing.T) { t.Fatalf("channel_ids = %q, want %q", got, want) } } + +func TestCommandIncidentListHelpSurfacesInsightIncidentExport(t *testing.T) { + saveAndResetGlobals(t) + + out, err := execCommand("incident", "list", "--help") + if err != nil { + t.Fatalf("incident list --help: %v", err) + } + if !strings.Contains(out, "fduty insight incident-export") { + t.Fatalf("help output missing incident export discovery hint:\n%s", out) + } +} diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 4063413..6b2ac74 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -11,6 +11,7 @@ Prereq: `SKILL.md` read. Read verbs are free. **Mutating verbs notify responders | want | verb | |---|---| | list / search active incidents | `list` | +| CSV export of incidents | `fduty insight incident-export` | | look up by 6-char UI num | `info --num ` | | full detail + AI summary for a 24-char id | `detail ` (narrative) or `info --incident-id ` (same endpoint) | | get structured data for one or more ids | `get [...]` | @@ -41,7 +42,9 @@ Prereq: `SKILL.md` read. Read verbs are free. **Mutating verbs notify responders ## Hot flow — triage an active incident ```bash -# 1. Find unacknowledged critical incidents (last 4h) +# 1. Find unacknowledged critical incidents (last 4h). +# toon/json list output is compact by default: +# incident_id,title,incident_severity,progress,start_time,channel_id fduty incident list --severity Critical --progress Triggered --since 4h --output-format toon # 2. Get AI summary + full detail (use the 24-char incident_id from step 1) @@ -63,6 +66,8 @@ fduty incident comment --comment "Root cause identified: DB failov fduty incident resolve --root-cause "DB primary failover delay" --resolution "Failover completed; latency normal." ``` +> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. + ## Hot flow — full fault analysis (read-only summary) When asked to **summarize / analyze** an incident — 详情 + 关联告警 + 变更 + 时间线 + 相似故障 + 复盘 — `incident detail` does **not** contain the alerts / timeline / similar / post-mortem / change data; each is its own command. **Your first action must be the bundled script** — do not hand-pick one or two commands and write the rest from memory. One call fetches all six aspects: