Skip to content

Commit 65a9731

Browse files
authored
Merge pull request #139 from flashcatcloud/fix/list-projection-notice
feat(cli): announce the default compact projection on stderr
2 parents 5316c25 + 445bd12 commit 65a9731

7 files changed

Lines changed: 63 additions & 10 deletions

File tree

internal/cli/alert_event.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ func newAlertEventListCmd() *cobra.Command {
8282
fieldNames := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"}
8383
if fields != "" {
8484
fieldNames = parseStringSlice(fields)
85+
} else {
86+
noteDefaultProjection(cmd.ErrOrStderr(), fieldNames)
8587
}
8688
proj, err := projectFields(result.Items, fieldNames)
8789
if err != nil {

internal/cli/command_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,28 @@ func execCommand(args ...string) (string, error) {
8484
return buf.String(), err
8585
}
8686

87+
// execCommandSplit is execCommand with stdout and stderr captured separately,
88+
// for tests that assert machine-readable stdout stays pure while advisory
89+
// notices (e.g. the default-projection note) land on stderr.
90+
func execCommandSplit(args ...string) (stdout, stderr string, err error) {
91+
resetCommandFlags(rootCmd)
92+
93+
outBuf := new(bytes.Buffer)
94+
errBuf := new(bytes.Buffer)
95+
rootCmd.SetOut(outBuf)
96+
rootCmd.SetErr(errBuf)
97+
rootCmd.SetArgs(args)
98+
99+
err = rootCmd.Execute()
100+
101+
rootCmd.SetArgs(nil)
102+
rootCmd.SetOut(nil)
103+
rootCmd.SetErr(nil)
104+
resetCommandFlags(rootCmd)
105+
106+
return outBuf.String(), errBuf.String(), err
107+
}
108+
87109
func resetCommandFlags(cmd *cobra.Command) {
88110
if cmd == nil {
89111
return

internal/cli/fieldproject.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"fmt"
5+
"io"
56
"reflect"
67
"sort"
78
"strings"
@@ -69,6 +70,16 @@ func projectFields(items any, fields []string) ([]map[string]any, error) {
6970
return out, nil
7071
}
7172

73+
// noteDefaultProjection announces on stderr that structured rows were reduced
74+
// to the command's compact default projection. Without it, a reader piping
75+
// stdout to jq sees an unselected key (labels, description, …) as null on
76+
// every row and can conclude the server never returns it, when it is one
77+
// --fields away. stderr keeps stdout byte-identical for jq/toon pipelines.
78+
func noteDefaultProjection(w io.Writer, fields []string) {
79+
_, _ = fmt.Fprintf(w, "note: rows projected to default compact fields (%s); other response fields are available via --fields\n",
80+
strings.Join(fields, ","))
81+
}
82+
7283
// boundProjectedOutput keeps the new agent-oriented projections below their
7384
// command budget without changing the selected keys. Short values remain byte
7485
// identical; when retained strings alone would overflow the actual JSON/TOON

internal/cli/fieldproject_test.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,29 +118,37 @@ func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) {
118118
stub := newGFStub(t)
119119
stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1}
120120

121-
out, err := execCommand("incident", "list", "--output-format", "json")
121+
out, stderrText, err := execCommandSplit("incident", "list", "--output-format", "json")
122122
if err != nil {
123-
t.Fatalf("execCommand: %v", err)
123+
t.Fatalf("execCommandSplit: %v", err)
124124
}
125125

126126
assertProjectedJSONFields(t, out, []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"})
127+
if !strings.Contains(stderrText, "note: rows projected to default compact fields") {
128+
t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText)
129+
}
127130
})
128131

129132
t.Run("toon default", func(t *testing.T) {
130133
saveAndResetGlobals(t)
131134
stub := newGFStub(t)
132135
stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1}
133136

134-
out, err := execCommand("incident", "list", "--output-format", "toon")
137+
out, stderrText, err := execCommandSplit("incident", "list", "--output-format", "toon")
135138
if err != nil {
136-
t.Fatalf("execCommand: %v", err)
139+
t.Fatalf("execCommandSplit: %v", err)
137140
}
138141

142+
// Positive keys must come from stdout alone: the stderr note embeds the
143+
// same field names, so a merged capture would satisfy this vacuously.
139144
for _, key := range []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"} {
140145
if !strings.Contains(out, key) {
141146
t.Errorf("default toon output missing compact key %q, got:\n%s", key, out)
142147
}
143148
}
149+
if !strings.Contains(stderrText, "note: rows projected to default compact fields") {
150+
t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText)
151+
}
144152
for _, key := range []string{"responders", "labels", "description"} {
145153
if strings.Contains(out, key) {
146154
t.Errorf("default toon output should not contain full-record key %q, got:\n%s", key, out)
@@ -394,13 +402,16 @@ func TestIncidentSimilarStructuredProjection(t *testing.T) {
394402
}
395403
stub.data = map[string]any{"items": items, "total": len(items)}
396404

397-
out, err := execCommand("incident", "similar", "inc-1", "--limit", "20", "--output-format", "json")
405+
out, stderrText, err := execCommandSplit("incident", "similar", "inc-1", "--limit", "20", "--output-format", "json")
398406
if err != nil {
399-
t.Fatalf("execCommand: %v", err)
407+
t.Fatalf("execCommandSplit: %v", err)
400408
}
401409
if len(out) >= 16*1024 {
402410
t.Fatalf("compact similar output is %d bytes, want <16 KiB", len(out))
403411
}
412+
if !strings.Contains(stderrText, "note: rows projected to default compact fields") {
413+
t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText)
414+
}
404415

405416
var rows []map[string]json.RawMessage
406417
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
@@ -482,13 +493,16 @@ func TestAlertEventListStructuredProjection(t *testing.T) {
482493
}
483494
stub.data = map[string]any{"items": items, "total": len(items)}
484495

485-
out, err := execCommand("alert-event", "list", "--limit", "30", "--output-format", "json")
496+
out, stderrText, err := execCommandSplit("alert-event", "list", "--limit", "30", "--output-format", "json")
486497
if err != nil {
487-
t.Fatalf("execCommand: %v", err)
498+
t.Fatalf("execCommandSplit: %v", err)
488499
}
489500
if len(out) >= 16*1024 {
490501
t.Fatalf("compact alert-event output is %d bytes, want <16 KiB", len(out))
491502
}
503+
if !strings.Contains(stderrText, "note: rows projected to default compact fields") {
504+
t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText)
505+
}
492506
var rows []map[string]json.RawMessage
493507
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
494508
t.Fatalf("parse compact alert-event json: %v\n%s", err, out)

internal/cli/incident.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ func newIncidentListCmd() *cobra.Command {
119119
if len(selectedFields) == 0 {
120120
return fmt.Errorf("--fields must name at least one field")
121121
}
122+
} else {
123+
noteDefaultProjection(cmd.ErrOrStderr(), selectedFields)
122124
}
123125
proj, err := projectFields(result.Items, selectedFields)
124126
if err != nil {
@@ -604,6 +606,8 @@ func newIncidentSimilarCmd() *cobra.Command {
604606
fieldNames := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "close_time", "ack_time", "alert_cnt", "root_cause", "score"}
605607
if fields != "" {
606608
fieldNames = parseStringSlice(fields)
609+
} else {
610+
noteDefaultProjection(cmd.ErrOrStderr(), fieldNames)
607611
}
608612
proj, err := projectFields(result.Items, fieldNames)
609613
if err != nil {

skills/flashduty/reference/alert.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ fduty alert feed <alert-id> --output-format toon
3838
fduty alert-event list --channel <channel-id> --since 1h --limit 30 --output-format toon
3939
```
4040

41-
Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened.
41+
Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened. In json/toon mode rows default to the compact projection `event_id,alert_id,event_severity,event_status,event_time,title` (a stderr note says so when it applies); any other response field is one `--fields` away — a key missing from the output means it wasn't selected, not that the server omits it.
4242

4343
## Hot flow — merge noisy alerts into an existing incident
4444

skills/flashduty/reference/incident.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Projected `similar` lists stay below 16 KiB, and projected `detail --fields` out
7575

7676
`comment` never accepts the text as a command-line argument — only `--comment-file <path>` (or `--comment-file -` to read stdin), so backticks/`$()`/quotes inside the comment are inert. The command also reads back every target's timeline after writing and exits non-zero unless it finds an entry matching what it sent, so `Commented on ...` is proof of content fidelity, not just acceptance — no separate manual read-back is needed. Leading and trailing whitespace is stripped before sending (the server strips it too, so this is what gets stored); everything else, including interior blank lines, is preserved exactly.
7777

78-
> `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.
78+
> `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. Any list-response field — including `labels` — is selectable this way (a key missing from the output means it wasn't selected, NOT that the server omits it; the command prints a stderr note when the default projection applies). The one exception is `alerts`: neither list nor detail responses ever fill it — use `incident alerts <id>` for an incident's alerts. Wide fields over many rows can exceed the 16 KiB structured-output bound and the command errors with "request fewer rows or fields" — lower `--limit`/page through, or use `insight` aggregates for distributions instead of dumping labels row by row.
7979
8080
## Hot flow — full fault analysis (read-only summary)
8181

0 commit comments

Comments
 (0)