Skip to content

Commit baa80f7

Browse files
committed
Merge feat/ai-sre: #46 table renderer, #47 relative-time, #48 drop pure-rename shadows, #50 positional args
2 parents e7d4dae + 37011f7 commit baa80f7

45 files changed

Lines changed: 3344 additions & 1210 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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/args.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@ func requireArgs(argNames ...string) cobra.PositionalArgs {
2424
}
2525
}
2626

27+
// requireExactArg returns a positional argument validator that requires exactly
28+
// one argument named name, producing friendly messages that match requireArgs style:
29+
//
30+
// - zero args: "missing <name>. Usage: ..."
31+
// - >1 args: "expects exactly one <name>. Usage: ..."
32+
func requireExactArg(name string) cobra.PositionalArgs {
33+
return func(cmd *cobra.Command, args []string) error {
34+
switch {
35+
case len(args) == 0:
36+
return fmt.Errorf("missing %s. Usage: %s", name, cmd.UseLine())
37+
case len(args) > 1:
38+
return fmt.Errorf("expects exactly one %s. Usage: %s", name, cmd.UseLine())
39+
}
40+
return nil
41+
}
42+
}
43+
2744
// requireExactlyOneFlag validates that exactly one of the named flags is set.
2845
func requireExactlyOneFlag(cmd *cobra.Command, flagNames ...string) error {
2946
set := 0

internal/cli/display_columns.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/flashcatcloud/flashduty-cli/internal/output"
7+
)
8+
9+
// colSpec is a display-only column for the generic table renderer: which row
10+
// field to show, its header, and an optional width cap. It NEVER affects flags or
11+
// json/toon output — a wrong entry degrades a single table column at worst, it
12+
// can't cause a functional error. Field is the Go struct field name on the row
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.
16+
type colSpec struct {
17+
Header string
18+
Field string
19+
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+
}
45+
}
46+
47+
// displayColumns maps a go-flashduty response row type (by Go type name) to its
48+
// human table columns, seeded from the hand-written column sets the curated
49+
// commands used before the CLI converged on generated commands. Row types with
50+
// no entry fall back to the reflective heuristic in generic_table.go.
51+
//
52+
// Names are intentionally not resolved here (e.g. ChannelItem shows TEAM_ID /
53+
// CREATOR_ID, not team/creator names): id→name enrichment belongs in the API
54+
// response, not the client. Until the API carries those names, the table shows
55+
// the ids; json/toon is unaffected either way.
56+
var displayColumns = map[string][]colSpec{
57+
"IncidentInfo": {
58+
{Header: "ID", Field: "IncidentID"},
59+
{Header: "TITLE", Field: "Title", MaxWidth: 50},
60+
{Header: "SEVERITY", Field: "IncidentSeverity"},
61+
{Header: "PROGRESS", Field: "Progress"},
62+
{Header: "CHANNEL", Field: "ChannelName"},
63+
{Header: "CREATED", Field: "StartTime"},
64+
},
65+
"PastIncidentItem": {
66+
{Header: "ID", Field: "IncidentID"},
67+
{Header: "TITLE", Field: "Title", MaxWidth: 50},
68+
{Header: "SEVERITY", Field: "IncidentSeverity"},
69+
{Header: "PROGRESS", Field: "Progress"},
70+
{Header: "CHANNEL", Field: "ChannelName"},
71+
{Header: "CREATED", Field: "StartTime"},
72+
},
73+
"AlertInfo": {
74+
{Header: "ALERT_ID", Field: "AlertID"},
75+
{Header: "TITLE", Field: "Title", MaxWidth: 50},
76+
{Header: "SEVERITY", Field: "AlertSeverity"},
77+
{Header: "STATUS", Field: "AlertStatus"},
78+
{Header: "STARTED", Field: "StartTime"},
79+
},
80+
"AlertItem": {
81+
{Header: "ID", Field: "AlertID"},
82+
{Header: "TITLE", Field: "Title", MaxWidth: 50},
83+
{Header: "SEVERITY", Field: "AlertSeverity"},
84+
{Header: "STATUS", Field: "AlertStatus"},
85+
{Header: "EVENTS", Field: "EventCnt"},
86+
{Header: "CHANNEL", Field: "ChannelName"},
87+
{Header: "STARTED", Field: "StartTime"},
88+
},
89+
"AlertEventItem": {
90+
{Header: "EVENT_ID", Field: "EventID"},
91+
{Header: "ALERT_ID", Field: "AlertID"},
92+
{Header: "SEVERITY", Field: "EventSeverity"},
93+
{Header: "STATUS", Field: "EventStatus"},
94+
{Header: "TIME", Field: "EventTime"},
95+
{Header: "TITLE", Field: "Title", MaxWidth: 50},
96+
},
97+
"ChangeItem": {
98+
{Header: "ID", Field: "ChangeID"},
99+
{Header: "TITLE", Field: "Title", MaxWidth: 50},
100+
{Header: "STATUS", Field: "ChangeStatus"},
101+
{Header: "CHANNEL", Field: "ChannelName"},
102+
{Header: "TIME", Field: "StartTime"},
103+
},
104+
"ChannelItem": {
105+
{Header: "ID", Field: "ChannelID"},
106+
{Header: "NAME", Field: "ChannelName", MaxWidth: 40},
107+
{Header: "TEAM_ID", Field: "TeamID"},
108+
{Header: "CREATOR_ID", Field: "CreatorID"},
109+
{Header: "STATUS", Field: "Status"},
110+
},
111+
"TeamItem": {
112+
{Header: "ID", Field: "TeamID"},
113+
{Header: "NAME", Field: "TeamName", MaxWidth: 40},
114+
},
115+
"MemberItem": {
116+
{Header: "ID", Field: "MemberID"},
117+
{Header: "NAME", Field: "MemberName", MaxWidth: 30},
118+
{Header: "EMAIL", Field: "Email"},
119+
{Header: "STATUS", Field: "Status"},
120+
{Header: "TIMEZONE", Field: "TimeZone"},
121+
},
122+
"FieldItem": {
123+
{Header: "ID", Field: "FieldID"},
124+
{Header: "NAME", Field: "FieldName"},
125+
{Header: "DISPLAY_NAME", Field: "DisplayName"},
126+
{Header: "TYPE", Field: "FieldType"},
127+
},
128+
"WarRoomItem": {
129+
{Header: "INTEGRATION", Field: "IntegrationID"},
130+
{Header: "CHAT_ID", Field: "ChatID"},
131+
{Header: "INCIDENT_ID", Field: "IncidentID"},
132+
{Header: "STATUS", Field: "Status"},
133+
{Header: "PLUGIN", Field: "PluginType"},
134+
{Header: "CREATED", Field: "CreatedAt"},
135+
},
136+
"WarRoomPersonItem": {
137+
{Header: "PERSON_ID", Field: "PersonID"},
138+
{Header: "NAME", Field: "PersonName"},
139+
{Header: "EMAIL", Field: "Email"},
140+
{Header: "STATUS", Field: "Status"},
141+
},
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+
},
164+
}

0 commit comments

Comments
 (0)