Skip to content

Commit 2cd4021

Browse files
committed
fix(cli): compact incident audit outputs
1 parent 45677ff commit 2cd4021

8 files changed

Lines changed: 316 additions & 17 deletions

File tree

‎internal/cli/alert_event.go‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,19 @@ func newAlertEventCmd() *cobra.Command {
2121
}
2222

2323
func newAlertEventListCmd() *cobra.Command {
24-
var severity, channel, integrationType, since, until string
24+
var severity, channel, integrationType, since, until, fields string
2525
var limit, page int
2626

2727
cmd := &cobra.Command{
2828
Use: "list",
2929
Short: "List alert events globally",
30-
Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type.", "Alerts", "EventReadList"),
30+
Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type. In json/toon mode, output defaults to compact event fields; use --fields to choose a different projection.", "Alerts", "EventReadList"),
3131
RunE: func(cmd *cobra.Command, args []string) error {
3232
return runCommand(cmd, args, func(ctx *RunContext) error {
33+
if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 {
34+
return fmt.Errorf("--fields must name at least one field")
35+
}
36+
3337
startTime, err := timeutil.Parse(since)
3438
if err != nil {
3539
return fmt.Errorf("invalid --since: %w", err)
@@ -77,6 +81,18 @@ func newAlertEventListCmd() *cobra.Command {
7781
{Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEventItem).Title }},
7882
}
7983

84+
if ctx.Structured() {
85+
fieldNames := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"}
86+
if fields != "" {
87+
fieldNames = parseStringSlice(fields)
88+
}
89+
proj, err := projectFields(result.Items, fieldNames)
90+
if err != nil {
91+
return err
92+
}
93+
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
94+
}
95+
8096
return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total))
8197
})
8298
},
@@ -90,6 +106,7 @@ func newAlertEventListCmd() *cobra.Command {
90106
cmd.Flags().StringVar(&until, "until", "now", "End time")
91107
cmd.Flags().IntVar(&limit, "limit", 20, "Max results")
92108
cmd.Flags().IntVar(&page, "page", 1, "Page number")
109+
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. event_id,alert_id,event_severity,event_status,event_time,title); ignored in table mode. Defaults to these compact event fields.")
93110

94111
return cmd
95112
}

‎internal/cli/fieldproject_test.go‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package cli
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"strings"
67
"testing"
8+
9+
"github.com/spf13/cobra"
710
)
811

912
// incidentRow / alertRow are multi-field stub payloads with the nested blobs
@@ -235,3 +238,155 @@ func TestFieldsUnknownFieldErrors(t *testing.T) {
235238
})
236239
}
237240
}
241+
242+
func TestIncidentSimilarStructuredProjection(t *testing.T) {
243+
saveAndResetGlobals(t)
244+
stub := newGFStub(t)
245+
items := make([]any, 20)
246+
for i := range items {
247+
row := incidentRow()
248+
row["incident_id"] = "similar-" + string(rune('a'+i))
249+
row["close_time"] = 1712000060
250+
row["ack_time"] = 1712000030
251+
row["alert_cnt"] = 3
252+
row["root_cause"] = "disk exhaustion"
253+
row["score"] = 0.9
254+
row["description"] = strings.Repeat("large description ", 100)
255+
row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}}
256+
items[i] = row
257+
}
258+
stub.data = map[string]any{"items": items, "total": len(items)}
259+
260+
out, err := execCommand("incident", "similar", "inc-1", "--limit", "20", "--output-format", "json")
261+
if err != nil {
262+
t.Fatalf("execCommand: %v", err)
263+
}
264+
if len(out) >= 16*1024 {
265+
t.Fatalf("compact similar output is %d bytes, want <16 KiB", len(out))
266+
}
267+
268+
var rows []map[string]json.RawMessage
269+
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
270+
t.Fatalf("parse compact similar json: %v\n%s", err, out)
271+
}
272+
want := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "close_time", "ack_time", "alert_cnt", "root_cause", "score"}
273+
if len(rows) != len(items) {
274+
t.Fatalf("got %d rows, want %d", len(rows), len(items))
275+
}
276+
for _, row := range rows {
277+
if len(row) != len(want) {
278+
t.Fatalf("got keys %v, want exactly %v", row, want)
279+
}
280+
for _, field := range want {
281+
if _, ok := row[field]; !ok {
282+
t.Errorf("projected row missing %q", field)
283+
}
284+
}
285+
if _, ok := row["description"]; ok {
286+
t.Errorf("projected row includes description: %v", row)
287+
}
288+
}
289+
290+
}
291+
292+
func TestIncidentDetailFieldsProjection(t *testing.T) {
293+
saveAndResetGlobals(t)
294+
stub := newGFStub(t)
295+
row := incidentRow()
296+
row["description"] = strings.Repeat("large description ", 500)
297+
row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}}
298+
stub.data = row
299+
300+
out, err := execCommand("incident", "detail", "inc-1", "--fields", "incident_id,title,root_cause", "--output-format", "json")
301+
if err != nil {
302+
t.Fatalf("execCommand: %v", err)
303+
}
304+
if len(out) >= 8*1024 {
305+
t.Fatalf("projected detail output is %d bytes, want <8 KiB", len(out))
306+
}
307+
var detail map[string]json.RawMessage
308+
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &detail); err != nil {
309+
t.Fatalf("parse projected detail json: %v\n%s", err, out)
310+
}
311+
if len(detail) != 3 || detail["incident_id"] == nil || detail["title"] == nil || detail["root_cause"] == nil {
312+
t.Fatalf("projected detail = %v, want exactly incident_id,title,root_cause", detail)
313+
}
314+
if _, ok := detail["description"]; ok {
315+
t.Errorf("projected detail includes description: %v", detail)
316+
}
317+
318+
}
319+
320+
func TestAlertEventListStructuredProjection(t *testing.T) {
321+
saveAndResetGlobals(t)
322+
stub := newGFStub(t)
323+
items := make([]any, 30)
324+
for i := range items {
325+
items[i] = map[string]any{
326+
"event_id": "event-" + string(rune('a'+i)),
327+
"alert_id": "alert-1",
328+
"event_severity": "Warning",
329+
"event_status": "Triggered",
330+
"event_time": 1712000000,
331+
"title": "CPU high",
332+
"description": strings.Repeat("large description ", 100),
333+
"labels": map[string]any{"host": "web-01"},
334+
"images": []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}},
335+
}
336+
}
337+
stub.data = map[string]any{"items": items, "total": len(items)}
338+
339+
out, err := execCommand("alert-event", "list", "--limit", "30", "--output-format", "json")
340+
if err != nil {
341+
t.Fatalf("execCommand: %v", err)
342+
}
343+
if len(out) >= 16*1024 {
344+
t.Fatalf("compact alert-event output is %d bytes, want <16 KiB", len(out))
345+
}
346+
var rows []map[string]json.RawMessage
347+
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
348+
t.Fatalf("parse compact alert-event json: %v\n%s", err, out)
349+
}
350+
want := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"}
351+
if len(rows) != len(items) {
352+
t.Fatalf("got %d rows, want %d", len(rows), len(items))
353+
}
354+
for _, row := range rows {
355+
if len(row) != len(want) {
356+
t.Fatalf("got keys %v, want exactly %v", row, want)
357+
}
358+
for _, field := range want {
359+
if _, ok := row[field]; !ok {
360+
t.Errorf("projected row missing %q", field)
361+
}
362+
}
363+
}
364+
365+
}
366+
367+
func TestStructuredFieldsEmptyErrors(t *testing.T) {
368+
cases := []struct {
369+
name string
370+
cmd func() *cobra.Command
371+
args []string
372+
}{
373+
{"incident similar", newIncidentSimilarCmd, []string{"inc-1", "--fields", ""}},
374+
{"incident detail", newIncidentDetailCmd, []string{"inc-1", "--fields", ""}},
375+
{"alert-event list", newAlertEventListCmd, []string{"--fields", ""}},
376+
}
377+
for _, tc := range cases {
378+
t.Run(tc.name, func(t *testing.T) {
379+
saveAndResetGlobals(t)
380+
newGFStub(t)
381+
flagOutputFormat = "json"
382+
cmd := tc.cmd()
383+
cmd.SetOut(new(bytes.Buffer))
384+
cmd.SetErr(cmd.OutOrStderr())
385+
cmd.SetArgs(tc.args)
386+
err := cmd.Execute()
387+
if err == nil || !strings.Contains(err.Error(), "--fields") {
388+
t.Fatalf("empty --fields error = %v, want --fields validation", err)
389+
}
390+
})
391+
}
392+
}

‎internal/cli/incident.go‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,7 @@ func newIncidentAlertsCmd() *cobra.Command {
564564
}
565565

566566
func newIncidentSimilarCmd() *cobra.Command {
567+
var fields string
567568
var limit int
568569

569570
cmd := &cobra.Command{
@@ -573,6 +574,10 @@ func newIncidentSimilarCmd() *cobra.Command {
573574
Args: requireArgs("incident_id"),
574575
RunE: func(cmd *cobra.Command, args []string) error {
575576
return runCommand(cmd, args, func(ctx *RunContext) error {
577+
if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 {
578+
return errors.New("--fields must name at least one field")
579+
}
580+
576581
result, _, err := ctx.Client.Incidents.PastList(cmdContext(ctx.Cmd), &flashduty.ListPastIncidentsRequest{
577582
IncidentID: ctx.Args[0],
578583
Limit: flashduty.Int64(int64(limit)),
@@ -586,12 +591,25 @@ func newIncidentSimilarCmd() *cobra.Command {
586591
return nil
587592
}
588593

594+
if ctx.Structured() {
595+
fieldNames := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "close_time", "ack_time", "alert_cnt", "root_cause", "score"}
596+
if fields != "" {
597+
fieldNames = parseStringSlice(fields)
598+
}
599+
proj, err := projectFields(result.Items, fieldNames)
600+
if err != nil {
601+
return err
602+
}
603+
return ctx.Printer.Print(proj, nil)
604+
}
605+
589606
return ctx.Printer.Print(result.Items, pastIncidentColumns())
590607
})
591608
},
592609
}
593610

594611
cmd.Flags().IntVar(&limit, "limit", 5, "Max results")
612+
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. Defaults to a compact incident summary.")
595613
return cmd
596614
}
597615

@@ -1301,13 +1319,19 @@ func resolveFeedOperators(rc *RunContext, items []flashduty.IncidentFeedItem) ma
13011319
}
13021320

13031321
func newIncidentDetailCmd() *cobra.Command {
1304-
return &cobra.Command{
1322+
var fields string
1323+
1324+
cmd := &cobra.Command{
13051325
Use: "detail <id>",
13061326
Short: "View full incident detail with AI summary",
13071327
Long: curatedLong("View full incident detail, including the AI summary, root cause, and resolution.", "Incidents", "Info"),
13081328
Args: requireArgs("incident_id"),
13091329
RunE: func(cmd *cobra.Command, args []string) error {
13101330
return runCommand(cmd, args, func(ctx *RunContext) error {
1331+
if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 {
1332+
return errors.New("--fields must name at least one field")
1333+
}
1334+
13111335
fullID, candidates, err := resolveIncidentArg(ctx, ctx.Args[0])
13121336
if err != nil {
13131337
return err
@@ -1324,6 +1348,13 @@ func newIncidentDetailCmd() *cobra.Command {
13241348
}
13251349

13261350
if ctx.Structured() {
1351+
if fields != "" {
1352+
proj, err := projectFields([]flashduty.IncidentInfo{*result}, parseStringSlice(fields))
1353+
if err != nil {
1354+
return err
1355+
}
1356+
return ctx.Printer.Print(proj[0], nil)
1357+
}
13271358
return ctx.Printer.Print(result, nil)
13281359
}
13291360

@@ -1332,6 +1363,8 @@ func newIncidentDetailCmd() *cobra.Command {
13321363
})
13331364
},
13341365
}
1366+
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,root_cause); ignored in table mode. Omit for full detail.")
1367+
return cmd
13351368
}
13361369

13371370
func printIncidentFullDetail(w io.Writer, inc *flashduty.IncidentInfo) {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"runtime"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestIncidentSummaryScriptCompactOutput(t *testing.T) {
13+
if runtime.GOOS == "windows" {
14+
t.Skip("Bash fixture is unavailable on Windows")
15+
}
16+
17+
root, err := filepath.Abs("../..")
18+
if err != nil {
19+
t.Fatalf("resolve repository root: %v", err)
20+
}
21+
script := filepath.Join(root, "skills", "flashduty", "scripts", "incident-summary.sh")
22+
log := filepath.Join(t.TempDir(), "fduty.log")
23+
bin := filepath.Join(t.TempDir(), "fduty")
24+
if err := os.WriteFile(bin, []byte("#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> \"$FDUTY_LOG\"\nprintf 'compact result\\n'\n"), 0o755); err != nil {
25+
t.Fatalf("write fake fduty: %v", err)
26+
}
27+
t.Setenv("FDUTY_LOG", log)
28+
t.Setenv("PATH", filepath.Dir(bin)+string(os.PathListSeparator)+os.Getenv("PATH"))
29+
30+
output, err := exec.Command("bash", script, "inc-1").CombinedOutput()
31+
if err != nil {
32+
t.Fatalf("run incident summary: %v\n%s", err, output)
33+
}
34+
invocations, err := os.ReadFile(log)
35+
if err != nil {
36+
t.Fatalf("read fake fduty log: %v", err)
37+
}
38+
lines := strings.FieldsFunc(strings.TrimSpace(string(invocations)), func(r rune) bool { return r == '\n' })
39+
if len(lines) != 6 {
40+
t.Fatalf("fduty calls = %d, want 6:\n%s", len(lines), invocations)
41+
}
42+
if strings.Contains(string(invocations), "--output-format toon") {
43+
t.Fatalf("summary forces toon instead of each command's compact default:\n%s", invocations)
44+
}
45+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package skilldoc
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestIncidentCardAvoidsUnboundedStructuredHotFlows(t *testing.T) {
11+
card, err := os.ReadFile(filepath.Join("..", "..", "skills", "flashduty", "reference", "incident.md"))
12+
if err != nil {
13+
t.Fatal(err)
14+
}
15+
16+
body := string(card)
17+
for description, command := range map[string]string{
18+
"triage list": "incident list --severity Critical --progress Triggered --since 4h --fields incident_id,title,incident_severity,progress,start_time,channel_id --output-format toon",
19+
"triage detail": "incident detail <incident-id> --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time --output-format toon",
20+
"summary detail": `incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time --output-format toon`,
21+
} {
22+
if !strings.Contains(body, command) {
23+
t.Errorf("incident %s must project structured output to the fields needed by the workflow", description)
24+
}
25+
}
26+
27+
for _, command := range []string{
28+
"incident alerts <incident-id> --output-format toon",
29+
`incident alerts "$ID" --output-format toon`,
30+
`incident timeline "$ID" --output-format toon`,
31+
"incident post-mortem-list --channel-ids <channel-id> --output-format toon",
32+
"change list --since 24h --output-format toon",
33+
"incident timeline <primary-incident-id> --output-format toon",
34+
} {
35+
if strings.Contains(body, command) {
36+
t.Errorf("incident hot flow must use the compact default instead of %q", command)
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)