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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
go 1.26.0

require (
github.com/flashcatcloud/go-flashduty v0.15.10
github.com/flashcatcloud/go-flashduty v0.15.12
github.com/mattn/go-runewidth v0.0.30
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/flashcatcloud/go-flashduty v0.15.10 h1:OfCx6EpF5tv+qSXGPZcEwEjXbo0KvQBOaw3ggWj+6Hs=
github.com/flashcatcloud/go-flashduty v0.15.10/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
github.com/flashcatcloud/go-flashduty v0.15.12 h1:+x+wSCcVKpxlBfGBMU5vftwk6ducoumYKmlmyDhEO8c=
github.com/flashcatcloud/go-flashduty v0.15.12/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
Expand Down
14 changes: 11 additions & 3 deletions internal/cli/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ func newAlertCmd() *cobra.Command {
}

func newAlertListCmd() *cobra.Command {
var severity, channel, integration, since, until, fields string
var active, recovered, muted bool
var severity, channel, integration, since, until, fields, alertIDs, alertKeys string
var active, recovered, muted, asc, byUpdatedAt 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\"))'. 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"),
Long: curatedLong("List alerts within a time window, optionally filtered by severity, channel, integration, alert ID/key, active/recovered/muted state. --asc sorts oldest first; --by-updated-at applies the window to last-updated time. 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 All @@ -54,6 +54,10 @@ func newAlertListCmd() *cobra.Command {
}
req.Limit = limit
req.Page = page
req.Asc = asc
req.ByUpdatedAt = byUpdatedAt
req.AlertIDs = parseStringSlice(alertIDs)
req.AlertKeys = parseStringSlice(alertKeys)

// Preserve legacy semantics: --active sends is_active=true,
// --recovered sends is_active=false, neither omits the filter.
Expand Down Expand Up @@ -118,6 +122,10 @@ func newAlertListCmd() *cobra.Command {
cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs")
cmd.Flags().StringVar(&integration, "integration", "", "Comma-separated integration IDs")
cmd.Flags().BoolVar(&muted, "muted", false, "Show ever-muted only")
cmd.Flags().StringVar(&alertIDs, "alert-ids", "", "Comma-separated alert IDs to return")
cmd.Flags().StringVar(&alertKeys, "alert-keys", "", "Comma-separated alert deduplication keys")
cmd.Flags().BoolVar(&asc, "asc", false, "Sort oldest first by start time (default newest first)")
cmd.Flags().BoolVar(&byUpdatedAt, "by-updated-at", false, "Apply --since/--until to the last-updated time instead of the start time")
cmd.Flags().StringVar(&since, "since", "24h", "Start time")
cmd.Flags().StringVar(&until, "until", "now", "End time")
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)")
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/alert_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func newAlertEventCmd() *cobra.Command {
func newAlertEventListCmd() *cobra.Command {
var severity, channel, integration, integrationType, since, until, fields string
var limit, page int
var asc bool

cmd := &cobra.Command{
Use: "list",
Expand All @@ -46,6 +47,7 @@ func newAlertEventListCmd() *cobra.Command {
}
input.Limit = limit
input.Page = page
input.Asc = asc

if severity != "" {
// go-flashduty takes severities as a comma-separated string.
Expand Down Expand Up @@ -120,6 +122,7 @@ func newAlertEventListCmd() *cobra.Command {
registerEnumFlag(cmd, "severity", severityEnum...)
cmd.Flags().StringVar(&integration, "integration", "", "Comma-separated integration IDs")
cmd.Flags().StringVar(&integrationType, "integration-type", "", "Comma-separated integration types (plugin keys, e.g. AliCloud,Prometheus) — not integration IDs; use --integration for that")
cmd.Flags().BoolVar(&asc, "asc", false, "Sort oldest first (default newest first)")
cmd.Flags().StringVar(&since, "since", "1h", "Start time")
cmd.Flags().StringVar(&until, "until", "now", "End time")
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)")
Expand Down
85 changes: 85 additions & 0 deletions internal/cli/alert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cli

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
Expand Down Expand Up @@ -214,3 +216,86 @@ func TestCommandAlertMergeDataAndCommentFileBothStdinErrors(t *testing.T) {
t.Fatalf("[alert-merge-double-stdin] expected the double-stdin-read error naming both flags, got: %v", err)
}
}

func TestCommandAlertListFilterFlagsReachWire(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)

if _, err := execCommand("alert", "list", "--alert-ids", "a1,a2", "--alert-keys", "k1, k2", "--asc", "--by-updated-at"); err != nil {
t.Fatalf("[alert-list-filters] unexpected error: %v", err)
}
if stub.lastPath != "/alert/list" {
t.Fatalf("[alert-list-filters] expected /alert/list, got %q", stub.lastPath)
}
for _, f := range []string{"asc", "by_updated_at"} {
if got := stub.lastBody[f]; got != true {
t.Errorf("[alert-list-filters] %s: want true, got %#v", f, got)
}
}
for field, want := range map[string]string{"alert_ids": "a1,a2", "alert_keys": "k1,k2"} {
got, _ := stub.lastBody[field].([]any)
var parts []string
for _, v := range got {
parts = append(parts, fmt.Sprint(v))
}
if strings.Join(parts, ",") != want {
t.Errorf("[alert-list-filters] %s: want %q, got %#v", field, want, stub.lastBody[field])
}
}
}

func TestCommandAlertListFilterFlagsDefaultOmitted(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)

if _, err := execCommand("alert", "list"); err != nil {
t.Fatalf("[alert-list-filters-default] unexpected error: %v", err)
}
for _, f := range []string{"alert_ids", "alert_keys", "asc", "by_updated_at"} {
if _, ok := stub.lastBody[f]; ok {
t.Errorf("[alert-list-filters-default] %s should be omitted by default, got %#v", f, stub.lastBody[f])
}
}
}

func TestCommandAlertEventListAsc(t *testing.T) {
for _, tc := range []struct {
name string
extra []string
want any
}{
{name: "default omits asc", want: nil},
{name: "flag sends asc", extra: []string{"--asc"}, want: true},
} {
t.Run(tc.name, func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)

if _, err := execCommand(append([]string{"alert-event", "list"}, tc.extra...)...); err != nil {
t.Fatalf("[alert-event-list-asc] unexpected error: %v", err)
}
if stub.lastPath != "/alert-event/list" {
t.Fatalf("[alert-event-list-asc] expected /alert-event/list, got %q", stub.lastPath)
}
if got := stub.lastBody["asc"]; got != tc.want {
t.Fatalf("[alert-event-list-asc] asc: want %#v, got %#v", tc.want, got)
}
})
}
}

func TestCommandAlertMergeCommentFileReachesWire(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)

path := filepath.Join(t.TempDir(), "comment.txt")
if err := os.WriteFile(path, []byte("merge `reason` $(x)"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := execCommand("alert", "merge", "a1", "--incident-id", "i1", "--comment-file", path); err != nil {
t.Fatalf("[alert-merge-comment] unexpected error: %v", err)
}
if got := stub.lastBody["comment"]; got != "merge `reason` $(x)" {
t.Fatalf("[alert-merge-comment] comment: got %#v", got)
}
}
15 changes: 13 additions & 2 deletions internal/cli/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ func newAuditCmd() *cobra.Command {
}

func newAuditSearchCmd() *cobra.Command {
var since, until, operation string
var since, until, operation, requestID string
var isWrite, isDangerous bool
var person int64
var limit, page int

cmd := &cobra.Command{
Use: "search",
Short: "Search audit logs",
Long: curatedLong("Search audit logs within a time window, optionally filtered by person and operation type. The --since/--until window must be < 90 days; --limit max is 99.", "AuditLogs", "Search"),
Long: curatedLong("Search audit logs within a time window, optionally filtered by person, operation type, write/read, risk level and request ID. The --since/--until window must be < 90 days; --limit max is 99.", "AuditLogs", "Search"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
startTime, err := timeutil.Parse(since)
Expand All @@ -41,6 +42,13 @@ func newAuditSearchCmd() *cobra.Command {
EndTime: endTime,
Limit: int64(limit),
PersonID: uint64(person),
RequestID: requestID,
}
if cmd.Flags().Changed("is-write") {
input.IsWrite = &isWrite
}
if cmd.Flags().Changed("is-dangerous") {
input.IsDangerous = &isDangerous
}
if operation != "" {
input.Operations = parseStringSlice(operation)
Expand Down Expand Up @@ -105,6 +113,9 @@ func newAuditSearchCmd() *cobra.Command {
cmd.Flags().StringVar(&until, "until", "now", "End time")
cmd.Flags().Int64Var(&person, "person", 0, "Filter by person ID")
cmd.Flags().StringVar(&operation, "operation", "", "Filter by exact operation name(s) from 'flashduty audit operation-list' (e.g. monitRule:write:update); comma-separate to match several in one call. Prefixes do NOT match (\"monitRule\" returns nothing).")
cmd.Flags().StringVar(&requestID, "request-id", "", "Filter to a single request by its request ID")
cmd.Flags().BoolVar(&isWrite, "is-write", false, "Only write operations; --is-write=false returns only read operations")
cmd.Flags().BoolVar(&isDangerous, "is-dangerous", false, "Only high-risk (dangerous) operations")
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 99)")
cmd.Flags().IntVar(&page, "page", 1, "Page number")

Expand Down
17 changes: 16 additions & 1 deletion internal/cli/change.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"encoding/json"
"fmt"

"github.com/flashcatcloud/go-flashduty"
Expand All @@ -20,7 +21,9 @@ func newChangeListCmd() *cobra.Command {
var channel string
var since, until string
var limit, page int
var query, integration string
var query, integration, filters string
var orderby string
var asc, includeEvents bool

cmd := &cobra.Command{
Use: "list",
Expand Down Expand Up @@ -72,6 +75,14 @@ func newChangeListCmd() *cobra.Command {
input.IntegrationIDs = integrationIDs
}
input.Query = query
input.Orderby = orderby
input.Asc = asc
input.IncludeEvents = includeEvents
if filters != "" {
if err := json.Unmarshal([]byte(filters), &input.Filters); err != nil {
return fmt.Errorf("invalid --filters: %w", err)
}
}

result, _, err := ctx.Client.Changes.List(cmdContext(ctx.Cmd), input)
if err != nil {
Expand All @@ -94,6 +105,10 @@ func newChangeListCmd() *cobra.Command {
cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs")
cmd.Flags().StringVar(&query, "query", "", "Free-text/regex search over change fields")
cmd.Flags().StringVar(&integration, "integration", "", "Comma-separated reporting integration IDs")
cmd.Flags().StringVar(&filters, "filters", "", `Structured filters ANDed onto the query, as a JSON array of {"key","oper","vals"} (oper IN or NOTIN; key like labels.env). Keys starting with "incident" are ignored`)
cmd.Flags().StringVar(&orderby, "orderby", "", "Sort field: start_time (default) or last_time")
cmd.Flags().BoolVar(&asc, "asc", false, "Sort in ascending order")
cmd.Flags().BoolVar(&includeEvents, "include-events", false, "Include the underlying change events for each change")
cmd.Flags().StringVar(&since, "since", "24h", "Start time (accepts 7d/24h/now, RFC3339, or Unix epoch; window must be < 31 days)")
cmd.Flags().StringVar(&until, "until", "now", "End time (accepts 7d/24h/now, RFC3339, or Unix epoch)")
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)")
Expand Down
54 changes: 39 additions & 15 deletions internal/cli/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package cli

import (
"fmt"
"regexp"
"strconv"
"strings"

"github.com/flashcatcloud/go-flashduty"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -37,31 +37,45 @@ type channelRow struct {
}

func newChannelListCmd() *cobra.Command {
var name string
var teamIDs []int64
var name, channelName, orderby string
var teamIDs, channelIDs []int64
var page, limit int
var asc, isBrief, isMyManaged, isMyStarred, isMyTeam bool

cmd := &cobra.Command{
Use: "list",
Short: "List channels",
Long: curatedLong("List channels in the account, optionally filtered by name or owning team.", "Channels", "ChannelList"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
// Legacy parity: the hand-written SDK called /channel/list with an
// empty body and applied the --name filter client-side as a
// case-insensitive substring match. go-flashduty's ChannelName field
// is an exact-match server filter, so we keep the client-side filter
// to preserve behavior. --team-ids, by contrast, is a server-side
// filter on the channel's owning team (empty = all teams, unchanged).
result, _, err := ctx.Client.Channels.ChannelList(cmdContext(ctx.Cmd), &flashduty.ListChannelsRequest{TeamIDs: teamIDs})
// --name keeps its case-insensitive substring semantics by sending
// the escaped text as the server's query regex, so the match runs
// across every page instead of only the page returned.
req := &flashduty.ListChannelsRequest{
TeamIDs: teamIDs,
ChannelIDs: channelIDs,
ChannelName: channelName,
Query: regexp.QuoteMeta(name),
Orderby: orderby,
Asc: asc,
IsBrief: isBrief,
IsMyManaged: isMyManaged,
IsMyStarred: isMyStarred,
IsMyTeam: isMyTeam,
}
if cmd.Flags().Changed("page") {
req.Page = page
}
if cmd.Flags().Changed("limit") {
req.Limit = limit
}
result, _, err := ctx.Client.Channels.ChannelList(cmdContext(ctx.Cmd), req)
if err != nil {
return err
}

rows := make([]channelRow, 0, len(result.Items))
for _, ch := range result.Items {
if name != "" && !strings.Contains(strings.ToLower(ch.ChannelName), strings.ToLower(name)) {
continue
}
rows = append(rows, channelRow{
ChannelID: ch.ChannelID,
ChannelName: ch.ChannelName,
Expand All @@ -82,12 +96,22 @@ func newChannelListCmd() *cobra.Command {
{Header: "CREATOR", Field: func(v any) string { return v.(channelRow).CreatorName }},
}

return ctx.PrintTotal(rows, cols, len(rows))
return ctx.PrintTotal(rows, cols, int(result.Total))
})
},
}

cmd.Flags().StringVar(&name, "name", "", "Search by name")
cmd.Flags().StringVar(&name, "name", "", "Case-insensitive substring of the channel name or description, server-side")
cmd.Flags().StringVar(&channelName, "channel-name", "", "Exact channel name, server-side")
cmd.Flags().Int64SliceVar(&channelIDs, "channel-ids", nil, "Filter by channel ID(s) (repeatable or comma-separated)")
cmd.Flags().BoolVar(&isMyTeam, "is-my-team", false, "Only channels owned by your teams (exclusive with --is-my-starred)")
cmd.Flags().BoolVar(&isMyStarred, "is-my-starred", false, "Only channels you starred (exclusive with --is-my-team)")
cmd.Flags().BoolVar(&isMyManaged, "is-my-managed", false, "Only channels you manage")
cmd.Flags().BoolVar(&isBrief, "is-brief", false, "Return only id, name, description and status, all matches without pagination")
cmd.Flags().StringVar(&orderby, "orderby", "", "Sort field: ranking, created_at, updated_at, channel_name, or last_incident_at (server default: created_at)")
cmd.Flags().BoolVar(&asc, "asc", false, "Sort in ascending order")
cmd.Flags().IntVar(&page, "page", 1, "Page number")
cmd.Flags().IntVar(&limit, "limit", 100, "Page size (server default 100)")
cmd.Flags().Int64SliceVar(&teamIDs, "team-ids", nil, "Filter by owning team ID(s), server-side (repeatable or comma-separated)")

return cmd
Expand Down
Loading
Loading