Skip to content
Closed
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
116 changes: 94 additions & 22 deletions internal/cli/fieldproject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -41,19 +42,84 @@ 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
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"}},
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
})
}
}
Expand Down
14 changes: 11 additions & 3 deletions internal/cli/incident.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <team|responder|channel> 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 <team|responder|channel> 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)
Expand Down Expand Up @@ -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
}
Expand Down
13 changes: 13 additions & 0 deletions internal/cli/incident_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"fmt"
"strings"
"testing"
)

Expand Down Expand Up @@ -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)
}
}
7 changes: 6 additions & 1 deletion skills/flashduty/reference/incident.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <num>` |
| full detail + AI summary for a 24-char id | `detail <id>` (narrative) or `info --incident-id <id>` (same endpoint) |
| get structured data for one or more ids | `get <id> [<id2>...]` |
Expand Down Expand Up @@ -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)
Expand All @@ -63,6 +66,8 @@ fduty incident comment <incident-id> --comment "Root cause identified: DB failov
fduty incident resolve <incident-id> --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 <id>` / `incident get <id>` 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:
Expand Down
Loading