Skip to content

Commit c231070

Browse files
committed
feat(cli): drop pure-rename curated shadows (insight team/channel/responder, member list)
Continues the generated-as-single-source convergence (after the table renderer and relative-time keystones). Removes the curated commands whose only difference from their generated twin is flag spelling — a clean rename, now that generated --start-time/--end-time accept relative time: - insight team/channel/responder: curated --since/--until → generated --start-time/--end-time (relative-time capable) PLUS the generated twin exposes severities, *_ids, fields, aggregate-unit, … (net gain). - member list: curated --name/--email → generated --query (the curated command already folded both into the SDK Query field; generated also keeps --role-id/--orderby/--asc and adds the rest of the member API). To preserve the curated human tables, the generic renderer gains an optional per-column formatter (colSpec.Format); DimensionInsightItem and ResponderInsightItem seeds render ACK%/MTTA/MTTR with the same percent/ duration formatting the curated tables used. json/toon output unchanged. insight top-alerts and incidents are KEPT curated for now — their generated twins differ by more than flag spelling (--limit→--k, offset→cursor pagination), so they're not pure renames. e2e: member filter tests migrated to --query. Doc/scan.sh references in fc-safari migrate in a paired PR; the dropped commands only disappear for agents once a new CLI is released into the sandbox, so the two land together.
1 parent 0ac50b9 commit c231070

8 files changed

Lines changed: 165 additions & 331 deletions

File tree

e2e/resource_filter_test.go

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package e2e_test
44

55
import (
66
"encoding/json"
7+
"fmt"
78
"strings"
89
"testing"
910
)
@@ -81,7 +82,19 @@ func TestChannelListNoTrunc(t *testing.T) {
8182
// Member filters
8283
// ---------------------------------------------------------------------------
8384

84-
// Test 132: member list --name
85+
// memberByID reports whether any row has the given member_id.
86+
func memberByID(items []map[string]any, id string) bool {
87+
for _, item := range items {
88+
if fmt.Sprintf("%v", item["member_id"]) == id {
89+
return true
90+
}
91+
}
92+
return false
93+
}
94+
95+
// Test 132: member list --query by name. The generated --query matches name OR
96+
// email server-side, so we assert the seed member is found (not that every row
97+
// matches by name — a result may match via email).
8598
func TestMemberListNameFilter(t *testing.T) {
8699
r := runCLI(t, "member", "list", "--json")
87100
requireSuccess(t, r)
@@ -90,13 +103,16 @@ func TestMemberListNameFilter(t *testing.T) {
90103
t.Skip("no members available")
91104
}
92105

106+
seedID := fmt.Sprintf("%v", members[0]["member_id"])
93107
filter := mustStringField(t, members[0], "member_name")
94-
r = runCLI(t, "member", "list", "--name", filter, "--json")
108+
r = runCLI(t, "member", "list", "--query", filter, "--json")
95109
requireSuccess(t, r)
96-
requireAllMatchSubstring(t, decodeObjectList(t, r.Stdout), "member_name", filter)
110+
if !memberByID(decodeObjectList(t, r.Stdout), seedID) {
111+
t.Fatalf("--query %q did not return seed member %s", filter, seedID)
112+
}
97113
}
98114

99-
// Test 133: member list --email
115+
// Test 133: member list --query by email.
100116
func TestMemberListEmailFilter(t *testing.T) {
101117
r := runCLI(t, "member", "list", "--json")
102118
requireSuccess(t, r)
@@ -105,37 +121,20 @@ func TestMemberListEmailFilter(t *testing.T) {
105121
t.Skip("no members available")
106122
}
107123

124+
seedID := fmt.Sprintf("%v", members[0]["member_id"])
108125
filter := mustStringField(t, members[0], "email")
109-
r = runCLI(t, "member", "list", "--email", filter, "--json")
110-
requireSuccess(t, r)
111-
requireAllMatchSubstring(t, decodeObjectList(t, r.Stdout), "email", filter)
112-
}
113-
114-
// Test 134: member list --name + --email combined
115-
func TestMemberListNameAndEmailFilter(t *testing.T) {
116-
r := runCLI(t, "member", "list", "--json")
126+
r = runCLI(t, "member", "list", "--query", filter, "--json")
117127
requireSuccess(t, r)
118-
members := decodeObjectList(t, r.Stdout)
119-
if len(members) == 0 {
120-
t.Skip("no members available")
128+
if !memberByID(decodeObjectList(t, r.Stdout), seedID) {
129+
t.Fatalf("--query %q did not return seed member %s", filter, seedID)
121130
}
131+
}
122132

123-
nameFilter := mustStringField(t, members[0], "member_name")
124-
emailFilter := mustStringField(t, members[0], "email")
125-
r = runCLI(t, "member", "list", "--name", nameFilter, "--email", emailFilter, "--json")
133+
// Test 134: member list --query returns valid JSON for an arbitrary term.
134+
func TestMemberListQueryReturnsJSON(t *testing.T) {
135+
r := runCLI(t, "member", "list", "--query", "a", "--json")
126136
requireSuccess(t, r)
127-
results := decodeObjectList(t, r.Stdout)
128-
if len(results) == 0 {
129-
t.Fatalf("expected at least one result for name=%q email=%q", nameFilter, emailFilter)
130-
}
131-
for _, item := range results {
132-
if !strings.Contains(mustStringField(t, item, "member_name"), nameFilter) {
133-
t.Fatalf("member_name did not match combined filter %q", nameFilter)
134-
}
135-
if !strings.Contains(mustStringField(t, item, "email"), emailFilter) {
136-
t.Fatalf("email did not match combined filter %q", emailFilter)
137-
}
138-
}
137+
requireValidJSON(t, r.Stdout)
139138
}
140139

141140
// Test 135: member list --page 1

e2e/resource_list_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ func TestMemberListJSON(t *testing.T) {
5050

5151
// Test 139: member list empty results
5252
func TestMemberListNoResults(t *testing.T) {
53-
r := runCLI(t, "member", "list", "--name", "nonexistent_xyz_999", "--email", "nonexistent_xyz")
53+
r := runCLI(t, "member", "list", "--query", "nonexistent_xyz_999")
5454
requireSuccess(t, r)
55-
requireContains(t, r.Stdout, "No members found.")
55+
requireContains(t, r.Stdout, "No results.")
5656
}
5757

5858
// ---------------------------------------------------------------------------

internal/cli/display_columns.go

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,47 @@
11
package cli
22

3+
import (
4+
"fmt"
5+
6+
"github.com/flashcatcloud/flashduty-cli/internal/output"
7+
)
8+
39
// colSpec is a display-only column for the generic table renderer: which row
410
// field to show, its header, and an optional width cap. It NEVER affects flags or
511
// json/toon output — a wrong entry degrades a single table column at worst, it
612
// can't cause a functional error. Field is the Go struct field name on the row
7-
// type; timestamp fields are detected and formatted automatically.
13+
// type; timestamp fields are detected and formatted automatically. Format, when
14+
// set, renders the raw field value with a semantic formatter (percent, duration)
15+
// the default scalar rendering doesn't apply — display-only, like the rest.
816
type colSpec struct {
917
Header string
1018
Field string
1119
MaxWidth int
20+
Format func(any) string
21+
}
22+
23+
// fmtPercent renders a 0..1 ratio as a whole-number percentage ("85%"), matching
24+
// the curated insight tables. A non-float value yields "".
25+
func fmtPercent(v any) string {
26+
if f, ok := v.(float64); ok {
27+
return fmt.Sprintf("%.0f%%", f*100)
28+
}
29+
return ""
30+
}
31+
32+
// fmtSecondsDuration renders a seconds count (int or float) as a human duration
33+
// ("2m 30s"), matching the curated insight MTTA/MTTR/engaged columns.
34+
func fmtSecondsDuration(v any) string {
35+
switch n := v.(type) {
36+
case float64:
37+
return output.FormatDurationFloat(n)
38+
case int64:
39+
return output.FormatDuration(int(n))
40+
case int:
41+
return output.FormatDuration(n)
42+
default:
43+
return ""
44+
}
1245
}
1346

1447
// displayColumns maps a go-flashduty response row type (by Go type name) to its
@@ -106,4 +139,26 @@ var displayColumns = map[string][]colSpec{
106139
{Header: "EMAIL", Field: "Email"},
107140
{Header: "STATUS", Field: "Status"},
108141
},
142+
// DimensionInsightItem backs both `insight team` and `insight channel` (same
143+
// Go type, different populated name field) — show both name columns, the
144+
// irrelevant one renders empty. Columns mirror the curated insight tables.
145+
"DimensionInsightItem": {
146+
{Header: "TEAM", Field: "TeamName", MaxWidth: 30},
147+
{Header: "CHANNEL", Field: "ChannelName", MaxWidth: 30},
148+
{Header: "INCIDENTS", Field: "TotalIncidentCnt"},
149+
{Header: "ACK%", Field: "AcknowledgementPct", Format: fmtPercent},
150+
{Header: "MTTA", Field: "MeanSecondsToAck", Format: fmtSecondsDuration},
151+
{Header: "MTTR", Field: "MeanSecondsToClose", Format: fmtSecondsDuration},
152+
{Header: "NOISE_REDUCTION", Field: "NoiseReductionPct", Format: fmtPercent},
153+
{Header: "ALERTS", Field: "TotalAlertCnt"},
154+
{Header: "EVENTS", Field: "TotalAlertEventCnt"},
155+
},
156+
"ResponderInsightItem": {
157+
{Header: "RESPONDER", Field: "ResponderName", MaxWidth: 30},
158+
{Header: "INCIDENTS", Field: "TotalIncidentCnt"},
159+
{Header: "ACK%", Field: "AcknowledgementPct", Format: fmtPercent},
160+
{Header: "MTTA", Field: "MeanSecondsToAck", Format: fmtSecondsDuration},
161+
{Header: "INTERRUPTIONS", Field: "TotalInterruptions"},
162+
{Header: "ENGAGED", Field: "TotalEngagedSeconds", Format: fmtSecondsDuration},
163+
},
109164
}

internal/cli/generic_table.go

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,15 @@ func columnsForType(rowType reflect.Type) []output.Column {
143143
if specs, ok := displayColumns[rowType.Name()]; ok {
144144
cols := make([]output.Column, 0, len(specs))
145145
for _, s := range specs {
146+
field, format := s.Field, s.Format
147+
fieldFn := func(item any) string { return fieldString(item, field) }
148+
if format != nil {
149+
fieldFn = func(item any) string { return format(fieldValue(item, field)) }
150+
}
146151
cols = append(cols, output.Column{
147152
Header: s.Header,
148153
MaxWidth: s.MaxWidth,
149-
Field: func(item any) string { return fieldString(item, s.Field) },
154+
Field: fieldFn,
150155
})
151156
}
152157
return cols
@@ -205,24 +210,54 @@ func renderVertical(ctx *RunContext, v reflect.Value) error {
205210
return ctx.Printer.Print(rows, cols)
206211
}
207212

208-
// fieldString reads the named Go field from a row item (deref'ing a pointer row)
209-
// and formats it. An absent field yields "" rather than panicking, so a stale
210-
// displayColumns entry degrades a column instead of crashing the command.
211-
func fieldString(item any, goField string) string {
213+
// derefStruct dereferences pointer chains and returns the underlying struct
214+
// reflect.Value. The second return is false when item is nil, not a struct after
215+
// dereferencing, or any pointer in the chain is nil.
216+
func derefStruct(item any) (reflect.Value, bool) {
212217
rv := reflect.ValueOf(item)
213218
for rv.Kind() == reflect.Pointer {
214219
if rv.IsNil() {
215-
return ""
220+
return reflect.Value{}, false
216221
}
217222
rv = rv.Elem()
218223
}
224+
if rv.Kind() != reflect.Struct {
225+
return reflect.Value{}, false
226+
}
227+
return rv, true
228+
}
229+
230+
// fieldString reads the named Go field from a row item and formats it as a
231+
// string. An absent field yields "" rather than panicking, so a stale
232+
// displayColumns entry degrades a column instead of crashing the command.
233+
func fieldString(item any, goField string) string {
234+
rv, ok := derefStruct(item)
235+
if !ok {
236+
return ""
237+
}
219238
fv := rv.FieldByName(goField)
220239
if !fv.IsValid() {
221240
return ""
222241
}
223242
return scalarString(fv)
224243
}
225244

245+
// fieldValue reads the named Go field's raw value from a row item, returning
246+
// nil when absent. It feeds a colSpec.Format function so a column can apply
247+
// semantic formatting (percent, duration) that the default scalar rendering
248+
// doesn't know about.
249+
func fieldValue(item any, goField string) any {
250+
rv, ok := derefStruct(item)
251+
if !ok {
252+
return nil
253+
}
254+
fv := rv.FieldByName(goField)
255+
if !fv.IsValid() || !fv.CanInterface() {
256+
return nil
257+
}
258+
return fv.Interface()
259+
}
260+
226261
// scalarString formats a scalar (or timestamp) reflect value. Non-scalars yield
227262
// "" — the generic table never renders nested objects/arrays.
228263
func scalarString(fv reflect.Value) string {

internal/cli/generic_table_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@ func TestRenderGenericTable_DisplayColumns(t *testing.T) {
6666
}
6767
}
6868

69+
func TestRenderGenericTable_FormattedColumns(t *testing.T) {
70+
// insight rows carry ratio/seconds fields the curated tables rendered as
71+
// percent/duration; the colSpec.Format path must apply that, not print the
72+
// raw float.
73+
var buf bytes.Buffer
74+
rows := []flashduty.DimensionInsightItem{
75+
{TeamName: "sre", TotalIncidentCnt: 4, AcknowledgementPct: 0.85, MeanSecondsToAck: 150},
76+
}
77+
if err := renderGenericTable(tableCtx(&buf), rows); err != nil {
78+
t.Fatalf("render: %v", err)
79+
}
80+
got := buf.String()
81+
for _, want := range []string{"TEAM", "ACK%", "MTTA", "sre", "85%"} {
82+
if !strings.Contains(got, want) {
83+
t.Errorf("output missing %q\n---\n%s", want, got)
84+
}
85+
}
86+
// The raw ratio must NOT leak — proves Format ran instead of scalarString.
87+
if strings.Contains(got, "0.85") {
88+
t.Errorf("raw ratio leaked; Format not applied\n%s", got)
89+
}
90+
}
91+
6992
func TestRenderGenericTable_Heuristic(t *testing.T) {
7093
var buf bytes.Buffer
7194
resp := &fakeListResp{Items: []heuristicRow{{Name: "alpha", Count: 7}}, Total: 1}
@@ -156,6 +179,8 @@ var displayColumnSamples = []any{
156179
flashduty.FieldItem{},
157180
flashduty.WarRoomItem{},
158181
flashduty.WarRoomPersonItem{},
182+
flashduty.DimensionInsightItem{},
183+
flashduty.ResponderInsightItem{},
159184
}
160185

161186
// TestDisplayColumns_FieldsResolve guards against typos: every displayColumns

0 commit comments

Comments
 (0)