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
59 changes: 29 additions & 30 deletions e2e/resource_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package e2e_test

import (
"encoding/json"
"fmt"
"strings"
"testing"
)
Expand Down Expand Up @@ -81,7 +82,19 @@ func TestChannelListNoTrunc(t *testing.T) {
// Member filters
// ---------------------------------------------------------------------------

// Test 132: member list --name
// memberByID reports whether any row has the given member_id.
func memberByID(items []map[string]any, id string) bool {
for _, item := range items {
if fmt.Sprintf("%v", item["member_id"]) == id {
return true
}
}
return false
}

// Test 132: member list --query by name. The generated --query matches name OR
// email server-side, so we assert the seed member is found (not that every row
// matches by name — a result may match via email).
func TestMemberListNameFilter(t *testing.T) {
r := runCLI(t, "member", "list", "--json")
requireSuccess(t, r)
Expand All @@ -90,13 +103,16 @@ func TestMemberListNameFilter(t *testing.T) {
t.Skip("no members available")
}

seedID := fmt.Sprintf("%v", members[0]["member_id"])
filter := mustStringField(t, members[0], "member_name")
r = runCLI(t, "member", "list", "--name", filter, "--json")
r = runCLI(t, "member", "list", "--query", filter, "--json")
requireSuccess(t, r)
requireAllMatchSubstring(t, decodeObjectList(t, r.Stdout), "member_name", filter)
if !memberByID(decodeObjectList(t, r.Stdout), seedID) {
t.Fatalf("--query %q did not return seed member %s", filter, seedID)
}
}

// Test 133: member list --email
// Test 133: member list --query by email.
func TestMemberListEmailFilter(t *testing.T) {
r := runCLI(t, "member", "list", "--json")
requireSuccess(t, r)
Expand All @@ -105,37 +121,20 @@ func TestMemberListEmailFilter(t *testing.T) {
t.Skip("no members available")
}

seedID := fmt.Sprintf("%v", members[0]["member_id"])
filter := mustStringField(t, members[0], "email")
r = runCLI(t, "member", "list", "--email", filter, "--json")
requireSuccess(t, r)
requireAllMatchSubstring(t, decodeObjectList(t, r.Stdout), "email", filter)
}

// Test 134: member list --name + --email combined
func TestMemberListNameAndEmailFilter(t *testing.T) {
r := runCLI(t, "member", "list", "--json")
r = runCLI(t, "member", "list", "--query", filter, "--json")
requireSuccess(t, r)
members := decodeObjectList(t, r.Stdout)
if len(members) == 0 {
t.Skip("no members available")
if !memberByID(decodeObjectList(t, r.Stdout), seedID) {
t.Fatalf("--query %q did not return seed member %s", filter, seedID)
}
}

nameFilter := mustStringField(t, members[0], "member_name")
emailFilter := mustStringField(t, members[0], "email")
r = runCLI(t, "member", "list", "--name", nameFilter, "--email", emailFilter, "--json")
// Test 134: member list --query returns valid JSON for an arbitrary term.
func TestMemberListQueryReturnsJSON(t *testing.T) {
r := runCLI(t, "member", "list", "--query", "a", "--json")
requireSuccess(t, r)
results := decodeObjectList(t, r.Stdout)
if len(results) == 0 {
t.Fatalf("expected at least one result for name=%q email=%q", nameFilter, emailFilter)
}
for _, item := range results {
if !strings.Contains(mustStringField(t, item, "member_name"), nameFilter) {
t.Fatalf("member_name did not match combined filter %q", nameFilter)
}
if !strings.Contains(mustStringField(t, item, "email"), emailFilter) {
t.Fatalf("email did not match combined filter %q", emailFilter)
}
}
requireValidJSON(t, r.Stdout)
}

// Test 135: member list --page 1
Expand Down
4 changes: 2 additions & 2 deletions e2e/resource_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ func TestMemberListJSON(t *testing.T) {

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

// ---------------------------------------------------------------------------
Expand Down
17 changes: 17 additions & 0 deletions internal/cli/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ func requireArgs(argNames ...string) cobra.PositionalArgs {
}
}

// requireExactArg returns a positional argument validator that requires exactly
// one argument named name, producing friendly messages that match requireArgs style:
//
// - zero args: "missing <name>. Usage: ..."
// - >1 args: "expects exactly one <name>. Usage: ..."
func requireExactArg(name string) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
switch {
case len(args) == 0:
return fmt.Errorf("missing %s. Usage: %s", name, cmd.UseLine())
case len(args) > 1:
return fmt.Errorf("expects exactly one %s. Usage: %s", name, cmd.UseLine())
}
return nil
}
}

// requireExactlyOneFlag validates that exactly one of the named flags is set.
func requireExactlyOneFlag(cmd *cobra.Command, flagNames ...string) error {
set := 0
Expand Down
164 changes: 164 additions & 0 deletions internal/cli/display_columns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package cli

import (
"fmt"

"github.com/flashcatcloud/flashduty-cli/internal/output"
)

// colSpec is a display-only column for the generic table renderer: which row
// field to show, its header, and an optional width cap. It NEVER affects flags or
// json/toon output — a wrong entry degrades a single table column at worst, it
// can't cause a functional error. Field is the Go struct field name on the row
// type; timestamp fields are detected and formatted automatically. Format, when
// set, renders the raw field value with a semantic formatter (percent, duration)
// the default scalar rendering doesn't apply — display-only, like the rest.
type colSpec struct {
Header string
Field string
MaxWidth int
Format func(any) string
}

// fmtPercent renders a 0..1 ratio as a whole-number percentage ("85%"), matching
// the curated insight tables. A non-float value yields "".
func fmtPercent(v any) string {
if f, ok := v.(float64); ok {
return fmt.Sprintf("%.0f%%", f*100)
}
return ""
}

// fmtSecondsDuration renders a seconds count (int or float) as a human duration
// ("2m 30s"), matching the curated insight MTTA/MTTR/engaged columns.
func fmtSecondsDuration(v any) string {
switch n := v.(type) {
case float64:
return output.FormatDurationFloat(n)
case int64:
return output.FormatDuration(int(n))
case int:
return output.FormatDuration(n)
default:
return ""
}
}

// displayColumns maps a go-flashduty response row type (by Go type name) to its
// human table columns, seeded from the hand-written column sets the curated
// commands used before the CLI converged on generated commands. Row types with
// no entry fall back to the reflective heuristic in generic_table.go.
//
// Names are intentionally not resolved here (e.g. ChannelItem shows TEAM_ID /
// CREATOR_ID, not team/creator names): id→name enrichment belongs in the API
// response, not the client. Until the API carries those names, the table shows
// the ids; json/toon is unaffected either way.
var displayColumns = map[string][]colSpec{
"IncidentInfo": {
{Header: "ID", Field: "IncidentID"},
{Header: "TITLE", Field: "Title", MaxWidth: 50},
{Header: "SEVERITY", Field: "IncidentSeverity"},
{Header: "PROGRESS", Field: "Progress"},
{Header: "CHANNEL", Field: "ChannelName"},
{Header: "CREATED", Field: "StartTime"},
},
"PastIncidentItem": {
{Header: "ID", Field: "IncidentID"},
{Header: "TITLE", Field: "Title", MaxWidth: 50},
{Header: "SEVERITY", Field: "IncidentSeverity"},
{Header: "PROGRESS", Field: "Progress"},
{Header: "CHANNEL", Field: "ChannelName"},
{Header: "CREATED", Field: "StartTime"},
},
"AlertInfo": {
{Header: "ALERT_ID", Field: "AlertID"},
{Header: "TITLE", Field: "Title", MaxWidth: 50},
{Header: "SEVERITY", Field: "AlertSeverity"},
{Header: "STATUS", Field: "AlertStatus"},
{Header: "STARTED", Field: "StartTime"},
},
"AlertItem": {
{Header: "ID", Field: "AlertID"},
{Header: "TITLE", Field: "Title", MaxWidth: 50},
{Header: "SEVERITY", Field: "AlertSeverity"},
{Header: "STATUS", Field: "AlertStatus"},
{Header: "EVENTS", Field: "EventCnt"},
{Header: "CHANNEL", Field: "ChannelName"},
{Header: "STARTED", Field: "StartTime"},
},
"AlertEventItem": {
{Header: "EVENT_ID", Field: "EventID"},
{Header: "ALERT_ID", Field: "AlertID"},
{Header: "SEVERITY", Field: "EventSeverity"},
{Header: "STATUS", Field: "EventStatus"},
{Header: "TIME", Field: "EventTime"},
{Header: "TITLE", Field: "Title", MaxWidth: 50},
},
"ChangeItem": {
{Header: "ID", Field: "ChangeID"},
{Header: "TITLE", Field: "Title", MaxWidth: 50},
{Header: "STATUS", Field: "ChangeStatus"},
{Header: "CHANNEL", Field: "ChannelName"},
{Header: "TIME", Field: "StartTime"},
},
"ChannelItem": {
{Header: "ID", Field: "ChannelID"},
{Header: "NAME", Field: "ChannelName", MaxWidth: 40},
{Header: "TEAM_ID", Field: "TeamID"},
{Header: "CREATOR_ID", Field: "CreatorID"},
{Header: "STATUS", Field: "Status"},
},
"TeamItem": {
{Header: "ID", Field: "TeamID"},
{Header: "NAME", Field: "TeamName", MaxWidth: 40},
},
"MemberItem": {
{Header: "ID", Field: "MemberID"},
{Header: "NAME", Field: "MemberName", MaxWidth: 30},
{Header: "EMAIL", Field: "Email"},
{Header: "STATUS", Field: "Status"},
{Header: "TIMEZONE", Field: "TimeZone"},
},
"FieldItem": {
{Header: "ID", Field: "FieldID"},
{Header: "NAME", Field: "FieldName"},
{Header: "DISPLAY_NAME", Field: "DisplayName"},
{Header: "TYPE", Field: "FieldType"},
},
"WarRoomItem": {
{Header: "INTEGRATION", Field: "IntegrationID"},
{Header: "CHAT_ID", Field: "ChatID"},
{Header: "INCIDENT_ID", Field: "IncidentID"},
{Header: "STATUS", Field: "Status"},
{Header: "PLUGIN", Field: "PluginType"},
{Header: "CREATED", Field: "CreatedAt"},
},
"WarRoomPersonItem": {
{Header: "PERSON_ID", Field: "PersonID"},
{Header: "NAME", Field: "PersonName"},
{Header: "EMAIL", Field: "Email"},
{Header: "STATUS", Field: "Status"},
},
// DimensionInsightItem backs both `insight team` and `insight channel` (same
// Go type, different populated name field) — show both name columns, the
// irrelevant one renders empty. Columns mirror the curated insight tables.
"DimensionInsightItem": {
{Header: "TEAM", Field: "TeamName", MaxWidth: 30},
{Header: "CHANNEL", Field: "ChannelName", MaxWidth: 30},
{Header: "INCIDENTS", Field: "TotalIncidentCnt"},
{Header: "ACK%", Field: "AcknowledgementPct", Format: fmtPercent},
{Header: "MTTA", Field: "MeanSecondsToAck", Format: fmtSecondsDuration},
{Header: "MTTR", Field: "MeanSecondsToClose", Format: fmtSecondsDuration},
{Header: "NOISE_REDUCTION", Field: "NoiseReductionPct", Format: fmtPercent},
{Header: "ALERTS", Field: "TotalAlertCnt"},
{Header: "EVENTS", Field: "TotalAlertEventCnt"},
},
"ResponderInsightItem": {
{Header: "RESPONDER", Field: "ResponderName", MaxWidth: 30},
{Header: "INCIDENTS", Field: "TotalIncidentCnt"},
{Header: "ACK%", Field: "AcknowledgementPct", Format: fmtPercent},
{Header: "MTTA", Field: "MeanSecondsToAck", Format: fmtSecondsDuration},
{Header: "INTERRUPTIONS", Field: "TotalInterruptions"},
{Header: "ENGAGED", Field: "TotalEngagedSeconds", Format: fmtSecondsDuration},
},
}
Loading