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.25.1

require (
github.com/flashcatcloud/go-flashduty v0.5.2
github.com/flashcatcloud/go-flashduty v0.5.3-0.20260602031007-62b37649b2f0
github.com/mattn/go-runewidth v0.0.23
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.5.2 h1:mYg/M0jqkil30WTLdICVtTJVGxEIGmae/3zBpRkwLRQ=
github.com/flashcatcloud/go-flashduty v0.5.2/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
github.com/flashcatcloud/go-flashduty v0.5.3-0.20260602031007-62b37649b2f0 h1:mk9ryHQVssVA3qqyH4ryqeWa6sW0tYdww3JWalN3ZH0=
github.com/flashcatcloud/go-flashduty v0.5.3-0.20260602031007-62b37649b2f0/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
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.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func newAlertListCmd() *cobra.Command {
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"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
if active && recovered {
Expand Down Expand Up @@ -115,6 +116,7 @@ func newAlertGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get <alert_id>",
Short: "Get alert detail",
Long: curatedLong("Get the full detail of a single alert by ID.", "Alerts", "ReadInfo"),
Args: requireArgs("alert_id"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
Expand Down Expand Up @@ -177,6 +179,7 @@ func newAlertEventsCmd() *cobra.Command {
return &cobra.Command{
Use: "events <alert_id>",
Short: "List alert events",
Long: curatedLong("List the individual events that compose a given alert.", "Alerts", "ReadEventList"),
Args: requireArgs("alert_id"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
Expand Down Expand Up @@ -212,6 +215,7 @@ func newAlertTimelineCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "timeline <alert_id>",
Short: "View alert timeline",
Long: curatedLong("View the chronological feed of timeline events (actions, state changes) for an alert.", "Alerts", "ReadFeed"),
Args: requireArgs("alert_id"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
Expand Down
1 change: 1 addition & 0 deletions internal/cli/alert_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func newAlertEventListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List alert events globally",
Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type.", "Alerts", "EventReadList"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
startTime, err := timeutil.Parse(since)
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func newAuditSearchCmd() *cobra.Command {
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"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
startTime, err := timeutil.Parse(since)
Expand Down Expand Up @@ -106,8 +107,8 @@ func newAuditSearchCmd() *cobra.Command {
cmd.Flags().StringVar(&since, "since", "7d", "Start time")
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 operation type")
cmd.Flags().IntVar(&limit, "limit", 20, "Max results")
cmd.Flags().StringVar(&operation, "operation", "", "Filter by exact operation name(s) from 'fduty 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().IntVar(&limit, "limit", 20, "Max results (max 99)")
cmd.Flags().IntVar(&page, "page", 1, "Page number")

return cmd
Expand Down
18 changes: 15 additions & 3 deletions internal/cli/change.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ func newChangeListCmd() *cobra.Command {
var channel string
var since, until string
var limit, page int
var query, integration string

cmd := &cobra.Command{
Use: "list",
Short: "List changes",
Long: curatedLong("List changes recorded in the change feed. Time window must be < 31 days; --limit max is 100.", "Changes", "List"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
startTime, err := timeutil.Parse(since)
Expand Down Expand Up @@ -65,6 +67,14 @@ func newChangeListCmd() *cobra.Command {
}
input.ChannelIDs = channelIDs
}
if integration != "" {
integrationIDs, err := parseIntSlice(integration)
if err != nil {
return fmt.Errorf("invalid --integration: %w", err)
}
input.IntegrationIDs = integrationIDs
}
input.Query = query

result, _, err := ctx.Client.Changes.List(cmdContext(ctx.Cmd), input)
if err != nil {
Expand All @@ -85,9 +95,11 @@ func newChangeListCmd() *cobra.Command {
}

cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs")
cmd.Flags().StringVar(&since, "since", "24h", "Start time")
cmd.Flags().StringVar(&until, "until", "now", "End time")
cmd.Flags().IntVar(&limit, "limit", 20, "Max results")
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(&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)")
cmd.Flags().IntVar(&page, "page", 1, "Page number")

return cmd
Expand Down
1 change: 1 addition & 0 deletions internal/cli/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func newChannelListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List channels",
Long: curatedLong("List channels in the account, optionally filtered by name.", "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
Expand Down
33 changes: 21 additions & 12 deletions internal/cli/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import (
// runCommand and passed to the command's handler function. Client is the
// typed go-flashduty SDK every command calls through.
type RunContext struct {
Client *flashduty.Client
Cmd *cobra.Command
Args []string
Writer io.Writer
Printer output.Printer
Format output.Format
Client *flashduty.Client
Cmd *cobra.Command
Args []string
Writer io.Writer
Printer output.Printer
Format output.Format
}

// Structured reports whether output should be a machine-readable dump (JSON or
Expand All @@ -36,12 +36,12 @@ func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) erro
return err
}
ctx := &RunContext{
Client: client,
Cmd: cmd,
Args: args,
Writer: cmd.OutOrStdout(),
Printer: newPrinter(cmd.OutOrStdout()),
Format: currentOutputFormat(),
Client: client,
Cmd: cmd,
Args: args,
Writer: cmd.OutOrStdout(),
Printer: newPrinter(cmd.OutOrStdout()),
Format: currentOutputFormat(),
}
return fn(ctx)
}
Expand Down Expand Up @@ -73,6 +73,15 @@ func (ctx *RunContext) WriteResult(message string) {
writeResult(ctx.Writer, message)
}

// WriteRaw writes a non-JSON response body (e.g. a CSV/file download surfaced
// on Response.Raw by the *export endpoints) straight to the output writer, so
// shell redirection (`> file.csv`) captures the bytes verbatim instead of the
// canned "OK: POST ..." acknowledgment.
func (ctx *RunContext) WriteRaw(body []byte) error {
_, err := ctx.Writer.Write(body)
return err
}

// WriteResultJSON outputs structured data in JSON or TOON mode, or a
// human-readable message in table mode. JSON stays indented (byte-compatible
// with the legacy --json path); TOON routes through the SDK marshaller.
Expand Down
76 changes: 0 additions & 76 deletions internal/cli/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,82 +248,6 @@ func TestCommandIncidentTimelineEmpty(t *testing.T) {
}
}

// ---------------------------------------------------------------------------
// Test 263: statuspage create-incident result with change_id
// ---------------------------------------------------------------------------

func TestCommandStatusPageCreateIncidentWithChangeID(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = map[string]any{"change_id": 12345}

out, err := execCommand("statuspage", "create-incident", "--page-id", "1", "--title", "Outage")
if err != nil {
t.Fatalf("[#263] unexpected error: %v", err)
}

expected := "Status incident created: 12345"
if !strings.Contains(out, expected) {
t.Errorf("[#263] expected output containing %q, got:\n%s", expected, out)
}
}

func TestCommandStatusPageCreateIncidentWithChangeID_JSON(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = map[string]any{"change_id": 12345}

out, err := execCommand("statuspage", "create-incident", "--page-id", "1", "--title", "Outage", "--json")
if err != nil {
t.Fatalf("[#263/json] unexpected error: %v", err)
}

var parsed map[string]string
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &parsed); err != nil {
t.Fatalf("[#263/json] failed to parse JSON output: %v\nraw output:\n%s", err, out)
}
if !strings.Contains(parsed["message"], "12345") {
t.Errorf("[#263/json] expected message containing '12345', got %q", parsed["message"])
}
}

// ---------------------------------------------------------------------------
// Test 264: statuspage create-incident result without change_id
// ---------------------------------------------------------------------------

func TestCommandStatusPageCreateIncidentWithoutChangeID(t *testing.T) {
saveAndResetGlobals(t)
newGFStub(t)

out, err := execCommand("statuspage", "create-incident", "--page-id", "1", "--title", "Outage")
if err != nil {
t.Fatalf("[#264] unexpected error: %v", err)
}

expected := "Status incident created successfully."
if !strings.Contains(out, expected) {
t.Errorf("[#264] expected output containing %q, got:\n%s", expected, out)
}
}

func TestCommandStatusPageCreateIncidentWithoutChangeID_JSON(t *testing.T) {
saveAndResetGlobals(t)
newGFStub(t)

out, err := execCommand("statuspage", "create-incident", "--page-id", "1", "--title", "Outage", "--json")
if err != nil {
t.Fatalf("[#264/json] unexpected error: %v", err)
}

var parsed map[string]string
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &parsed); err != nil {
t.Fatalf("[#264/json] failed to parse JSON output: %v\nraw output:\n%s", err, out)
}
if parsed["message"] != "Status incident created successfully." {
t.Errorf("[#264/json] expected message %q, got %q", "Status incident created successfully.", parsed["message"])
}
}

// ---------------------------------------------------------------------------
// Test 321: member list with PersonInfos
// ---------------------------------------------------------------------------
Expand Down
129 changes: 129 additions & 0 deletions internal/cli/coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package cli

import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"
)

// loadSpecPaths reads every GET/POST operation from the openapi spec shipped in
// the linked go-flashduty module — the same spec cligen generates against —
// returning operationId -> path.
func loadSpecPaths(t *testing.T) map[string]string {
t.Helper()
out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/flashcatcloud/go-flashduty").Output()
if err != nil {
t.Fatalf("locate go-flashduty module: %v", err)
}
specPath := filepath.Join(strings.TrimSpace(string(out)), "openapi", "openapi.en.json")
data, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("read spec: %v", err)
}
var spec struct {
Paths map[string]map[string]struct {
OperationID string `json:"operationId"`
Tags []string `json:"tags"`
} `json:"paths"`
}
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("parse spec: %v", err)
}
ids := map[string]string{}
for path, methods := range spec.Paths {
for verb, op := range methods {
v := strings.ToUpper(verb)
if v != "GET" && v != "POST" {
continue
}
if op.OperationID == "" || len(op.Tags) == 0 {
continue
}
ids[op.OperationID] = path
}
}
return ids
}

// pathCommand derives the path-is-king command for an API path: the first
// segment is the group, the remaining segments hyphen-join into the verb. This
// mirrors cligen's cliGroup/cliVerb (spec path segments are already kebab-case,
// so a plain join matches).
func pathCommand(apiPath string) string {
var segs []string
for _, s := range strings.Split(apiPath, "/") {
if s != "" {
segs = append(segs, s)
}
}
if len(segs) == 0 {
return ""
}
if len(segs) == 1 {
return segs[0]
}
return segs[0] + " " + strings.Join(segs[1:], "-")
}

// leafCommandPaths walks the real rootCmd (built by init()) and returns the set
// of every command path a user can invoke, minus the "flashduty " root prefix.
func leafCommandPaths() map[string]bool {
set := map[string]bool{}
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
for _, sub := range c.Commands() {
path := strings.TrimPrefix(sub.CommandPath(), "flashduty ")
set[path] = true
walk(sub)
}
}
walk(rootCmd)
return set
}

// TestEveryOperationHasPathCommand is the core invariant of the path-is-king
// command tree: every public operation in the spec is reachable at the command
// derived mechanically from its API path (group = first segment, verb = the
// rest hyphen-joined). An agent that knows only the API path can always invoke
// the operation without guessing — generated commands provide the path-name and
// curated commands win the exact name when they already own it.
func TestEveryOperationHasPathCommand(t *testing.T) {
specPaths := loadSpecPaths(t)
leaves := leafCommandPaths()
var missing []string
for _, apiPath := range specPaths {
cmd := pathCommand(apiPath)
if !leaves[cmd] {
missing = append(missing, cmd+" (<= "+apiPath+")")
}
}
if len(missing) > 0 {
t.Errorf("%d operations have no command at their path-name:\n %s",
len(missing), strings.Join(missing, "\n "))
}
t.Logf("path-is-king: all %d operations reachable at their path-name", len(specPaths))
}

// TestGeneratorTargetsFullSpec asserts the generator emitted a command for every
// spec operation (no gaps, no phantom manifest entries from a stale run).
func TestGeneratorTargetsFullSpec(t *testing.T) {
specPaths := loadSpecPaths(t)
gen := map[string]bool{}
for _, id := range generatedOpIDs {
gen[id] = true
if _, ok := specPaths[id]; !ok {
t.Errorf("manifest op %q is not in the current spec (regenerate cligen)", id)
}
}
for id := range specPaths {
if !gen[id] {
t.Errorf("op %q has no generated command (regenerate cligen)", id)
}
}
t.Logf("generator targets %d/%d spec operations", len(gen), len(specPaths))
}
Loading
Loading