Skip to content
Merged
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
13 changes: 11 additions & 2 deletions internal/cli/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }},
Expand All @@ -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
}
Expand Down
100 changes: 100 additions & 0 deletions internal/cli/fieldproject.go
Original file line number Diff line number Diff line change
@@ -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
}
237 changes: 237 additions & 0 deletions internal/cli/fieldproject_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading