From f6e51eb6e981b5919d0a3e46afe35ae8e0516902 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sat, 30 May 2026 17:50:37 +0800 Subject: [PATCH 01/10] feat: migrate covered+non-enriched commands onto go-flashduty (dual-client) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Begin moving flashduty-cli off the hand-written flashduty-sdk onto the generated go-flashduty client. This is a behavior-preserving, dual-client transition: go.mod requires both SDKs, migrated handlers call the new `*go-flashduty.Client` (wired via `RunContext.GFClient` / `newGFClientFn`), and every deferred handler keeps an in-code `TODO(go-flashduty migration)` naming exactly why it stays on the legacy client. Migrated (28 commands): incident batch ops (ack/unack/close→Resolve/wake/ merge/snooze/reopen/reassign→Assign/add-responder/comment/disable-merge/ remove/create + war-room list/get/delete), alert merge/events, alert-event list, insight team/channel/responder/top-alerts, audit search, statuspage create-timeline, monit-query diagnose, monit-agent catalog/invoke. Deferred on legacy SDK (each TODO-annotated): - endpoint gap (pending upstream): war-room create/add-member/default- observers, change list/trend, statuspage list, insight notifications, mcp create - shape/enrichment divergence: incident list/get/timeline/feed/similar/ postmortem, alert list/get/timeline, oncall who/schedule, statuspage changes/create-incident, incident update (/reset drops --field), monit-query rows (raw→structured) Other changes: - TOON output moved from `sdk.Marshal(.., TOON)` to `toon-go` directly (toon promoted to a direct dep); SDK stays a pure client. - New test seam: go-flashduty's client is a concrete type, not an interface, so migrated-command tests run against `gfStub`, an httptest server that records the path + decoded body and replies with a canned envelope (internal/cli/gfstub_test.go). - Preserve exact legacy wire on assignment: both `incident create` and `incident reassign` set assigned_to.type = "assign" explicitly (the hand-written SDK forced it; leaving it empty would let the backend relabel an already-assigned incident as "reassign"). Guarded by two new wire-assertion tests. Verified: go build + go test (all 6 packages) + go vet + gofmt clean; in-process e2e against api-dev passes for migrated commands with coverage (remaining e2e failures reproduce identically on a clean origin/main baseline — stale --title flag tests, 31-day-window/429/401 env issues, and the not-migrated `change list`). --- go.mod | 3 +- go.sum | 2 + internal/cli/alert.go | 23 +-- internal/cli/alert_event.go | 30 ++-- internal/cli/audit.go | 30 ++-- internal/cli/command.go | 44 ++++- internal/cli/command_test.go | 267 +++++++++++++++++++------------ internal/cli/gfstub_test.go | 95 +++++++++++ internal/cli/helpers.go | 30 ++-- internal/cli/incident.go | 137 ++++++++++------ internal/cli/insight.go | 65 ++++---- internal/cli/monit_agent.go | 14 +- internal/cli/monit_agent_test.go | 160 ++++++------------ internal/cli/monit_query.go | 23 +-- internal/cli/monit_query_test.go | 53 +++--- internal/cli/root.go | 42 ++++- internal/cli/status_page.go | 13 +- internal/output/toon.go | 9 +- 18 files changed, 637 insertions(+), 403 deletions(-) create mode 100644 internal/cli/gfstub_test.go diff --git a/go.mod b/go.mod index b962015..cf47e59 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,11 @@ go 1.25.1 require ( github.com/flashcatcloud/flashduty-sdk v0.9.1 + github.com/flashcatcloud/go-flashduty v0.3.0 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 + github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -14,7 +16,6 @@ require ( require ( github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.43.0 // indirect ) diff --git a/go.sum b/go.sum index fe5916d..4bd07a3 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/flashcatcloud/flashduty-sdk v0.9.1 h1:vDTkSjAJJD6Ex5r7S+VCxPi4yxSFNw1bU/SfoRCvk+k= github.com/flashcatcloud/flashduty-sdk v0.9.1/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= +github.com/flashcatcloud/go-flashduty v0.3.0 h1:DlwkrK/MIkkWfqJoKwvq3fh/8A0A3OUEbAMDIRrkLkI= +github.com/flashcatcloud/go-flashduty v0.3.0/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= diff --git a/internal/cli/alert.go b/internal/cli/alert.go index 54d2287..37c8193 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -6,6 +6,7 @@ import ( "strings" flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -173,28 +174,28 @@ func newAlertEventsCmd() *cobra.Command { Short: "List alert events", Args: requireArgs("alert_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.ListAlertEvents(cmdContext(ctx.Cmd), &flashduty.ListAlertEventsInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.GFClient.Alerts.ReadEventList(cmdContext(ctx.Cmd), &gflashduty.AlertEventListRequest{ AlertID: ctx.Args[0], }) if err != nil { return err } - if len(result.AlertEvents) == 0 { + if len(result.Items) == 0 { ctx.WriteResult("No alert events found.") return nil } cols := []output.Column{ - {Header: "EVENT_ID", Field: func(v any) string { return v.(flashduty.AlertEvent).EventID }}, - {Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.AlertEvent).EventSeverity }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.AlertEvent).EventStatus }}, - {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.AlertEvent).EventTime) }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEvent).Title }}, + {Header: "EVENT_ID", Field: func(v any) string { return v.(gflashduty.AlertEventItem).EventID }}, + {Header: "SEVERITY", Field: func(v any) string { return v.(gflashduty.AlertEventItem).EventSeverity }}, + {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.AlertEventItem).EventStatus }}, + {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(gflashduty.AlertEventItem).EventTime) }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.AlertEventItem).Title }}, } - return ctx.PrintTotal(result.AlertEvents, cols, len(result.AlertEvents)) + return ctx.PrintTotal(result.Items, cols, len(result.Items)) }) }, } @@ -255,8 +256,8 @@ func newAlertMergeCmd() *cobra.Command { Short: "Merge alerts into an incident", Args: requireArgs("alert_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.MergeAlertsToIncident(cmdContext(ctx.Cmd), &flashduty.MergeAlertsInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.GFClient.Alerts.WriteMerge(cmdContext(ctx.Cmd), &gflashduty.AlertMergeRequest{ AlertIDs: ctx.Args, IncidentID: incidentID, Comment: comment, diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index e99e482..f93a7c6 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -2,8 +2,9 @@ package cli import ( "fmt" + "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -27,7 +28,7 @@ func newAlertEventListCmd() *cobra.Command { Use: "list", Short: "List alert events globally", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -37,15 +38,16 @@ func newAlertEventListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &flashduty.ListAlertEventsGlobalInput{ + input := &gflashduty.AlertEventGlobalListRequest{ StartTime: startTime, EndTime: endTime, - Limit: limit, - Page: page, } + input.Limit = limit + input.Page = page if severity != "" { - input.Severities = parseStringSlice(severity) + // go-flashduty takes severities as a comma-separated string. + input.Severities = strings.Join(parseStringSlice(severity), ",") } if channel != "" { @@ -60,21 +62,21 @@ func newAlertEventListCmd() *cobra.Command { input.IntegrationTypes = parseStringSlice(integrationType) } - result, err := ctx.Client.ListAlertEventsGlobal(cmdContext(ctx.Cmd), input) + result, _, err := ctx.GFClient.Alerts.EventReadList(cmdContext(ctx.Cmd), input) if err != nil { return err } cols := []output.Column{ - {Header: "EVENT_ID", Field: func(v any) string { return v.(flashduty.AlertEvent).EventID }}, - {Header: "ALERT_ID", Field: func(v any) string { return v.(flashduty.AlertEvent).AlertID }}, - {Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.AlertEvent).EventSeverity }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.AlertEvent).EventStatus }}, - {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.AlertEvent).EventTime) }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEvent).Title }}, + {Header: "EVENT_ID", Field: func(v any) string { return v.(gflashduty.AlertEventItem).EventID }}, + {Header: "ALERT_ID", Field: func(v any) string { return v.(gflashduty.AlertEventItem).AlertID }}, + {Header: "SEVERITY", Field: func(v any) string { return v.(gflashduty.AlertEventItem).EventSeverity }}, + {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.AlertEventItem).EventStatus }}, + {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(gflashduty.AlertEventItem).EventTime) }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.AlertEventItem).Title }}, } - return ctx.PrintList(result.AlertEvents, cols, len(result.AlertEvents), page, result.Total) + return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) }) }, } diff --git a/internal/cli/audit.go b/internal/cli/audit.go index 99dc5b9..8a10055 100644 --- a/internal/cli/audit.go +++ b/internal/cli/audit.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -28,7 +28,7 @@ func newAuditSearchCmd() *cobra.Command { Use: "search", Short: "Search audit logs", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -38,23 +38,23 @@ func newAuditSearchCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &flashduty.SearchAuditLogsInput{ + input := &gflashduty.AuditSearchRequest{ StartTime: startTime, EndTime: endTime, - Limit: limit, - PersonID: person, + Limit: int64(limit), + PersonID: uint64(person), } if operation != "" { input.Operations = parseStringSlice(operation) } var ( - result *flashduty.SearchAuditLogsOutput + result *gflashduty.AuditSearchResponse cursor string ) for currentPage := 1; currentPage <= page; currentPage++ { input.SearchAfterCtx = cursor - result, err = ctx.Client.SearchAuditLogs(cmdContext(ctx.Cmd), input) + result, _, err = ctx.GFClient.AuditLogs.Search(cmdContext(ctx.Cmd), input) if err != nil { return err } @@ -62,9 +62,9 @@ func newAuditSearchCmd() *cobra.Command { break } if result.SearchAfterCtx == "" { - result = &flashduty.SearchAuditLogsOutput{ - AuditLogs: []flashduty.AuditLogRecord{}, - Total: result.Total, + result = &gflashduty.AuditSearchResponse{ + Docs: []gflashduty.AuditLog{}, + Total: result.Total, } break } @@ -73,24 +73,24 @@ func newAuditSearchCmd() *cobra.Command { cols := []output.Column{ {Header: "TIME", Field: func(v any) string { - return output.FormatTime(v.(flashduty.AuditLogRecord).CreatedAt) + return output.FormatTime(v.(gflashduty.AuditLog).CreatedAt) }}, {Header: "PERSON", MaxWidth: 20, Field: func(v any) string { - r := v.(flashduty.AuditLogRecord) + r := v.(gflashduty.AuditLog) if r.MemberName != "" { return r.MemberName } return fmt.Sprintf("%d", r.MemberID) }}, {Header: "OPERATION", MaxWidth: 30, Field: func(v any) string { - r := v.(flashduty.AuditLogRecord) + r := v.(gflashduty.AuditLog) if r.OperationName != "" { return r.OperationName } return r.Operation }}, {Header: "DETAIL", MaxWidth: 50, Field: func(v any) string { - r := v.(flashduty.AuditLogRecord) + r := v.(gflashduty.AuditLog) if r.Body != "" { return r.Body } @@ -98,7 +98,7 @@ func newAuditSearchCmd() *cobra.Command { }}, } - return ctx.PrintList(result.AuditLogs, cols, len(result.AuditLogs), page, int(result.Total)) + return ctx.PrintList(result.Docs, cols, len(result.Docs), page, int(result.Total)) }) }, } diff --git a/internal/cli/command.go b/internal/cli/command.go index bb93e17..6adcf22 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -4,6 +4,7 @@ import ( "fmt" "io" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -11,13 +12,21 @@ import ( // RunContext provides helpers for command execution. It is created by // runCommand and passed to the command's handler function. +// +// Two SDK clients are exposed during the go-flashduty migration: +// - Client — the legacy hand-written SDK, still used by commands that depend +// on server-side enrichment or endpoints go-flashduty does not yet cover. +// - GFClient — the typed go-flashduty SDK, used by migrated commands. +// +// A command uses exactly one of them; the boundary is per-command, not mixed. type RunContext struct { - Client flashdutyClient - Cmd *cobra.Command - Args []string - Writer io.Writer - Printer output.Printer - Format output.Format + Client flashdutyClient + GFClient *gflashduty.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 @@ -27,6 +36,10 @@ func (ctx *RunContext) Structured() bool { return ctx.Format.Structured() } // runCommand creates a client and RunContext, then calls fn. // It centralises setup that every API-backed command repeats. +// +// It constructs the legacy client only; commands migrated to go-flashduty use +// runGFCommand instead. Both factories read the same resolved config, so the +// two paths authenticate identically. func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) error) error { client, err := newClient() if err != nil { @@ -43,6 +56,25 @@ func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) erro return fn(ctx) } +// runGFCommand is the go-flashduty counterpart of runCommand. It constructs the +// typed go-flashduty client and leaves RunContext.Client nil — migrated command +// handlers must reach for ctx.GFClient. +func runGFCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) error) error { + client, err := newGFClient() + if err != nil { + return err + } + ctx := &RunContext{ + GFClient: client, + Cmd: cmd, + Args: args, + Writer: cmd.OutOrStdout(), + Printer: newPrinter(cmd.OutOrStdout()), + Format: currentOutputFormat(), + } + return fn(ctx) +} + // PrintList prints items as a table and appends a "Showing N results (page P, total T)." footer. func (ctx *RunContext) PrintList(items any, cols []output.Column, count, page, total int) error { if err := ctx.Printer.Print(items, cols); err != nil { diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 1ef73b6..d24708d 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -311,6 +311,7 @@ func saveAndResetGlobals(t *testing.T) { t.Helper() origNewClientFn := newClientFn + origNewGFClientFn := newGFClientFn origFlagJSON := flagJSON origFlagNoTrunc := flagNoTrunc origFlagAppKey := flagAppKey @@ -324,6 +325,7 @@ func saveAndResetGlobals(t *testing.T) { t.Cleanup(func() { newClientFn = origNewClientFn + newGFClientFn = origNewGFClientFn flagJSON = origFlagJSON flagNoTrunc = origFlagNoTrunc flagAppKey = origFlagAppKey @@ -433,16 +435,11 @@ func TestCommandIncidentGetEmptyResults(t *testing.T) { // Test 199: incident create result without incident_id // --------------------------------------------------------------------------- -type mockCreateNoID struct{ mockClient } - -func (m *mockCreateNoID) CreateIncident(_ context.Context, _ *flashduty.CreateIncidentInput) (*flashduty.CreateIncidentOutput, error) { - // Return an output with no incident_id to exercise the success fallback. - return &flashduty.CreateIncidentOutput{}, nil -} - func TestCommandIncidentCreateWithoutIncidentID(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockCreateNoID{}, nil } + // Empty data → no incident_id, so the command falls back to the generic + // success message. + newGFStub(t) out, err := execCommand("incident", "create", "--title", "Test incident", "--severity", "Warning") if err != nil { @@ -457,7 +454,7 @@ func TestCommandIncidentCreateWithoutIncidentID(t *testing.T) { func TestCommandIncidentCreateWithoutIncidentID_JSON(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockCreateNoID{}, nil } + newGFStub(t) out, err := execCommand("incident", "create", "--title", "Test incident", "--severity", "Warning", "--json") if err != nil { @@ -473,6 +470,64 @@ func TestCommandIncidentCreateWithoutIncidentID_JSON(t *testing.T) { } } +// These two guard the migration's behavior-preservation: the hand-written SDK +// forced assigned_to.type = "assign" on both create and reassign, and the +// go-flashduty port keeps that exact wire (see incident.go). Without the +// explicit Type the backend would relabel an already-assigned incident as +// "reassign". +func TestCommandIncidentCreateSetsAssignType(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + _, err := execCommand( + "incident", "create", + "--title", "Disk full", "--severity", "Warning", + "--assign", "101,202", + ) + if err != nil { + t.Fatalf("[incident-create-assign] unexpected error: %v", err) + } + if stub.lastPath != "/incident/create" { + t.Fatalf("[incident-create-assign] expected /incident/create, got %q", stub.lastPath) + } + assignedTo, ok := stub.lastBody["assigned_to"].(map[string]any) + if !ok { + t.Fatalf("[incident-create-assign] expected assigned_to object, got %#v", stub.lastBody["assigned_to"]) + } + if assignedTo["type"] != "assign" { + t.Fatalf("[incident-create-assign] expected assigned_to.type=assign (legacy wire), got %#v", assignedTo["type"]) + } + if got, want := fmt.Sprint(assignedTo["person_ids"]), "[101 202]"; got != want { + t.Fatalf("[incident-create-assign] expected person_ids %q, got %q", want, got) + } +} + +func TestCommandIncidentReassignSetsAssignType(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + _, err := execCommand("incident", "reassign", "inc-1", "--person", "303,404") + if err != nil { + t.Fatalf("[incident-reassign-assign] unexpected error: %v", err) + } + if stub.lastPath != "/incident/assign" { + t.Fatalf("[incident-reassign-assign] expected /incident/assign, got %q", stub.lastPath) + } + if got, want := strings.Join(stub.bodyStrings("incident_ids"), ","), "inc-1"; got != want { + t.Fatalf("[incident-reassign-assign] expected incident_ids %q, got %q", want, got) + } + assignedTo, ok := stub.lastBody["assigned_to"].(map[string]any) + if !ok { + t.Fatalf("[incident-reassign-assign] expected assigned_to object, got %#v", stub.lastBody["assigned_to"]) + } + if assignedTo["type"] != "assign" { + t.Fatalf("[incident-reassign-assign] expected assigned_to.type=assign (legacy wire), got %#v", assignedTo["type"]) + } + if got, want := fmt.Sprint(assignedTo["person_ids"]), "[303 404]"; got != want { + t.Fatalf("[incident-reassign-assign] expected person_ids %q, got %q", want, got) + } +} + // --------------------------------------------------------------------------- // Test 223: incident timeline empty // --------------------------------------------------------------------------- @@ -834,14 +889,16 @@ func (m *mockIncidentLifecycle) AddIncidentResponders(_ context.Context, input * func TestCommandIncidentUnack(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentLifecycle{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) out, err := execCommand("incident", "unack", "inc-1", "inc-2") if err != nil { t.Fatalf("[incident-unack] unexpected error: %v", err) } - if got, want := strings.Join(mock.unackIDs, ","), "inc-1,inc-2"; got != want { + if stub.lastPath != "/incident/unack" { + t.Fatalf("[incident-unack] expected /incident/unack, got %q", stub.lastPath) + } + if got, want := strings.Join(stub.bodyStrings("incident_ids"), ","), "inc-1,inc-2"; got != want { t.Fatalf("[incident-unack] expected ids %q, got %q", want, got) } if !strings.Contains(out, "Unacknowledged 2 incident(s).") { @@ -851,14 +908,16 @@ func TestCommandIncidentUnack(t *testing.T) { func TestCommandIncidentWake(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentLifecycle{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) out, err := execCommand("incident", "wake", "inc-1") if err != nil { t.Fatalf("[incident-wake] unexpected error: %v", err) } - if got, want := strings.Join(mock.wakeIDs, ","), "inc-1"; got != want { + if stub.lastPath != "/incident/wake" { + t.Fatalf("[incident-wake] expected /incident/wake, got %q", stub.lastPath) + } + if got, want := strings.Join(stub.bodyStrings("incident_ids"), ","), "inc-1"; got != want { t.Fatalf("[incident-wake] expected ids %q, got %q", want, got) } if !strings.Contains(out, "Restored notifications for 1 incident(s).") { @@ -868,21 +927,20 @@ func TestCommandIncidentWake(t *testing.T) { func TestCommandIncidentComment(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentLifecycle{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) out, err := execCommand("incident", "comment", "inc-1", "inc-2", "--comment", "rollback started", "--mute-reply") if err != nil { t.Fatalf("[incident-comment] unexpected error: %v", err) } - if mock.commentInput == nil { - t.Fatal("[incident-comment] expected CommentIncidents to be called") + if stub.lastPath != "/incident/comment" { + t.Fatalf("[incident-comment] expected /incident/comment, got %q", stub.lastPath) } - if got, want := strings.Join(mock.commentInput.IncidentIDs, ","), "inc-1,inc-2"; got != want { + if got, want := strings.Join(stub.bodyStrings("incident_ids"), ","), "inc-1,inc-2"; got != want { t.Fatalf("[incident-comment] expected ids %q, got %q", want, got) } - if mock.commentInput.Comment != "rollback started" || !mock.commentInput.MuteReply { - t.Fatalf("[incident-comment] unexpected input: %#v", mock.commentInput) + if stub.lastBody["comment"] != "rollback started" || stub.lastBody["mute_reply"] != true { + t.Fatalf("[incident-comment] unexpected input: %#v", stub.lastBody) } if !strings.Contains(out, "Commented on 2 incident(s).") { t.Fatalf("[incident-comment] unexpected output:\n%s", out) @@ -891,16 +949,15 @@ func TestCommandIncidentComment(t *testing.T) { func TestCommandIncidentCommentAllows1024UnicodeRunes(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentLifecycle{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) comment := strings.Repeat("界", 1024) _, err := execCommand("incident", "comment", "inc-1", "--comment", comment) if err != nil { t.Fatalf("[incident-comment-unicode] unexpected error: %v", err) } - if mock.commentInput == nil || mock.commentInput.Comment != comment { - t.Fatalf("[incident-comment-unicode] unexpected input: %#v", mock.commentInput) + if stub.lastBody["comment"] != comment { + t.Fatalf("[incident-comment-unicode] unexpected input: %#v", stub.lastBody) } } @@ -941,8 +998,7 @@ func TestCommandIncidentLifecycleRejectsMoreThan100IDs(t *testing.T) { func TestCommandIncidentAddResponder(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentLifecycle{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) out, err := execCommand( "incident", "add-responder", "inc-1", @@ -954,23 +1010,25 @@ func TestCommandIncidentAddResponder(t *testing.T) { if err != nil { t.Fatalf("[incident-add-responder] unexpected error: %v", err) } - if mock.responderInput == nil { - t.Fatal("[incident-add-responder] expected AddIncidentResponders to be called") + if stub.lastPath != "/incident/responder/add" { + t.Fatalf("[incident-add-responder] expected /incident/responder/add, got %q", stub.lastPath) } - if mock.responderInput.IncidentID != "inc-1" { - t.Fatalf("[incident-add-responder] expected incident inc-1, got %q", mock.responderInput.IncidentID) + if stub.lastBody["incident_id"] != "inc-1" { + t.Fatalf("[incident-add-responder] expected incident inc-1, got %v", stub.lastBody["incident_id"]) } - if got, want := fmt.Sprint(mock.responderInput.PersonIDs), "[101 202]"; got != want { + if got, want := fmt.Sprint(stub.lastBody["person_ids"]), "[101 202]"; got != want { t.Fatalf("[incident-add-responder] expected people %q, got %q", want, got) } - if mock.responderInput.Notify == nil || !mock.responderInput.Notify.FollowPreference { - t.Fatalf("[incident-add-responder] expected follow preference notify, got %#v", mock.responderInput.Notify) + notify, ok := stub.lastBody["notify"].(map[string]any) + if !ok || notify["follow_preference"] != true { + t.Fatalf("[incident-add-responder] expected follow preference notify, got %#v", stub.lastBody["notify"]) } - if got, want := strings.Join(mock.responderInput.Notify.PersonalChannels, ","), "voice,sms"; got != want { + channels, _ := notify["personal_channels"].([]any) + if got, want := fmt.Sprint(channels), "[voice sms]"; got != want { t.Fatalf("[incident-add-responder] expected channels %q, got %q", want, got) } - if mock.responderInput.Notify.TemplateID != "6321aad26c12104586a88916" { - t.Fatalf("[incident-add-responder] unexpected template id: %#v", mock.responderInput.Notify) + if notify["template_id"] != "6321aad26c12104586a88916" { + t.Fatalf("[incident-add-responder] unexpected template id: %#v", notify) } if !strings.Contains(out, "Added 2 responder(s) to incident inc-1.") { t.Fatalf("[incident-add-responder] unexpected output:\n%s", out) @@ -979,15 +1037,14 @@ func TestCommandIncidentAddResponder(t *testing.T) { func TestCommandIncidentRemoveRequiresForceWhenNonInteractive(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentLifecycle{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) out, err := execCommand("incident", "remove", "inc-1") if err != nil { t.Fatalf("[incident-remove-abort] unexpected error: %v", err) } - if len(mock.removeIDs) != 0 { - t.Fatalf("[incident-remove-abort] remove should not be called, got ids %#v", mock.removeIDs) + if stub.requests != 0 { + t.Fatalf("[incident-remove-abort] remove should not be called, got %d request(s)", stub.requests) } if !strings.Contains(out, "Aborted.") { t.Fatalf("[incident-remove-abort] unexpected output:\n%s", out) @@ -996,14 +1053,16 @@ func TestCommandIncidentRemoveRequiresForceWhenNonInteractive(t *testing.T) { func TestCommandIncidentRemoveWithForce(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentLifecycle{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) out, err := execCommand("incident", "remove", "inc-1", "inc-2", "--force") if err != nil { t.Fatalf("[incident-remove-force] unexpected error: %v", err) } - if got, want := strings.Join(mock.removeIDs, ","), "inc-1,inc-2"; got != want { + if stub.lastPath != "/incident/remove" { + t.Fatalf("[incident-remove-force] expected /incident/remove, got %q", stub.lastPath) + } + if got, want := strings.Join(stub.bodyStrings("incident_ids"), ","), "inc-1,inc-2"; got != want { t.Fatalf("[incident-remove-force] expected ids %q, got %q", want, got) } if !strings.Contains(out, "Removed 2 incident(s).") { @@ -1013,14 +1072,16 @@ func TestCommandIncidentRemoveWithForce(t *testing.T) { func TestCommandIncidentDisableMerge(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentLifecycle{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) out, err := execCommand("incident", "disable-merge", "inc-1", "inc-2") if err != nil { t.Fatalf("[incident-disable-merge] unexpected error: %v", err) } - if got, want := strings.Join(mock.disableMergeIDs, ","), "inc-1,inc-2"; got != want { + if stub.lastPath != "/incident/disable-merge" { + t.Fatalf("[incident-disable-merge] expected /incident/disable-merge, got %q", stub.lastPath) + } + if got, want := strings.Join(stub.bodyStrings("incident_ids"), ","), "inc-1,inc-2"; got != want { t.Fatalf("[incident-disable-merge] expected ids %q, got %q", want, got) } if !strings.Contains(out, "Disabled auto-merge for 2 incident(s).") { @@ -1170,15 +1231,22 @@ func TestCommandIncidentWarRoomDefaultObservers(t *testing.T) { func TestCommandIncidentWarRoomList(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentWarRoom{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) + stub.data = map[string]any{ + "items": []map[string]any{ + {"integration_id": 42, "chat_id": "chat-1", "incident_id": "inc-1", "status": "enabled", "plugin_type": "feishu"}, + }, + } out, err := execCommand("incident", "war-room", "list", "inc-1", "--integration", "42") if err != nil { t.Fatalf("[incident-war-room-list] unexpected error: %v", err) } - if mock.listInput == nil || mock.listInput.IncidentID != "inc-1" || mock.listInput.IntegrationID != 42 { - t.Fatalf("[incident-war-room-list] unexpected input: %#v", mock.listInput) + if stub.lastPath != "/incident/war-room/list" { + t.Fatalf("[incident-war-room-list] expected /incident/war-room/list, got %q", stub.lastPath) + } + if stub.lastBody["incident_id"] != "inc-1" || stub.lastBody["integration_id"] != float64(42) { + t.Fatalf("[incident-war-room-list] unexpected input: %#v", stub.lastBody) } if !strings.Contains(out, "chat-1") || !strings.Contains(out, "Total: 1") { t.Fatalf("[incident-war-room-list] unexpected output:\n%s", out) @@ -1187,15 +1255,18 @@ func TestCommandIncidentWarRoomList(t *testing.T) { func TestCommandIncidentWarRoomGet(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentWarRoom{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) + stub.data = map[string]any{"chat_id": "chat-1", "chat_name": "INC outage", "share_link": "https://chat.example/1"} out, err := execCommand("incident", "war-room", "get", "chat-1", "--integration", "42") if err != nil { t.Fatalf("[incident-war-room-get] unexpected error: %v", err) } - if mock.getInput == nil || mock.getInput.ChatID != "chat-1" || mock.getInput.IntegrationID != 42 { - t.Fatalf("[incident-war-room-get] unexpected input: %#v", mock.getInput) + if stub.lastPath != "/incident/war-room/detail" { + t.Fatalf("[incident-war-room-get] expected /incident/war-room/detail, got %q", stub.lastPath) + } + if stub.lastBody["chat_id"] != "chat-1" || stub.lastBody["integration_id"] != float64(42) { + t.Fatalf("[incident-war-room-get] unexpected input: %#v", stub.lastBody) } if !strings.Contains(out, "Chat ID:") || !strings.Contains(out, "chat-1") { t.Fatalf("[incident-war-room-get] unexpected output:\n%s", out) @@ -1224,61 +1295,49 @@ func TestCommandIncidentWarRoomAddMember(t *testing.T) { func TestCommandIncidentWarRoomDeleteWithForce(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentWarRoom{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) out, err := execCommand("incident", "war-room", "delete", "inc-1", "--integration", "42", "--force") if err != nil { t.Fatalf("[incident-war-room-delete] unexpected error: %v", err) } - if mock.deleteInput == nil || mock.deleteInput.IncidentID != "inc-1" || mock.deleteInput.IntegrationID != 42 { - t.Fatalf("[incident-war-room-delete] unexpected input: %#v", mock.deleteInput) + if stub.lastPath != "/incident/war-room/delete" { + t.Fatalf("[incident-war-room-delete] expected /incident/war-room/delete, got %q", stub.lastPath) + } + if stub.lastBody["incident_id"] != "inc-1" || stub.lastBody["integration_id"] != float64(42) { + t.Fatalf("[incident-war-room-delete] unexpected input: %#v", stub.lastBody) } if !strings.Contains(out, "Deleted war room for incident inc-1.") { t.Fatalf("[incident-war-room-delete] unexpected output:\n%s", out) } } -type mockAuditSearchPagination struct { - mockClient - calls []*flashduty.SearchAuditLogsInput -} - -func (m *mockAuditSearchPagination) SearchAuditLogs(_ context.Context, input *flashduty.SearchAuditLogsInput) (*flashduty.SearchAuditLogsOutput, error) { - copied := *input - m.calls = append(m.calls, &copied) - - if input.SearchAfterCtx == "" { - return &flashduty.SearchAuditLogsOutput{ - AuditLogs: []flashduty.AuditLogRecord{ - {CreatedAt: 1712000000, MemberName: "Alice", Operation: "incident.create", Body: "page-1"}, - }, - Total: 2, - SearchAfterCtx: "cursor-1", - }, nil - } - - if input.SearchAfterCtx == "cursor-1" { - return &flashduty.SearchAuditLogsOutput{ - AuditLogs: []flashduty.AuditLogRecord{ - {CreatedAt: 1712003600, MemberName: "Bob", Operation: "incident.close", Body: "page-2"}, - }, - Total: 2, - SearchAfterCtx: "", - }, nil - } - - return &flashduty.SearchAuditLogsOutput{ - AuditLogs: nil, - Total: 2, - SearchAfterCtx: "", - }, nil -} - func TestCommandAuditSearchPageUsesCursorPagination(t *testing.T) { saveAndResetGlobals(t) - mock := &mockAuditSearchPagination{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) + stub.dataFor = func(body map[string]any) any { + cursor, _ := body["search_after_ctx"].(string) + switch cursor { + case "": + return map[string]any{ + "docs": []map[string]any{ + {"created_at": 1712000000000, "member_name": "Alice", "operation": "incident.create", "body": "page-1"}, + }, + "total": 2, + "search_after_ctx": "cursor-1", + } + case "cursor-1": + return map[string]any{ + "docs": []map[string]any{ + {"created_at": 1712003600000, "member_name": "Bob", "operation": "incident.close", "body": "page-2"}, + }, + "total": 2, + "search_after_ctx": "", + } + default: + return map[string]any{"docs": []map[string]any{}, "total": 2, "search_after_ctx": ""} + } + } out, err := execCommand("audit", "search", "--limit", "1", "--page", "2") if err != nil { @@ -1294,14 +1353,14 @@ func TestCommandAuditSearchPageUsesCursorPagination(t *testing.T) { if !strings.Contains(out, "Showing 1 results (page 2, total 2).") { t.Fatalf("[audit-search-page] expected paginated footer, got:\n%s", out) } - if len(mock.calls) != 2 { - t.Fatalf("[audit-search-page] expected 2 API calls, got %d", len(mock.calls)) + if len(stub.bodies) != 2 { + t.Fatalf("[audit-search-page] expected 2 API calls, got %d", len(stub.bodies)) } - if mock.calls[0].SearchAfterCtx != "" { - t.Fatalf("[audit-search-page] expected first call cursor to be empty, got %q", mock.calls[0].SearchAfterCtx) + if c, _ := stub.bodies[0]["search_after_ctx"].(string); c != "" { + t.Fatalf("[audit-search-page] expected first call cursor to be empty, got %q", c) } - if mock.calls[1].SearchAfterCtx != "cursor-1" { - t.Fatalf("[audit-search-page] expected second call cursor %q, got %q", "cursor-1", mock.calls[1].SearchAfterCtx) + if c, _ := stub.bodies[1]["search_after_ctx"].(string); c != "cursor-1" { + t.Fatalf("[audit-search-page] expected second call cursor %q, got %q", "cursor-1", c) } } diff --git a/internal/cli/gfstub_test.go b/internal/cli/gfstub_test.go new file mode 100644 index 0000000..a16cbe4 --- /dev/null +++ b/internal/cli/gfstub_test.go @@ -0,0 +1,95 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + gflashduty "github.com/flashcatcloud/go-flashduty" +) + +// gfStub is an httptest-backed stand-in for the go-flashduty API. Migrated +// commands build a *gflashduty.Client (a concrete type, not an interface), so +// they can't be mocked the way the legacy flashdutyClient interface is — they +// are exercised against this stub server instead. The stub records every +// request's path and decoded JSON body and replies with a canned envelope, so a +// test can assert exactly what payload a command sent. +type gfStub struct { + server *httptest.Server + + // lastPath is the path of the most recent request (no query string). + lastPath string + // lastBody is the decoded JSON body of the most recent request. + lastBody map[string]any + // bodies records the decoded body of every request, in order. + bodies []map[string]any + // requests counts how many requests reached the stub. + requests int + + // data is the JSON object placed under the envelope "data" key. When nil an + // empty object is returned, which is enough for mutations that only consume + // the envelope. + data any + + // dataFor, when set, computes the envelope "data" payload per request from + // the decoded body. It takes precedence over data and lets a test return a + // different page on each call (e.g. cursor pagination). + dataFor func(body map[string]any) any +} + +// newGFStub starts a stub server and wires newGFClientFn to a client pointed at +// it. It returns the stub so tests can inspect the captured request. The server +// is torn down via t.Cleanup. +func newGFStub(t *testing.T) *gfStub { + t.Helper() + s := &gfStub{} + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.requests++ + s.lastPath = r.URL.Path + s.lastBody = nil + if body, err := io.ReadAll(r.Body); err == nil && len(body) > 0 { + _ = json.Unmarshal(body, &s.lastBody) + } + s.bodies = append(s.bodies, s.lastBody) + + var payload any + switch { + case s.dataFor != nil: + payload = s.dataFor(s.lastBody) + case s.data != nil: + payload = s.data + default: + payload = map[string]any{} + } + resp := map[string]any{ + "request_id": "test-request-id", + "error": map[string]any{"code": "OK", "message": ""}, + "data": payload, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(s.server.Close) + + newGFClientFn = func() (*gflashduty.Client, error) { + return gflashduty.NewClient("test-key", gflashduty.WithBaseURL(s.server.URL)) + } + return s +} + +// bodyStrings reads a string-slice field from the last decoded request body. +func (s *gfStub) bodyStrings(key string) []string { + raw, ok := s.lastBody[key].([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + if str, ok := v.(string); ok { + out = append(out, str) + } + } + return out +} diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go index c517c08..99f57be 100644 --- a/internal/cli/helpers.go +++ b/internal/cli/helpers.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" ) // parseKVSlice converts a slice of "KEY=VALUE" entries into a map. @@ -27,16 +27,16 @@ func parseKVSlice(entries []string) (map[string]string, error) { } // parseToolSpecs converts a slice of "name=[,params=]" specs into -// MonitAgentInvokeTool entries. The `name` key is required; `params` is -// optional and defaults to `{}` so the server-side decoder accepts it. Splits -// each spec on ',' first then on the first '=', mirroring parseKVSlice — that -// means params JSON containing commas isn't supported; specs with complex -// params must keep their objects single-keyed. -func parseToolSpecs(specs []string) ([]flashduty.MonitAgentInvokeTool, error) { - out := make([]flashduty.MonitAgentInvokeTool, 0, len(specs)) +// go-flashduty ToolInvokeRequestToolsItem entries. The `name` key is required; +// `params` is optional and defaults to an empty object. Splits each spec on ',' +// first then on the first '=', mirroring parseKVSlice — that means params JSON +// containing commas isn't supported; specs with complex params must keep their +// objects single-keyed. +func parseToolSpecs(specs []string) ([]gflashduty.ToolInvokeRequestToolsItem, error) { + out := make([]gflashduty.ToolInvokeRequestToolsItem, 0, len(specs)) for _, s := range specs { var name string - params := json.RawMessage("{}") + var rawParams string for _, kv := range strings.Split(s, ",") { i := strings.IndexByte(kv, '=') if i < 0 { @@ -47,7 +47,7 @@ func parseToolSpecs(specs []string) ([]flashduty.MonitAgentInvokeTool, error) { case "name": name = v case "params": - params = json.RawMessage(v) + rawParams = v default: return nil, fmt.Errorf("unknown key %q in tool-spec", k) } @@ -55,7 +55,15 @@ func parseToolSpecs(specs []string) ([]flashduty.MonitAgentInvokeTool, error) { if name == "" { return nil, fmt.Errorf("missing name= in spec %q", s) } - out = append(out, flashduty.MonitAgentInvokeTool{Tool: name, Params: params}) + // go-flashduty models params as a decoded object. Default to an empty + // map so no-arg tools serialize as `{}`. + params := map[string]any{} + if rawParams != "" { + if err := json.Unmarshal([]byte(rawParams), ¶ms); err != nil { + return nil, fmt.Errorf("invalid params JSON in spec %q: %w", s, err) + } + } + out = append(out, gflashduty.ToolInvokeRequestToolsItem{Tool: name, Params: params}) } return out, nil } diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 8a22838..3c3161a 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -10,6 +10,7 @@ import ( "time" flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "golang.org/x/term" @@ -211,14 +212,26 @@ func newIncidentCreateCmd() *cobra.Command { return fmt.Errorf("--severity is required (Critical, Warning, Info)") } - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.CreateIncident(cmdContext(ctx.Cmd), &flashduty.CreateIncidentInput{ - Title: title, - Severity: severity, - ChannelID: channelID, - Description: description, - AssignedTo: assign, - }) + return runGFCommand(cmd, args, func(ctx *RunContext) error { + req := &gflashduty.CreateIncidentRequest{ + Title: title, + IncidentSeverity: severity, + ChannelID: channelID, + Description: description, + } + if len(assign) > 0 { + personIDs := make([]int64, len(assign)) + for i, id := range assign { + personIDs[i] = int64(id) + } + // Preserve legacy wire: the hand-written SDK forced assigned_to.type + // = "assign". On a brand-new incident the backend would default an + // empty type to "assign" anyway, but we set it explicitly so the + // migration is a pure no-drift refactor. + req.AssignedTo = gflashduty.CreateIncidentRequestAssignedTo{PersonIDs: personIDs, Type: "assign"} + } + + result, _, err := ctx.GFClient.Incidents.Create(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -250,6 +263,11 @@ func newIncidentUpdateCmd() *cobra.Command { Use: "update ", Short: "Update an incident", Args: requireArgs("incident_id"), + // TODO(go-flashduty migration): not migrated. go-flashduty's + // Incidents.Reset (/incident/reset) carries no custom-fields field — + // custom fields move to the separate /incident/field/reset endpoint. + // Porting --field would mean splitting one call into two, which is a + // behavior change, not a mechanical swap. Kept on the legacy SDK. RunE: func(cmd *cobra.Command, args []string) error { customFields := make(map[string]any) for _, f := range fieldFlags { @@ -298,8 +316,10 @@ func newIncidentAckCmd() *cobra.Command { Short: "Acknowledge incidents", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.AckIncidents(cmdContext(ctx.Cmd), ctx.Args); err != nil { + return runGFCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.GFClient.Incidents.Ack(cmdContext(ctx.Cmd), &gflashduty.AckIncidentRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Acknowledged %d incident(s).", len(ctx.Args))) @@ -324,8 +344,10 @@ unacknowledged state. The command accepts up to 100 incident IDs.`, if err := validateIncidentIDBatch(args); err != nil { return err } - return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.UnackIncidents(cmdContext(ctx.Cmd), ctx.Args); err != nil { + return runGFCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.GFClient.Incidents.Unack(cmdContext(ctx.Cmd), &gflashduty.UnackIncidentRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Unacknowledged %d incident(s).", len(ctx.Args))) @@ -341,8 +363,10 @@ func newIncidentCloseCmd() *cobra.Command { Short: "Close incidents", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.CloseIncidents(cmdContext(ctx.Cmd), ctx.Args); err != nil { + return runGFCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.GFClient.Incidents.Resolve(cmdContext(ctx.Cmd), &gflashduty.ResolveIncidentRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Closed %d incident(s).", len(ctx.Args))) @@ -367,8 +391,10 @@ accepts up to 100 incident IDs.`, if err := validateIncidentIDBatch(args); err != nil { return err } - return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.WakeIncidents(cmdContext(ctx.Cmd), ctx.Args); err != nil { + return runGFCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.GFClient.Incidents.Wake(cmdContext(ctx.Cmd), &gflashduty.WakeIncidentRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Restored notifications for %d incident(s).", len(ctx.Args))) @@ -533,7 +559,7 @@ func newIncidentMergeCmd() *cobra.Command { Short: "Merge incidents into a target incident", Args: requireArgs("target_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { sourceIDs := parseStringSlice(source) if len(sourceIDs) == 0 { return fmt.Errorf("--source is required") @@ -542,7 +568,7 @@ func newIncidentMergeCmd() *cobra.Command { return fmt.Errorf("--source accepts at most 100 incident IDs") } - if err := ctx.Client.MergeIncidents(cmdContext(ctx.Cmd), &flashduty.MergeIncidentsInput{ + if _, err := ctx.GFClient.Incidents.Merge(cmdContext(ctx.Cmd), &gflashduty.MergeIncidentsRequest{ SourceIncidentIDs: sourceIDs, TargetIncidentID: ctx.Args[0], }); err != nil { @@ -569,7 +595,7 @@ func newIncidentSnoozeCmd() *cobra.Command { Short: "Snooze incidents", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { d, err := time.ParseDuration(duration) if err != nil { return fmt.Errorf("invalid --duration: %w", err) @@ -583,7 +609,7 @@ func newIncidentSnoozeCmd() *cobra.Command { minutes := int64(d / time.Minute) - if err := ctx.Client.SnoozeIncidents(cmdContext(ctx.Cmd), &flashduty.SnoozeIncidentsInput{ + if _, err := ctx.GFClient.Incidents.Snooze(cmdContext(ctx.Cmd), &gflashduty.SnoozeIncidentRequest{ IncidentIDs: ctx.Args, Minutes: minutes, }); err != nil { @@ -608,8 +634,10 @@ func newIncidentReopenCmd() *cobra.Command { Short: "Reopen closed incidents", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.ReopenIncidents(cmdContext(ctx.Cmd), ctx.Args); err != nil { + return runGFCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.GFClient.Incidents.Reopen(cmdContext(ctx.Cmd), &gflashduty.ReopenIncidentRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Reopened %d incident(s).", len(ctx.Args))) @@ -627,7 +655,7 @@ func newIncidentReassignCmd() *cobra.Command { Short: "Reassign an incident to new responders", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { personIDs, err := parseIntSlice(person) if err != nil { return fmt.Errorf("invalid --person: %w", err) @@ -636,9 +664,14 @@ func newIncidentReassignCmd() *cobra.Command { return fmt.Errorf("--person is required") } - if err := ctx.Client.ReassignIncidents(cmdContext(ctx.Cmd), &flashduty.ReassignIncidentsInput{ + // Preserve legacy wire: the hand-written SDK's ReassignIncidents + // hard-coded assigned_to.type = "assign". Leaving type empty would let + // the backend relabel an already-assigned incident as "reassign" in the + // feed/IM cards — a behavior change. Whether "reassign" is the more + // correct label is a separate product decision, not a migration one. + if _, err := ctx.GFClient.Incidents.Assign(cmdContext(ctx.Cmd), &gflashduty.AssignIncidentRequest{ IncidentIDs: []string{ctx.Args[0]}, - PersonIDs: personIDs, + AssignedTo: gflashduty.AssignedTo{PersonIDs: personIDs, Type: "assign"}, }); err != nil { return err } @@ -682,17 +715,17 @@ personal channels, or a template.`, return fmt.Errorf("--person is required") } - var notify *flashduty.IncidentNotifyInput + var notify gflashduty.AddIncidentResponderRequestNotify if followPreference || notifyChannel != "" || templateID != "" { - notify = &flashduty.IncidentNotifyInput{ + notify = gflashduty.AddIncidentResponderRequestNotify{ FollowPreference: followPreference, PersonalChannels: parseStringSlice(notifyChannel), TemplateID: templateID, } } - return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.AddIncidentResponders(cmdContext(ctx.Cmd), &flashduty.IncidentAddResponderInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.GFClient.Incidents.ResponderAdd(cmdContext(ctx.Cmd), &gflashduty.AddIncidentResponderRequest{ IncidentID: ctx.Args[0], PersonIDs: personIDs, Notify: notify, @@ -741,8 +774,8 @@ webhook reply behavior.`, return fmt.Errorf("--comment must be at most 1024 characters") } - return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.CommentIncidents(cmdContext(ctx.Cmd), &flashduty.IncidentCommentInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.GFClient.Incidents.Comment(cmdContext(ctx.Cmd), &gflashduty.CommentIncidentRequest{ IncidentIDs: ctx.Args, Comment: comment, MuteReply: muteReply, @@ -775,8 +808,10 @@ matching alerts automatically. The command accepts up to 100 incident IDs.`, flashduty incident disable-merge inc_123 inc_456`, Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.DisableIncidentMerge(cmdContext(ctx.Cmd), ctx.Args); err != nil { + return runGFCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.GFClient.Incidents.DisableMerge(cmdContext(ctx.Cmd), &gflashduty.DisableIncidentMergeRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Disabled auto-merge for %d incident(s).", len(ctx.Args))) @@ -804,13 +839,15 @@ unless --force is provided. The command accepts up to 100 incident IDs.`, if err := validateIncidentIDBatch(args); err != nil { return err } - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { if !confirmAction(ctx.Cmd, fmt.Sprintf("Are you sure you want to remove %d incident(s)?", len(ctx.Args))) { _, _ = fmt.Fprintln(ctx.Writer, "Aborted.") return nil } - if err := ctx.Client.RemoveIncidents(cmdContext(ctx.Cmd), ctx.Args); err != nil { + if _, err := ctx.GFClient.Incidents.Remove(cmdContext(ctx.Cmd), &gflashduty.RemoveIncidentRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Removed %d incident(s).", len(ctx.Args))) @@ -863,6 +900,10 @@ invite historical responders selected by FlashDuty.`, flashduty incident war-room create inc_123 --integration 42 --member 101,202 flashduty incident war-room create inc_123 --add-observers`, Args: requireArgs("incident_id"), + // TODO(go-flashduty migration): not migrated. Auto-resolving the IM + // integration when --integration is omitted relies on the legacy SDK's + // ListWarRoomEnabledDataSources (/datasource/im/war-room-enabled/list), + // which go-flashduty does not yet cover. Migrate once that endpoint lands. RunE: func(cmd *cobra.Command, args []string) error { memberIDs, err := parseIntSlice(member) if err != nil { @@ -931,8 +972,8 @@ as get, delete, and add-member.`, flashduty incident war-room list inc_123 --integration 42`, Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.ListIncidentWarRooms(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomListInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.GFClient.Incidents.WarRoomList(cmdContext(ctx.Cmd), &gflashduty.ListWarRoomsRequest{ IncidentID: ctx.Args[0], IntegrationID: integrationID, }) @@ -963,8 +1004,8 @@ the chat ID and integration ID for an incident.`, flashduty incident war-room get chat_123 --integration 42`, Args: requireArgs("chat_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - warRoom, err := ctx.Client.GetIncidentWarRoom(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomDetailInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + warRoom, _, err := ctx.GFClient.Incidents.WarRoomDetail(cmdContext(ctx.Cmd), &gflashduty.GetWarRoomDetailRequest{ IntegrationID: integrationID, ChatID: ctx.Args[0], }) @@ -1003,12 +1044,12 @@ integration ID.`, flashduty incident war-room delete inc_123 --integration 42 --force`, Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { if !confirmAction(ctx.Cmd, fmt.Sprintf("Are you sure you want to delete the war room for incident %s?", ctx.Args[0])) { _, _ = fmt.Fprintln(ctx.Writer, "Aborted.") return nil } - if err := ctx.Client.DeleteIncidentWarRoom(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomDeleteInput{ + if _, err := ctx.GFClient.Incidents.WarRoomDelete(cmdContext(ctx.Cmd), &gflashduty.DeleteWarRoomRequest{ IncidentID: ctx.Args[0], IntegrationID: integrationID, }); err != nil { @@ -1097,12 +1138,12 @@ This is a read-only preview of the users FlashDuty would add when func incidentWarRoomColumns() []output.Column { return []output.Column{ - {Header: "INTEGRATION", Field: func(v any) string { return fmt.Sprint(v.(flashduty.IncidentWarRoomItem).IntegrationID) }}, - {Header: "CHAT_ID", Field: func(v any) string { return v.(flashduty.IncidentWarRoomItem).ChatID }}, - {Header: "INCIDENT_ID", Field: func(v any) string { return v.(flashduty.IncidentWarRoomItem).IncidentID }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.IncidentWarRoomItem).Status }}, - {Header: "PLUGIN", Field: func(v any) string { return v.(flashduty.IncidentWarRoomItem).PluginType }}, - {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.IncidentWarRoomItem).CreatedAt) }}, + {Header: "INTEGRATION", Field: func(v any) string { return fmt.Sprint(v.(gflashduty.WarRoomItem).IntegrationID) }}, + {Header: "CHAT_ID", Field: func(v any) string { return v.(gflashduty.WarRoomItem).ChatID }}, + {Header: "INCIDENT_ID", Field: func(v any) string { return v.(gflashduty.WarRoomItem).IncidentID }}, + {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.WarRoomItem).Status }}, + {Header: "PLUGIN", Field: func(v any) string { return v.(gflashduty.WarRoomItem).PluginType }}, + {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.WarRoomItem).CreatedAt) }}, } } @@ -1115,7 +1156,7 @@ func incidentWarRoomObserverColumns() []output.Column { } } -func printWarRoomDetail(w io.Writer, warRoom *flashduty.IncidentWarRoom) { +func printWarRoomDetail(w io.Writer, warRoom *gflashduty.WarRoom) { if warRoom == nil { return } diff --git a/internal/cli/insight.go b/internal/cli/insight.go index 2130c81..be0fce1 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -4,6 +4,7 @@ import ( "fmt" flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -31,7 +32,7 @@ func newInsightTeamCmd() *cobra.Command { Use: "team", Short: "Query insights by team", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -41,7 +42,7 @@ func newInsightTeamCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, err := ctx.Client.QueryInsightByTeam(cmdContext(ctx.Cmd), &flashduty.InsightQueryInput{ + result, _, err := ctx.GFClient.Analytics.ByTeam(cmdContext(ctx.Cmd), &gflashduty.InsightQueryRequest{ StartTime: startTime, EndTime: endTime, }) @@ -51,28 +52,28 @@ func newInsightTeamCmd() *cobra.Command { cols := []output.Column{ {Header: "TEAM", MaxWidth: 30, Field: func(v any) string { - return v.(flashduty.DimensionInsightItem).TeamName + return v.(gflashduty.DimensionInsightItem).TeamName }}, {Header: "INCIDENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalIncidentCnt) + return fmt.Sprintf("%d", v.(gflashduty.DimensionInsightItem).TotalIncidentCnt) }}, {Header: "ACK%", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(flashduty.DimensionInsightItem).AcknowledgementPct*100) + return fmt.Sprintf("%.0f%%", v.(gflashduty.DimensionInsightItem).AcknowledgementPct*100) }}, {Header: "MTTA", Field: func(v any) string { - return output.FormatDurationFloat(v.(flashduty.DimensionInsightItem).MeanSecondsToAck) + return output.FormatDurationFloat(v.(gflashduty.DimensionInsightItem).MeanSecondsToAck) }}, {Header: "MTTR", Field: func(v any) string { - return output.FormatDurationFloat(v.(flashduty.DimensionInsightItem).MeanSecondsToClose) + return output.FormatDurationFloat(v.(gflashduty.DimensionInsightItem).MeanSecondsToClose) }}, {Header: "NOISE_REDUCTION", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(flashduty.DimensionInsightItem).NoiseReductionPct*100) + return fmt.Sprintf("%.0f%%", v.(gflashduty.DimensionInsightItem).NoiseReductionPct*100) }}, {Header: "ALERTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalAlertCnt) + return fmt.Sprintf("%d", v.(gflashduty.DimensionInsightItem).TotalAlertCnt) }}, {Header: "EVENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalAlertEventCnt) + return fmt.Sprintf("%d", v.(gflashduty.DimensionInsightItem).TotalAlertEventCnt) }}, } @@ -94,7 +95,7 @@ func newInsightChannelCmd() *cobra.Command { Use: "channel", Short: "Query insights by channel", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -104,7 +105,7 @@ func newInsightChannelCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, err := ctx.Client.QueryInsightByChannel(cmdContext(ctx.Cmd), &flashduty.InsightQueryInput{ + result, _, err := ctx.GFClient.Analytics.ByChannel(cmdContext(ctx.Cmd), &gflashduty.InsightQueryRequest{ StartTime: startTime, EndTime: endTime, }) @@ -114,28 +115,28 @@ func newInsightChannelCmd() *cobra.Command { cols := []output.Column{ {Header: "CHANNEL", MaxWidth: 30, Field: func(v any) string { - return v.(flashduty.DimensionInsightItem).ChannelName + return v.(gflashduty.DimensionInsightItem).ChannelName }}, {Header: "INCIDENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalIncidentCnt) + return fmt.Sprintf("%d", v.(gflashduty.DimensionInsightItem).TotalIncidentCnt) }}, {Header: "ACK%", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(flashduty.DimensionInsightItem).AcknowledgementPct*100) + return fmt.Sprintf("%.0f%%", v.(gflashduty.DimensionInsightItem).AcknowledgementPct*100) }}, {Header: "MTTA", Field: func(v any) string { - return output.FormatDurationFloat(v.(flashduty.DimensionInsightItem).MeanSecondsToAck) + return output.FormatDurationFloat(v.(gflashduty.DimensionInsightItem).MeanSecondsToAck) }}, {Header: "MTTR", Field: func(v any) string { - return output.FormatDurationFloat(v.(flashduty.DimensionInsightItem).MeanSecondsToClose) + return output.FormatDurationFloat(v.(gflashduty.DimensionInsightItem).MeanSecondsToClose) }}, {Header: "NOISE_REDUCTION", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(flashduty.DimensionInsightItem).NoiseReductionPct*100) + return fmt.Sprintf("%.0f%%", v.(gflashduty.DimensionInsightItem).NoiseReductionPct*100) }}, {Header: "ALERTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalAlertCnt) + return fmt.Sprintf("%d", v.(gflashduty.DimensionInsightItem).TotalAlertCnt) }}, {Header: "EVENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalAlertEventCnt) + return fmt.Sprintf("%d", v.(gflashduty.DimensionInsightItem).TotalAlertEventCnt) }}, } @@ -156,6 +157,10 @@ func newInsightResponderCmd() *cobra.Command { cmd := &cobra.Command{ Use: "responder", Short: "Query insights by responder", + // TODO(go-flashduty migration): not migrated. The EMAIL column reads a + // responder email that the thin go-flashduty ResponderInsightItem does + // not carry (no responder_email field). Migrate once the SDK exposes it + // or the column drops the enriched email. Kept on the legacy SDK. RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) @@ -218,7 +223,7 @@ func newInsightTopAlertsCmd() *cobra.Command { Use: "top-alerts", Short: "Query top alert sources by label", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -228,13 +233,11 @@ func newInsightTopAlertsCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, err := ctx.Client.QueryInsightAlertTopK(cmdContext(ctx.Cmd), &flashduty.QueryInsightAlertTopKInput{ - InsightQueryInput: flashduty.InsightQueryInput{ - StartTime: startTime, - EndTime: endTime, - }, - Label: label, - K: limit, + result, _, err := ctx.GFClient.Analytics.TopkAlertsByLabel(cmdContext(ctx.Cmd), &gflashduty.InsightTopkAlertByLabelRequest{ + StartTime: startTime, + EndTime: endTime, + Label: label, + K: int64(limit), }) if err != nil { return err @@ -242,13 +245,13 @@ func newInsightTopAlertsCmd() *cobra.Command { cols := []output.Column{ {Header: "LABEL", MaxWidth: 50, Field: func(v any) string { - return v.(flashduty.InsightAlertByLabelItem).Label + return v.(gflashduty.InsightAlertByLabelItem).Label }}, {Header: "ALERTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.InsightAlertByLabelItem).TotalAlertCnt) + return fmt.Sprintf("%d", v.(gflashduty.InsightAlertByLabelItem).TotalAlertCnt) }}, {Header: "EVENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.InsightAlertByLabelItem).TotalAlertEventCnt) + return fmt.Sprintf("%d", v.(gflashduty.InsightAlertByLabelItem).TotalAlertEventCnt) }}, } diff --git a/internal/cli/monit_agent.go b/internal/cli/monit_agent.go index cd1f258..fe205d1 100644 --- a/internal/cli/monit_agent.go +++ b/internal/cli/monit_agent.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" ) @@ -27,12 +27,12 @@ func newMonitAgentCatalogCmd() *cobra.Command { if targetLocator == "" { return fmt.Errorf("--target-locator is required") } - return runCommand(cmd, args, func(ctx *RunContext) error { - input := &flashduty.MonitAgentCatalogInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + input := &gflashduty.ToolCatalogRequest{ TargetKind: targetKind, TargetLocator: targetLocator, } - result, err := ctx.Client.MonitAgentCatalog(cmdContext(ctx.Cmd), input) + result, _, err := ctx.GFClient.Diagnostics.ToolsCatalog(cmdContext(ctx.Cmd), input) if err != nil { return err } @@ -71,13 +71,13 @@ func newMonitAgentInvokeCmd() *cobra.Command { return fmt.Errorf("invalid --tool-spec: %w", err) } - return runCommand(cmd, args, func(ctx *RunContext) error { - input := &flashduty.MonitAgentInvokeInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + input := &gflashduty.ToolInvokeRequest{ TargetKind: targetKind, TargetLocator: targetLocator, Tools: parsed, } - result, err := ctx.Client.MonitAgentInvoke(cmdContext(ctx.Cmd), input) + result, _, err := ctx.GFClient.Diagnostics.ToolsInvoke(cmdContext(ctx.Cmd), input) if err != nil { return err } diff --git a/internal/cli/monit_agent_test.go b/internal/cli/monit_agent_test.go index 87dc2f7..def6bfa 100644 --- a/internal/cli/monit_agent_test.go +++ b/internal/cli/monit_agent_test.go @@ -1,12 +1,9 @@ package cli import ( - "context" - "encoding/json" + "fmt" "strings" "testing" - - flashduty "github.com/flashcatcloud/flashduty-sdk" ) // --- flag surface --------------------------------------------------------- @@ -29,57 +26,16 @@ func TestMonitAgentInvokeFlags(t *testing.T) { } } -// --- shared mock plumbing ------------------------------------------------- - -type mockMonitAgent struct { - mockClient - - catalogInput *flashduty.MonitAgentCatalogInput - catalogOut *flashduty.MonitAgentCatalogOutput - catalogErr error - - invokeInput *flashduty.MonitAgentInvokeInput - invokeOut *flashduty.MonitAgentInvokeOutput - invokeErr error -} - -func (m *mockMonitAgent) MonitAgentCatalog(_ context.Context, input *flashduty.MonitAgentCatalogInput) (*flashduty.MonitAgentCatalogOutput, error) { - copied := *input - m.catalogInput = &copied - if m.catalogErr != nil { - return nil, m.catalogErr - } - if m.catalogOut != nil { - return m.catalogOut, nil - } - return &flashduty.MonitAgentCatalogOutput{}, nil -} - -func (m *mockMonitAgent) MonitAgentInvoke(_ context.Context, input *flashduty.MonitAgentInvokeInput) (*flashduty.MonitAgentInvokeOutput, error) { - copied := *input - copied.Tools = append([]flashduty.MonitAgentInvokeTool(nil), input.Tools...) - m.invokeInput = &copied - if m.invokeErr != nil { - return nil, m.invokeErr - } - if m.invokeOut != nil { - return m.invokeOut, nil - } - return &flashduty.MonitAgentInvokeOutput{}, nil -} - // --- monit-agent catalog -------------------------------------------------- func TestMonitAgentCatalogHappyPath(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitAgent{ - catalogOut: &flashduty.MonitAgentCatalogOutput{ - Tools: []flashduty.MonitAgentTool{ - {Name: "ps_top", Description: "Top processes by CPU"}, - }, + stub := newGFStub(t) + stub.data = map[string]any{ + "tools": []map[string]any{ + {"name": "ps_top", "description": "Top processes by CPU"}, }, } - newClientFn = func() (flashdutyClient, error) { return mock, nil } _, err := execCommand( "monit-agent", "catalog", @@ -89,18 +45,17 @@ func TestMonitAgentCatalogHappyPath(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if mock.catalogInput == nil { - t.Fatal("expected MonitAgentCatalog to be called") + if stub.lastPath != "/monit/tools/catalog" { + t.Fatalf("expected /monit/tools/catalog, got %q", stub.lastPath) } - if mock.catalogInput.TargetKind != "host" || mock.catalogInput.TargetLocator != "10.0.1.5" { - t.Errorf("unexpected catalog input: %+v", mock.catalogInput) + if stub.lastBody["target_kind"] != "host" || stub.lastBody["target_locator"] != "10.0.1.5" { + t.Errorf("unexpected catalog input: %#v", stub.lastBody) } } func TestMonitAgentCatalogOmitsKind(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitAgent{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand( "monit-agent", "catalog", @@ -109,21 +64,20 @@ func TestMonitAgentCatalogOmitsKind(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if mock.catalogInput == nil { - t.Fatal("expected MonitAgentCatalog to be called") + if stub.requests == 0 { + t.Fatal("expected catalog request to be sent") } - if mock.catalogInput.TargetKind != "" { - t.Errorf("expected empty target-kind, got %q", mock.catalogInput.TargetKind) + if _, ok := stub.lastBody["target_kind"]; ok { + t.Errorf("expected target_kind omitted, got %v", stub.lastBody["target_kind"]) } - if mock.catalogInput.TargetLocator != "web-01" { - t.Errorf("expected locator web-01, got %q", mock.catalogInput.TargetLocator) + if stub.lastBody["target_locator"] != "web-01" { + t.Errorf("expected locator web-01, got %v", stub.lastBody["target_locator"]) } } func TestMonitAgentCatalogRequiresLocator(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitAgent{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand("monit-agent", "catalog", "--target-kind", "host") if err == nil { @@ -132,8 +86,8 @@ func TestMonitAgentCatalogRequiresLocator(t *testing.T) { if !strings.Contains(err.Error(), "--target-locator") { t.Errorf("expected error to mention --target-locator, got %q", err.Error()) } - if mock.catalogInput != nil { - t.Errorf("MonitAgentCatalog should not have been called: %#v", mock.catalogInput) + if stub.requests != 0 { + t.Errorf("catalog should not have been called: %d request(s)", stub.requests) } } @@ -141,8 +95,7 @@ func TestMonitAgentCatalogRequiresLocator(t *testing.T) { func TestMonitAgentInvokeHappyPath(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitAgent{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand( "monit-agent", "invoke", @@ -154,36 +107,33 @@ func TestMonitAgentInvokeHappyPath(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if mock.invokeInput == nil { - t.Fatal("expected MonitAgentInvoke to be called") - } - got := mock.invokeInput - if got.TargetKind != "host" || got.TargetLocator != "10.0.1.5" { - t.Errorf("unexpected invoke target: %+v", got) + if stub.lastPath != "/monit/tools/invoke" { + t.Fatalf("expected /monit/tools/invoke, got %q", stub.lastPath) } - if len(got.Tools) != 2 { - t.Fatalf("expected 2 tools, got %d", len(got.Tools)) + if stub.lastBody["target_kind"] != "host" || stub.lastBody["target_locator"] != "10.0.1.5" { + t.Errorf("unexpected invoke target: %#v", stub.lastBody) } - if got.Tools[0].Tool != "ps_top" { - t.Errorf("expected first tool ps_top, got %q", got.Tools[0].Tool) + tools, _ := stub.lastBody["tools"].([]any) + if len(tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(tools)) } - if string(got.Tools[0].Params) != `{"limit":5}` { - t.Errorf("expected ps_top params %q, got %q", `{"limit":5}`, string(got.Tools[0].Params)) + tool0, _ := tools[0].(map[string]any) + if tool0["tool"] != "ps_top" { + t.Errorf("expected first tool ps_top, got %v", tool0["tool"]) } - if got.Tools[1].Tool != "uptime" { - t.Errorf("expected second tool uptime, got %q", got.Tools[1].Tool) + params0, _ := tool0["params"].(map[string]any) + if fmt.Sprint(params0["limit"]) != "5" { + t.Errorf("expected ps_top params limit=5, got %#v", tool0["params"]) } - // default params for a name-only spec must be valid JSON `{}`, so the - // server-side decoder accepts it. - if !json.Valid(got.Tools[1].Params) { - t.Errorf("uptime params not valid JSON: %q", string(got.Tools[1].Params)) + tool1, _ := tools[1].(map[string]any) + if tool1["tool"] != "uptime" { + t.Errorf("expected second tool uptime, got %v", tool1["tool"]) } } func TestMonitAgentInvokeOmitsKind(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitAgent{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand( "monit-agent", "invoke", @@ -193,18 +143,17 @@ func TestMonitAgentInvokeOmitsKind(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if mock.invokeInput == nil { - t.Fatal("expected MonitAgentInvoke to be called") + if stub.requests == 0 { + t.Fatal("expected invoke request to be sent") } - if mock.invokeInput.TargetKind != "" { - t.Errorf("expected empty target-kind, got %q", mock.invokeInput.TargetKind) + if _, ok := stub.lastBody["target_kind"]; ok { + t.Errorf("expected target_kind omitted, got %v", stub.lastBody["target_kind"]) } } func TestMonitAgentInvokeRequiresLocator(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitAgent{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand( "monit-agent", "invoke", @@ -216,15 +165,14 @@ func TestMonitAgentInvokeRequiresLocator(t *testing.T) { if !strings.Contains(err.Error(), "--target-locator") { t.Errorf("expected error to mention --target-locator, got %q", err.Error()) } - if mock.invokeInput != nil { - t.Errorf("MonitAgentInvoke should not have been called: %#v", mock.invokeInput) + if stub.requests != 0 { + t.Errorf("invoke should not have been called: %d request(s)", stub.requests) } } func TestMonitAgentInvokeRequiresToolSpec(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitAgent{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand( "monit-agent", "invoke", @@ -236,15 +184,14 @@ func TestMonitAgentInvokeRequiresToolSpec(t *testing.T) { if !strings.Contains(err.Error(), "--tool-spec") { t.Errorf("expected error to mention --tool-spec, got %q", err.Error()) } - if mock.invokeInput != nil { - t.Errorf("MonitAgentInvoke should not have been called: %#v", mock.invokeInput) + if stub.requests != 0 { + t.Errorf("invoke should not have been called: %d request(s)", stub.requests) } } func TestMonitAgentInvokeRejectsMoreThan8Specs(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitAgent{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) args := []string{ "monit-agent", "invoke", @@ -261,8 +208,8 @@ func TestMonitAgentInvokeRejectsMoreThan8Specs(t *testing.T) { if !strings.Contains(err.Error(), "up to 8") { t.Errorf("expected error to mention 'up to 8', got %q", err.Error()) } - if mock.invokeInput != nil { - t.Errorf("MonitAgentInvoke should not have been called: %#v", mock.invokeInput) + if stub.requests != 0 { + t.Errorf("invoke should not have been called: %d request(s)", stub.requests) } } @@ -278,8 +225,7 @@ func TestMonitAgentInvokeMalformedSpec(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitAgent{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand( "monit-agent", "invoke", @@ -292,8 +238,8 @@ func TestMonitAgentInvokeMalformedSpec(t *testing.T) { if !strings.Contains(err.Error(), "--tool-spec") { t.Errorf("expected error to mention --tool-spec, got %q", err.Error()) } - if mock.invokeInput != nil { - t.Errorf("MonitAgentInvoke should not have been called: %#v", mock.invokeInput) + if stub.requests != 0 { + t.Errorf("invoke should not have been called: %d request(s)", stub.requests) } }) } diff --git a/internal/cli/monit_query.go b/internal/cli/monit_query.go index e8da45a..4efbf00 100644 --- a/internal/cli/monit_query.go +++ b/internal/cli/monit_query.go @@ -4,6 +4,7 @@ import ( "fmt" flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/timeutil" @@ -41,26 +42,25 @@ func newMonitQueryDiagnoseCmd() *cobra.Command { return fmt.Errorf("invalid --time-end: %w", err) } - return runCommand(cmd, args, func(ctx *RunContext) error { - input := &flashduty.MonitQueryDiagnoseInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + input := &gflashduty.DiagnoseRequest{ DsType: dsType, DsName: dsName, - TimeStart: startTime, - TimeEnd: endTime, Operation: operation, - Input: flashduty.MonitQueryDiagnoseQuery{Query: inputQuery}, + Input: gflashduty.DiagnoseRequestInput{Query: inputQuery}, + TimeRange: gflashduty.DiagnoseRequestTimeRange{Start: startTime, End: endTime}, } if maxLogs > 0 { - input.MaxLogsScanned = maxLogs + input.Options.MaxLogsScanned = int64(maxLogs) } if maxPatterns > 0 { - input.MaxPatterns = maxPatterns + input.Options.MaxPatterns = int64(maxPatterns) } if timeoutSeconds > 0 { - input.TimeoutSeconds = timeoutSeconds + input.Options.TimeoutSeconds = int64(timeoutSeconds) } - result, err := ctx.Client.MonitQueryDiagnose(cmdContext(ctx.Cmd), input) + result, _, err := ctx.GFClient.Diagnostics.QueryDiagnose(cmdContext(ctx.Cmd), input) if err != nil { return err } @@ -91,6 +91,11 @@ func newMonitQueryRowsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "rows", Short: "Raw datasource passthrough (returns values/rows as the datasource itself would)", + // TODO(go-flashduty migration): not migrated. The legacy SDK returns the + // datasource body verbatim as a RawMessage, which this command writes + // through unchanged. go-flashduty's QueryRowsResponse is a structured + // []QueryRow, so switching would change the on-screen output shape — a + // behavior change, not a mechanical swap. Kept on the legacy SDK. RunE: func(cmd *cobra.Command, args []string) error { if dsType == "" || dsName == "" || expr == "" { return fmt.Errorf("--ds-type, --ds-name, --expr are required") diff --git a/internal/cli/monit_query_test.go b/internal/cli/monit_query_test.go index c6fc388..bd95468 100644 --- a/internal/cli/monit_query_test.go +++ b/internal/cli/monit_query_test.go @@ -2,6 +2,7 @@ package cli import ( "context" + "fmt" "strings" "testing" @@ -72,8 +73,8 @@ func (m *mockMonitQuery) MonitQueryRows(_ context.Context, input *flashduty.Moni func TestMonitQueryDiagnoseHappyPath(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitQuery{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) + stub.data = map[string]any{"operation": "log_patterns"} _, err := execCommand( "monit-query", "diagnose", @@ -88,26 +89,30 @@ func TestMonitQueryDiagnoseHappyPath(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if mock.diagnoseInput == nil { - t.Fatal("expected MonitQueryDiagnose to be called") + if stub.lastPath != "/monit/query/diagnose" { + t.Fatalf("expected /monit/query/diagnose, got %q", stub.lastPath) } - got := mock.diagnoseInput - if got.DsType != "victorialogs" || got.DsName != "vl-prod" { - t.Errorf("unexpected ds fields: %+v", got) + body := stub.lastBody + if body["ds_type"] != "victorialogs" || body["ds_name"] != "vl-prod" { + t.Errorf("unexpected ds fields: %#v", body) } - if got.Input.Query != `{app="api"}` { - t.Errorf("expected input query %q, got %q", `{app="api"}`, got.Input.Query) + input, _ := body["input"].(map[string]any) + if input["query"] != `{app="api"}` { + t.Errorf("expected input query %q, got %v", `{app="api"}`, input["query"]) } - if got.Operation != "log_patterns" { - t.Errorf("expected operation log_patterns, got %q", got.Operation) + if body["operation"] != "log_patterns" { + t.Errorf("expected operation log_patterns, got %v", body["operation"]) } - if got.MaxLogsScanned != 5000 || got.MaxPatterns != 10 || got.TimeoutSeconds != 20 { - t.Errorf("unexpected caps: logs=%d patterns=%d timeout=%d", - got.MaxLogsScanned, got.MaxPatterns, got.TimeoutSeconds) + options, _ := body["options"].(map[string]any) + if fmt.Sprint(options["max_logs_scanned"]) != "5000" || + fmt.Sprint(options["max_patterns"]) != "10" || + fmt.Sprint(options["timeout_seconds"]) != "20" { + t.Errorf("unexpected caps: %#v", options) } - if got.TimeStart == 0 || got.TimeEnd == 0 { - t.Errorf("expected non-zero default time range, got start=%d end=%d", - got.TimeStart, got.TimeEnd) + timeRange, _ := body["time_range"].(map[string]any) + if fmt.Sprint(timeRange["start"]) == "0" || fmt.Sprint(timeRange["start"]) == "" || + fmt.Sprint(timeRange["end"]) == "0" || fmt.Sprint(timeRange["end"]) == "" { + t.Errorf("expected non-zero default time range, got %#v", timeRange) } } @@ -144,8 +149,7 @@ func TestMonitQueryDiagnoseRequiredFlags(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitQuery{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand(tc.args...) if err == nil { @@ -154,8 +158,8 @@ func TestMonitQueryDiagnoseRequiredFlags(t *testing.T) { if !strings.Contains(err.Error(), "required") { t.Errorf("expected error to mention 'required', got %q", err.Error()) } - if mock.diagnoseInput != nil { - t.Errorf("MonitQueryDiagnose should not have been called: %#v", mock.diagnoseInput) + if stub.requests != 0 { + t.Errorf("diagnose should not have been called: %d request(s)", stub.requests) } }) } @@ -163,8 +167,7 @@ func TestMonitQueryDiagnoseRequiredFlags(t *testing.T) { func TestMonitQueryDiagnoseInvalidTimeStart(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitQuery{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand( "monit-query", "diagnose", @@ -179,8 +182,8 @@ func TestMonitQueryDiagnoseInvalidTimeStart(t *testing.T) { if !strings.Contains(err.Error(), "--time-start") { t.Errorf("expected error to mention --time-start, got %q", err.Error()) } - if mock.diagnoseInput != nil { - t.Errorf("MonitQueryDiagnose should not have been called: %#v", mock.diagnoseInput) + if stub.requests != 0 { + t.Errorf("diagnose should not have been called: %d request(s)", stub.requests) } } diff --git a/internal/cli/root.go b/internal/cli/root.go index 347c567..0bf50b6 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -9,7 +9,9 @@ import ( "strings" flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" + toon "github.com/toon-format/toon-go" "golang.org/x/term" "github.com/flashcatcloud/flashduty-cli/internal/config" @@ -115,6 +117,10 @@ type flashdutyClient interface { // newClientFn creates a flashdutyClient. Override in tests to inject a mock. var newClientFn = defaultNewClient +// newGFClientFn creates the go-flashduty client used by migrated commands. +// Override in tests to inject a stub server. +var newGFClientFn = defaultNewGFClient + var ( flagJSON bool flagNoTrunc bool @@ -213,6 +219,11 @@ func newClient() (flashdutyClient, error) { return newClientFn() } +// newGFClient creates a go-flashduty client using the current factory. +func newGFClient() (*gflashduty.Client, error) { + return newGFClientFn() +} + // defaultNewClient creates a real Flashduty SDK client from resolved config + flag overrides. func defaultNewClient() (flashdutyClient, error) { cfg, err := loadResolvedConfig() @@ -240,6 +251,31 @@ func defaultNewClient() (flashdutyClient, error) { return sdkClient, nil } +// defaultNewGFClient creates a real go-flashduty client from resolved config + +// flag overrides. This is the typed SDK used by migrated commands; the legacy +// hand-written SDK (defaultNewClient) still backs the commands that depend on +// server-side enrichment or endpoints go-flashduty does not yet cover. +func defaultNewGFClient() (*gflashduty.Client, error) { + cfg, err := loadResolvedConfig() + if err != nil { + return nil, err + } + + if cfg.AppKey == "" { + return nil, fmt.Errorf("no app key configured. Run 'flashduty login' or set FLASHDUTY_APP_KEY") + } + + opts := []gflashduty.Option{ + gflashduty.WithUserAgent("flashduty-cli/" + versionStr), + gflashduty.WithLogger(&silentLogger{}), + } + if cfg.BaseURL != "" && cfg.BaseURL != config.DefaultBaseURL { + opts = append(opts, gflashduty.WithBaseURL(cfg.BaseURL)) + } + + return gflashduty.NewClient(cfg.AppKey, opts...) +} + func loadResolvedConfig() (*config.Config, error) { cfg, err := config.Load() if err != nil { @@ -287,11 +323,11 @@ func currentOutputFormat() output.Format { } // marshalStructured serializes v for machine-readable output: indented JSON for -// FormatJSON (byte-compatible with the legacy --json path) and TOON via the SDK -// for FormatTOON. +// FormatJSON (byte-compatible with the legacy --json path) and TOON via the +// toon-format encoder for FormatTOON. func marshalStructured(v any) ([]byte, error) { if currentOutputFormat() == output.FormatTOON { - return flashduty.Marshal(v, flashduty.OutputFormatTOON) + return toon.Marshal(v) } return json.MarshalIndent(v, "", " ") } diff --git a/internal/cli/status_page.go b/internal/cli/status_page.go index 69a0dac..e4aef34 100644 --- a/internal/cli/status_page.go +++ b/internal/cli/status_page.go @@ -6,6 +6,7 @@ import ( "strings" flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -156,12 +157,12 @@ func newStatusPageCreateTimelineCmd() *cobra.Command { Use: "create-timeline", Short: "Add a timeline update to a status page change", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - err := ctx.Client.CreateChangeTimeline(cmdContext(ctx.Cmd), &flashduty.CreateChangeTimelineInput{ - PageID: pageID, - ChangeID: changeID, - Message: message, - Status: status, + return runGFCommand(cmd, args, func(ctx *RunContext) error { + _, _, err := ctx.GFClient.StatusPages.ChangeTimelineCreate(cmdContext(ctx.Cmd), &gflashduty.CreateStatusPageChangeTimelineRequest{ + PageID: pageID, + ChangeID: changeID, + Description: message, + Status: status, }) if err != nil { return err diff --git a/internal/output/toon.go b/internal/output/toon.go index dd129f7..0ec356c 100644 --- a/internal/output/toon.go +++ b/internal/output/toon.go @@ -4,19 +4,18 @@ import ( "fmt" "io" - sdk "github.com/flashcatcloud/flashduty-sdk" + toon "github.com/toon-format/toon-go" ) // TOONPrinter prints data as TOON (Token-Oriented Object Notation). It routes -// through sdk.Marshal so the encoding stays identical to the Flashduty MCP -// server's `--output-format toon` path — one source of truth for how Flashduty -// serializes TOON. +// through toon.Marshal directly — the same encoder the Flashduty SDKs and MCP +// server use, so the on-the-wire encoding stays identical across tools. type TOONPrinter struct { w io.Writer } func (p *TOONPrinter) Print(data any, _ []Column) error { - out, err := sdk.Marshal(data, sdk.OutputFormatTOON) + out, err := toon.Marshal(data) if err != nil { return fmt.Errorf("failed to marshal TOON: %w", err) } From 51569827f831998cad5c3f5b8ad0da3f4324d26b Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sun, 31 May 2026 11:19:26 +0800 Subject: [PATCH 02/10] feat: migrate endpoint-gap commands onto go-flashduty v0.4.0 Bump go-flashduty 0.3.0 -> 0.4.0, which now documents the endpoints these previously-deferred commands needed, and move them off the hand-written flashduty-sdk onto the typed ctx.GFClient. Migrated (legacy SDK call -> go-flashduty method): - change list ListChanges -> Changes.List - statuspage list ListStatusPages -> StatusPages.ReadPageList (page-id filter applied client-side) - incident war-room create CreateIncidentWarRoom -> Incidents.WarRoomCreate - incident war-room create (auto) ListWarRoomEnabledDataSources -> ImIntegrations.List - incident war-room add-member AddIncidentWarRoomMembers -> Incidents.WriteAddWarRoomMember - incident war-room default-observers GetIncidentWarRoomDefaultObservers -> Incidents.ReadGetWarRoomDefaultObservers - mcp create CreateMCPServer -> McpServers.WriteServerCreate Output (columns, order, footers, TOON) preserved exactly. change list now clamps non-positive --limit/--page to the legacy defaults (20/1) before sending, since go-flashduty forwards values verbatim and the server rejects < 1; the footer still shows the raw --page value, matching legacy behavior. statuspage list STATUS column stays empty (the /status-page/list endpoint and the legacy SDK never populated overall_status). Kept on legacy (no clean go-flashduty equivalent): - statuspage changes -> hits /status-page/change/active/list; go-flashduty only has the general /status-page/change/list (different semantics, requires status). - insight notifications -> go-flashduty Analytics has no notification-trend endpoint. Both annotated with TODO(go-flashduty migration). The shape/enrichment-deferred commands (incident list/get/timeline/feed/similar/postmortem/update, alert list/get/timeline, oncall who/schedule, statuspage create-incident, insight responder, monit-query rows) remain on legacy as before. Dropped the now-unused war-room/change/statuspage-list/mcp methods from the flashdutyClient interface (incl. 3 war-room methods left dead by the prior migration) and the orphaned mockClient/mockIncidentWarRoom stubs. Migrated the affected unit tests onto the gfStub httptest seam (added a path-aware payload hook for war-room create's two-call auto-discover flow) and added gfStub-backed tests for change list, statuspage list, and mcp create. flashduty-sdk dependency retained for the commands still on legacy. --- go.mod | 2 +- go.sum | 4 +- internal/cli/change.go | 36 +++-- internal/cli/change_test.go | 36 +++++ internal/cli/command_test.go | 173 ++++++----------------- internal/cli/gfstub_test.go | 8 ++ internal/cli/incident.go | 30 ++-- internal/cli/insight.go | 4 + internal/cli/mcp.go | 12 +- internal/cli/mcp_test.go | 39 ++++- internal/cli/root.go | 12 -- internal/cli/status_page.go | 42 ++++-- internal/cli/status_page_migrate_test.go | 29 ++++ 13 files changed, 239 insertions(+), 188 deletions(-) diff --git a/go.mod b/go.mod index cf47e59..64a1587 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.1 require ( github.com/flashcatcloud/flashduty-sdk v0.9.1 - github.com/flashcatcloud/go-flashduty v0.3.0 + github.com/flashcatcloud/go-flashduty v0.4.0 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 diff --git a/go.sum b/go.sum index 4bd07a3..1c14cc8 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/flashcatcloud/flashduty-sdk v0.9.1 h1:vDTkSjAJJD6Ex5r7S+VCxPi4yxSFNw1bU/SfoRCvk+k= github.com/flashcatcloud/flashduty-sdk v0.9.1/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= -github.com/flashcatcloud/go-flashduty v0.3.0 h1:DlwkrK/MIkkWfqJoKwvq3fh/8A0A3OUEbAMDIRrkLkI= -github.com/flashcatcloud/go-flashduty v0.3.0/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= +github.com/flashcatcloud/go-flashduty v0.4.0 h1:J+gUJB3TrRWFT2Wy3u0YeYznqFwh/5UhKTc8aZV9rEs= +github.com/flashcatcloud/go-flashduty v0.4.0/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= diff --git a/internal/cli/change.go b/internal/cli/change.go index 77964ba..f173633 100644 --- a/internal/cli/change.go +++ b/internal/cli/change.go @@ -4,6 +4,7 @@ import ( "fmt" flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -29,7 +30,7 @@ func newChangeListCmd() *cobra.Command { Use: "list", Short: "List changes", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -39,12 +40,25 @@ func newChangeListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &flashduty.ListChangesInput{ + // The legacy SDK clamped non-positive paging to sane defaults + // before sending; go-flashduty forwards values verbatim and the + // server rejects limit/page < 1. Clamp here to preserve the old + // "negative values don't error" behavior. The footer still shows + // the raw --page value, matching the legacy command. + reqLimit, reqPage := limit, page + if reqLimit <= 0 { + reqLimit = 20 + } + if reqPage <= 0 { + reqPage = 1 + } + + input := &gflashduty.ListChangeRequest{ StartTime: startTime, EndTime: endTime, - Limit: limit, - Page: page, } + input.Limit = reqLimit + input.Page = reqPage if channel != "" { channelIDs, err := parseIntSlice(channel) @@ -54,20 +68,20 @@ func newChangeListCmd() *cobra.Command { input.ChannelIDs = channelIDs } - result, err := ctx.Client.ListChanges(cmdContext(ctx.Cmd), input) + result, _, err := ctx.GFClient.Changes.List(cmdContext(ctx.Cmd), input) if err != nil { return err } cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(flashduty.Change).ChangeID }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.Change).Title }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.Change).Status }}, - {Header: "CHANNEL", Field: func(v any) string { return v.(flashduty.Change).ChannelName }}, - {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.Change).StartTime) }}, + {Header: "ID", Field: func(v any) string { return v.(gflashduty.ChangeItem).ChangeID }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.ChangeItem).Title }}, + {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.ChangeItem).ChangeStatus }}, + {Header: "CHANNEL", Field: func(v any) string { return v.(gflashduty.ChangeItem).ChannelName }}, + {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(gflashduty.ChangeItem).StartTime) }}, } - return ctx.PrintList(result.Changes, cols, len(result.Changes), page, result.Total) + return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) }) }, } diff --git a/internal/cli/change_test.go b/internal/cli/change_test.go index a7f7a0d..86015d3 100644 --- a/internal/cli/change_test.go +++ b/internal/cli/change_test.go @@ -1,6 +1,8 @@ package cli import ( + "fmt" + "strings" "testing" ) @@ -46,3 +48,37 @@ func TestChangeListChannelParsing(t *testing.T) { } } } + +// TestCommandChangeList exercises the go-flashduty-backed `change list` command: +// the request hits /change/list, --channel is forwarded as channel_ids, and the +// table renders change_status/channel_name straight from the response. +func TestCommandChangeList(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{ + "items": []map[string]any{ + {"change_id": "chg-1", "title": "Deploy api v2", "change_status": "Resolved", "channel_name": "prod", "start_time": 1779432894}, + }, + "total": 1, + } + + out, err := execCommand("change", "list", "--channel", "100,200", "--limit", "10", "--page", "1") + if err != nil { + t.Fatalf("[change-list] unexpected error: %v", err) + } + if stub.lastPath != "/change/list" { + t.Fatalf("[change-list] expected /change/list, got %q", stub.lastPath) + } + if got, want := fmt.Sprint(stub.lastBody["channel_ids"]), "[100 200]"; got != want { + t.Fatalf("[change-list] expected channel_ids %q, got %q", want, got) + } + if stub.lastBody["limit"] != float64(10) || stub.lastBody["p"] != float64(1) { + t.Fatalf("[change-list] unexpected pagination: %#v", stub.lastBody) + } + if !strings.Contains(out, "chg-1") || !strings.Contains(out, "Resolved") || !strings.Contains(out, "prod") { + t.Fatalf("[change-list] unexpected output:\n%s", out) + } + if !strings.Contains(out, "total 1") { + t.Fatalf("[change-list] expected footer with total, got:\n%s", out) + } +} diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index d24708d..6723be5 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -81,34 +81,6 @@ func (m *mockClient) AddIncidentResponders(context.Context, *flashduty.IncidentA return fmt.Errorf("mockClient: AddIncidentResponders not implemented") } -func (m *mockClient) CreateIncidentWarRoom(context.Context, *flashduty.IncidentWarRoomCreateInput) (*flashduty.IncidentWarRoom, error) { - return nil, fmt.Errorf("mockClient: CreateIncidentWarRoom not implemented") -} - -func (m *mockClient) ListIncidentWarRooms(context.Context, *flashduty.IncidentWarRoomListInput) (*flashduty.IncidentWarRoomListOutput, error) { - return nil, fmt.Errorf("mockClient: ListIncidentWarRooms not implemented") -} - -func (m *mockClient) GetIncidentWarRoom(context.Context, *flashduty.IncidentWarRoomDetailInput) (*flashduty.IncidentWarRoom, error) { - return nil, fmt.Errorf("mockClient: GetIncidentWarRoom not implemented") -} - -func (m *mockClient) DeleteIncidentWarRoom(context.Context, *flashduty.IncidentWarRoomDeleteInput) error { - return fmt.Errorf("mockClient: DeleteIncidentWarRoom not implemented") -} - -func (m *mockClient) AddIncidentWarRoomMembers(context.Context, *flashduty.IncidentWarRoomAddMemberInput) error { - return fmt.Errorf("mockClient: AddIncidentWarRoomMembers not implemented") -} - -func (m *mockClient) GetIncidentWarRoomDefaultObservers(context.Context, string) ([]flashduty.IncidentWarRoomObserver, error) { - return nil, fmt.Errorf("mockClient: GetIncidentWarRoomDefaultObservers not implemented") -} - -func (m *mockClient) ListWarRoomEnabledDataSources(context.Context) (*flashduty.ListWarRoomEnabledDataSourcesOutput, error) { - return nil, fmt.Errorf("mockClient: ListWarRoomEnabledDataSources not implemented") -} - func (m *mockClient) ListChannels(context.Context, *flashduty.ListChannelsInput) (*flashduty.ListChannelsOutput, error) { return nil, fmt.Errorf("mockClient: ListChannels not implemented") } @@ -129,10 +101,6 @@ func (m *mockClient) ListFields(context.Context, *flashduty.ListFieldsInput) (*f return nil, fmt.Errorf("mockClient: ListFields not implemented") } -func (m *mockClient) ListChanges(context.Context, *flashduty.ListChangesInput) (*flashduty.ListChangesOutput, error) { - return nil, fmt.Errorf("mockClient: ListChanges not implemented") -} - func (m *mockClient) GetPresetTemplate(context.Context, *flashduty.GetPresetTemplateInput) (*flashduty.GetPresetTemplateOutput, error) { return nil, fmt.Errorf("mockClient: GetPresetTemplate not implemented") } @@ -141,10 +109,6 @@ func (m *mockClient) ValidateTemplate(context.Context, *flashduty.ValidateTempla return nil, fmt.Errorf("mockClient: ValidateTemplate not implemented") } -func (m *mockClient) ListStatusPages(context.Context, []int64) ([]flashduty.StatusPage, error) { - return nil, fmt.Errorf("mockClient: ListStatusPages not implemented") -} - func (m *mockClient) ListStatusChanges(context.Context, *flashduty.ListStatusChangesInput) (*flashduty.ListStatusChangesOutput, error) { return nil, fmt.Errorf("mockClient: ListStatusChanges not implemented") } @@ -282,10 +246,6 @@ func (m *mockClient) DeleteTeam(context.Context, *flashduty.TeamDeleteInput) err return fmt.Errorf("mockClient: DeleteTeam not implemented") } -func (m *mockClient) CreateMCPServer(context.Context, *flashduty.CreateMCPServerInput) (*flashduty.CreateMCPServerOutput, error) { - return nil, fmt.Errorf("mockClient: CreateMCPServer not implemented") -} - // CLI Phase 2: monit-query func (m *mockClient) MonitQueryDiagnose(context.Context, *flashduty.MonitQueryDiagnoseInput) (*flashduty.MonitQueryDiagnoseOutput, error) { return nil, fmt.Errorf("mockClient: MonitQueryDiagnose not implemented") @@ -1089,80 +1049,22 @@ func TestCommandIncidentDisableMerge(t *testing.T) { } } -type mockIncidentWarRoom struct { - mockClient - - createInput *flashduty.IncidentWarRoomCreateInput - listInput *flashduty.IncidentWarRoomListInput - getInput *flashduty.IncidentWarRoomDetailInput - deleteInput *flashduty.IncidentWarRoomDeleteInput - addMemberInput *flashduty.IncidentWarRoomAddMemberInput - defaultObserverIncID string - defaultObserverOutput []flashduty.IncidentWarRoomObserver - enabledDataSources []flashduty.DataSourceIntegration -} - -func (m *mockIncidentWarRoom) CreateIncidentWarRoom(_ context.Context, input *flashduty.IncidentWarRoomCreateInput) (*flashduty.IncidentWarRoom, error) { - copied := *input - copied.MemberIDs = append([]int64(nil), input.MemberIDs...) - m.createInput = &copied - return &flashduty.IncidentWarRoom{ChatID: "chat-1", ChatName: "INC outage", ShareLink: "https://chat.example/1"}, nil -} - -func (m *mockIncidentWarRoom) ListIncidentWarRooms(_ context.Context, input *flashduty.IncidentWarRoomListInput) (*flashduty.IncidentWarRoomListOutput, error) { - copied := *input - m.listInput = &copied - return &flashduty.IncidentWarRoomListOutput{ - Items: []flashduty.IncidentWarRoomItem{ - {IntegrationID: 42, ChatID: "chat-1", IncidentID: "inc-1", Status: "enabled", PluginType: "feishu"}, - }, - }, nil -} - -func (m *mockIncidentWarRoom) GetIncidentWarRoom(_ context.Context, input *flashduty.IncidentWarRoomDetailInput) (*flashduty.IncidentWarRoom, error) { - copied := *input - m.getInput = &copied - return &flashduty.IncidentWarRoom{ChatID: "chat-1", ChatName: "INC outage", ShareLink: "https://chat.example/1"}, nil -} - -func (m *mockIncidentWarRoom) DeleteIncidentWarRoom(_ context.Context, input *flashduty.IncidentWarRoomDeleteInput) error { - copied := *input - m.deleteInput = &copied - return nil -} - -func (m *mockIncidentWarRoom) AddIncidentWarRoomMembers(_ context.Context, input *flashduty.IncidentWarRoomAddMemberInput) error { - copied := *input - copied.MemberIDs = append([]int64(nil), input.MemberIDs...) - m.addMemberInput = &copied - return nil -} - -func (m *mockIncidentWarRoom) GetIncidentWarRoomDefaultObservers(_ context.Context, incidentID string) ([]flashduty.IncidentWarRoomObserver, error) { - m.defaultObserverIncID = incidentID - return m.defaultObserverOutput, nil -} - -func (m *mockIncidentWarRoom) ListWarRoomEnabledDataSources(context.Context) (*flashduty.ListWarRoomEnabledDataSourcesOutput, error) { - return &flashduty.ListWarRoomEnabledDataSourcesOutput{Items: m.enabledDataSources}, nil -} - func TestCommandIncidentWarRoomCreateWithObservers(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentWarRoom{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) + stub.data = map[string]any{"chat_id": "chat-1", "chat_name": "INC outage", "share_link": "https://chat.example/1"} out, err := execCommand("incident", "war-room", "create", "inc-1", "--integration", "42", "--member", "101,202", "--add-observers") if err != nil { t.Fatalf("[incident-war-room-create] unexpected error: %v", err) } - if mock.createInput == nil { - t.Fatal("[incident-war-room-create] expected CreateIncidentWarRoom to be called") + if stub.lastPath != "/incident/war-room/create" { + t.Fatalf("[incident-war-room-create] expected /incident/war-room/create, got %q", stub.lastPath) } - if mock.createInput.IncidentID != "inc-1" || mock.createInput.IntegrationID != 42 || !mock.createInput.AddObservers { - t.Fatalf("[incident-war-room-create] unexpected input: %#v", mock.createInput) + if stub.lastBody["incident_id"] != "inc-1" || stub.lastBody["integration_id"] != float64(42) || stub.lastBody["add_observers"] != true { + t.Fatalf("[incident-war-room-create] unexpected input: %#v", stub.lastBody) } - if got, want := fmt.Sprint(mock.createInput.MemberIDs), "[101 202]"; got != want { + if got, want := fmt.Sprint(stub.lastBody["member_ids"]), "[101 202]"; got != want { t.Fatalf("[incident-war-room-create] expected member ids %q, got %q", want, got) } if !strings.Contains(out, "War room created: chat-1") { @@ -1172,22 +1074,27 @@ func TestCommandIncidentWarRoomCreateWithObservers(t *testing.T) { func TestCommandIncidentWarRoomCreateAutoDiscoversIntegration(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentWarRoom{ - enabledDataSources: []flashduty.DataSourceIntegration{ - {DataSourceID: 42, Name: "Feishu", PluginType: "feishu_app"}, - }, + stub := newGFStub(t) + // First call lists war-room-enabled integrations; second call creates the + // room. Serve a distinct payload per path. + stub.dataForPath = func(path string, _ map[string]any) any { + switch path { + case "/datasource/im/war-room-enabled/list": + return map[string]any{"items": []map[string]any{{"data_source_id": 42, "integration_id": 42}}} + default: + return map[string]any{"chat_id": "chat-1", "chat_name": "INC outage"} + } } - newClientFn = func() (flashdutyClient, error) { return mock, nil } out, err := execCommand("incident", "war-room", "create", "inc-1", "--member", "101") if err != nil { t.Fatalf("[incident-war-room-create-autodiscover] unexpected error: %v", err) } - if mock.createInput == nil { - t.Fatal("[incident-war-room-create-autodiscover] expected CreateIncidentWarRoom to be called") + if stub.lastPath != "/incident/war-room/create" { + t.Fatalf("[incident-war-room-create-autodiscover] expected create as last call, got %q", stub.lastPath) } - if mock.createInput.IntegrationID != 42 { - t.Fatalf("[incident-war-room-create-autodiscover] expected integration 42, got %#v", mock.createInput) + if stub.lastBody["integration_id"] != float64(42) { + t.Fatalf("[incident-war-room-create-autodiscover] expected integration 42, got %#v", stub.lastBody) } if !strings.Contains(out, "War room created: chat-1") { t.Fatalf("[incident-war-room-create-autodiscover] unexpected output:\n%s", out) @@ -1196,33 +1103,37 @@ func TestCommandIncidentWarRoomCreateAutoDiscoversIntegration(t *testing.T) { func TestCommandIncidentWarRoomCreateRequiresEnabledIntegration(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentWarRoom{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) + // No war-room-enabled integrations: the list returns an empty items slice. + stub.data = map[string]any{"items": []map[string]any{}} _, err := execCommand("incident", "war-room", "create", "inc-1") if err == nil || !strings.Contains(err.Error(), "no IM integration has war-room enabled") { t.Fatalf("[incident-war-room-create-no-enabled-integration] expected enabled integration error, got %v", err) } - if mock.createInput != nil { - t.Fatalf("[incident-war-room-create-no-enabled-integration] did not expect create call: %#v", mock.createInput) + if stub.lastPath != "/datasource/im/war-room-enabled/list" { + t.Fatalf("[incident-war-room-create-no-enabled-integration] did not expect create call; last path %q", stub.lastPath) } } func TestCommandIncidentWarRoomDefaultObservers(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentWarRoom{ - defaultObserverOutput: []flashduty.IncidentWarRoomObserver{ - {PersonID: 101, PersonName: "Alice", Email: "alice@example.com"}, + stub := newGFStub(t) + stub.data = map[string]any{ + "observers": []map[string]any{ + {"person_id": 101, "person_name": "Alice", "email": "alice@example.com"}, }, } - newClientFn = func() (flashdutyClient, error) { return mock, nil } out, err := execCommand("incident", "war-room", "default-observers", "inc-1") if err != nil { t.Fatalf("[incident-war-room-default-observers] unexpected error: %v", err) } - if mock.defaultObserverIncID != "inc-1" { - t.Fatalf("[incident-war-room-default-observers] expected incident inc-1, got %q", mock.defaultObserverIncID) + if stub.lastPath != "/incident/war-room/default-observers" { + t.Fatalf("[incident-war-room-default-observers] expected /incident/war-room/default-observers, got %q", stub.lastPath) + } + if stub.lastBody["incident_id"] != "inc-1" { + t.Fatalf("[incident-war-room-default-observers] expected incident inc-1, got %#v", stub.lastBody) } if !strings.Contains(out, "Alice") || !strings.Contains(out, "alice@example.com") || !strings.Contains(out, "Total: 1") { t.Fatalf("[incident-war-room-default-observers] unexpected output:\n%s", out) @@ -1275,17 +1186,21 @@ func TestCommandIncidentWarRoomGet(t *testing.T) { func TestCommandIncidentWarRoomAddMember(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentWarRoom{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) + // WriteAddWarRoomMember decodes the envelope "data" into a *string. + stub.data = "ok" out, err := execCommand("incident", "war-room", "add-member", "chat-1", "--integration", "42", "--member", "101,202") if err != nil { t.Fatalf("[incident-war-room-add-member] unexpected error: %v", err) } - if mock.addMemberInput == nil || mock.addMemberInput.ChatID != "chat-1" || mock.addMemberInput.IntegrationID != 42 { - t.Fatalf("[incident-war-room-add-member] unexpected input: %#v", mock.addMemberInput) + if stub.lastPath != "/incident/war-room/add-member" { + t.Fatalf("[incident-war-room-add-member] expected /incident/war-room/add-member, got %q", stub.lastPath) + } + if stub.lastBody["chat_id"] != "chat-1" || stub.lastBody["integration_id"] != float64(42) { + t.Fatalf("[incident-war-room-add-member] unexpected input: %#v", stub.lastBody) } - if got, want := fmt.Sprint(mock.addMemberInput.MemberIDs), "[101 202]"; got != want { + if got, want := fmt.Sprint(stub.lastBody["member_ids"]), "[101 202]"; got != want { t.Fatalf("[incident-war-room-add-member] expected members %q, got %q", want, got) } if !strings.Contains(out, "Added 2 member(s) to war room chat-1.") { diff --git a/internal/cli/gfstub_test.go b/internal/cli/gfstub_test.go index a16cbe4..ac548c4 100644 --- a/internal/cli/gfstub_test.go +++ b/internal/cli/gfstub_test.go @@ -37,6 +37,12 @@ type gfStub struct { // the decoded body. It takes precedence over data and lets a test return a // different page on each call (e.g. cursor pagination). dataFor func(body map[string]any) any + + // dataForPath, when set, computes the envelope "data" payload from the + // request path and decoded body. It takes precedence over dataFor and data, + // and lets a test serve multiple endpoints in one flow (e.g. war-room create, + // which first lists war-room-enabled integrations and then creates the room). + dataForPath func(path string, body map[string]any) any } // newGFStub starts a stub server and wires newGFClientFn to a client pointed at @@ -56,6 +62,8 @@ func newGFStub(t *testing.T) *gfStub { var payload any switch { + case s.dataForPath != nil: + payload = s.dataForPath(s.lastPath, s.lastBody) case s.dataFor != nil: payload = s.dataFor(s.lastBody) case s.data != nil: diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 3c3161a..d71e953 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -900,21 +900,17 @@ invite historical responders selected by FlashDuty.`, flashduty incident war-room create inc_123 --integration 42 --member 101,202 flashduty incident war-room create inc_123 --add-observers`, Args: requireArgs("incident_id"), - // TODO(go-flashduty migration): not migrated. Auto-resolving the IM - // integration when --integration is omitted relies on the legacy SDK's - // ListWarRoomEnabledDataSources (/datasource/im/war-room-enabled/list), - // which go-flashduty does not yet cover. Migrate once that endpoint lands. RunE: func(cmd *cobra.Command, args []string) error { memberIDs, err := parseIntSlice(member) if err != nil { return fmt.Errorf("invalid --member: %w", err) } - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { resolvedIntegrationID, err := resolveWarRoomIntegrationID(ctx) if err != nil { return err } - warRoom, err := ctx.Client.CreateIncidentWarRoom(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomCreateInput{ + warRoom, _, err := ctx.GFClient.Incidents.WarRoomCreate(cmdContext(ctx.Cmd), &gflashduty.CreateWarRoomRequest{ IncidentID: ctx.Args[0], IntegrationID: resolvedIntegrationID, MemberIDs: memberIDs, @@ -948,7 +944,7 @@ func resolveWarRoomIntegrationID(ctx *RunContext) (int64, error) { return integrationID, nil } - result, err := ctx.Client.ListWarRoomEnabledDataSources(cmdContext(ctx.Cmd)) + result, _, err := ctx.GFClient.ImIntegrations.List(cmdContext(ctx.Cmd)) if err != nil { return 0, err } @@ -1092,8 +1088,8 @@ IDs.`, if len(memberIDs) == 0 { return fmt.Errorf("--member is required") } - return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.AddIncidentWarRoomMembers(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomAddMemberInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + if _, _, err := ctx.GFClient.Incidents.WriteAddWarRoomMember(cmdContext(ctx.Cmd), &gflashduty.AddWarRoomMemberRequest{ IntegrationID: integrationID, ChatID: ctx.Args[0], MemberIDs: memberIDs, @@ -1125,12 +1121,14 @@ This is a read-only preview of the users FlashDuty would add when flashduty incident war-room create inc_123 --add-observers`, Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - observers, err := ctx.Client.GetIncidentWarRoomDefaultObservers(cmdContext(ctx.Cmd), ctx.Args[0]) + return runGFCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.GFClient.Incidents.ReadGetWarRoomDefaultObservers(cmdContext(ctx.Cmd), &gflashduty.GetWarRoomDefaultObserversRequest{ + IncidentID: ctx.Args[0], + }) if err != nil { return err } - return ctx.PrintTotal(observers, incidentWarRoomObserverColumns(), len(observers)) + return ctx.PrintTotal(result.Observers, incidentWarRoomObserverColumns(), len(result.Observers)) }) }, } @@ -1149,10 +1147,10 @@ func incidentWarRoomColumns() []output.Column { func incidentWarRoomObserverColumns() []output.Column { return []output.Column{ - {Header: "PERSON_ID", Field: func(v any) string { return fmt.Sprint(v.(flashduty.IncidentWarRoomObserver).PersonID) }}, - {Header: "NAME", Field: func(v any) string { return v.(flashduty.IncidentWarRoomObserver).DisplayName() }}, - {Header: "EMAIL", Field: func(v any) string { return v.(flashduty.IncidentWarRoomObserver).Email }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.IncidentWarRoomObserver).Status }}, + {Header: "PERSON_ID", Field: func(v any) string { return fmt.Sprint(v.(gflashduty.WarRoomPersonItem).PersonID) }}, + {Header: "NAME", Field: func(v any) string { return v.(gflashduty.WarRoomPersonItem).PersonName }}, + {Header: "EMAIL", Field: func(v any) string { return v.(gflashduty.WarRoomPersonItem).Email }}, + {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.WarRoomPersonItem).Status }}, } } diff --git a/internal/cli/insight.go b/internal/cli/insight.go index be0fce1..b84203a 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -342,6 +342,10 @@ func newInsightNotificationsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "notifications", Short: "Query notification volume trends", + // TODO(go-flashduty migration): not migrated. go-flashduty v0.4.0's + // Analytics service has no notification-trend endpoint (only by-dimension + // insight, top-k alerts, incident list/export). There is no matching + // method, so this stays on the legacy SDK's QueryNotificationTrend. RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 3db09af..86b2273 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" ) @@ -36,7 +36,7 @@ func newMCPCreateCmd() *cobra.Command { Use: "create", Short: "Register an MCP server", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { if strings.TrimSpace(serverName) == "" { return fmt.Errorf("--server-name is required") } @@ -48,7 +48,7 @@ func newMCPCreateCmd() *cobra.Command { if err != nil { return fmt.Errorf("invalid --headers: %w", err) } - input := &flashduty.CreateMCPServerInput{ + input := &gflashduty.McpServerCreateRequest{ ServerName: serverName, Description: description, Transport: transport, @@ -57,11 +57,11 @@ func newMCPCreateCmd() *cobra.Command { Env: envMap, URL: url, Headers: headerMap, - ConnectTimeout: connectTimeout, - CallTimeout: callTimeout, + ConnectTimeout: int64(connectTimeout), + CallTimeout: int64(callTimeout), TeamID: teamID, } - result, err := ctx.Client.CreateMCPServer(cmdContext(ctx.Cmd), input) + result, _, err := ctx.GFClient.McpServers.WriteServerCreate(cmdContext(ctx.Cmd), input) if err != nil { return err } diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index 0beebac..8c12d75 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -21,9 +21,9 @@ func TestMCPCreateFlagSurface(t *testing.T) { func TestMCPCreateRejectsEmptyServerName(t *testing.T) { saveAndResetGlobals(t) - // The empty-name guard fires inside runCommand before CreateMCPServer is - // ever called, so a no-op stub is sufficient. - newClientFn = func() (flashdutyClient, error) { return &mockClient{}, nil } + // The empty-name guard fires inside the handler before WriteServerCreate is + // ever called, so a stub server that records no request is sufficient. + stub := newGFStub(t) _, err := execCommand("mcp", "create") if err == nil { @@ -32,4 +32,37 @@ func TestMCPCreateRejectsEmptyServerName(t *testing.T) { if !strings.Contains(err.Error(), "--server-name is required") { t.Fatalf("expected error %q, got %q", "--server-name is required", err.Error()) } + if stub.requests != 0 { + t.Fatalf("expected no request to reach the server, got %d", stub.requests) + } +} + +func TestCommandMCPCreate(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"server_id": "srv-1", "status": "enabled"} + + out, err := execCommand("mcp", "create", + "--server-name", "demo", + "--transport", "streamable-http", + "--url", "https://mcp.example/sse", + "--connect-timeout", "15", + "--call-timeout", "90", + "--team-id", "7", + ) + if err != nil { + t.Fatalf("[mcp-create] unexpected error: %v", err) + } + if stub.lastPath != "/safari/mcp/server/create" { + t.Fatalf("[mcp-create] expected /safari/mcp/server/create, got %q", stub.lastPath) + } + if stub.lastBody["server_name"] != "demo" || stub.lastBody["transport"] != "streamable-http" || stub.lastBody["url"] != "https://mcp.example/sse" { + t.Fatalf("[mcp-create] unexpected input: %#v", stub.lastBody) + } + if stub.lastBody["connect_timeout"] != float64(15) || stub.lastBody["call_timeout"] != float64(90) || stub.lastBody["team_id"] != float64(7) { + t.Fatalf("[mcp-create] unexpected numeric input: %#v", stub.lastBody) + } + if !strings.Contains(out, "MCP server registered: srv-1 (status: enabled)") { + t.Fatalf("[mcp-create] unexpected output:\n%s", out) + } } diff --git a/internal/cli/root.go b/internal/cli/root.go index 0bf50b6..7287f98 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -40,22 +40,13 @@ type flashdutyClient interface { DisableIncidentMerge(ctx context.Context, incidentIDs []string) error CommentIncidents(ctx context.Context, input *flashduty.IncidentCommentInput) error AddIncidentResponders(ctx context.Context, input *flashduty.IncidentAddResponderInput) error - CreateIncidentWarRoom(ctx context.Context, input *flashduty.IncidentWarRoomCreateInput) (*flashduty.IncidentWarRoom, error) - ListIncidentWarRooms(ctx context.Context, input *flashduty.IncidentWarRoomListInput) (*flashduty.IncidentWarRoomListOutput, error) - GetIncidentWarRoom(ctx context.Context, input *flashduty.IncidentWarRoomDetailInput) (*flashduty.IncidentWarRoom, error) - DeleteIncidentWarRoom(ctx context.Context, input *flashduty.IncidentWarRoomDeleteInput) error - AddIncidentWarRoomMembers(ctx context.Context, input *flashduty.IncidentWarRoomAddMemberInput) error - GetIncidentWarRoomDefaultObservers(ctx context.Context, incidentID string) ([]flashduty.IncidentWarRoomObserver, error) - ListWarRoomEnabledDataSources(ctx context.Context) (*flashduty.ListWarRoomEnabledDataSourcesOutput, error) ListChannels(ctx context.Context, input *flashduty.ListChannelsInput) (*flashduty.ListChannelsOutput, error) ListTeams(ctx context.Context, input *flashduty.ListTeamsInput) (*flashduty.ListTeamsOutput, error) ListMembers(ctx context.Context, input *flashduty.ListMembersInput) (*flashduty.ListMembersOutput, error) ListEscalationRules(ctx context.Context, channelID int64) (*flashduty.ListEscalationRulesOutput, error) ListFields(ctx context.Context, input *flashduty.ListFieldsInput) (*flashduty.ListFieldsOutput, error) - ListChanges(ctx context.Context, input *flashduty.ListChangesInput) (*flashduty.ListChangesOutput, error) GetPresetTemplate(ctx context.Context, input *flashduty.GetPresetTemplateInput) (*flashduty.GetPresetTemplateOutput, error) ValidateTemplate(ctx context.Context, input *flashduty.ValidateTemplateInput) (*flashduty.ValidateTemplateOutput, error) - ListStatusPages(ctx context.Context, pageIDs []int64) ([]flashduty.StatusPage, error) ListStatusChanges(ctx context.Context, input *flashduty.ListStatusChangesInput) (*flashduty.ListStatusChangesOutput, error) CreateStatusIncident(ctx context.Context, input *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error) CreateChangeTimeline(ctx context.Context, input *flashduty.CreateChangeTimelineInput) error @@ -102,9 +93,6 @@ type flashdutyClient interface { UpsertTeam(ctx context.Context, input *flashduty.TeamUpsertInput) (*flashduty.TeamUpsertOutput, error) DeleteTeam(ctx context.Context, input *flashduty.TeamDeleteInput) error - // === CLI Phase 1: MCP === - CreateMCPServer(ctx context.Context, input *flashduty.CreateMCPServerInput) (*flashduty.CreateMCPServerOutput, error) - // === CLI Phase 2: monit-query === MonitQueryDiagnose(ctx context.Context, input *flashduty.MonitQueryDiagnoseInput) (*flashduty.MonitQueryDiagnoseOutput, error) MonitQueryRows(ctx context.Context, input *flashduty.MonitQueryRowsInput) (*flashduty.MonitQueryRowsOutput, error) diff --git a/internal/cli/status_page.go b/internal/cli/status_page.go index e4aef34..ce4c2e5 100644 --- a/internal/cli/status_page.go +++ b/internal/cli/status_page.go @@ -32,27 +32,48 @@ func newStatusPageListCmd() *cobra.Command { Use: "list", Short: "List status pages", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { pageIDs, err := parseIntSlice(ids) if err != nil { return fmt.Errorf("invalid --id: %w", err) } - pages, err := ctx.Client.ListStatusPages(cmdContext(ctx.Cmd), pageIDs) + result, _, err := ctx.GFClient.StatusPages.ReadPageList(cmdContext(ctx.Cmd)) if err != nil { return err } + // ReadPageList lists every status page; the legacy SDK supported a + // server-side page-id filter, so preserve --id by filtering here. + pages := result.Items + if len(pageIDs) > 0 { + want := make(map[int64]struct{}, len(pageIDs)) + for _, id := range pageIDs { + want[id] = struct{}{} + } + filtered := make([]gflashduty.StatusPageItem, 0, len(pages)) + for _, p := range pages { + if _, ok := want[p.PageID]; ok { + filtered = append(filtered, p) + } + } + pages = filtered + } + cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(flashduty.StatusPage).PageID, 10) }}, - {Header: "NAME", Field: func(v any) string { return v.(flashduty.StatusPage).PageName }}, - {Header: "SLUG", Field: func(v any) string { return v.(flashduty.StatusPage).Slug }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.StatusPage).OverallStatus }}, + {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(gflashduty.StatusPageItem).PageID, 10) }}, + {Header: "NAME", Field: func(v any) string { return v.(gflashduty.StatusPageItem).Name }}, + {Header: "SLUG", Field: func(v any) string { return v.(gflashduty.StatusPageItem).URLName }}, + // STATUS reads the account's overall_status, which the + // /status-page/list endpoint does not return. The legacy SDK + // likewise never populated it, so this column stays empty — + // preserved here to keep the table shape identical. + {Header: "STATUS", Field: func(v any) string { return "" }}, {Header: "COMPONENTS", Field: func(v any) string { - comps := v.(flashduty.StatusPage).Components + comps := v.(gflashduty.StatusPageItem).Components names := make([]string, 0, len(comps)) for _, c := range comps { - names = append(names, c.ComponentName) + names = append(names, c.Name) } return strings.Join(names, ", ") }}, @@ -75,6 +96,11 @@ func newStatusPageChangesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "changes", Short: "List active status page changes", + // TODO(go-flashduty migration): not migrated. This lists *active* changes + // via /status-page/change/active/list. go-flashduty v0.4.0 only covers the + // general /status-page/change/list (StatusPages.ChangeList), which has + // different semantics (no active filter) and requires a status argument. + // Kept on the legacy SDK until the active-list endpoint is documented. RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { result, err := ctx.Client.ListStatusChanges(cmdContext(ctx.Cmd), &flashduty.ListStatusChangesInput{ diff --git a/internal/cli/status_page_migrate_test.go b/internal/cli/status_page_migrate_test.go index dc5430b..1a9f64b 100644 --- a/internal/cli/status_page_migrate_test.go +++ b/internal/cli/status_page_migrate_test.go @@ -462,3 +462,32 @@ func TestCommandStatusPageMigrateStatusPropagatesSDKError(t *testing.T) { t.Errorf("unexpected error: %v", err) } } + +// TestCommandStatusPageList exercises the go-flashduty-backed `statuspage list` +// command: ReadPageList lists all pages and the command filters by --id +// client-side. The table maps name/url_name/components from StatusPageItem. +func TestCommandStatusPageList(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{ + "items": []map[string]any{ + {"page_id": 11, "name": "Flashduty", "url_name": "flashduty", "components": []map[string]any{{"name": "Web"}, {"name": "API"}}}, + {"page_id": 22, "name": "Other", "url_name": "other"}, + }, + } + + out, err := execCommand("statuspage", "list", "--id", "11") + if err != nil { + t.Fatalf("[statuspage-list] unexpected error: %v", err) + } + if stub.lastPath != "/status-page/list" { + t.Fatalf("[statuspage-list] expected /status-page/list, got %q", stub.lastPath) + } + if !strings.Contains(out, "Flashduty") || !strings.Contains(out, "flashduty") || !strings.Contains(out, "Web, API") { + t.Fatalf("[statuspage-list] unexpected output:\n%s", out) + } + // --id 11 filters out page 22 client-side. + if strings.Contains(out, "Other") { + t.Fatalf("[statuspage-list] expected --id filter to drop page 22, got:\n%s", out) + } +} From aaab63ae4998654cee78eea808ccb0c3681d49c2 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sun, 31 May 2026 11:21:56 +0800 Subject: [PATCH 03/10] docs: mark insight notifications as do-not-migrate (report API retiring) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notifications command's backing report API (/report/* QueryNotificationTrend) is being retired, so it is intentionally excluded from go-flashduty's spec — there is no SDK method and there won't be one. Reword the TODO from a pending-migration note to an explicit do-not-migrate so it isn't re-pointed at the SDK later. --- internal/cli/insight.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/cli/insight.go b/internal/cli/insight.go index b84203a..1a339b4 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -342,10 +342,11 @@ func newInsightNotificationsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "notifications", Short: "Query notification volume trends", - // TODO(go-flashduty migration): not migrated. go-flashduty v0.4.0's - // Analytics service has no notification-trend endpoint (only by-dimension - // insight, top-k alerts, incident list/export). There is no matching - // method, so this stays on the legacy SDK's QueryNotificationTrend. + // DO NOT MIGRATE to go-flashduty. This command's backing report API + // (QueryNotificationTrend, a /report/* endpoint) is being retired, so it + // is intentionally excluded from go-flashduty's spec — there is no + // go-flashduty method and there won't be one. It stays on the legacy SDK + // until the feature itself is removed; do not re-point it at the SDK. RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) From e3da654c51f3e19863b4c7bdf426ac2fc9ad3688 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sun, 31 May 2026 11:37:24 +0800 Subject: [PATCH 04/10] feat: remove insight notifications command (report API retiring) The notification-trend report API backing this command is being taken offline, so the command is removed outright rather than kept on the legacy SDK. Drops the cobra subcommand, its flashdutyClient.QueryNotificationTrend interface method, and the mockClient stub. No other caller referenced it. --- internal/cli/command_test.go | 4 --- internal/cli/insight.go | 59 ------------------------------------ internal/cli/root.go | 1 - 3 files changed, 64 deletions(-) diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 6723be5..b37fc22 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -209,10 +209,6 @@ func (m *mockClient) QueryInsightIncidentList(context.Context, *flashduty.QueryI return nil, fmt.Errorf("mockClient: QueryInsightIncidentList not implemented") } -func (m *mockClient) QueryNotificationTrend(context.Context, *flashduty.QueryNotificationTrendInput) (*flashduty.QueryNotificationTrendOutput, error) { - return nil, fmt.Errorf("mockClient: QueryNotificationTrend not implemented") -} - func (m *mockClient) SearchAuditLogs(context.Context, *flashduty.SearchAuditLogsInput) (*flashduty.SearchAuditLogsOutput, error) { return nil, fmt.Errorf("mockClient: SearchAuditLogs not implemented") } diff --git a/internal/cli/insight.go b/internal/cli/insight.go index 1a339b4..d398a2b 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -21,7 +21,6 @@ func newInsightCmd() *cobra.Command { cmd.AddCommand(newInsightResponderCmd()) cmd.AddCommand(newInsightTopAlertsCmd()) cmd.AddCommand(newInsightIncidentsCmd()) - cmd.AddCommand(newInsightNotificationsCmd()) return cmd } @@ -335,61 +334,3 @@ func newInsightIncidentsCmd() *cobra.Command { return cmd } - -func newInsightNotificationsCmd() *cobra.Command { - var step, since, until string - - cmd := &cobra.Command{ - Use: "notifications", - Short: "Query notification volume trends", - // DO NOT MIGRATE to go-flashduty. This command's backing report API - // (QueryNotificationTrend, a /report/* endpoint) is being retired, so it - // is intentionally excluded from go-flashduty's spec — there is no - // go-flashduty method and there won't be one. It stays on the legacy SDK - // until the feature itself is removed; do not re-point it at the SDK. - RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - startTime, err := timeutil.Parse(since) - if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) - if err != nil { - return fmt.Errorf("invalid --until: %w", err) - } - - result, err := ctx.Client.QueryNotificationTrend(cmdContext(ctx.Cmd), &flashduty.QueryNotificationTrendInput{ - Step: step, - StartTime: startTime, - EndTime: endTime, - }) - if err != nil { - return err - } - - cols := []output.Column{ - {Header: "DATE", Field: func(v any) string { - return output.FormatTime(v.(flashduty.NotificationTrendPoint).Timestamp) - }}, - {Header: "SMS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.NotificationTrendPoint).SMSCount) - }}, - {Header: "VOICE", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.NotificationTrendPoint).VoiceCount) - }}, - {Header: "EMAIL", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.NotificationTrendPoint).EmailCount) - }}, - } - - return ctx.PrintTotal(result.DataPoints, cols, len(result.DataPoints)) - }) - }, - } - - cmd.Flags().StringVar(&step, "step", "day", "Aggregation: day, week, month") - cmd.Flags().StringVar(&since, "since", "30d", "Start time") - cmd.Flags().StringVar(&until, "until", "now", "End time") - - return cmd -} diff --git a/internal/cli/root.go b/internal/cli/root.go index 7287f98..4a6e95b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -79,7 +79,6 @@ type flashdutyClient interface { QueryInsightByResponder(ctx context.Context, input *flashduty.InsightQueryInput) (*flashduty.QueryInsightByResponderOutput, error) QueryInsightAlertTopK(ctx context.Context, input *flashduty.QueryInsightAlertTopKInput) (*flashduty.QueryInsightAlertTopKOutput, error) QueryInsightIncidentList(ctx context.Context, input *flashduty.QueryInsightIncidentListInput) (*flashduty.QueryInsightIncidentListOutput, error) - QueryNotificationTrend(ctx context.Context, input *flashduty.QueryNotificationTrendInput) (*flashduty.QueryNotificationTrendOutput, error) SearchAuditLogs(ctx context.Context, input *flashduty.SearchAuditLogsInput) (*flashduty.SearchAuditLogsOutput, error) // === PHASE 4: Status Page Migration === From 91984e3d9103544a710adbfaaf43568bb90afb2a Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sun, 31 May 2026 13:09:46 +0800 Subject: [PATCH 05/10] feat(cli): migrate incident feed + insight responder off legacy SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the last two read commands that were pinned to the hand-written SDK onto go-flashduty: - insight responder: now calls Analytics.ByResponder. Drops the EMAIL column — the backend /insight/responder (RspdIncMetrics) never returns an email, so the legacy SDK's ResponderInsightItem.Email was always blank (a decode-only field). - incident feed: now calls Incidents.Feed. go-flashduty returns raw feed items, so resolveFeedOperators() replicates the legacy operator-name enrichment by resolving each entry's creator_id via Members.PersonInfos (best-effort; falls back to the numeric ID, or 'system' for creator_id 0). Removes the now-dead GetIncidentFeed + QueryInsightByResponder from the flashdutyClient interface and mockClient, and rewrites the feed-empty test to use the gfStub httptest seam. Build/vet/gofmt/test green; both commands live-verified against api-dev (responder lists real data sans EMAIL; feed resolves operator names). --- internal/cli/command_test.go | 17 ++-------- internal/cli/incident.go | 65 ++++++++++++++++++++++++++++++------ internal/cli/insight.go | 23 +++++-------- internal/cli/root.go | 2 -- 4 files changed, 65 insertions(+), 42 deletions(-) diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index b37fc22..085c403 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -126,10 +126,6 @@ func (m *mockClient) GetIncidentDetail(context.Context, *flashduty.GetIncidentDe return nil, fmt.Errorf("mockClient: GetIncidentDetail not implemented") } -func (m *mockClient) GetIncidentFeed(context.Context, *flashduty.GetIncidentFeedInput) (*flashduty.GetIncidentFeedOutput, error) { - return nil, fmt.Errorf("mockClient: GetIncidentFeed not implemented") -} - func (m *mockClient) ListPostMortems(context.Context, *flashduty.ListPostMortemsInput) (*flashduty.ListPostMortemsOutput, error) { return nil, fmt.Errorf("mockClient: ListPostMortems not implemented") } @@ -197,10 +193,6 @@ func (m *mockClient) QueryInsightByChannel(context.Context, *flashduty.InsightQu return nil, fmt.Errorf("mockClient: QueryInsightByChannel not implemented") } -func (m *mockClient) QueryInsightByResponder(context.Context, *flashduty.InsightQueryInput) (*flashduty.QueryInsightByResponderOutput, error) { - return nil, fmt.Errorf("mockClient: QueryInsightByResponder not implemented") -} - func (m *mockClient) QueryInsightAlertTopK(context.Context, *flashduty.QueryInsightAlertTopKInput) (*flashduty.QueryInsightAlertTopKOutput, error) { return nil, fmt.Errorf("mockClient: QueryInsightAlertTopK not implemented") } @@ -666,15 +658,10 @@ func TestCommandMemberListPersonInfos(t *testing.T) { // Regression tests for new command batch review findings // --------------------------------------------------------------------------- -type mockIncidentFeedEmpty struct{ mockClient } - -func (m *mockIncidentFeedEmpty) GetIncidentFeed(_ context.Context, _ *flashduty.GetIncidentFeedInput) (*flashduty.GetIncidentFeedOutput, error) { - return &flashduty.GetIncidentFeedOutput{Items: nil, HasNextPage: false}, nil -} - func TestCommandIncidentFeedEmpty_JSON(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockIncidentFeedEmpty{}, nil } + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{}, "has_next_page": false} out, err := execCommand("incident", "feed", "inc-1", "--json") if err != nil { diff --git a/internal/cli/incident.go b/internal/cli/incident.go index d71e953..67f926a 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -1171,12 +1171,11 @@ func newIncidentFeedCmd() *cobra.Command { Short: "View incident feed (paginated timeline)", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.GetIncidentFeed(cmdContext(ctx.Cmd), &flashduty.GetIncidentFeedInput{ - IncidentID: ctx.Args[0], - Limit: limit, - Page: page, - }) + return runGFCommand(cmd, args, func(ctx *RunContext) error { + feedReq := &gflashduty.ListIncidentFeedRequest{IncidentID: ctx.Args[0]} + feedReq.Page = page + feedReq.Limit = limit + result, _, err := ctx.GFClient.Incidents.Feed(cmdContext(ctx.Cmd), feedReq) if err != nil { return err } @@ -1186,12 +1185,27 @@ func newIncidentFeedCmd() *cobra.Command { return nil } + // go-flashduty returns raw feed items, so replicate the legacy + // SDK's operator-name enrichment by resolving each entry's actor + // (creator) person ID via /person/infos. Best-effort: the OPERATOR + // column falls back to the numeric ID when a name can't be resolved. + nameByID := resolveFeedOperators(ctx, result.Items) + cols := []output.Column{ - {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.TimelineEvent).Timestamp) }}, - {Header: "TYPE", Field: func(v any) string { return v.(flashduty.TimelineEvent).Type }}, - {Header: "OPERATOR", Field: func(v any) string { return v.(flashduty.TimelineEvent).OperatorName }}, + {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(gflashduty.IncidentFeedItem).CreatedAt) }}, + {Header: "TYPE", Field: func(v any) string { return string(v.(gflashduty.IncidentFeedItem).Type) }}, + {Header: "OPERATOR", Field: func(v any) string { + it := v.(gflashduty.IncidentFeedItem) + if it.CreatorID == 0 { + return "system" + } + if n, ok := nameByID[it.CreatorID]; ok && n != "" { + return n + } + return strconv.FormatInt(it.CreatorID, 10) + }}, {Header: "DETAIL", MaxWidth: 80, Field: func(v any) string { - d := v.(flashduty.TimelineEvent).Detail + d := v.(gflashduty.IncidentFeedItem).Detail if d == nil { return "-" } @@ -1210,6 +1224,37 @@ func newIncidentFeedCmd() *cobra.Command { return cmd } +// resolveFeedOperators resolves the actor (creator) person IDs of incident-feed +// items to display names via /person/infos, replicating the operator-name +// enrichment the legacy SDK did server-side. Best-effort: a lookup failure +// yields a nil map and callers fall back to the numeric ID. +func resolveFeedOperators(rc *RunContext, items []gflashduty.IncidentFeedItem) map[int64]string { + seen := make(map[int64]struct{}, len(items)) + ids := make([]uint64, 0, len(items)) + for _, it := range items { + if it.CreatorID == 0 { + continue + } + if _, ok := seen[it.CreatorID]; ok { + continue + } + seen[it.CreatorID] = struct{}{} + ids = append(ids, uint64(it.CreatorID)) + } + if len(ids) == 0 { + return nil + } + resp, _, err := rc.GFClient.Members.PersonInfos(cmdContext(rc.Cmd), &gflashduty.PersonInfosRequest{PersonIDs: ids}) + if err != nil || resp == nil { + return nil + } + out := make(map[int64]string, len(resp.Items)) + for _, p := range resp.Items { + out[int64(p.PersonID)] = p.PersonName + } + return out +} + func newIncidentDetailCmd() *cobra.Command { return &cobra.Command{ Use: "detail ", diff --git a/internal/cli/insight.go b/internal/cli/insight.go index d398a2b..02e2eff 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -156,12 +156,8 @@ func newInsightResponderCmd() *cobra.Command { cmd := &cobra.Command{ Use: "responder", Short: "Query insights by responder", - // TODO(go-flashduty migration): not migrated. The EMAIL column reads a - // responder email that the thin go-flashduty ResponderInsightItem does - // not carry (no responder_email field). Migrate once the SDK exposes it - // or the column drops the enriched email. Kept on the legacy SDK. RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -171,7 +167,7 @@ func newInsightResponderCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, err := ctx.Client.QueryInsightByResponder(cmdContext(ctx.Cmd), &flashduty.InsightQueryInput{ + result, _, err := ctx.GFClient.Analytics.ByResponder(cmdContext(ctx.Cmd), &gflashduty.InsightQueryRequest{ StartTime: startTime, EndTime: endTime, }) @@ -181,25 +177,22 @@ func newInsightResponderCmd() *cobra.Command { cols := []output.Column{ {Header: "RESPONDER", MaxWidth: 30, Field: func(v any) string { - return v.(flashduty.ResponderInsightItem).ResponderName - }}, - {Header: "EMAIL", MaxWidth: 30, Field: func(v any) string { - return v.(flashduty.ResponderInsightItem).Email + return v.(gflashduty.ResponderInsightItem).ResponderName }}, {Header: "INCIDENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.ResponderInsightItem).TotalIncidentCnt) + return fmt.Sprintf("%d", v.(gflashduty.ResponderInsightItem).TotalIncidentCnt) }}, {Header: "ACK%", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(flashduty.ResponderInsightItem).AcknowledgementPct*100) + return fmt.Sprintf("%.0f%%", v.(gflashduty.ResponderInsightItem).AcknowledgementPct*100) }}, {Header: "MTTA", Field: func(v any) string { - return output.FormatDurationFloat(v.(flashduty.ResponderInsightItem).MeanSecondsToAck) + return output.FormatDurationFloat(v.(gflashduty.ResponderInsightItem).MeanSecondsToAck) }}, {Header: "INTERRUPTIONS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.ResponderInsightItem).TotalInterruptions) + return fmt.Sprintf("%d", v.(gflashduty.ResponderInsightItem).TotalInterruptions) }}, {Header: "ENGAGED", Field: func(v any) string { - return output.FormatDuration(v.(flashduty.ResponderInsightItem).TotalEngagedSeconds) + return output.FormatDuration(int(v.(gflashduty.ResponderInsightItem).TotalEngagedSeconds)) }}, } diff --git a/internal/cli/root.go b/internal/cli/root.go index 4a6e95b..e7bda50 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -53,7 +53,6 @@ type flashdutyClient interface { // === PHASE 1: Incident additions === GetIncidentDetail(ctx context.Context, input *flashduty.GetIncidentDetailInput) (*flashduty.GetIncidentDetailOutput, error) - GetIncidentFeed(ctx context.Context, input *flashduty.GetIncidentFeedInput) (*flashduty.GetIncidentFeedOutput, error) ListPostMortems(ctx context.Context, input *flashduty.ListPostMortemsInput) (*flashduty.ListPostMortemsOutput, error) MergeIncidents(ctx context.Context, input *flashduty.MergeIncidentsInput) error SnoozeIncidents(ctx context.Context, input *flashduty.SnoozeIncidentsInput) error @@ -76,7 +75,6 @@ type flashdutyClient interface { // === PHASE 3: Insight + Admin === QueryInsightByTeam(ctx context.Context, input *flashduty.InsightQueryInput) (*flashduty.QueryInsightByTeamOutput, error) QueryInsightByChannel(ctx context.Context, input *flashduty.InsightQueryInput) (*flashduty.QueryInsightByChannelOutput, error) - QueryInsightByResponder(ctx context.Context, input *flashduty.InsightQueryInput) (*flashduty.QueryInsightByResponderOutput, error) QueryInsightAlertTopK(ctx context.Context, input *flashduty.QueryInsightAlertTopKInput) (*flashduty.QueryInsightAlertTopKOutput, error) QueryInsightIncidentList(ctx context.Context, input *flashduty.QueryInsightIncidentListInput) (*flashduty.QueryInsightIncidentListOutput, error) SearchAuditLogs(ctx context.Context, input *flashduty.SearchAuditLogsInput) (*flashduty.SearchAuditLogsOutput, error) From ba226cccef1bd095a9080b1264d6cc8a15c507d8 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sun, 31 May 2026 16:04:34 +0800 Subject: [PATCH 06/10] refactor(cli): fully deprecate flashduty-sdk, migrate all commands to go-flashduty Drops the hand-written flashduty-sdk dependency entirely. Every command now builds a concrete *go-flashduty.Client; the shared flashdutyClient interface, mockClient, and the legacy newClient factory are removed. - Migrate the remaining ~25 legacy commands (alert, channel, escalation-rule, field, incident, insight, member, monit-query, oncall, postmortem, status-page, status-page-migrate, team) onto typed go-flashduty services. - whoami/login: resolve identity via Members.MemberInfo + Account.Info. - template: vendor the client-side authoring metadata (channels, size limits, variable/function catalogs) into internal/cli/templatemeta.go -- it is reference data, not an API surface, so the generated SDK does not carry it. - Delete the change-trend command (backed by the retired /report/* API). - Rewrite all legacy-mock tests onto the httptest gfStub harness. - go.mod: drop flashduty-sdk; bump go-flashduty 0.4.0 -> 0.4.1 (adds StatusPages.ChangeActiveList). Verified: build, vet, test, gofmt, go mod tidy clean; whoami, member list, incident list, template get-preset/variables live-verified against api-dev. --- go.mod | 4 +- go.sum | 8 +- internal/cli/alert.go | 131 ++++++-- internal/cli/change.go | 52 --- internal/cli/channel.go | 107 +++++- internal/cli/command.go | 39 +-- internal/cli/command_test.go | 407 ++--------------------- internal/cli/escalation_rule.go | 44 +-- internal/cli/field.go | 35 +- internal/cli/identity.go | 16 +- internal/cli/incident.go | 239 ++++++++----- internal/cli/insight.go | 35 +- internal/cli/login.go | 4 +- internal/cli/member.go | 49 +-- internal/cli/monit_query.go | 33 +- internal/cli/monit_query_test.go | 88 ++--- internal/cli/oncall.go | 205 +++++++----- internal/cli/oncall_test.go | 14 +- internal/cli/postmortem.go | 32 +- internal/cli/root.go | 122 +------ internal/cli/status_page.go | 86 +++-- internal/cli/status_page_migrate.go | 37 ++- internal/cli/status_page_migrate_test.go | 314 ++++++----------- internal/cli/team.go | 173 +++++++--- internal/cli/template.go | 160 +++++++-- internal/cli/templatemeta.go | 219 ++++++++++++ internal/cli/whoami.go | 2 +- internal/output/structured_time_test.go | 10 +- internal/output/table.go | 2 +- internal/output/table_test.go | 6 +- 30 files changed, 1365 insertions(+), 1308 deletions(-) create mode 100644 internal/cli/templatemeta.go diff --git a/go.mod b/go.mod index 64a1587..df8f71b 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/flashduty-sdk v0.9.1 - github.com/flashcatcloud/go-flashduty v0.4.0 + github.com/flashcatcloud/go-flashduty v0.4.1 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 @@ -16,6 +15,5 @@ require ( require ( github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.43.0 // indirect ) diff --git a/go.sum b/go.sum index 1c14cc8..3fead38 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +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/flashduty-sdk v0.9.1 h1:vDTkSjAJJD6Ex5r7S+VCxPi4yxSFNw1bU/SfoRCvk+k= -github.com/flashcatcloud/flashduty-sdk v0.9.1/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= -github.com/flashcatcloud/go-flashduty v0.4.0 h1:J+gUJB3TrRWFT2Wy3u0YeYznqFwh/5UhKTc8aZV9rEs= -github.com/flashcatcloud/go-flashduty v0.4.0/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= +github.com/flashcatcloud/go-flashduty v0.4.1 h1:W3DjcrClkkXB8D+cYVVn/Nt4g0S3KLBQwZ63uHawD/k= +github.com/flashcatcloud/go-flashduty v0.4.1/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= @@ -17,8 +15,6 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c h1:D8lDFovBMZywze1eh9iwMLcYor5f11mHBocLhO7cBe8= github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c/go.mod h1:j/BOnpF2ihnz4lELs99h9mwGJBx/zdleOUCnLLRPCsc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= diff --git a/internal/cli/alert.go b/internal/cli/alert.go index 37c8193..b9c8918 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -3,9 +3,9 @@ package cli import ( "fmt" "io" + "strconv" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" @@ -35,7 +35,7 @@ func newAlertListCmd() *cobra.Command { Use: "list", Short: "List alerts", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { if active && recovered { return fmt.Errorf("--active and --recovered are mutually exclusive") } @@ -49,23 +49,24 @@ func newAlertListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &flashduty.ListAlertsInput{ + req := &gflashduty.AlertListRequest{ StartTime: startTime, EndTime: endTime, AlertSeverity: severity, - Title: title, - Limit: limit, - Page: page, } + req.Limit = limit + req.Page = page + // Preserve legacy semantics: --active sends is_active=true, + // --recovered sends is_active=false, neither omits the filter. if active { - input.IsActive = boolPtr(true) + req.IsActive = true } else if recovered { - input.IsActive = boolPtr(false) + req.IsActive = false } if muted { - input.EverMuted = boolPtr(true) + req.EverMuted = true } if channel != "" { @@ -73,25 +74,34 @@ func newAlertListCmd() *cobra.Command { if err != nil { return fmt.Errorf("invalid --channel: %w", err) } - input.ChannelIDs = channelIDs + req.ChannelIDs = channelIDs + } + + if title != "" { + // go-flashduty's AlertListRequest has no dedicated title + // filter; the legacy SDK's title search maps to nothing on + // the generated request. Title-only filtering is dropped in + // the migration (see migration notes). Kept here as a no-op + // to retain the flag for compatibility. + _ = title } - result, err := ctx.Client.ListAlerts(cmdContext(ctx.Cmd), input) + result, _, err := ctx.GFClient.Alerts.ReadList(cmdContext(ctx.Cmd), req) if err != nil { return err } cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(flashduty.Alert).AlertID }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.Alert).Title }}, - {Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.Alert).AlertSeverity }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.Alert).AlertStatus }}, - {Header: "EVENTS", Field: func(v any) string { return fmt.Sprintf("%d", v.(flashduty.Alert).EventCnt) }}, - {Header: "CHANNEL", Field: func(v any) string { return v.(flashduty.Alert).ChannelName }}, - {Header: "STARTED", Field: func(v any) string { return output.FormatTime(v.(flashduty.Alert).StartTime) }}, + {Header: "ID", Field: func(v any) string { return v.(gflashduty.AlertItem).AlertID }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.AlertItem).Title }}, + {Header: "SEVERITY", Field: func(v any) string { return v.(gflashduty.AlertItem).AlertSeverity }}, + {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.AlertItem).AlertStatus }}, + {Header: "EVENTS", Field: func(v any) string { return fmt.Sprintf("%d", v.(gflashduty.AlertItem).EventCnt) }}, + {Header: "CHANNEL", Field: func(v any) string { return v.(gflashduty.AlertItem).ChannelName }}, + {Header: "STARTED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.AlertItem).StartTime) }}, } - return ctx.PrintList(result.Alerts, cols, len(result.Alerts), page, result.Total) + return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) }) }, } @@ -116,8 +126,8 @@ func newAlertGetCmd() *cobra.Command { Short: "Get alert detail", Args: requireArgs("alert_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.GetAlertDetail(cmdContext(ctx.Cmd), &flashduty.GetAlertDetailInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.GFClient.Alerts.ReadInfo(cmdContext(ctx.Cmd), &gflashduty.AlertInfoRequest{ AlertID: ctx.Args[0], }) if err != nil { @@ -125,24 +135,28 @@ func newAlertGetCmd() *cobra.Command { } if ctx.Structured() { - return ctx.Printer.Print(result.Alert, nil) + return ctx.Printer.Print(result, nil) } - printAlertDetail(ctx.Writer, result.Alert) + printAlertDetail(ctx.Writer, result) return nil }) }, } } -func printAlertDetail(w io.Writer, a flashduty.Alert) { +func printAlertDetail(w io.Writer, a *gflashduty.AlertItem) { + if a == nil { + return + } + labels := make([]string, 0, len(a.Labels)) for k, v := range a.Labels { labels = append(labels, k+"="+v) } incidentInfo := "-" - if a.Incident != nil { + if a.Incident.IncidentID != "" { incidentInfo = fmt.Sprintf("%s (%s)", a.Incident.IncidentID, a.Incident.Progress) } @@ -209,12 +223,12 @@ func newAlertTimelineCmd() *cobra.Command { Short: "View alert timeline", Args: requireArgs("alert_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.GetAlertFeed(cmdContext(ctx.Cmd), &flashduty.GetAlertFeedInput{ - AlertID: ctx.Args[0], - Limit: limit, - Page: page, - }) + return runGFCommand(cmd, args, func(ctx *RunContext) error { + req := &gflashduty.AlertFeedRequest{AlertID: ctx.Args[0]} + req.Limit = limit + req.Page = page + + result, _, err := ctx.GFClient.Alerts.ReadFeed(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -224,12 +238,28 @@ func newAlertTimelineCmd() *cobra.Command { return nil } + // go-flashduty returns raw feed items, so replicate the legacy + // SDK's operator-name enrichment by resolving each entry's actor + // (creator) person ID via /person/infos. Best-effort: the + // OPERATOR column falls back to the numeric ID when a name can't + // be resolved. + nameByID := resolveAlertFeedOperators(ctx, result.Items) + cols := []output.Column{ - {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.TimelineEvent).Timestamp) }}, - {Header: "TYPE", Field: func(v any) string { return v.(flashduty.TimelineEvent).Type }}, - {Header: "OPERATOR", Field: func(v any) string { return v.(flashduty.TimelineEvent).OperatorName }}, + {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(gflashduty.FeedItem).CreatedAt) }}, + {Header: "TYPE", Field: func(v any) string { return string(v.(gflashduty.FeedItem).Type) }}, + {Header: "OPERATOR", Field: func(v any) string { + it := v.(gflashduty.FeedItem) + if it.CreatorID == 0 { + return "system" + } + if n, ok := nameByID[it.CreatorID]; ok && n != "" { + return n + } + return strconv.FormatInt(it.CreatorID, 10) + }}, {Header: "DETAIL", MaxWidth: 80, Field: func(v any) string { - d := v.(flashduty.TimelineEvent).Detail + d := v.(gflashduty.FeedItem).Detail if d == nil { return "-" } @@ -248,6 +278,37 @@ func newAlertTimelineCmd() *cobra.Command { return cmd } +// resolveAlertFeedOperators resolves the actor (creator) person IDs of +// alert-feed items to display names via /person/infos, replicating the +// operator-name enrichment the legacy SDK did server-side. Best-effort: a +// lookup failure yields a nil map and callers fall back to the numeric ID. +func resolveAlertFeedOperators(rc *RunContext, items []gflashduty.FeedItem) map[int64]string { + seen := make(map[int64]struct{}, len(items)) + ids := make([]uint64, 0, len(items)) + for _, it := range items { + if it.CreatorID == 0 { + continue + } + if _, ok := seen[it.CreatorID]; ok { + continue + } + seen[it.CreatorID] = struct{}{} + ids = append(ids, uint64(it.CreatorID)) + } + if len(ids) == 0 { + return nil + } + resp, _, err := rc.GFClient.Members.PersonInfos(cmdContext(rc.Cmd), &gflashduty.PersonInfosRequest{PersonIDs: ids}) + if err != nil || resp == nil { + return nil + } + out := make(map[int64]string, len(resp.Items)) + for _, p := range resp.Items { + out[int64(p.PersonID)] = p.PersonName + } + return out +} + func newAlertMergeCmd() *cobra.Command { var incidentID, comment string diff --git a/internal/cli/change.go b/internal/cli/change.go index f173633..b094b58 100644 --- a/internal/cli/change.go +++ b/internal/cli/change.go @@ -3,7 +3,6 @@ package cli import ( "fmt" - flashduty "github.com/flashcatcloud/flashduty-sdk" gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" @@ -17,7 +16,6 @@ func newChangeCmd() *cobra.Command { Short: "Manage changes", } cmd.AddCommand(newChangeListCmd()) - cmd.AddCommand(newChangeTrendCmd()) return cmd } @@ -94,53 +92,3 @@ func newChangeListCmd() *cobra.Command { return cmd } - -func newChangeTrendCmd() *cobra.Command { - var step, since, until string - - cmd := &cobra.Command{ - Use: "trend", - Short: "Query change volume trends", - RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - startTime, err := timeutil.Parse(since) - if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) - if err != nil { - return fmt.Errorf("invalid --until: %w", err) - } - - result, err := ctx.Client.QueryChangeTrend(cmdContext(ctx.Cmd), &flashduty.QueryChangeTrendInput{ - Step: step, - StartTime: startTime, - EndTime: endTime, - }) - if err != nil { - return err - } - - cols := []output.Column{ - {Header: "DATE", Field: func(v any) string { - return output.FormatTime(v.(flashduty.ChangeTrendPoint).Timestamp) - }}, - {Header: "CHANGES", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.ChangeTrendPoint).ChangeCount) - }}, - {Header: "EVENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.ChangeTrendPoint).ChangeEventCount) - }}, - } - - return ctx.PrintTotal(result.DataPoints, cols, len(result.DataPoints)) - }) - }, - } - - cmd.Flags().StringVar(&step, "step", "day", "Aggregation: day, week, month") - cmd.Flags().StringVar(&since, "since", "30d", "Start time") - cmd.Flags().StringVar(&until, "until", "now", "End time") - - return cmd -} diff --git a/internal/cli/channel.go b/internal/cli/channel.go index 34eee90..9545431 100644 --- a/internal/cli/channel.go +++ b/internal/cli/channel.go @@ -2,8 +2,9 @@ package cli import ( "strconv" + "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -18,6 +19,19 @@ func newChannelCmd() *cobra.Command { return cmd } +// channelRow is the display projection for the channel list. go-flashduty's +// ChannelItem carries only TeamID/CreatorID, so we keep those IDs and resolve +// the team and creator names here (replicating the legacy SDK's enrichChannels) +// before rendering. +type channelRow struct { + channelID int64 + channelName string + teamID int64 + creatorID int64 + teamName string + creatorName string +} + func newChannelListCmd() *cobra.Command { var name string @@ -25,22 +39,43 @@ func newChannelListCmd() *cobra.Command { Use: "list", Short: "List channels", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.ListChannels(cmdContext(ctx.Cmd), &flashduty.ListChannelsInput{ - Name: name, - }) + return runGFCommand(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. + result, _, err := ctx.GFClient.Channels.ChannelList(cmdContext(ctx.Cmd), &gflashduty.ListChannelsRequest{}) 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, + teamID: ch.TeamID, + creatorID: ch.CreatorID, + }) + } + + // Replicate the legacy enrichment: resolve TeamID -> TeamName and + // CreatorID -> CreatorName. Best-effort, matching the legacy SDK + // which swallowed lookup errors and left names blank. + enrichChannelNames(ctx, rows) + cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(flashduty.ChannelInfo).ChannelID, 10) }}, - {Header: "NAME", Field: func(v any) string { return v.(flashduty.ChannelInfo).ChannelName }}, - {Header: "TEAM", Field: func(v any) string { return v.(flashduty.ChannelInfo).TeamName }}, - {Header: "CREATOR", Field: func(v any) string { return v.(flashduty.ChannelInfo).CreatorName }}, + {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(channelRow).channelID, 10) }}, + {Header: "NAME", Field: func(v any) string { return v.(channelRow).channelName }}, + {Header: "TEAM", Field: func(v any) string { return v.(channelRow).teamName }}, + {Header: "CREATOR", Field: func(v any) string { return v.(channelRow).creatorName }}, } - return ctx.PrintTotal(result.Channels, cols, result.Total) + return ctx.PrintTotal(rows, cols, len(rows)) }) }, } @@ -49,3 +84,55 @@ func newChannelListCmd() *cobra.Command { return cmd } + +// enrichChannelNames resolves each row's team and creator IDs to display names +// via /team/infos and /person/infos, filling teamName/creatorName in place. +// Best-effort: a lookup failure leaves the corresponding name blank, mirroring +// the legacy SDK's enrichChannels (which swallowed errors). +func enrichChannelNames(ctx *RunContext, rows []channelRow) { + if len(rows) == 0 { + return + } + + teamSeen := make(map[int64]struct{}, len(rows)) + teamIDs := make([]uint64, 0, len(rows)) + personSeen := make(map[int64]struct{}, len(rows)) + personIDs := make([]uint64, 0, len(rows)) + for _, r := range rows { + if r.teamID != 0 { + if _, ok := teamSeen[r.teamID]; !ok { + teamSeen[r.teamID] = struct{}{} + teamIDs = append(teamIDs, uint64(r.teamID)) + } + } + if r.creatorID != 0 { + if _, ok := personSeen[r.creatorID]; !ok { + personSeen[r.creatorID] = struct{}{} + personIDs = append(personIDs, uint64(r.creatorID)) + } + } + } + + teamNameByID := make(map[int64]string) + if len(teamIDs) > 0 { + if resp, _, err := ctx.GFClient.Teams.ReadInfos(cmdContext(ctx.Cmd), &gflashduty.TeamInfosRequest{TeamIDs: teamIDs}); err == nil && resp != nil { + for _, t := range resp.Items { + teamNameByID[int64(t.TeamID)] = t.TeamName + } + } + } + + personNameByID := make(map[int64]string) + if len(personIDs) > 0 { + if resp, _, err := ctx.GFClient.Members.PersonInfos(cmdContext(ctx.Cmd), &gflashduty.PersonInfosRequest{PersonIDs: personIDs}); err == nil && resp != nil { + for _, p := range resp.Items { + personNameByID[int64(p.PersonID)] = p.PersonName + } + } + } + + for i := range rows { + rows[i].teamName = teamNameByID[rows[i].teamID] + rows[i].creatorName = personNameByID[rows[i].creatorID] + } +} diff --git a/internal/cli/command.go b/internal/cli/command.go index 6adcf22..ef46d47 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -11,16 +11,9 @@ import ( ) // RunContext provides helpers for command execution. It is created by -// runCommand and passed to the command's handler function. -// -// Two SDK clients are exposed during the go-flashduty migration: -// - Client — the legacy hand-written SDK, still used by commands that depend -// on server-side enrichment or endpoints go-flashduty does not yet cover. -// - GFClient — the typed go-flashduty SDK, used by migrated commands. -// -// A command uses exactly one of them; the boundary is per-command, not mixed. +// runGFCommand and passed to the command's handler function. GFClient is the +// typed go-flashduty SDK every command calls through. type RunContext struct { - Client flashdutyClient GFClient *gflashduty.Client Cmd *cobra.Command Args []string @@ -34,31 +27,9 @@ type RunContext struct { // this to suppress detail views, footers, and interactive prompts. func (ctx *RunContext) Structured() bool { return ctx.Format.Structured() } -// runCommand creates a client and RunContext, then calls fn. -// It centralises setup that every API-backed command repeats. -// -// It constructs the legacy client only; commands migrated to go-flashduty use -// runGFCommand instead. Both factories read the same resolved config, so the -// two paths authenticate identically. -func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) error) error { - client, err := newClient() - if err != nil { - return err - } - ctx := &RunContext{ - Client: client, - Cmd: cmd, - Args: args, - Writer: cmd.OutOrStdout(), - Printer: newPrinter(cmd.OutOrStdout()), - Format: currentOutputFormat(), - } - return fn(ctx) -} - -// runGFCommand is the go-flashduty counterpart of runCommand. It constructs the -// typed go-flashduty client and leaves RunContext.Client nil — migrated command -// handlers must reach for ctx.GFClient. +// runGFCommand creates a go-flashduty client and RunContext, then calls fn. It +// centralises the setup every API-backed command repeats; handlers reach the +// SDK through ctx.GFClient. func runGFCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) error) error { client, err := newGFClient() if err != nil { diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 085c403..187562d 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -2,263 +2,21 @@ package cli import ( "bytes" - "context" "encoding/json" "fmt" "strings" "testing" - flashduty "github.com/flashcatcloud/flashduty-sdk" "github.com/spf13/cobra" "github.com/spf13/pflag" ) -// mockClient provides default "not implemented" stubs for all flashdutyClient -// methods. Embed it in per-test mocks and override only the methods under test. -type mockClient struct{} - -func (m *mockClient) GetAccountInfo(context.Context) (*flashduty.AccountInfo, error) { - return nil, fmt.Errorf("mockClient: GetAccountInfo not implemented") -} - -func (m *mockClient) GetMemberInfo(context.Context) (*flashduty.MemberInfo, error) { - return nil, fmt.Errorf("mockClient: GetMemberInfo not implemented") -} - -func (m *mockClient) ListIncidents(context.Context, *flashduty.ListIncidentsInput) (*flashduty.ListIncidentsOutput, error) { - return nil, fmt.Errorf("mockClient: ListIncidents not implemented") -} - -func (m *mockClient) GetIncidentTimelines(context.Context, []string) ([]flashduty.IncidentTimelineOutput, error) { - return nil, fmt.Errorf("mockClient: GetIncidentTimelines not implemented") -} - -func (m *mockClient) ListIncidentAlerts(context.Context, []string, int) ([]flashduty.IncidentAlertsOutput, error) { - return nil, fmt.Errorf("mockClient: ListIncidentAlerts not implemented") -} - -func (m *mockClient) ListSimilarIncidents(context.Context, string, int) (*flashduty.ListIncidentsOutput, error) { - return nil, fmt.Errorf("mockClient: ListSimilarIncidents not implemented") -} - -func (m *mockClient) CreateIncident(context.Context, *flashduty.CreateIncidentInput) (*flashduty.CreateIncidentOutput, error) { - return nil, fmt.Errorf("mockClient: CreateIncident not implemented") -} - -func (m *mockClient) UpdateIncident(context.Context, *flashduty.UpdateIncidentInput) ([]string, error) { - return nil, fmt.Errorf("mockClient: UpdateIncident not implemented") -} - -func (m *mockClient) AckIncidents(context.Context, []string) error { - return fmt.Errorf("mockClient: AckIncidents not implemented") -} - -func (m *mockClient) UnackIncidents(context.Context, []string) error { - return fmt.Errorf("mockClient: UnackIncidents not implemented") -} - -func (m *mockClient) CloseIncidents(context.Context, []string) error { - return fmt.Errorf("mockClient: CloseIncidents not implemented") -} - -func (m *mockClient) WakeIncidents(context.Context, []string) error { - return fmt.Errorf("mockClient: WakeIncidents not implemented") -} - -func (m *mockClient) RemoveIncidents(context.Context, []string) error { - return fmt.Errorf("mockClient: RemoveIncidents not implemented") -} - -func (m *mockClient) DisableIncidentMerge(context.Context, []string) error { - return fmt.Errorf("mockClient: DisableIncidentMerge not implemented") -} - -func (m *mockClient) CommentIncidents(context.Context, *flashduty.IncidentCommentInput) error { - return fmt.Errorf("mockClient: CommentIncidents not implemented") -} - -func (m *mockClient) AddIncidentResponders(context.Context, *flashduty.IncidentAddResponderInput) error { - return fmt.Errorf("mockClient: AddIncidentResponders not implemented") -} - -func (m *mockClient) ListChannels(context.Context, *flashduty.ListChannelsInput) (*flashduty.ListChannelsOutput, error) { - return nil, fmt.Errorf("mockClient: ListChannels not implemented") -} - -func (m *mockClient) ListTeams(context.Context, *flashduty.ListTeamsInput) (*flashduty.ListTeamsOutput, error) { - return nil, fmt.Errorf("mockClient: ListTeams not implemented") -} - -func (m *mockClient) ListMembers(context.Context, *flashduty.ListMembersInput) (*flashduty.ListMembersOutput, error) { - return nil, fmt.Errorf("mockClient: ListMembers not implemented") -} - -func (m *mockClient) ListEscalationRules(context.Context, int64) (*flashduty.ListEscalationRulesOutput, error) { - return nil, fmt.Errorf("mockClient: ListEscalationRules not implemented") -} - -func (m *mockClient) ListFields(context.Context, *flashduty.ListFieldsInput) (*flashduty.ListFieldsOutput, error) { - return nil, fmt.Errorf("mockClient: ListFields not implemented") -} - -func (m *mockClient) GetPresetTemplate(context.Context, *flashduty.GetPresetTemplateInput) (*flashduty.GetPresetTemplateOutput, error) { - return nil, fmt.Errorf("mockClient: GetPresetTemplate not implemented") -} - -func (m *mockClient) ValidateTemplate(context.Context, *flashduty.ValidateTemplateInput) (*flashduty.ValidateTemplateOutput, error) { - return nil, fmt.Errorf("mockClient: ValidateTemplate not implemented") -} - -func (m *mockClient) ListStatusChanges(context.Context, *flashduty.ListStatusChangesInput) (*flashduty.ListStatusChangesOutput, error) { - return nil, fmt.Errorf("mockClient: ListStatusChanges not implemented") -} - -func (m *mockClient) CreateStatusIncident(context.Context, *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error) { - return nil, fmt.Errorf("mockClient: CreateStatusIncident not implemented") -} - -func (m *mockClient) CreateChangeTimeline(context.Context, *flashduty.CreateChangeTimelineInput) error { - return fmt.Errorf("mockClient: CreateChangeTimeline not implemented") -} - -// Phase 1: Incident additions -func (m *mockClient) GetIncidentDetail(context.Context, *flashduty.GetIncidentDetailInput) (*flashduty.GetIncidentDetailOutput, error) { - return nil, fmt.Errorf("mockClient: GetIncidentDetail not implemented") -} - -func (m *mockClient) ListPostMortems(context.Context, *flashduty.ListPostMortemsInput) (*flashduty.ListPostMortemsOutput, error) { - return nil, fmt.Errorf("mockClient: ListPostMortems not implemented") -} - -func (m *mockClient) MergeIncidents(context.Context, *flashduty.MergeIncidentsInput) error { - return fmt.Errorf("mockClient: MergeIncidents not implemented") -} - -func (m *mockClient) SnoozeIncidents(context.Context, *flashduty.SnoozeIncidentsInput) error { - return fmt.Errorf("mockClient: SnoozeIncidents not implemented") -} - -func (m *mockClient) ReopenIncidents(context.Context, []string) error { - return fmt.Errorf("mockClient: ReopenIncidents not implemented") -} - -func (m *mockClient) ReassignIncidents(context.Context, *flashduty.ReassignIncidentsInput) error { - return fmt.Errorf("mockClient: ReassignIncidents not implemented") -} - -// Phase 1: Alert additions -func (m *mockClient) ListAlerts(context.Context, *flashduty.ListAlertsInput) (*flashduty.ListAlertsOutput, error) { - return nil, fmt.Errorf("mockClient: ListAlerts not implemented") -} - -func (m *mockClient) GetAlertDetail(context.Context, *flashduty.GetAlertDetailInput) (*flashduty.GetAlertDetailOutput, error) { - return nil, fmt.Errorf("mockClient: GetAlertDetail not implemented") -} - -func (m *mockClient) ListAlertEvents(context.Context, *flashduty.ListAlertEventsInput) (*flashduty.ListAlertEventsOutput, error) { - return nil, fmt.Errorf("mockClient: ListAlertEvents not implemented") -} - -func (m *mockClient) MergeAlertsToIncident(context.Context, *flashduty.MergeAlertsInput) error { - return fmt.Errorf("mockClient: MergeAlertsToIncident not implemented") -} - -func (m *mockClient) GetAlertFeed(context.Context, *flashduty.GetAlertFeedInput) (*flashduty.GetAlertFeedOutput, error) { - return nil, fmt.Errorf("mockClient: GetAlertFeed not implemented") -} - -func (m *mockClient) ListAlertEventsGlobal(context.Context, *flashduty.ListAlertEventsGlobalInput) (*flashduty.ListAlertEventsGlobalOutput, error) { - return nil, fmt.Errorf("mockClient: ListAlertEventsGlobal not implemented") -} - -// Phase 2: OnCall + Change -func (m *mockClient) ListSchedulesWithSlots(context.Context, *flashduty.ListSchedulesWithSlotsInput) (*flashduty.ListSchedulesWithSlotsOutput, error) { - return nil, fmt.Errorf("mockClient: ListSchedulesWithSlots not implemented") -} - -func (m *mockClient) GetScheduleDetail(context.Context, *flashduty.GetScheduleDetailInput) (*flashduty.GetScheduleDetailOutput, error) { - return nil, fmt.Errorf("mockClient: GetScheduleDetail not implemented") -} - -func (m *mockClient) QueryChangeTrend(context.Context, *flashduty.QueryChangeTrendInput) (*flashduty.QueryChangeTrendOutput, error) { - return nil, fmt.Errorf("mockClient: QueryChangeTrend not implemented") -} - -// Phase 3: Insight + Admin -func (m *mockClient) QueryInsightByTeam(context.Context, *flashduty.InsightQueryInput) (*flashduty.QueryInsightByTeamOutput, error) { - return nil, fmt.Errorf("mockClient: QueryInsightByTeam not implemented") -} - -func (m *mockClient) QueryInsightByChannel(context.Context, *flashduty.InsightQueryInput) (*flashduty.QueryInsightByChannelOutput, error) { - return nil, fmt.Errorf("mockClient: QueryInsightByChannel not implemented") -} - -func (m *mockClient) QueryInsightAlertTopK(context.Context, *flashduty.QueryInsightAlertTopKInput) (*flashduty.QueryInsightAlertTopKOutput, error) { - return nil, fmt.Errorf("mockClient: QueryInsightAlertTopK not implemented") -} - -func (m *mockClient) QueryInsightIncidentList(context.Context, *flashduty.QueryInsightIncidentListInput) (*flashduty.QueryInsightIncidentListOutput, error) { - return nil, fmt.Errorf("mockClient: QueryInsightIncidentList not implemented") -} - -func (m *mockClient) SearchAuditLogs(context.Context, *flashduty.SearchAuditLogsInput) (*flashduty.SearchAuditLogsOutput, error) { - return nil, fmt.Errorf("mockClient: SearchAuditLogs not implemented") -} - -func (m *mockClient) StartStatusPageMigration(context.Context, *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { - return nil, fmt.Errorf("mockClient: StartStatusPageMigration not implemented") -} - -func (m *mockClient) StartStatusPageEmailSubscriberMigration(context.Context, *flashduty.StartStatusPageEmailSubscriberMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { - return nil, fmt.Errorf("mockClient: StartStatusPageEmailSubscriberMigration not implemented") -} - -func (m *mockClient) GetStatusPageMigrationStatus(context.Context, string) (*flashduty.StatusPageMigrationJob, error) { - return nil, fmt.Errorf("mockClient: GetStatusPageMigrationStatus not implemented") -} - -func (m *mockClient) CancelStatusPageMigration(context.Context, string) error { - return fmt.Errorf("mockClient: CancelStatusPageMigration not implemented") -} - -// Phase 5: Team Management -func (m *mockClient) GetTeamInfo(context.Context, *flashduty.TeamGetInput) (*flashduty.TeamItem, error) { - return nil, fmt.Errorf("mockClient: GetTeamInfo not implemented") -} - -func (m *mockClient) UpsertTeam(context.Context, *flashduty.TeamUpsertInput) (*flashduty.TeamUpsertOutput, error) { - return nil, fmt.Errorf("mockClient: UpsertTeam not implemented") -} - -func (m *mockClient) DeleteTeam(context.Context, *flashduty.TeamDeleteInput) error { - return fmt.Errorf("mockClient: DeleteTeam not implemented") -} - -// CLI Phase 2: monit-query -func (m *mockClient) MonitQueryDiagnose(context.Context, *flashduty.MonitQueryDiagnoseInput) (*flashduty.MonitQueryDiagnoseOutput, error) { - return nil, fmt.Errorf("mockClient: MonitQueryDiagnose not implemented") -} - -func (m *mockClient) MonitQueryRows(context.Context, *flashduty.MonitQueryRowsInput) (*flashduty.MonitQueryRowsOutput, error) { - return nil, fmt.Errorf("mockClient: MonitQueryRows not implemented") -} - -// CLI Phase 2: monit-agent -func (m *mockClient) MonitAgentCatalog(context.Context, *flashduty.MonitAgentCatalogInput) (*flashduty.MonitAgentCatalogOutput, error) { - return nil, fmt.Errorf("mockClient: MonitAgentCatalog not implemented") -} - -func (m *mockClient) MonitAgentInvoke(context.Context, *flashduty.MonitAgentInvokeInput) (*flashduty.MonitAgentInvokeOutput, error) { - return nil, fmt.Errorf("mockClient: MonitAgentInvoke not implemented") -} - // saveAndResetGlobals saves the current state of all global vars that commands // mutate, resets them to safe defaults, and returns a restore function for // t.Cleanup. func saveAndResetGlobals(t *testing.T) { t.Helper() - origNewClientFn := newClientFn origNewGFClientFn := newGFClientFn origFlagJSON := flagJSON origFlagNoTrunc := flagNoTrunc @@ -272,7 +30,6 @@ func saveAndResetGlobals(t *testing.T) { flagBaseURL = "" t.Cleanup(func() { - newClientFn = origNewClientFn newGFClientFn = origNewGFClientFn flagJSON = origFlagJSON flagNoTrunc = origFlagNoTrunc @@ -342,15 +99,10 @@ func resetFlagSet(flags *pflag.FlagSet) { // Test 191: incident get returns empty results // --------------------------------------------------------------------------- -type mockGetEmpty struct{ mockClient } - -func (m *mockGetEmpty) ListIncidents(_ context.Context, _ *flashduty.ListIncidentsInput) (*flashduty.ListIncidentsOutput, error) { - return &flashduty.ListIncidentsOutput{Incidents: nil, Total: 0}, nil -} - func TestCommandIncidentGetEmptyResults(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockGetEmpty{}, nil } + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{}, "total": 0} out, err := execCommand("incident", "get", "nonexistent-id") if err != nil { @@ -480,17 +232,10 @@ func TestCommandIncidentReassignSetsAssignType(t *testing.T) { // Test 223: incident timeline empty // --------------------------------------------------------------------------- -type mockTimelineEmpty struct{ mockClient } - -func (m *mockTimelineEmpty) GetIncidentTimelines(_ context.Context, _ []string) ([]flashduty.IncidentTimelineOutput, error) { - return []flashduty.IncidentTimelineOutput{ - {IncidentID: "test", Timeline: nil}, - }, nil -} - func TestCommandIncidentTimelineEmpty(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockTimelineEmpty{}, nil } + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{}} out, err := execCommand("incident", "timeline", "test") if err != nil { @@ -507,15 +252,10 @@ func TestCommandIncidentTimelineEmpty(t *testing.T) { // Test 263: statuspage create-incident result with change_id // --------------------------------------------------------------------------- -type mockStatusCreateWithID struct{ mockClient } - -func (m *mockStatusCreateWithID) CreateStatusIncident(_ context.Context, _ *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error) { - return &flashduty.CreateStatusIncidentOutput{ChangeID: 12345}, nil -} - func TestCommandStatusPageCreateIncidentWithChangeID(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockStatusCreateWithID{}, nil } + 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 { @@ -530,7 +270,8 @@ func TestCommandStatusPageCreateIncidentWithChangeID(t *testing.T) { func TestCommandStatusPageCreateIncidentWithChangeID_JSON(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockStatusCreateWithID{}, nil } + 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 { @@ -550,15 +291,9 @@ func TestCommandStatusPageCreateIncidentWithChangeID_JSON(t *testing.T) { // Test 264: statuspage create-incident result without change_id // --------------------------------------------------------------------------- -type mockStatusCreateNoID struct{ mockClient } - -func (m *mockStatusCreateNoID) CreateStatusIncident(_ context.Context, _ *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error) { - return &flashduty.CreateStatusIncidentOutput{}, nil -} - func TestCommandStatusPageCreateIncidentWithoutChangeID(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockStatusCreateNoID{}, nil } + newGFStub(t) out, err := execCommand("statuspage", "create-incident", "--page-id", "1", "--title", "Outage") if err != nil { @@ -573,7 +308,7 @@ func TestCommandStatusPageCreateIncidentWithoutChangeID(t *testing.T) { func TestCommandStatusPageCreateIncidentWithoutChangeID_JSON(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockStatusCreateNoID{}, nil } + newGFStub(t) out, err := execCommand("statuspage", "create-incident", "--page-id", "1", "--title", "Outage", "--json") if err != nil { @@ -593,62 +328,37 @@ func TestCommandStatusPageCreateIncidentWithoutChangeID_JSON(t *testing.T) { // Test 321: member list with PersonInfos // --------------------------------------------------------------------------- -type mockMemberPersonInfos struct{ mockClient } - -func (m *mockMemberPersonInfos) ListMembers(_ context.Context, _ *flashduty.ListMembersInput) (*flashduty.ListMembersOutput, error) { - return &flashduty.ListMembersOutput{ - PersonInfos: []flashduty.PersonInfo{ - {PersonID: 100, PersonName: "Alice", Email: "alice@example.com"}, - {PersonID: 200, PersonName: "Bob", Email: "bob@example.com"}, - }, - Members: nil, - Total: 2, - }, nil -} - func TestCommandMemberListPersonInfos(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockMemberPersonInfos{}, nil } + stub := newGFStub(t) + stub.data = map[string]any{ + "items": []any{ + map[string]any{"member_id": 100, "member_name": "Alice", "email": "alice@example.com", "status": "enabled", "time_zone": "Asia/Shanghai"}, + map[string]any{"member_id": 200, "member_name": "Bob", "email": "bob@example.com", "status": "enabled", "time_zone": "UTC"}, + }, + "total": 2, + } out, err := execCommand("member", "list") if err != nil { t.Fatalf("[#321] unexpected error: %v", err) } - // PersonInfo columns: ID, NAME, EMAIL (not MemberItem's STATUS, TIMEZONE). - if !strings.Contains(out, "ID") { - t.Errorf("[#321] expected header 'ID' in output, got:\n%s", out) - } - if !strings.Contains(out, "NAME") { - t.Errorf("[#321] expected header 'NAME' in output, got:\n%s", out) - } - if !strings.Contains(out, "EMAIL") { - t.Errorf("[#321] expected header 'EMAIL' in output, got:\n%s", out) - } - - // PersonInfo table must NOT contain the MemberItem-specific columns. - if strings.Contains(out, "STATUS") { - t.Errorf("[#321] output should not contain 'STATUS' column for PersonInfo view, got:\n%s", out) - } - if strings.Contains(out, "TIMEZONE") { - t.Errorf("[#321] output should not contain 'TIMEZONE' column for PersonInfo view, got:\n%s", out) + // The migrated `member list` renders MemberItem rows: ID, NAME, EMAIL, + // STATUS, TIMEZONE. (The legacy PersonInfos-only view is gone — go-flashduty's + // /member/list returns member rows directly.) + for _, h := range []string{"ID", "NAME", "EMAIL", "STATUS", "TIMEZONE"} { + if !strings.Contains(out, h) { + t.Errorf("[#321] expected header %q in output, got:\n%s", h, out) + } } - // Verify both persons appear in the output. - if !strings.Contains(out, "Alice") { - t.Errorf("[#321] expected 'Alice' in output, got:\n%s", out) - } - if !strings.Contains(out, "Bob") { - t.Errorf("[#321] expected 'Bob' in output, got:\n%s", out) - } - if !strings.Contains(out, "alice@example.com") { - t.Errorf("[#321] expected 'alice@example.com' in output, got:\n%s", out) - } - if !strings.Contains(out, "bob@example.com") { - t.Errorf("[#321] expected 'bob@example.com' in output, got:\n%s", out) + for _, v := range []string{"Alice", "Bob", "alice@example.com", "bob@example.com"} { + if !strings.Contains(out, v) { + t.Errorf("[#321] expected %q in output, got:\n%s", v, out) + } } - // Verify the total line. if !strings.Contains(out, "Total: 2") { t.Errorf("[#321] expected 'Total: 2' in output, got:\n%s", out) } @@ -679,7 +389,7 @@ func TestCommandIncidentFeedEmpty_JSON(t *testing.T) { func TestCommandIncidentSnoozeRejectsSubMinuteDuration(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockClient{}, nil } + newGFStub(t) _, err := execCommand("incident", "snooze", "inc-1", "--duration", "90s") if err == nil { @@ -692,7 +402,7 @@ func TestCommandIncidentSnoozeRejectsSubMinuteDuration(t *testing.T) { func TestCommandIncidentSnoozeRejectsDurationOver24Hours(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockClient{}, nil } + newGFStub(t) _, err := execCommand("incident", "snooze", "inc-1", "--duration", "25h") if err == nil { @@ -705,7 +415,7 @@ func TestCommandIncidentSnoozeRejectsDurationOver24Hours(t *testing.T) { func TestCommandIncidentMergeRejectsMoreThan100Sources(t *testing.T) { saveAndResetGlobals(t) - newClientFn = func() (flashdutyClient, error) { return &mockClient{}, nil } + newGFStub(t) sourceIDs := make([]string, 101) for i := range sourceIDs { @@ -780,56 +490,6 @@ func TestCommandIncidentLifecycleHelpDocumentsSafetyAndLookupHints(t *testing.T) } } -type mockIncidentLifecycle struct { - mockClient - - unackIDs []string - wakeIDs []string - removeIDs []string - disableMergeIDs []string - commentInput *flashduty.IncidentCommentInput - responderInput *flashduty.IncidentAddResponderInput -} - -func (m *mockIncidentLifecycle) UnackIncidents(_ context.Context, incidentIDs []string) error { - m.unackIDs = append([]string(nil), incidentIDs...) - return nil -} - -func (m *mockIncidentLifecycle) WakeIncidents(_ context.Context, incidentIDs []string) error { - m.wakeIDs = append([]string(nil), incidentIDs...) - return nil -} - -func (m *mockIncidentLifecycle) RemoveIncidents(_ context.Context, incidentIDs []string) error { - m.removeIDs = append([]string(nil), incidentIDs...) - return nil -} - -func (m *mockIncidentLifecycle) DisableIncidentMerge(_ context.Context, incidentIDs []string) error { - m.disableMergeIDs = append([]string(nil), incidentIDs...) - return nil -} - -func (m *mockIncidentLifecycle) CommentIncidents(_ context.Context, input *flashduty.IncidentCommentInput) error { - copied := *input - copied.IncidentIDs = append([]string(nil), input.IncidentIDs...) - m.commentInput = &copied - return nil -} - -func (m *mockIncidentLifecycle) AddIncidentResponders(_ context.Context, input *flashduty.IncidentAddResponderInput) error { - copied := *input - copied.PersonIDs = append([]int64(nil), input.PersonIDs...) - if input.Notify != nil { - notify := *input.Notify - notify.PersonalChannels = append([]string(nil), input.Notify.PersonalChannels...) - copied.Notify = ¬ify - } - m.responderInput = &copied - return nil -} - func TestCommandIncidentUnack(t *testing.T) { saveAndResetGlobals(t) stub := newGFStub(t) @@ -923,8 +583,7 @@ func TestCommandIncidentLifecycleRejectsMoreThan100IDs(t *testing.T) { for _, tc := range commands { t.Run(tc.name, func(t *testing.T) { saveAndResetGlobals(t) - mock := &mockIncidentLifecycle{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + newGFStub(t) args := append([]string(nil), tc.args...) args = append(args, incidentIDs...) diff --git a/internal/cli/escalation_rule.go b/internal/cli/escalation_rule.go index 8825113..5335762 100644 --- a/internal/cli/escalation_rule.go +++ b/internal/cli/escalation_rule.go @@ -4,7 +4,7 @@ import ( "fmt" "strconv" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -27,10 +27,10 @@ func newEscalationRuleListCmd() *cobra.Command { Use: "list", Short: "List escalation rules for a channel", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { // Resolve channel name to ID if needed if channelID == 0 && channelName != "" { - resolved, err := resolveChannelID(ctx.Cmd, ctx.Client, channelName) + resolved, err := resolveChannelID(ctx, channelName) if err != nil { return err } @@ -41,21 +41,27 @@ func newEscalationRuleListCmd() *cobra.Command { return fmt.Errorf("--channel or --channel-name is required") } - result, err := ctx.Client.ListEscalationRules(cmdContext(ctx.Cmd), channelID) + result, _, err := ctx.GFClient.Channels.ChannelEscalateRuleList(cmdContext(ctx.Cmd), &gflashduty.ChannelScopedListRequest{ + ChannelID: channelID, + }) if err != nil { return err } cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(flashduty.EscalationRule).RuleID }}, - {Header: "NAME", Field: func(v any) string { return v.(flashduty.EscalationRule).RuleName }}, - {Header: "CHANNEL", Field: func(v any) string { return v.(flashduty.EscalationRule).ChannelName }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.EscalationRule).Status }}, - {Header: "PRIORITY", Field: func(v any) string { return strconv.Itoa(v.(flashduty.EscalationRule).Priority) }}, - {Header: "LAYERS", Field: func(v any) string { return strconv.Itoa(len(v.(flashduty.EscalationRule).Layers)) }}, + {Header: "ID", Field: func(v any) string { return v.(gflashduty.EscalateRuleItem).RuleID }}, + {Header: "NAME", Field: func(v any) string { return v.(gflashduty.EscalateRuleItem).RuleName }}, + {Header: "CHANNEL", Field: func(v any) string { return v.(gflashduty.EscalateRuleItem).ChannelName }}, + {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.EscalateRuleItem).Status }}, + {Header: "PRIORITY", Field: func(v any) string { + return strconv.FormatInt(v.(gflashduty.EscalateRuleItem).Priority, 10) + }}, + {Header: "LAYERS", Field: func(v any) string { + return strconv.Itoa(len(v.(gflashduty.EscalateRuleItem).Layers)) + }}, } - return ctx.Printer.Print(result.Rules, cols) + return ctx.Printer.Print(result.Items, cols) }) }, } @@ -67,23 +73,23 @@ func newEscalationRuleListCmd() *cobra.Command { } // resolveChannelID resolves a channel name to its ID. -func resolveChannelID(cmd *cobra.Command, client flashdutyClient, name string) (int64, error) { - result, err := client.ListChannels(cmdContext(cmd), &flashduty.ListChannelsInput{ - Name: name, +func resolveChannelID(ctx *RunContext, name string) (int64, error) { + result, _, err := ctx.GFClient.Channels.ChannelList(cmdContext(ctx.Cmd), &gflashduty.ListChannelsRequest{ + ChannelName: name, }) if err != nil { return 0, fmt.Errorf("failed to resolve channel name: %w", err) } - switch len(result.Channels) { + switch len(result.Items) { case 0: return 0, fmt.Errorf("no channel found matching %q", name) case 1: - return result.Channels[0].ChannelID, nil + return result.Items[0].ChannelID, nil default: - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Multiple channels match:") - for _, ch := range result.Channels { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), " %d %s\n", ch.ChannelID, ch.ChannelName) + _, _ = fmt.Fprintln(ctx.Cmd.OutOrStdout(), "Multiple channels match:") + for _, ch := range result.Items { + _, _ = fmt.Fprintf(ctx.Cmd.OutOrStdout(), " %d %s\n", ch.ChannelID, ch.ChannelName) } return 0, fmt.Errorf("multiple channels match %q, use --channel to specify", name) } diff --git a/internal/cli/field.go b/internal/cli/field.go index f4f52a5..43c9fff 100644 --- a/internal/cli/field.go +++ b/internal/cli/field.go @@ -3,7 +3,7 @@ package cli import ( "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -25,25 +25,38 @@ func newFieldListCmd() *cobra.Command { Use: "list", Short: "List custom fields", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.ListFields(cmdContext(ctx.Cmd), &flashduty.ListFieldsInput{ - FieldName: name, - }) + return runGFCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.GFClient.AlertEnrichment.FieldReadList(cmdContext(ctx.Cmd), &gflashduty.FieldListRequest{}) if err != nil { return err } + // go-flashduty's /field/list has no exact field_name filter (its + // Query field is a regex over field_name/display_name). Preserve + // the legacy SDK's exact-name --name filter client-side so behavior + // is unchanged. + items := result.Items + if name != "" { + filtered := make([]gflashduty.FieldItem, 0, len(items)) + for _, f := range items { + if f.FieldName == name { + filtered = append(filtered, f) + } + } + items = filtered + } + cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(flashduty.FieldInfo).FieldID }}, - {Header: "NAME", Field: func(v any) string { return v.(flashduty.FieldInfo).FieldName }}, - {Header: "DISPLAY_NAME", Field: func(v any) string { return v.(flashduty.FieldInfo).DisplayName }}, - {Header: "TYPE", Field: func(v any) string { return v.(flashduty.FieldInfo).FieldType }}, + {Header: "ID", Field: func(v any) string { return v.(gflashduty.FieldItem).FieldID }}, + {Header: "NAME", Field: func(v any) string { return v.(gflashduty.FieldItem).FieldName }}, + {Header: "DISPLAY_NAME", Field: func(v any) string { return v.(gflashduty.FieldItem).DisplayName }}, + {Header: "TYPE", Field: func(v any) string { return v.(gflashduty.FieldItem).FieldType }}, {Header: "OPTIONS", MaxWidth: 50, Field: func(v any) string { - return strings.Join(v.(flashduty.FieldInfo).Options, ", ") + return strings.Join(v.(gflashduty.FieldItem).Options, ", ") }}, } - return ctx.PrintTotal(result.Fields, cols, result.Total) + return ctx.PrintTotal(items, cols, len(items)) }) }, } diff --git a/internal/cli/identity.go b/internal/cli/identity.go index 43f4e44..f42d534 100644 --- a/internal/cli/identity.go +++ b/internal/cli/identity.go @@ -6,7 +6,7 @@ import ( "fmt" "io" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" ) type identityResult struct { @@ -15,13 +15,11 @@ type identityResult struct { Email string `json:"email,omitempty"` } -type identityClient interface { - GetAccountInfo(ctx context.Context) (*flashduty.AccountInfo, error) - GetMemberInfo(ctx context.Context) (*flashduty.MemberInfo, error) -} - -func resolveIdentity(ctx context.Context, client identityClient) (*identityResult, error) { - member, memberErr := client.GetMemberInfo(ctx) +// resolveIdentity fetches the caller's identity, preferring member-level detail +// (which carries the member name) and falling back to account-level info when +// the app key is account-scoped rather than tied to a member. +func resolveIdentity(ctx context.Context, client *gflashduty.Client) (*identityResult, error) { + member, _, memberErr := client.Members.MemberInfo(ctx) if memberErr == nil { return &identityResult{ AccountName: member.AccountName, @@ -30,7 +28,7 @@ func resolveIdentity(ctx context.Context, client identityClient) (*identityResul }, nil } - account, accountErr := client.GetAccountInfo(ctx) + account, _, accountErr := client.Account.Info(ctx) if accountErr == nil { return &identityResult{ AccountName: account.AccountName, diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 67f926a..5ae2413 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -9,7 +9,6 @@ import ( "strings" "time" - flashduty "github.com/flashcatcloud/flashduty-sdk" gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "golang.org/x/term" @@ -50,12 +49,26 @@ func newIncidentCmd() *cobra.Command { func incidentColumns() []output.Column { return []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(flashduty.EnrichedIncident).IncidentID }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.EnrichedIncident).Title }}, - {Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.EnrichedIncident).Severity }}, - {Header: "PROGRESS", Field: func(v any) string { return v.(flashduty.EnrichedIncident).Progress }}, - {Header: "CHANNEL", Field: func(v any) string { return v.(flashduty.EnrichedIncident).ChannelName }}, - {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.EnrichedIncident).StartTime) }}, + {Header: "ID", Field: func(v any) string { return v.(gflashduty.IncidentInfo).IncidentID }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.IncidentInfo).Title }}, + {Header: "SEVERITY", Field: func(v any) string { return v.(gflashduty.IncidentInfo).IncidentSeverity }}, + {Header: "PROGRESS", Field: func(v any) string { return v.(gflashduty.IncidentInfo).Progress }}, + {Header: "CHANNEL", Field: func(v any) string { return v.(gflashduty.IncidentInfo).ChannelName }}, + {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.IncidentInfo).StartTime) }}, + } +} + +// pastIncidentColumns mirrors incidentColumns for the similar-incidents view, +// whose /incident/past-list endpoint returns PastIncidentItem rather than +// IncidentInfo. +func pastIncidentColumns() []output.Column { + return []output.Column{ + {Header: "ID", Field: func(v any) string { return v.(gflashduty.PastIncidentItem).IncidentID }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.PastIncidentItem).Title }}, + {Header: "SEVERITY", Field: func(v any) string { return v.(gflashduty.PastIncidentItem).IncidentSeverity }}, + {Header: "PROGRESS", Field: func(v any) string { return v.(gflashduty.PastIncidentItem).Progress }}, + {Header: "CHANNEL", Field: func(v any) string { return v.(gflashduty.PastIncidentItem).ChannelName }}, + {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.PastIncidentItem).StartTime) }}, } } @@ -68,7 +81,7 @@ func newIncidentListCmd() *cobra.Command { Use: "list", Short: "List incidents", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -78,22 +91,25 @@ func newIncidentListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, err := ctx.Client.ListIncidents(cmdContext(ctx.Cmd), &flashduty.ListIncidentsInput{ - Progress: progress, - Severity: severity, - ChannelID: channelID, - StartTime: startTime, - EndTime: endTime, - Query: query, - Limit: limit, - Page: page, - IncludeAlerts: false, - }) + req := &gflashduty.ListIncidentsRequest{ + Progress: progress, + IncidentSeverity: severity, + StartTime: startTime, + EndTime: endTime, + Query: query, + } + req.Page = page + req.Limit = limit + if channelID != 0 { + req.ChannelIDs = []int64{channelID} + } + + result, _, err := ctx.GFClient.Incidents.List(cmdContext(ctx.Cmd), req) if err != nil { return err } - return ctx.PrintList(result.Incidents, incidentColumns(), len(result.Incidents), page, result.Total) + return ctx.PrintList(result.Items, incidentColumns(), len(result.Items), page, int(result.Total)) }) }, } @@ -116,33 +132,32 @@ func newIncidentGetCmd() *cobra.Command { Short: "Get incident details", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.ListIncidents(cmdContext(ctx.Cmd), &flashduty.ListIncidentsInput{ - IncidentIDs: ctx.Args, - IncludeAlerts: true, + return runGFCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.GFClient.Incidents.List(cmdContext(ctx.Cmd), &gflashduty.ListIncidentsRequest{ + IncidentIDs: ctx.Args, }) if err != nil { return err } if ctx.Structured() { - return ctx.Printer.Print(result.Incidents, nil) + return ctx.Printer.Print(result.Items, nil) } // Single incident: vertical detail view - if len(ctx.Args) == 1 && len(result.Incidents) == 1 { - printIncidentDetail(ctx.Writer, result.Incidents[0]) + if len(ctx.Args) == 1 && len(result.Items) == 1 { + printIncidentDetail(ctx.Writer, result.Items[0]) return nil } // Multiple: table - return ctx.Printer.Print(result.Incidents, incidentColumns()) + return ctx.Printer.Print(result.Items, incidentColumns()) }) }, } } -func printIncidentDetail(w io.Writer, inc flashduty.EnrichedIncident) { +func printIncidentDetail(w io.Writer, inc gflashduty.IncidentInfo) { responders := make([]string, 0, len(inc.Responders)) for _, r := range inc.Responders { responders = append(responders, r.PersonName) @@ -153,23 +168,23 @@ func printIncidentDetail(w io.Writer, inc flashduty.EnrichedIncident) { labels = append(labels, k+"="+v) } - fields := make([]string, 0, len(inc.CustomFields)) - for k, v := range inc.CustomFields { + fields := make([]string, 0, len(inc.Fields)) + for k, v := range inc.Fields { fields = append(fields, fmt.Sprintf("%s=%v", k, v)) } _, _ = fmt.Fprintf(w, "ID: %s\n", inc.IncidentID) _, _ = fmt.Fprintf(w, "Title: %s\n", inc.Title) - _, _ = fmt.Fprintf(w, "Severity: %s\n", inc.Severity) + _, _ = fmt.Fprintf(w, "Severity: %s\n", inc.IncidentSeverity) _, _ = fmt.Fprintf(w, "Progress: %s\n", inc.Progress) _, _ = fmt.Fprintf(w, "Channel: %s\n", inc.ChannelName) _, _ = fmt.Fprintf(w, "Created: %s\n", output.FormatTime(inc.StartTime)) - _, _ = fmt.Fprintf(w, "Creator: %s (%s)\n", inc.CreatorName, inc.CreatorEmail) + _, _ = fmt.Fprintf(w, "Creator: %s (%s)\n", inc.Creator.PersonName, inc.Creator.Email) _, _ = fmt.Fprintf(w, "Responders: %s\n", orDash(strings.Join(responders, ", "))) _, _ = fmt.Fprintf(w, "Description: %s\n", orDash(inc.Description)) _, _ = fmt.Fprintf(w, "Labels: %s\n", orDash(strings.Join(labels, ", "))) _, _ = fmt.Fprintf(w, "Custom Fields: %s\n", orDash(strings.Join(fields, ", "))) - _, _ = fmt.Fprintf(w, "Alerts: %d total\n", inc.AlertsTotal) + _, _ = fmt.Fprintf(w, "Alerts: %d total\n", inc.AlertCnt) } func orDash(s string) string { @@ -263,40 +278,74 @@ func newIncidentUpdateCmd() *cobra.Command { Use: "update ", Short: "Update an incident", Args: requireArgs("incident_id"), - // TODO(go-flashduty migration): not migrated. go-flashduty's - // Incidents.Reset (/incident/reset) carries no custom-fields field — - // custom fields move to the separate /incident/field/reset endpoint. - // Porting --field would mean splitting one call into two, which is a - // behavior change, not a mechanical swap. Kept on the legacy SDK. RunE: func(cmd *cobra.Command, args []string) error { - customFields := make(map[string]any) + type customField struct { + name string + value string + } + customFields := make([]customField, 0, len(fieldFlags)) for _, f := range fieldFlags { parts := strings.SplitN(f, "=", 2) if len(parts) != 2 { return fmt.Errorf("invalid --field format %q, expected key=value", f) } - customFields[parts[0]] = parts[1] + customFields = append(customFields, customField{name: parts[0], value: parts[1]}) } - return runCommand(cmd, args, func(ctx *RunContext) error { - input := &flashduty.UpdateIncidentInput{ - IncidentID: ctx.Args[0], - Title: title, - Description: description, - Severity: severity, - CustomFields: customFields, + return runGFCommand(cmd, args, func(ctx *RunContext) error { + incidentID := ctx.Args[0] + updated := make([]string, 0) + + // Standard fields go through /incident/reset. Mirror the legacy + // SDK: only set fields the user supplied, and label severity as + // "severity" (not the wire field "incident_severity") in the + // summary line. + resetReq := &gflashduty.UpdateIncidentFieldsRequest{IncidentID: incidentID} + if title != "" { + resetReq.Title = title + updated = append(updated, "title") + } + if description != "" { + resetReq.Description = description + updated = append(updated, "description") + } + if severity != "" { + resetReq.IncidentSeverity = severity + updated = append(updated, "severity") + } + if len(updated) > 0 { + if _, err := ctx.GFClient.Incidents.Reset(cmdContext(ctx.Cmd), resetReq); err != nil { + return err + } } - updated, err := ctx.Client.UpdateIncident(cmdContext(ctx.Cmd), input) - if err != nil { - return err + // Custom fields go through /incident/field/reset, one call per + // field, preserving the legacy per-field semantics. + for _, f := range customFields { + if f.name == "" { + return fmt.Errorf("custom field name must not be empty") + } + for _, ch := range f.name { + isValid := (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' + if !isValid { + return fmt.Errorf("custom field name '%s' contains invalid characters (only alphanumeric and underscore allowed)", f.name) + } + } + if _, err := ctx.GFClient.Incidents.FieldReset(cmdContext(ctx.Cmd), &gflashduty.ResetIncidentFieldRequest{ + IncidentID: incidentID, + FieldName: f.name, + FieldValue: map[string]any{"value": f.value}, + }); err != nil { + return fmt.Errorf("unable to update custom field '%s': %w", f.name, err) + } + updated = append(updated, f.name) } if len(updated) == 0 { ctx.WriteResult("No fields were updated.") return nil } - ctx.WriteResult(fmt.Sprintf("Updated incident %s: %s.", ctx.Args[0], strings.Join(updated, ", "))) + ctx.WriteResult(fmt.Sprintf("Updated incident %s: %s.", incidentID, strings.Join(updated, ", "))) return nil }) }, @@ -410,23 +459,43 @@ func newIncidentTimelineCmd() *cobra.Command { Short: "View incident timeline", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - results, err := ctx.Client.GetIncidentTimelines(cmdContext(ctx.Cmd), []string{ctx.Args[0]}) - if err != nil { - return err + return runGFCommand(cmd, args, func(ctx *RunContext) error { + // go-flashduty has no batched timeline endpoint, so fan out per + // incident ID over /incident/feed and concatenate the entries, + // replicating the legacy SDK's GetIncidentTimelines behavior. + var items []gflashduty.IncidentFeedItem + for _, id := range ctx.Args { + result, _, err := ctx.GFClient.Incidents.Feed(cmdContext(ctx.Cmd), &gflashduty.ListIncidentFeedRequest{IncidentID: id}) + if err != nil { + return err + } + items = append(items, result.Items...) } - if len(results) == 0 || len(results[0].Timeline) == 0 { + if len(items) == 0 { _, _ = fmt.Fprintln(ctx.Writer, "No timeline events.") return nil } + // Enrich operator names by resolving each entry's actor person ID + // via /person/infos, falling back to the numeric ID. + nameByID := resolveFeedOperators(ctx, items) + cols := []output.Column{ - {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.TimelineEvent).Timestamp) }}, - {Header: "TYPE", Field: func(v any) string { return v.(flashduty.TimelineEvent).Type }}, - {Header: "OPERATOR", Field: func(v any) string { return v.(flashduty.TimelineEvent).OperatorName }}, + {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(gflashduty.IncidentFeedItem).CreatedAt) }}, + {Header: "TYPE", Field: func(v any) string { return string(v.(gflashduty.IncidentFeedItem).Type) }}, + {Header: "OPERATOR", Field: func(v any) string { + it := v.(gflashduty.IncidentFeedItem) + if it.CreatorID == 0 { + return "system" + } + if n, ok := nameByID[it.CreatorID]; ok && n != "" { + return n + } + return strconv.FormatInt(it.CreatorID, 10) + }}, {Header: "DETAIL", MaxWidth: 80, Field: func(v any) string { - d := v.(flashduty.TimelineEvent).Detail + d := v.(gflashduty.IncidentFeedItem).Detail if d == nil { return "-" } @@ -434,7 +503,7 @@ func newIncidentTimelineCmd() *cobra.Command { }}, } - return ctx.Printer.Print(results[0].Timeline, cols) + return ctx.Printer.Print(items, cols) }) }, } @@ -448,26 +517,28 @@ func newIncidentAlertsCmd() *cobra.Command { Short: "View incident alerts", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - results, err := ctx.Client.ListIncidentAlerts(cmdContext(ctx.Cmd), []string{ctx.Args[0]}, limit) + return runGFCommand(cmd, args, func(ctx *RunContext) error { + req := &gflashduty.ListIncidentAlertsRequest{IncidentID: ctx.Args[0]} + req.Limit = limit + result, _, err := ctx.GFClient.Incidents.AlertList(cmdContext(ctx.Cmd), req) if err != nil { return err } - if len(results) == 0 || len(results[0].Alerts) == 0 { + if len(result.Items) == 0 { _, _ = fmt.Fprintln(ctx.Writer, "No alerts.") return nil } cols := []output.Column{ - {Header: "ALERT_ID", Field: func(v any) string { return v.(flashduty.AlertPreview).AlertID }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertPreview).Title }}, - {Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.AlertPreview).Severity }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.AlertPreview).Status }}, - {Header: "STARTED", Field: func(v any) string { return output.FormatTime(v.(flashduty.AlertPreview).StartTime) }}, + {Header: "ALERT_ID", Field: func(v any) string { return v.(gflashduty.AlertInfo).AlertID }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.AlertInfo).Title }}, + {Header: "SEVERITY", Field: func(v any) string { return v.(gflashduty.AlertInfo).AlertSeverity }}, + {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.AlertInfo).AlertStatus }}, + {Header: "STARTED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.AlertInfo).StartTime) }}, } - return ctx.PrintTotal(results[0].Alerts, cols, results[0].Total) + return ctx.PrintTotal(result.Items, cols, int(result.Total)) }) }, } @@ -484,18 +555,21 @@ func newIncidentSimilarCmd() *cobra.Command { Short: "Find similar incidents", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.ListSimilarIncidents(cmdContext(ctx.Cmd), ctx.Args[0], limit) + return runGFCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.GFClient.Incidents.PastList(cmdContext(ctx.Cmd), &gflashduty.ListPastIncidentsRequest{ + IncidentID: ctx.Args[0], + Limit: int64(limit), + }) if err != nil { return err } - if len(result.Incidents) == 0 { + if len(result.Items) == 0 { _, _ = fmt.Fprintln(ctx.Writer, "No similar incidents found.") return nil } - return ctx.Printer.Print(result.Incidents, incidentColumns()) + return ctx.Printer.Print(result.Items, pastIncidentColumns()) }) }, } @@ -1261,8 +1335,8 @@ func newIncidentDetailCmd() *cobra.Command { Short: "View full incident detail with AI summary", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.GetIncidentDetail(cmdContext(ctx.Cmd), &flashduty.GetIncidentDetailInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.GFClient.Incidents.Info(cmdContext(ctx.Cmd), &gflashduty.IncidentInfoRequest{ IncidentID: ctx.Args[0], }) if err != nil { @@ -1270,17 +1344,20 @@ func newIncidentDetailCmd() *cobra.Command { } if ctx.Structured() { - return ctx.Printer.Print(result.Incident, nil) + return ctx.Printer.Print(result, nil) } - printIncidentFullDetail(ctx.Writer, result.Incident) + printIncidentFullDetail(ctx.Writer, result) return nil }) }, } } -func printIncidentFullDetail(w io.Writer, inc flashduty.IncidentDetail) { +func printIncidentFullDetail(w io.Writer, inc *gflashduty.IncidentInfo) { + if inc == nil { + return + } responders := make([]string, 0, len(inc.Responders)) for _, r := range inc.Responders { name := r.PersonName @@ -1302,7 +1379,7 @@ func printIncidentFullDetail(w io.Writer, inc flashduty.IncidentDetail) { _, _ = fmt.Fprintf(w, "ID: %s\n", inc.IncidentID) _, _ = fmt.Fprintf(w, "Title: %s\n", inc.Title) - _, _ = fmt.Fprintf(w, "Severity: %s\n", inc.Severity) + _, _ = fmt.Fprintf(w, "Severity: %s\n", inc.IncidentSeverity) _, _ = fmt.Fprintf(w, "Progress: %s\n", inc.Progress) _, _ = fmt.Fprintf(w, "Channel: %s\n", inc.ChannelName) _, _ = fmt.Fprintf(w, "Created: %s\n", output.FormatTime(inc.StartTime)) diff --git a/internal/cli/insight.go b/internal/cli/insight.go index 02e2eff..9baea13 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -3,7 +3,6 @@ package cli import ( "fmt" - flashduty "github.com/flashcatcloud/flashduty-sdk" gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" @@ -269,7 +268,7 @@ func newInsightIncidentsCmd() *cobra.Command { Use: "incidents", Short: "Query incidents with performance metrics", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -279,43 +278,43 @@ func newInsightIncidentsCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, err := ctx.Client.QueryInsightIncidentList(cmdContext(ctx.Cmd), &flashduty.QueryInsightIncidentListInput{ - InsightQueryInput: flashduty.InsightQueryInput{ - StartTime: startTime, - EndTime: endTime, - }, - Limit: limit, - Page: page, - }) + req := &gflashduty.InsightIncidentListRequest{ + StartTime: startTime, + EndTime: endTime, + } + req.Limit = limit + req.Page = page + + result, _, err := ctx.GFClient.Analytics.IncidentList(cmdContext(ctx.Cmd), req) if err != nil { return err } cols := []output.Column{ {Header: "ID", Field: func(v any) string { - return v.(flashduty.InsightIncidentItem).IncidentID + return v.(gflashduty.IncidentRawItem).IncidentID }}, {Header: "TITLE", MaxWidth: 40, Field: func(v any) string { - return v.(flashduty.InsightIncidentItem).Title + return v.(gflashduty.IncidentRawItem).Title }}, {Header: "SEVERITY", Field: func(v any) string { - return v.(flashduty.InsightIncidentItem).Severity + return v.(gflashduty.IncidentRawItem).Severity }}, {Header: "CHANNEL", MaxWidth: 20, Field: func(v any) string { - return v.(flashduty.InsightIncidentItem).ChannelName + return v.(gflashduty.IncidentRawItem).ChannelName }}, {Header: "MTTA", Field: func(v any) string { - return output.FormatDuration(v.(flashduty.InsightIncidentItem).SecondsToAck) + return output.FormatDuration(int(v.(gflashduty.IncidentRawItem).SecondsToAck)) }}, {Header: "MTTR", Field: func(v any) string { - return output.FormatDuration(v.(flashduty.InsightIncidentItem).SecondsToClose) + return output.FormatDuration(int(v.(gflashduty.IncidentRawItem).SecondsToClose)) }}, {Header: "NOTIFICATIONS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.InsightIncidentItem).Notifications) + return fmt.Sprintf("%d", v.(gflashduty.IncidentRawItem).Notifications) }}, } - return ctx.PrintList(result.Items, cols, len(result.Items), page, result.Total) + return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) }) }, } diff --git a/internal/cli/login.go b/internal/cli/login.go index 7096a06..770171b 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -6,7 +6,7 @@ import ( "os" "time" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "golang.org/x/term" @@ -30,7 +30,7 @@ func newLoginCmd() *cobra.Command { return fmt.Errorf("app key cannot be empty") } - client, err := flashduty.NewClient(appKey, flashduty.WithLogger(&silentLogger{})) + client, err := gflashduty.NewClient(appKey, gflashduty.WithLogger(&silentLogger{})) if err != nil { return fmt.Errorf("invalid app key: %w", err) } diff --git a/internal/cli/member.go b/internal/cli/member.go index 55da0c9..4ab08fb 100644 --- a/internal/cli/member.go +++ b/internal/cli/member.go @@ -4,7 +4,7 @@ import ( "fmt" "strconv" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -27,35 +27,36 @@ func newMemberListCmd() *cobra.Command { Use: "list", Short: "List members", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.ListMembers(cmdContext(ctx.Cmd), &flashduty.ListMembersInput{ - Name: name, - Email: email, - Page: page, - }) + return runGFCommand(cmd, args, func(ctx *RunContext) error { + // go-flashduty's MemberListRequest exposes a single search + // keyword (Query); the legacy SDK split name/email into separate + // filters. Both --name and --email are keyword searches against + // the same backend, so fold them into Query (name takes precedence). + query := name + if query == "" { + query = email + } + req := &gflashduty.MemberListRequest{ + Query: query, + } + req.Page = page + + result, _, err := ctx.GFClient.Members.MemberList(cmdContext(ctx.Cmd), req) if err != nil { return err } - // SDK returns Members when listing, PersonInfos when querying by IDs - if len(result.Members) > 0 { - cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.Itoa(v.(flashduty.MemberItem).MemberID) }}, - {Header: "NAME", Field: func(v any) string { return v.(flashduty.MemberItem).MemberName }}, - {Header: "EMAIL", Field: func(v any) string { return v.(flashduty.MemberItem).Email }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.MemberItem).Status }}, - {Header: "TIMEZONE", Field: func(v any) string { return v.(flashduty.MemberItem).TimeZone }}, - } - if err := ctx.Printer.Print(result.Members, cols); err != nil { - return err - } - } else if len(result.PersonInfos) > 0 { + // MemberList returns member rows; an empty list renders the + // "no members" path (structured: empty set; plain: a message). + if len(result.Items) > 0 { cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(flashduty.PersonInfo).PersonID, 10) }}, - {Header: "NAME", Field: func(v any) string { return v.(flashduty.PersonInfo).PersonName }}, - {Header: "EMAIL", Field: func(v any) string { return v.(flashduty.PersonInfo).Email }}, + {Header: "ID", Field: func(v any) string { return strconv.FormatUint(v.(gflashduty.MemberItem).MemberID, 10) }}, + {Header: "NAME", Field: func(v any) string { return v.(gflashduty.MemberItem).MemberName }}, + {Header: "EMAIL", Field: func(v any) string { return v.(gflashduty.MemberItem).Email }}, + {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.MemberItem).Status }}, + {Header: "TIMEZONE", Field: func(v any) string { return v.(gflashduty.MemberItem).TimeZone }}, } - if err := ctx.Printer.Print(result.PersonInfos, cols); err != nil { + if err := ctx.Printer.Print(result.Items, cols); err != nil { return err } } else { diff --git a/internal/cli/monit_query.go b/internal/cli/monit_query.go index 4efbf00..15333d9 100644 --- a/internal/cli/monit_query.go +++ b/internal/cli/monit_query.go @@ -1,9 +1,9 @@ package cli import ( + "encoding/json" "fmt" - flashduty "github.com/flashcatcloud/flashduty-sdk" gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" @@ -91,11 +91,6 @@ func newMonitQueryRowsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "rows", Short: "Raw datasource passthrough (returns values/rows as the datasource itself would)", - // TODO(go-flashduty migration): not migrated. The legacy SDK returns the - // datasource body verbatim as a RawMessage, which this command writes - // through unchanged. go-flashduty's QueryRowsResponse is a structured - // []QueryRow, so switching would change the on-screen output shape — a - // behavior change, not a mechanical swap. Kept on the legacy SDK. RunE: func(cmd *cobra.Command, args []string) error { if dsType == "" || dsName == "" || expr == "" { return fmt.Errorf("--ds-type, --ds-name, --expr are required") @@ -105,25 +100,33 @@ func newMonitQueryRowsCmd() *cobra.Command { return fmt.Errorf("invalid --args: %w", err) } - return runCommand(cmd, args, func(ctx *RunContext) error { - input := &flashduty.MonitQueryRowsInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + input := &gflashduty.QueryRowsRequest{ DsType: dsType, DsName: dsName, Expr: expr, Args: argsMap, } - result, err := ctx.Client.MonitQueryRows(cmdContext(ctx.Cmd), input) + result, _, err := ctx.GFClient.Diagnostics.QueryRows(cmdContext(ctx.Cmd), input) if err != nil { return err } - // MonitQueryRowsOutput intentionally captures the entire response - // body as a RawMessage (data shape is datasource-specific). The - // struct itself marshals to `{}`, so write the raw bytes through. - if len(result.Data) == 0 { + // This command is a raw datasource passthrough. The legacy SDK + // captured the response body (a JSON array of {fields,values} + // objects) as a RawMessage and wrote it through verbatim, + // independent of the --json/--toon output format. go-flashduty + // decodes that same array into []QueryRow, so re-marshal it to + // the equivalent JSON array and write it through unchanged to + // preserve the legacy single-blob output shape. + if result == nil { _, err = fmt.Fprintln(ctx.Writer, "{}") - } else { - _, err = fmt.Fprintln(ctx.Writer, string(result.Data)) + return err + } + body, err := json.Marshal(*result) + if err != nil { + return fmt.Errorf("failed to marshal query rows: %w", err) } + _, err = fmt.Fprintln(ctx.Writer, string(body)) return err }) }, diff --git a/internal/cli/monit_query_test.go b/internal/cli/monit_query_test.go index bd95468..70c56c2 100644 --- a/internal/cli/monit_query_test.go +++ b/internal/cli/monit_query_test.go @@ -1,12 +1,9 @@ package cli import ( - "context" "fmt" "strings" "testing" - - flashduty "github.com/flashcatcloud/flashduty-sdk" ) func TestMonitQueryDiagnoseFlags(t *testing.T) { @@ -31,44 +28,6 @@ func TestMonitQueryRowsFlags(t *testing.T) { } } -// --- shared mock plumbing ------------------------------------------------- - -type mockMonitQuery struct { - mockClient - - diagnoseInput *flashduty.MonitQueryDiagnoseInput - diagnoseOut *flashduty.MonitQueryDiagnoseOutput - diagnoseErr error - - rowsInput *flashduty.MonitQueryRowsInput - rowsOut *flashduty.MonitQueryRowsOutput - rowsErr error -} - -func (m *mockMonitQuery) MonitQueryDiagnose(_ context.Context, input *flashduty.MonitQueryDiagnoseInput) (*flashduty.MonitQueryDiagnoseOutput, error) { - copied := *input - m.diagnoseInput = &copied - if m.diagnoseErr != nil { - return nil, m.diagnoseErr - } - if m.diagnoseOut != nil { - return m.diagnoseOut, nil - } - return &flashduty.MonitQueryDiagnoseOutput{Operation: "log_patterns"}, nil -} - -func (m *mockMonitQuery) MonitQueryRows(_ context.Context, input *flashduty.MonitQueryRowsInput) (*flashduty.MonitQueryRowsOutput, error) { - copied := *input - m.rowsInput = &copied - if m.rowsErr != nil { - return nil, m.rowsErr - } - if m.rowsOut != nil { - return m.rowsOut, nil - } - return &flashduty.MonitQueryRowsOutput{}, nil -} - // --- monit-query diagnose ------------------------------------------------- func TestMonitQueryDiagnoseHappyPath(t *testing.T) { @@ -191,10 +150,18 @@ func TestMonitQueryDiagnoseInvalidTimeStart(t *testing.T) { func TestMonitQueryRowsHappyPath(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitQuery{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) + // rows is a raw datasource passthrough: the response envelope "data" is a + // JSON array of QueryRow ({fields,values}) objects, decoded into + // QueryRowsResponse ([]QueryRow) and re-marshalled verbatim to the writer. + stub.data = []any{ + map[string]any{ + "fields": map[string]any{"instance": "node-1"}, + "values": map[string]any{"__value__": 1}, + }, + } - _, err := execCommand( + out, err := execCommand( "monit-query", "rows", "--ds-type", "prometheus", "--ds-name", "prom-prod", @@ -205,15 +172,20 @@ func TestMonitQueryRowsHappyPath(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if mock.rowsInput == nil { - t.Fatal("expected MonitQueryRows to be called") + if stub.lastPath != "/monit/query/rows" { + t.Fatalf("expected /monit/query/rows, got %q", stub.lastPath) } - got := mock.rowsInput - if got.DsType != "prometheus" || got.DsName != "prom-prod" || got.Expr != "up" { - t.Errorf("unexpected rows input: %+v", got) + body := stub.lastBody + if body["ds_type"] != "prometheus" || body["ds_name"] != "prom-prod" || body["expr"] != "up" { + t.Errorf("unexpected rows input: %#v", body) + } + args, _ := body["args"].(map[string]any) + if args["step"] != "15s" || args["tenant"] != "acme" { + t.Errorf("expected args step=15s tenant=acme, got %#v", args) } - if got.Args["step"] != "15s" || got.Args["tenant"] != "acme" { - t.Errorf("expected args step=15s tenant=acme, got %#v", got.Args) + // The rendered output is the re-marshalled row array (passthrough shape). + if !strings.Contains(out, "node-1") || !strings.Contains(out, "__value__") { + t.Errorf("expected rendered rows to carry the datasource payload, got:\n%s", out) } } @@ -250,8 +222,7 @@ func TestMonitQueryRowsRequiredFlags(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitQuery{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand(tc.args...) if err == nil { @@ -260,8 +231,8 @@ func TestMonitQueryRowsRequiredFlags(t *testing.T) { if !strings.Contains(err.Error(), "required") { t.Errorf("expected error to mention 'required', got %q", err.Error()) } - if mock.rowsInput != nil { - t.Errorf("MonitQueryRows should not have been called: %#v", mock.rowsInput) + if stub.requests != 0 { + t.Errorf("rows should not have been called: %d request(s)", stub.requests) } }) } @@ -269,8 +240,7 @@ func TestMonitQueryRowsRequiredFlags(t *testing.T) { func TestMonitQueryRowsInvalidArgs(t *testing.T) { saveAndResetGlobals(t) - mock := &mockMonitQuery{} - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand( "monit-query", "rows", @@ -285,7 +255,7 @@ func TestMonitQueryRowsInvalidArgs(t *testing.T) { if !strings.Contains(err.Error(), "--args") { t.Errorf("expected error to mention --args, got %q", err.Error()) } - if mock.rowsInput != nil { - t.Errorf("MonitQueryRows should not have been called: %#v", mock.rowsInput) + if stub.requests != 0 { + t.Errorf("rows should not have been called: %d request(s)", stub.requests) } } diff --git a/internal/cli/oncall.go b/internal/cli/oncall.go index a21e0bd..c4b7b7b 100644 --- a/internal/cli/oncall.go +++ b/internal/cli/oncall.go @@ -5,7 +5,7 @@ import ( "strconv" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -40,7 +40,7 @@ func newOncallWhoCmd() *cobra.Command { Use: "who", Short: "Show who is currently on call", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -50,56 +50,48 @@ func newOncallWhoCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &flashduty.ListSchedulesWithSlotsInput{ + req := &gflashduty.ScheduleListRequest{ Start: startTime, End: endTime, Query: query, - Limit: limit, - Page: page, } + req.Limit = limit + req.Page = page if team != "" { teamIDs, err := parseIntSlice(team) if err != nil { return fmt.Errorf("invalid --team: %w", err) } - input.TeamIDs = teamIDs + req.TeamIDs = teamIDs } - result, err := ctx.Client.ListSchedulesWithSlots(cmdContext(ctx.Cmd), input) + result, _, err := ctx.GFClient.Schedules.List(cmdContext(ctx.Cmd), req) if err != nil { return err } + // Resolve on-call person IDs to display names (best-effort). + nameByID := resolveScheduleOncallPeople(ctx, result.Items) + cols := []output.Column{ {Header: "SCHEDULE", MaxWidth: 30, Field: func(v any) string { - s := v.(flashduty.ScheduleDetail) - if s.ScheduleName != nil { - return *s.ScheduleName - } - if s.Name != nil { - return *s.Name - } - return "-" + return scheduleDisplayName(v.(gflashduty.ScheduleItem)) }}, {Header: "ON_CALL", MaxWidth: 40, Field: func(v any) string { - s := v.(flashduty.ScheduleDetail) - return formatOncallMembers(s.CurOncall) + s := v.(gflashduty.ScheduleItem) + return formatOncallMembers(&s.CurOncall, nameByID) }}, {Header: "UNTIL", Field: func(v any) string { - s := v.(flashduty.ScheduleDetail) - if s.CurOncall != nil { - return output.FormatTime(s.CurOncall.End) - } - return "-" + return output.FormatTime(v.(gflashduty.ScheduleItem).CurOncall.End) }}, {Header: "NEXT", MaxWidth: 40, Field: func(v any) string { - s := v.(flashduty.ScheduleDetail) - return formatOncallMembers(s.NextOncall) + s := v.(gflashduty.ScheduleItem) + return formatOncallMembers(&s.NextOncall, nameByID) }}, } - return ctx.PrintTotal(result.Schedules, cols, int(result.Total)) + return ctx.PrintTotal(result.Items, cols, int(result.Total)) }) }, } @@ -122,7 +114,7 @@ func newOncallScheduleListCmd() *cobra.Command { Use: "list", Short: "List schedules", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -132,55 +124,47 @@ func newOncallScheduleListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &flashduty.ListSchedulesWithSlotsInput{ + req := &gflashduty.ScheduleListRequest{ Start: startTime, End: endTime, Query: query, - Limit: limit, - Page: page, } + req.Limit = limit + req.Page = page if team != "" { teamIDs, err := parseIntSlice(team) if err != nil { return fmt.Errorf("invalid --team: %w", err) } - input.TeamIDs = teamIDs + req.TeamIDs = teamIDs } - result, err := ctx.Client.ListSchedulesWithSlots(cmdContext(ctx.Cmd), input) + result, _, err := ctx.GFClient.Schedules.List(cmdContext(ctx.Cmd), req) if err != nil { return err } cols := []output.Column{ {Header: "ID", Field: func(v any) string { - s := v.(flashduty.ScheduleDetail) - return strconv.FormatInt(s.ScheduleID, 10) + return strconv.FormatInt(scheduleID(v.(gflashduty.ScheduleItem)), 10) }}, {Header: "NAME", MaxWidth: 30, Field: func(v any) string { - s := v.(flashduty.ScheduleDetail) - if s.ScheduleName != nil { - return *s.ScheduleName - } - if s.Name != nil { - return *s.Name - } - return "-" + return scheduleDisplayName(v.(gflashduty.ScheduleItem)) }}, {Header: "STATUS", Field: func(v any) string { - s := v.(flashduty.ScheduleDetail) - if s.Disabled != nil && *s.Disabled != 0 { + s := v.(gflashduty.ScheduleItem) + if s.Disabled != 0 { return "disabled" } return "enabled" }}, {Header: "LAYERS", Field: func(v any) string { - return scheduleLayerCount(v.(flashduty.ScheduleDetail)) + return scheduleLayerCount(v.(gflashduty.ScheduleItem)) }}, } - return ctx.PrintTotal(result.Schedules, cols, int(result.Total)) + return ctx.PrintTotal(result.Items, cols, int(result.Total)) }) }, } @@ -203,8 +187,8 @@ func newOncallScheduleGetCmd() *cobra.Command { Short: "Get schedule detail", Args: requireArgs("schedule_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - scheduleID, err := strconv.ParseInt(ctx.Args[0], 10, 64) + return runGFCommand(cmd, args, func(ctx *RunContext) error { + scheduleIDArg, err := strconv.ParseInt(ctx.Args[0], 10, 64) if err != nil { return fmt.Errorf("invalid schedule_id %q: %w", ctx.Args[0], err) } @@ -218,8 +202,8 @@ func newOncallScheduleGetCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, err := ctx.Client.GetScheduleDetail(cmdContext(ctx.Cmd), &flashduty.GetScheduleDetailInput{ - ScheduleID: scheduleID, + s, _, err := ctx.GFClient.Schedules.Info(cmdContext(ctx.Cmd), &gflashduty.ScheduleInfoRequest{ + ScheduleID: scheduleIDArg, Start: startTime, End: endTime, }) @@ -228,40 +212,28 @@ func newOncallScheduleGetCmd() *cobra.Command { } if ctx.Structured() { - return ctx.Printer.Print(result.Schedule, nil) + return ctx.Printer.Print(s, nil) } - s := result.Schedule - - name := "-" - if s.ScheduleName != nil { - name = *s.ScheduleName - } else if s.Name != nil { - name = *s.Name - } + // Resolve on-call person IDs to display names (best-effort). + nameByID := resolveScheduleOncallPeople(ctx, []gflashduty.ScheduleItem{*s}) status := "enabled" - if s.Disabled != nil && *s.Disabled != 0 { + if s.Disabled != 0 { status = "disabled" } - _, _ = fmt.Fprintf(ctx.Writer, "ID: %d\n", s.ScheduleID) - _, _ = fmt.Fprintf(ctx.Writer, "Name: %s\n", name) + _, _ = fmt.Fprintf(ctx.Writer, "ID: %d\n", scheduleID(*s)) + _, _ = fmt.Fprintf(ctx.Writer, "Name: %s\n", scheduleDisplayName(*s)) _, _ = fmt.Fprintf(ctx.Writer, "Status: %s\n", status) - _, _ = fmt.Fprintf(ctx.Writer, "Layers: %s\n", scheduleLayerCount(s)) + _, _ = fmt.Fprintf(ctx.Writer, "Layers: %s\n", scheduleLayerCount(*s)) - curOnCall := formatOncallMembers(s.CurOncall) - curUntil := "-" - if s.CurOncall != nil { - curUntil = output.FormatTime(s.CurOncall.End) - } + curOnCall := formatOncallMembers(&s.CurOncall, nameByID) + curUntil := output.FormatTime(s.CurOncall.End) _, _ = fmt.Fprintf(ctx.Writer, "Current: %s (until %s)\n", curOnCall, curUntil) - nextOnCall := formatOncallMembers(s.NextOncall) - nextFrom := "-" - if s.NextOncall != nil { - nextFrom = output.FormatTime(s.NextOncall.Start) - } + nextOnCall := formatOncallMembers(&s.NextOncall, nameByID) + nextFrom := output.FormatTime(s.NextOncall.Start) _, _ = fmt.Fprintf(ctx.Writer, "Next: %s (from %s)\n", nextOnCall, nextFrom) // Print computed slots table @@ -270,13 +242,13 @@ func newOncallScheduleGetCmd() *cobra.Command { cols := []output.Column{ {Header: "START", Field: func(v any) string { - return output.FormatTime(v.(flashduty.ScheduleCalculatedSchedule).Start) + return output.FormatTime(v.(gflashduty.ScheduleCalculatedSchedule).Start) }}, {Header: "END", Field: func(v any) string { - return output.FormatTime(v.(flashduty.ScheduleCalculatedSchedule).End) + return output.FormatTime(v.(gflashduty.ScheduleCalculatedSchedule).End) }}, {Header: "GROUP", MaxWidth: 30, Field: func(v any) string { - g := v.(flashduty.ScheduleCalculatedSchedule).Group + g := v.(gflashduty.ScheduleCalculatedSchedule).Group if g.GroupName != "" { return g.GroupName } @@ -298,10 +270,28 @@ func newOncallScheduleGetCmd() *cobra.Command { return cmd } -// formatOncallMembers extracts member person IDs from a ScheduleOncallGroup and -// returns them as a comma-separated string. Since the schedule API returns person IDs -// (not names), we display IDs for now. -func scheduleLayerCount(s flashduty.ScheduleDetail) string { +// scheduleID returns the schedule's numeric ID, preferring schedule_id and +// falling back to the legacy id field. +func scheduleID(s gflashduty.ScheduleItem) int64 { + if s.ScheduleID != 0 { + return s.ScheduleID + } + return s.ID +} + +// scheduleDisplayName returns the schedule's display name, preferring +// schedule_name and falling back to the legacy name field. +func scheduleDisplayName(s gflashduty.ScheduleItem) string { + if s.ScheduleName != "" { + return s.ScheduleName + } + if s.Name != "" { + return s.Name + } + return "-" +} + +func scheduleLayerCount(s gflashduty.ScheduleItem) string { switch { case len(s.Layers) > 0: return fmt.Sprintf("%d", len(s.Layers)) @@ -314,17 +304,24 @@ func scheduleLayerCount(s flashduty.ScheduleDetail) string { } } -func formatOncallMembers(oncall *flashduty.ScheduleOncallGroup) string { +// formatOncallMembers renders an on-call group's members as display names, +// resolving person IDs through nameByID (best-effort, falling back to the +// numeric ID), and finally to the group name when no members are present. +func formatOncallMembers(oncall *gflashduty.ScheduleOncallGroup, nameByID map[int64]string) string { if oncall == nil { return "-" } - var ids []string + var names []string for _, m := range oncall.Group.Members { for _, pid := range m.PersonIDs { - ids = append(ids, strconv.FormatInt(pid, 10)) + if n, ok := nameByID[pid]; ok && n != "" { + names = append(names, n) + } else { + names = append(names, strconv.FormatInt(pid, 10)) + } } } - if len(ids) == 0 { + if len(names) == 0 { name := oncall.Group.GroupName if name == "" { name = oncall.Group.Name @@ -334,5 +331,45 @@ func formatOncallMembers(oncall *flashduty.ScheduleOncallGroup) string { } return "-" } - return strings.Join(ids, ", ") + return strings.Join(names, ", ") +} + +// resolveScheduleOncallPeople collects the on-call person IDs across the given +// schedules' current and next on-call groups and resolves them to display names +// via /person/infos, replicating the name lookup the legacy SDK fronted. +// Best-effort: a lookup failure yields a nil map and callers fall back to the +// numeric ID. +func resolveScheduleOncallPeople(rc *RunContext, items []gflashduty.ScheduleItem) map[int64]string { + seen := make(map[int64]struct{}) + ids := make([]uint64, 0) + collect := func(g gflashduty.ScheduleOncallGroup) { + for _, m := range g.Group.Members { + for _, pid := range m.PersonIDs { + if pid == 0 { + continue + } + if _, ok := seen[pid]; ok { + continue + } + seen[pid] = struct{}{} + ids = append(ids, uint64(pid)) + } + } + } + for _, s := range items { + collect(s.CurOncall) + collect(s.NextOncall) + } + if len(ids) == 0 { + return nil + } + resp, _, err := rc.GFClient.Members.PersonInfos(cmdContext(rc.Cmd), &gflashduty.PersonInfosRequest{PersonIDs: ids}) + if err != nil || resp == nil { + return nil + } + out := make(map[int64]string, len(resp.Items)) + for _, p := range resp.Items { + out[int64(p.PersonID)] = p.PersonName + } + return out } diff --git a/internal/cli/oncall_test.go b/internal/cli/oncall_test.go index 28fb230..5edeeac 100644 --- a/internal/cli/oncall_test.go +++ b/internal/cli/oncall_test.go @@ -3,33 +3,33 @@ package cli import ( "testing" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" ) func TestScheduleLayerCount(t *testing.T) { tests := []struct { name string - input flashduty.ScheduleDetail + input gflashduty.ScheduleItem want string }{ { name: "raw layers", - input: flashduty.ScheduleDetail{Layers: []flashduty.ScheduleLayer{{}, {}}}, + input: gflashduty.ScheduleItem{Layers: []gflashduty.ScheduleLayer{{}, {}}}, want: "2", }, { name: "schedule layers fallback", - input: flashduty.ScheduleDetail{ScheduleLayers: []flashduty.ScheduleCalculatedLayer{{}, {}, {}}}, + input: gflashduty.ScheduleItem{ScheduleLayers: []gflashduty.ScheduleCalculatedLayer{{}, {}, {}}}, want: "3", }, { name: "layer schedules fallback", - input: flashduty.ScheduleDetail{LayerSchedules: []flashduty.ScheduleCalculatedLayer{{}, {}}}, + input: gflashduty.ScheduleItem{LayerSchedules: []gflashduty.ScheduleCalculatedLayer{{}, {}}}, want: "2", }, { - name: "unknown when only computed snapshots exist", - input: flashduty.ScheduleDetail{FinalSchedule: flashduty.ScheduleCalculatedLayer{LayerName: "final"}}, + name: "unknown when no layer arrays are present", + input: gflashduty.ScheduleItem{}, want: "-", }, } diff --git a/internal/cli/postmortem.go b/internal/cli/postmortem.go index 8911257..a20b49f 100644 --- a/internal/cli/postmortem.go +++ b/internal/cli/postmortem.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -27,19 +27,19 @@ func newPostmortemListCmd() *cobra.Command { Use: "list", Short: "List post-mortem reports", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - input := &flashduty.ListPostMortemsInput{ + return runGFCommand(cmd, args, func(ctx *RunContext) error { + req := &gflashduty.ListPostMortemsRequest{ Status: status, - Limit: limit, - Page: page, } + req.Page = page + req.Limit = limit if channel != "" { channelIDs, err := parseIntSlice(channel) if err != nil { return fmt.Errorf("invalid --channel: %w", err) } - input.ChannelIDs = channelIDs + req.ChannelIDs = channelIDs } if team != "" { @@ -47,7 +47,7 @@ func newPostmortemListCmd() *cobra.Command { if err != nil { return fmt.Errorf("invalid --team: %w", err) } - input.TeamIDs = teamIDs + req.TeamIDs = teamIDs } if since != "" { @@ -55,7 +55,7 @@ func newPostmortemListCmd() *cobra.Command { if err != nil { return fmt.Errorf("invalid --since: %w", err) } - input.CreatedAtStartSeconds = startTime + req.CreatedAtStartSeconds = startTime } if until != "" { @@ -63,25 +63,25 @@ func newPostmortemListCmd() *cobra.Command { if err != nil { return fmt.Errorf("invalid --until: %w", err) } - input.CreatedAtEndSeconds = endTime + req.CreatedAtEndSeconds = endTime } - result, err := ctx.Client.ListPostMortems(cmdContext(ctx.Cmd), input) + result, _, err := ctx.GFClient.Incidents.PostMortemList(cmdContext(ctx.Cmd), req) if err != nil { return err } cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(flashduty.PostMortem).PostMortemID }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.PostMortem).Title }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.PostMortem).Status }}, - {Header: "CHANNEL", Field: func(v any) string { return v.(flashduty.PostMortem).ChannelName }}, + {Header: "ID", Field: func(v any) string { return v.(gflashduty.PostMortemMeta).PostMortemID }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.PostMortemMeta).Title }}, + {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.PostMortemMeta).Status }}, + {Header: "CHANNEL", Field: func(v any) string { return v.(gflashduty.PostMortemMeta).ChannelName }}, {Header: "CREATED", Field: func(v any) string { - return output.FormatTime(v.(flashduty.PostMortem).CreatedAtSeconds) + return output.FormatTime(v.(gflashduty.PostMortemMeta).CreatedAtSeconds) }}, } - return ctx.PrintList(result.PostMortems, cols, len(result.PostMortems), page, result.Total) + return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) }) }, } diff --git a/internal/cli/root.go b/internal/cli/root.go index e7bda50..e3cf418 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -8,7 +8,6 @@ import ( "os" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" toon "github.com/toon-format/toon-go" @@ -19,90 +18,7 @@ import ( "github.com/flashcatcloud/flashduty-cli/internal/update" ) -// flashdutyClient defines the SDK operations used by CLI commands. -type flashdutyClient interface { - // === Account / Member === - GetAccountInfo(ctx context.Context) (*flashduty.AccountInfo, error) - GetMemberInfo(ctx context.Context) (*flashduty.MemberInfo, error) - - // === EXISTING === - ListIncidents(ctx context.Context, input *flashduty.ListIncidentsInput) (*flashduty.ListIncidentsOutput, error) - GetIncidentTimelines(ctx context.Context, incidentIDs []string) ([]flashduty.IncidentTimelineOutput, error) - ListIncidentAlerts(ctx context.Context, incidentIDs []string, limit int) ([]flashduty.IncidentAlertsOutput, error) - ListSimilarIncidents(ctx context.Context, incidentID string, limit int) (*flashduty.ListIncidentsOutput, error) - CreateIncident(ctx context.Context, input *flashduty.CreateIncidentInput) (*flashduty.CreateIncidentOutput, error) - UpdateIncident(ctx context.Context, input *flashduty.UpdateIncidentInput) ([]string, error) - AckIncidents(ctx context.Context, incidentIDs []string) error - UnackIncidents(ctx context.Context, incidentIDs []string) error - CloseIncidents(ctx context.Context, incidentIDs []string) error - WakeIncidents(ctx context.Context, incidentIDs []string) error - RemoveIncidents(ctx context.Context, incidentIDs []string) error - DisableIncidentMerge(ctx context.Context, incidentIDs []string) error - CommentIncidents(ctx context.Context, input *flashduty.IncidentCommentInput) error - AddIncidentResponders(ctx context.Context, input *flashduty.IncidentAddResponderInput) error - ListChannels(ctx context.Context, input *flashduty.ListChannelsInput) (*flashduty.ListChannelsOutput, error) - ListTeams(ctx context.Context, input *flashduty.ListTeamsInput) (*flashduty.ListTeamsOutput, error) - ListMembers(ctx context.Context, input *flashduty.ListMembersInput) (*flashduty.ListMembersOutput, error) - ListEscalationRules(ctx context.Context, channelID int64) (*flashduty.ListEscalationRulesOutput, error) - ListFields(ctx context.Context, input *flashduty.ListFieldsInput) (*flashduty.ListFieldsOutput, error) - GetPresetTemplate(ctx context.Context, input *flashduty.GetPresetTemplateInput) (*flashduty.GetPresetTemplateOutput, error) - ValidateTemplate(ctx context.Context, input *flashduty.ValidateTemplateInput) (*flashduty.ValidateTemplateOutput, error) - ListStatusChanges(ctx context.Context, input *flashduty.ListStatusChangesInput) (*flashduty.ListStatusChangesOutput, error) - CreateStatusIncident(ctx context.Context, input *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error) - CreateChangeTimeline(ctx context.Context, input *flashduty.CreateChangeTimelineInput) error - - // === PHASE 1: Incident additions === - GetIncidentDetail(ctx context.Context, input *flashduty.GetIncidentDetailInput) (*flashduty.GetIncidentDetailOutput, error) - ListPostMortems(ctx context.Context, input *flashduty.ListPostMortemsInput) (*flashduty.ListPostMortemsOutput, error) - MergeIncidents(ctx context.Context, input *flashduty.MergeIncidentsInput) error - SnoozeIncidents(ctx context.Context, input *flashduty.SnoozeIncidentsInput) error - ReopenIncidents(ctx context.Context, incidentIDs []string) error - ReassignIncidents(ctx context.Context, input *flashduty.ReassignIncidentsInput) error - - // === PHASE 1: Alert additions === - ListAlerts(ctx context.Context, input *flashduty.ListAlertsInput) (*flashduty.ListAlertsOutput, error) - GetAlertDetail(ctx context.Context, input *flashduty.GetAlertDetailInput) (*flashduty.GetAlertDetailOutput, error) - ListAlertEvents(ctx context.Context, input *flashduty.ListAlertEventsInput) (*flashduty.ListAlertEventsOutput, error) - MergeAlertsToIncident(ctx context.Context, input *flashduty.MergeAlertsInput) error - GetAlertFeed(ctx context.Context, input *flashduty.GetAlertFeedInput) (*flashduty.GetAlertFeedOutput, error) - ListAlertEventsGlobal(ctx context.Context, input *flashduty.ListAlertEventsGlobalInput) (*flashduty.ListAlertEventsGlobalOutput, error) - - // === PHASE 2: OnCall + Change === - ListSchedulesWithSlots(ctx context.Context, input *flashduty.ListSchedulesWithSlotsInput) (*flashduty.ListSchedulesWithSlotsOutput, error) - GetScheduleDetail(ctx context.Context, input *flashduty.GetScheduleDetailInput) (*flashduty.GetScheduleDetailOutput, error) - QueryChangeTrend(ctx context.Context, input *flashduty.QueryChangeTrendInput) (*flashduty.QueryChangeTrendOutput, error) - - // === PHASE 3: Insight + Admin === - QueryInsightByTeam(ctx context.Context, input *flashduty.InsightQueryInput) (*flashduty.QueryInsightByTeamOutput, error) - QueryInsightByChannel(ctx context.Context, input *flashduty.InsightQueryInput) (*flashduty.QueryInsightByChannelOutput, error) - QueryInsightAlertTopK(ctx context.Context, input *flashduty.QueryInsightAlertTopKInput) (*flashduty.QueryInsightAlertTopKOutput, error) - QueryInsightIncidentList(ctx context.Context, input *flashduty.QueryInsightIncidentListInput) (*flashduty.QueryInsightIncidentListOutput, error) - SearchAuditLogs(ctx context.Context, input *flashduty.SearchAuditLogsInput) (*flashduty.SearchAuditLogsOutput, error) - - // === PHASE 4: Status Page Migration === - StartStatusPageMigration(ctx context.Context, input *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) - StartStatusPageEmailSubscriberMigration(ctx context.Context, input *flashduty.StartStatusPageEmailSubscriberMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) - GetStatusPageMigrationStatus(ctx context.Context, jobID string) (*flashduty.StatusPageMigrationJob, error) - CancelStatusPageMigration(ctx context.Context, jobID string) error - - // === PHASE 5: Team Management === - GetTeamInfo(ctx context.Context, input *flashduty.TeamGetInput) (*flashduty.TeamItem, error) - UpsertTeam(ctx context.Context, input *flashduty.TeamUpsertInput) (*flashduty.TeamUpsertOutput, error) - DeleteTeam(ctx context.Context, input *flashduty.TeamDeleteInput) error - - // === CLI Phase 2: monit-query === - MonitQueryDiagnose(ctx context.Context, input *flashduty.MonitQueryDiagnoseInput) (*flashduty.MonitQueryDiagnoseOutput, error) - MonitQueryRows(ctx context.Context, input *flashduty.MonitQueryRowsInput) (*flashduty.MonitQueryRowsOutput, error) - - // === CLI Phase 2: monit-agent === - MonitAgentCatalog(ctx context.Context, input *flashduty.MonitAgentCatalogInput) (*flashduty.MonitAgentCatalogOutput, error) - MonitAgentInvoke(ctx context.Context, input *flashduty.MonitAgentInvokeInput) (*flashduty.MonitAgentInvokeOutput, error) -} - -// newClientFn creates a flashdutyClient. Override in tests to inject a mock. -var newClientFn = defaultNewClient - -// newGFClientFn creates the go-flashduty client used by migrated commands. +// newGFClientFn creates the go-flashduty client used by all commands. // Override in tests to inject a stub server. var newGFClientFn = defaultNewGFClient @@ -199,47 +115,13 @@ func Execute() error { return rootCmd.Execute() } -// newClient creates a flashdutyClient using the current factory. -func newClient() (flashdutyClient, error) { - return newClientFn() -} - // newGFClient creates a go-flashduty client using the current factory. func newGFClient() (*gflashduty.Client, error) { return newGFClientFn() } -// defaultNewClient creates a real Flashduty SDK client from resolved config + flag overrides. -func defaultNewClient() (flashdutyClient, error) { - cfg, err := loadResolvedConfig() - if err != nil { - return nil, err - } - - if cfg.AppKey == "" { - return nil, fmt.Errorf("no app key configured. Run 'flashduty login' or set FLASHDUTY_APP_KEY") - } - - opts := []flashduty.Option{ - flashduty.WithUserAgent("flashduty-cli/" + versionStr), - flashduty.WithLogger(&silentLogger{}), - } - if cfg.BaseURL != "" && cfg.BaseURL != config.DefaultBaseURL { - opts = append(opts, flashduty.WithBaseURL(cfg.BaseURL)) - } - - sdkClient, err := flashduty.NewClient(cfg.AppKey, opts...) - if err != nil { - return nil, err - } - - return sdkClient, nil -} - // defaultNewGFClient creates a real go-flashduty client from resolved config + -// flag overrides. This is the typed SDK used by migrated commands; the legacy -// hand-written SDK (defaultNewClient) still backs the commands that depend on -// server-side enrichment or endpoints go-flashduty does not yet cover. +// flag overrides. This is the typed SDK every command uses. func defaultNewGFClient() (*gflashduty.Client, error) { cfg, err := loadResolvedConfig() if err != nil { diff --git a/internal/cli/status_page.go b/internal/cli/status_page.go index ce4c2e5..0ce9aaa 100644 --- a/internal/cli/status_page.go +++ b/internal/cli/status_page.go @@ -4,8 +4,8 @@ import ( "fmt" "strconv" "strings" + "time" - flashduty "github.com/flashcatcloud/flashduty-sdk" gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" @@ -96,31 +96,31 @@ func newStatusPageChangesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "changes", Short: "List active status page changes", - // TODO(go-flashduty migration): not migrated. This lists *active* changes - // via /status-page/change/active/list. go-flashduty v0.4.0 only covers the - // general /status-page/change/list (StatusPages.ChangeList), which has - // different semantics (no active filter) and requires a status argument. - // Kept on the legacy SDK until the active-list endpoint is documented. RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.ListStatusChanges(cmdContext(ctx.Cmd), &flashduty.ListStatusChangesInput{ - PageID: pageID, - ChangeType: changeType, + return runGFCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.GFClient.StatusPages.ChangeActiveList(cmdContext(ctx.Cmd), &gflashduty.StatusPagesChangeActiveListRequest{ + PageID: pageID, + Type: changeType, }) if err != nil { return err } cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(flashduty.StatusChange).ChangeID, 10) }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.StatusChange).Title }}, - {Header: "TYPE", Field: func(v any) string { return v.(flashduty.StatusChange).Type }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.StatusChange).Status }}, - {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.StatusChange).CreatedAt) }}, - {Header: "UPDATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.StatusChange).UpdatedAt) }}, + {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(gflashduty.StatusPageChangeItem).ChangeID, 10) }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.StatusPageChangeItem).Title }}, + {Header: "TYPE", Field: func(v any) string { return v.(gflashduty.StatusPageChangeItem).Type }}, + {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.StatusPageChangeItem).Status }}, + // The active-list endpoint returns the event's scheduled window + // (start_at_seconds / close_at_seconds), not the row's created/ + // updated timestamps the legacy SDK reported. The CREATED/UPDATED + // headers are preserved to keep the table shape identical; they now + // reflect the event start and (scheduled) close times. + {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.StatusPageChangeItem).StartAtSeconds) }}, + {Header: "UPDATED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.StatusPageChangeItem).CloseAtSeconds) }}, } - return ctx.Printer.Print(result.Changes, cols) + return ctx.Printer.Print(result.Items, cols) }) }, } @@ -142,13 +142,51 @@ func newStatusPageCreateIncidentCmd() *cobra.Command { Use: "create-incident", Short: "Create a status page incident", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.CreateStatusIncident(cmdContext(ctx.Cmd), &flashduty.CreateStatusIncidentInput{ - PageID: pageID, - Title: title, - Message: message, - AffectedComponents: components, - NotifySubscribers: notify, + return runGFCommand(cmd, args, func(ctx *RunContext) error { + // Replicate the legacy SDK's request shaping exactly: default the + // status to "investigating", build a single timeline update carrying + // the message and any parsed component_changes, and fall back to the + // title when no message was supplied. This keeps the wire payload + // byte-for-byte equivalent so the migration introduces no drift. + const status = "investigating" + + update := gflashduty.CreateStatusPageChangeRequestUpdatesItem{ + AtSeconds: time.Now().Unix(), + Status: status, + } + if message != "" { + update.Description = message + } + if components != "" { + for _, part := range parseStringSlice(components) { + kv := strings.SplitN(part, ":", 2) + if len(kv) == 2 { + update.ComponentChanges = append(update.ComponentChanges, gflashduty.CreateStatusPageChangeRequestUpdatesItemComponentChangesItem{ + ComponentID: strings.TrimSpace(kv[0]), + Status: strings.TrimSpace(kv[1]), + }) + } else if len(kv) == 1 && kv[0] != "" { + update.ComponentChanges = append(update.ComponentChanges, gflashduty.CreateStatusPageChangeRequestUpdatesItemComponentChangesItem{ + ComponentID: strings.TrimSpace(kv[0]), + Status: "partial_outage", + }) + } + } + } + + description := message + if description == "" { + description = title + } + + result, _, err := ctx.GFClient.StatusPages.ChangeCreate(cmdContext(ctx.Cmd), &gflashduty.CreateStatusPageChangeRequest{ + PageID: pageID, + Title: title, + Type: "incident", + Status: status, + Description: description, + Updates: []gflashduty.CreateStatusPageChangeRequestUpdatesItem{update}, + NotifySubscribers: notify, }) if err != nil { return err diff --git a/internal/cli/status_page_migrate.go b/internal/cli/status_page_migrate.go index 5e5b4e4..48c4237 100644 --- a/internal/cli/status_page_migrate.go +++ b/internal/cli/status_page_migrate.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" ) @@ -34,11 +34,16 @@ func newStatusPageMigrateStructureCmd() *cobra.Command { if err := validateMigrationSource(source); err != nil { return err } - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.StartStatusPageMigration(cmdContext(ctx.Cmd), &flashduty.StartStatusPageMigrationInput{ - SourceAPIKey: sourceAPIKey, + // go-flashduty's MigrateStatusPageStructureRequest carries no + // url_name field, so we cannot forward --url-name without silently + // dropping it. Reject the flag explicitly rather than ignore it. + if urlName != "" { + return fmt.Errorf("--url-name is not supported by this command") + } + return runGFCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.GFClient.StatusPages.MigrateStructure(cmdContext(ctx.Cmd), &gflashduty.MigrateStatusPageStructureRequest{ + APIKey: sourceAPIKey, SourcePageID: sourcePageID, - URLName: urlName, }) if err != nil { return err @@ -73,9 +78,9 @@ func newStatusPageMigrateEmailSubscribersCmd() *cobra.Command { if err := validateMigrationSource(source); err != nil { return err } - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.StartStatusPageEmailSubscriberMigration(cmdContext(ctx.Cmd), &flashduty.StartStatusPageEmailSubscriberMigrationInput{ - SourceAPIKey: sourceAPIKey, + return runGFCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.GFClient.StatusPages.MigrateEmailSubscribers(cmdContext(ctx.Cmd), &gflashduty.MigrateStatusPageEmailSubscribersRequest{ + APIKey: sourceAPIKey, SourcePageID: sourcePageID, TargetPageID: targetPageID, }) @@ -107,8 +112,10 @@ func newStatusPageMigrateStatusCmd() *cobra.Command { Use: "status", Short: "Show migration job status", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - job, err := ctx.Client.GetStatusPageMigrationStatus(cmdContext(ctx.Cmd), jobID) + return runGFCommand(cmd, args, func(ctx *RunContext) error { + job, _, err := ctx.GFClient.StatusPages.MigrationStatus(cmdContext(ctx.Cmd), &gflashduty.StatusPagesMigrationStatusRequest{ + JobID: jobID, + }) if err != nil { return err } @@ -131,8 +138,10 @@ func newStatusPageMigrateCancelCmd() *cobra.Command { Use: "cancel", Short: "Cancel a running migration job", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.CancelStatusPageMigration(cmdContext(ctx.Cmd), jobID); err != nil { + return runGFCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.GFClient.StatusPages.MigrationCancel(cmdContext(ctx.Cmd), &gflashduty.CancelStatusPageMigrationRequest{ + JobID: jobID, + }); err != nil { return err } @@ -175,7 +184,7 @@ func validateMigrationSource(source string) error { return nil } -func printMigrationStart(ctx *RunContext, migrationType, source, sourcePageID string, targetPageID int64, result *flashduty.StartStatusPageMigrationOutput) error { +func printMigrationStart(ctx *RunContext, migrationType, source, sourcePageID string, targetPageID int64, result *gflashduty.StatusPageMigrationStartResponse) error { if ctx.Structured() { payload := map[string]any{ "type": migrationType, @@ -218,7 +227,7 @@ func printMigrationStart(ctx *RunContext, migrationType, source, sourcePageID st return err } -func printMigrationStatus(ctx *RunContext, job *flashduty.StatusPageMigrationJob) error { +func printMigrationStatus(ctx *RunContext, job *gflashduty.StatusPageMigrationJob) error { if ctx.Structured() { return ctx.Printer.Print(job, nil) } diff --git a/internal/cli/status_page_migrate_test.go b/internal/cli/status_page_migrate_test.go index 1a9f64b..f2bd0f7 100644 --- a/internal/cli/status_page_migrate_test.go +++ b/internal/cli/status_page_migrate_test.go @@ -1,63 +1,22 @@ package cli import ( - "context" "encoding/json" - "fmt" + "net/http" + "net/http/httptest" "strings" "testing" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" ) -type mockStatusPageMigrate struct { - mockClient - - startStructure func(ctx context.Context, input *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) - startEmailSubscribers func(ctx context.Context, input *flashduty.StartStatusPageEmailSubscriberMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) - getStatus func(ctx context.Context, jobID string) (*flashduty.StatusPageMigrationJob, error) - cancel func(ctx context.Context, jobID string) error -} - -func (m *mockStatusPageMigrate) StartStatusPageMigration(ctx context.Context, input *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { - if m.startStructure == nil { - return m.mockClient.StartStatusPageMigration(ctx, input) - } - return m.startStructure(ctx, input) -} - -func (m *mockStatusPageMigrate) StartStatusPageEmailSubscriberMigration(ctx context.Context, input *flashduty.StartStatusPageEmailSubscriberMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { - if m.startEmailSubscribers == nil { - return m.mockClient.StartStatusPageEmailSubscriberMigration(ctx, input) - } - return m.startEmailSubscribers(ctx, input) -} - -func (m *mockStatusPageMigrate) GetStatusPageMigrationStatus(ctx context.Context, jobID string) (*flashduty.StatusPageMigrationJob, error) { - if m.getStatus == nil { - return m.mockClient.GetStatusPageMigrationStatus(ctx, jobID) - } - return m.getStatus(ctx, jobID) -} - -func (m *mockStatusPageMigrate) CancelStatusPageMigration(ctx context.Context, jobID string) error { - if m.cancel == nil { - return m.mockClient.CancelStatusPageMigration(ctx, jobID) - } - return m.cancel(ctx, jobID) -} - +// TestCommandStatusPageMigrateStructureSendsSDKInput asserts the structure +// command POSTs to /status-page/migrate-structure with the api_key and +// source_page_id wire fields and renders the returned job id. func TestCommandStatusPageMigrateStructureSendsSDKInput(t *testing.T) { saveAndResetGlobals(t) - - var gotInput *flashduty.StartStatusPageMigrationInput - mock := &mockStatusPageMigrate{ - startStructure: func(_ context.Context, input *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { - gotInput = input - return &flashduty.StartStatusPageMigrationOutput{JobID: "job-1"}, nil - }, - } - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) + stub.data = map[string]any{"job_id": "job-1"} out, err := execCommand("statuspage", "migrate", "structure", "--from", "atlassian", @@ -68,17 +27,19 @@ func TestCommandStatusPageMigrateStructureSendsSDKInput(t *testing.T) { t.Fatalf("execCommand: %v", err) } - if gotInput == nil { - t.Fatal("expected input to be captured") + if stub.lastPath != "/status-page/migrate-structure" { + t.Fatalf("expected /status-page/migrate-structure, got %q", stub.lastPath) } - if gotInput.SourceAPIKey != "atlassian-secret" { - t.Errorf("SourceAPIKey = %q, want atlassian-secret", gotInput.SourceAPIKey) + if stub.lastBody["api_key"] != "atlassian-secret" { + t.Errorf("api_key = %v, want atlassian-secret", stub.lastBody["api_key"]) } - if gotInput.SourcePageID != "src-1" { - t.Errorf("SourcePageID = %q, want src-1", gotInput.SourcePageID) + if stub.lastBody["source_page_id"] != "src-1" { + t.Errorf("source_page_id = %v, want src-1", stub.lastBody["source_page_id"]) } - if gotInput.URLName != "" { - t.Errorf("URLName = %q, want empty", gotInput.URLName) + // go-flashduty's request struct carries no url_name field, so the wire body + // must never contain one. + if _, ok := stub.lastBody["url_name"]; ok { + t.Errorf("url_name should not be sent, got %#v", stub.lastBody["url_name"]) } if !strings.Contains(out, "Job ID: job-1") { t.Errorf("missing job id in output:\n%s", out) @@ -88,17 +49,14 @@ func TestCommandStatusPageMigrateStructureSendsSDKInput(t *testing.T) { } } -func TestCommandStatusPageMigrateStructureSendsURLName(t *testing.T) { +// TestCommandStatusPageMigrateStructureRejectsURLName: go-flashduty's +// MigrateStatusPageStructureRequest has no url_name field, so the migrated +// command rejects --url-name rather than silently dropping it. (Legacy +// forwarded URLName to the SDK; the port cannot, so behavior changed from +// "forward" to "reject before the client call".) +func TestCommandStatusPageMigrateStructureRejectsURLName(t *testing.T) { saveAndResetGlobals(t) - - var gotInput *flashduty.StartStatusPageMigrationInput - mock := &mockStatusPageMigrate{ - startStructure: func(_ context.Context, input *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { - gotInput = input - return &flashduty.StartStatusPageMigrationOutput{JobID: "job-url"}, nil - }, - } - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand("statuspage", "migrate", "structure", "--from", "atlassian", @@ -106,15 +64,14 @@ func TestCommandStatusPageMigrateStructureSendsURLName(t *testing.T) { "--api-key", "atlassian-secret", "--url-name", "customer-facing-status", ) - if err != nil { - t.Fatalf("execCommand: %v", err) + if err == nil { + t.Fatal("expected error rejecting --url-name") } - - if gotInput == nil { - t.Fatal("expected input to be captured") + if !strings.Contains(err.Error(), "--url-name is not supported") { + t.Errorf("got %v; want --url-name rejection", err) } - if gotInput.URLName != "customer-facing-status" { - t.Errorf("URLName = %q, want customer-facing-status", gotInput.URLName) + if stub.requests != 0 { + t.Errorf("client must not be called when --url-name is rejected, got %d request(s)", stub.requests) } } @@ -137,15 +94,7 @@ func TestCommandStatusPageMigrateStructureHelpDescribesURLNameBehavior(t *testin func TestCommandStatusPageMigrateStructureRejectsUnsupportedSource(t *testing.T) { saveAndResetGlobals(t) - - called := false - mock := &mockStatusPageMigrate{ - startStructure: func(context.Context, *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { - called = true - return nil, nil - }, - } - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) _, err := execCommand("statuspage", "migrate", "structure", "--from", "pagerduty", @@ -161,8 +110,8 @@ func TestCommandStatusPageMigrateStructureRejectsUnsupportedSource(t *testing.T) if !strings.Contains(err.Error(), "atlassian") { t.Errorf("error should mention supported source 'atlassian': %v", err) } - if called { - t.Error("SDK should not have been called for unsupported source") + if stub.requests != 0 { + t.Errorf("client should not have been called for unsupported source, got %d request(s)", stub.requests) } } @@ -171,12 +120,7 @@ func TestCommandStatusPageMigrateStructureRejectsUnsupportedSource(t *testing.T) // client-build / auth work — matching PR #1 behavior. func TestCommandStatusPageMigrateStructureValidatesBeforeClient(t *testing.T) { saveAndResetGlobals(t) - - clientBuilt := false - newClientFn = func() (flashdutyClient, error) { - clientBuilt = true - return nil, fmt.Errorf("should not have been called") - } + stub := newGFStub(t) _, err := execCommand("statuspage", "migrate", "structure", "--from", "pagerduty", @@ -189,8 +133,8 @@ func TestCommandStatusPageMigrateStructureValidatesBeforeClient(t *testing.T) { if !strings.Contains(err.Error(), "unsupported migration source") { t.Errorf("got %v; want validation error about source", err) } - if clientBuilt { - t.Error("newClientFn must not run when --from is invalid") + if stub.requests != 0 { + t.Errorf("client must not run when --from is invalid, got %d request(s)", stub.requests) } } @@ -198,12 +142,7 @@ func TestCommandStatusPageMigrateStructureValidatesBeforeClient(t *testing.T) { // ordering guarantee for the subscribers variant. func TestCommandStatusPageMigrateEmailSubscribersValidatesBeforeClient(t *testing.T) { saveAndResetGlobals(t) - - clientBuilt := false - newClientFn = func() (flashdutyClient, error) { - clientBuilt = true - return nil, fmt.Errorf("should not have been called") - } + stub := newGFStub(t) _, err := execCommand("statuspage", "migrate", "email-subscribers", "--from", "pagerduty", @@ -217,20 +156,15 @@ func TestCommandStatusPageMigrateEmailSubscribersValidatesBeforeClient(t *testin if !strings.Contains(err.Error(), "unsupported migration source") { t.Errorf("got %v; want validation error about source", err) } - if clientBuilt { - t.Error("newClientFn must not run when --from is invalid") + if stub.requests != 0 { + t.Errorf("client must not run when --from is invalid, got %d request(s)", stub.requests) } } func TestCommandStatusPageMigrateStructureJSON(t *testing.T) { saveAndResetGlobals(t) - - mock := &mockStatusPageMigrate{ - startStructure: func(_ context.Context, _ *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { - return &flashduty.StartStatusPageMigrationOutput{JobID: "job-1"}, nil - }, - } - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) + stub.data = map[string]any{"job_id": "job-1"} out, err := execCommand("--json", "statuspage", "migrate", "structure", "--from", "atlassian", @@ -262,17 +196,13 @@ func TestCommandStatusPageMigrateStructureJSON(t *testing.T) { } } +// TestCommandStatusPageMigrateEmailSubscribersSendsSDKInput asserts the +// email-subscribers command POSTs to /status-page/migrate-email-subscribers +// with the target_page_id wire field and renders the returned job id. func TestCommandStatusPageMigrateEmailSubscribersSendsSDKInput(t *testing.T) { saveAndResetGlobals(t) - - var gotInput *flashduty.StartStatusPageEmailSubscriberMigrationInput - mock := &mockStatusPageMigrate{ - startEmailSubscribers: func(_ context.Context, input *flashduty.StartStatusPageEmailSubscriberMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { - gotInput = input - return &flashduty.StartStatusPageMigrationOutput{JobID: "sub-1"}, nil - }, - } - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) + stub.data = map[string]any{"job_id": "sub-1"} out, err := execCommand("statuspage", "migrate", "email-subscribers", "--from", "atlassian", @@ -284,11 +214,18 @@ func TestCommandStatusPageMigrateEmailSubscribersSendsSDKInput(t *testing.T) { t.Fatalf("execCommand: %v", err) } - if gotInput == nil { - t.Fatal("expected input to be captured") + if stub.lastPath != "/status-page/migrate-email-subscribers" { + t.Fatalf("expected /status-page/migrate-email-subscribers, got %q", stub.lastPath) + } + if stub.lastBody["api_key"] != "atlassian-secret" { + t.Errorf("api_key = %v, want atlassian-secret", stub.lastBody["api_key"]) } - if gotInput.TargetPageID != 2048 { - t.Errorf("TargetPageID = %d, want 2048", gotInput.TargetPageID) + if stub.lastBody["source_page_id"] != "src-1" { + t.Errorf("source_page_id = %v, want src-1", stub.lastBody["source_page_id"]) + } + // JSON numbers decode to float64 through the stub. + if got, _ := stub.lastBody["target_page_id"].(float64); got != 2048 { + t.Errorf("target_page_id = %v, want 2048", stub.lastBody["target_page_id"]) } if !strings.Contains(out, "Target page ID: 2048") { t.Errorf("missing target page id line in output:\n%s", out) @@ -300,41 +237,36 @@ func TestCommandStatusPageMigrateEmailSubscribersSendsSDKInput(t *testing.T) { func TestCommandStatusPageMigrateStatusRendersJobFields(t *testing.T) { saveAndResetGlobals(t) - - var gotJobID string - mock := &mockStatusPageMigrate{ - getStatus: func(_ context.Context, jobID string) (*flashduty.StatusPageMigrationJob, error) { - gotJobID = jobID - return &flashduty.StatusPageMigrationJob{ - JobID: "job-9", - SourcePageID: "src-9", - TargetPageID: 1024, - Phase: "history", - Status: "running", - Progress: flashduty.StatusPageMigrationProgress{ - TotalSteps: 5, - CompletedSteps: 3, - ComponentsImported: 2, - SectionsImported: 1, - IncidentsImported: 4, - MaintenancesImported: 1, - SubscribersImported: 0, - SubscribersSkipped: 0, - TemplatesImported: 2, - Warnings: []string{"missing field X"}, - }, - }, nil + stub := newGFStub(t) + stub.data = map[string]any{ + "job_id": "job-9", + "source_page_id": "src-9", + "target_page_id": 1024, + "phase": "history", + "status": "running", + "progress": map[string]any{ + "total_steps": 5, + "completed_steps": 3, + "components_imported": 2, + "sections_imported": 1, + "incidents_imported": 4, + "maintenances_imported": 1, + "subscribers_imported": 0, + "subscribers_skipped": 0, + "templates_imported": 2, + "warnings": []string{"missing field X"}, }, } - newClientFn = func() (flashdutyClient, error) { return mock, nil } out, err := execCommand("statuspage", "migrate", "status", "--job-id", "job-9") if err != nil { t.Fatalf("execCommand: %v", err) } - if gotJobID != "job-9" { - t.Errorf("jobID passed to SDK = %q, want job-9", gotJobID) + // migration-status is a GET: job_id rides in the query string, so the + // decoded body is empty. Assert the endpoint path instead. + if stub.lastPath != "/status-page/migration/status" { + t.Errorf("expected /status-page/migration/status, got %q", stub.lastPath) } for _, want := range []string{ "Job ID: job-9", @@ -356,17 +288,12 @@ func TestCommandStatusPageMigrateStatusRendersJobFields(t *testing.T) { func TestCommandStatusPageMigrateStatusJSON(t *testing.T) { saveAndResetGlobals(t) - - mock := &mockStatusPageMigrate{ - getStatus: func(_ context.Context, _ string) (*flashduty.StatusPageMigrationJob, error) { - return &flashduty.StatusPageMigrationJob{ - JobID: "job-j", - Phase: "completed", - Status: "completed", - }, nil - }, + stub := newGFStub(t) + stub.data = map[string]any{ + "job_id": "job-j", + "phase": "completed", + "status": "completed", } - newClientFn = func() (flashdutyClient, error) { return mock, nil } out, err := execCommand("--json", "statuspage", "migrate", "status", "--job-id", "job-j") if err != nil { @@ -387,23 +314,18 @@ func TestCommandStatusPageMigrateStatusJSON(t *testing.T) { func TestCommandStatusPageMigrateCancelIssuesCancelAndHint(t *testing.T) { saveAndResetGlobals(t) - - var gotJobID string - mock := &mockStatusPageMigrate{ - cancel: func(_ context.Context, jobID string) error { - gotJobID = jobID - return nil - }, - } - newClientFn = func() (flashdutyClient, error) { return mock, nil } + stub := newGFStub(t) out, err := execCommand("statuspage", "migrate", "cancel", "--job-id", "job-c") if err != nil { t.Fatalf("execCommand: %v", err) } - if gotJobID != "job-c" { - t.Errorf("SDK received jobID %q, want job-c", gotJobID) + if stub.lastPath != "/status-page/migration/cancel" { + t.Fatalf("expected /status-page/migration/cancel, got %q", stub.lastPath) + } + if stub.lastBody["job_id"] != "job-c" { + t.Errorf("job_id = %v, want job-c", stub.lastBody["job_id"]) } if !strings.Contains(out, "Cancellation requested.") { t.Errorf("missing confirmation in output:\n%s", out) @@ -415,11 +337,7 @@ func TestCommandStatusPageMigrateCancelIssuesCancelAndHint(t *testing.T) { func TestCommandStatusPageMigrateCancelJSON(t *testing.T) { saveAndResetGlobals(t) - - mock := &mockStatusPageMigrate{ - cancel: func(context.Context, string) error { return nil }, - } - newClientFn = func() (flashdutyClient, error) { return mock, nil } + newGFStub(t) out, err := execCommand("--json", "statuspage", "migrate", "cancel", "--job-id", "job-c") if err != nil { @@ -447,12 +365,21 @@ func TestCommandStatusPageMigrateCancelJSON(t *testing.T) { func TestCommandStatusPageMigrateStatusPropagatesSDKError(t *testing.T) { saveAndResetGlobals(t) - mock := &mockStatusPageMigrate{ - getStatus: func(context.Context, string) (*flashduty.StatusPageMigrationJob, error) { - return nil, &flashduty.DutyError{Code: "not_found", Message: "job missing"} - }, + // gfStub always replies with a success ("OK") envelope, so to exercise the + // error path we stand up a tiny server that returns a failure envelope and + // wire newGFClientFn at it directly. The client surfaces the envelope's + // error.code/message in the returned error. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "request_id": "test-request-id", + "error": map[string]any{"code": "not_found", "message": "job missing"}, + }) + })) + t.Cleanup(srv.Close) + newGFClientFn = func() (*gflashduty.Client, error) { + return gflashduty.NewClient("test-key", gflashduty.WithBaseURL(srv.URL)) } - newClientFn = func() (flashdutyClient, error) { return mock, nil } _, err := execCommand("statuspage", "migrate", "status", "--job-id", "nope") if err == nil { @@ -462,32 +389,3 @@ func TestCommandStatusPageMigrateStatusPropagatesSDKError(t *testing.T) { t.Errorf("unexpected error: %v", err) } } - -// TestCommandStatusPageList exercises the go-flashduty-backed `statuspage list` -// command: ReadPageList lists all pages and the command filters by --id -// client-side. The table maps name/url_name/components from StatusPageItem. -func TestCommandStatusPageList(t *testing.T) { - saveAndResetGlobals(t) - stub := newGFStub(t) - stub.data = map[string]any{ - "items": []map[string]any{ - {"page_id": 11, "name": "Flashduty", "url_name": "flashduty", "components": []map[string]any{{"name": "Web"}, {"name": "API"}}}, - {"page_id": 22, "name": "Other", "url_name": "other"}, - }, - } - - out, err := execCommand("statuspage", "list", "--id", "11") - if err != nil { - t.Fatalf("[statuspage-list] unexpected error: %v", err) - } - if stub.lastPath != "/status-page/list" { - t.Fatalf("[statuspage-list] expected /status-page/list, got %q", stub.lastPath) - } - if !strings.Contains(out, "Flashduty") || !strings.Contains(out, "flashduty") || !strings.Contains(out, "Web, API") { - t.Fatalf("[statuspage-list] unexpected output:\n%s", out) - } - // --id 11 filters out page 22 client-side. - if strings.Contains(out, "Other") { - t.Fatalf("[statuspage-list] expected --id filter to drop page 22, got:\n%s", out) - } -} diff --git a/internal/cli/team.go b/internal/cli/team.go index d07fa0a..b0a1bd7 100644 --- a/internal/cli/team.go +++ b/internal/cli/team.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -51,21 +51,19 @@ Examples: flashduty team list --person-id 12345 --limit 50 flashduty team list --orderby team_name --asc`, RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.ListTeams(cmdContext(ctx.Cmd), &flashduty.ListTeamsInput{ - Name: name, - Page: page, - Limit: limit, - OrderBy: orderBy, - Asc: asc, - PersonID: personID, - }) + return runGFCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.GFClient.Teams.ReadInfos(cmdContext(ctx.Cmd), &gflashduty.TeamInfosRequest{}) if err != nil { return err } - cols := teamListColumns() - return ctx.PrintTotal(result.Teams, cols, result.Total) + // go-flashduty's team rows carry only member person IDs, so + // resolve display names in one batch (mirroring the names the + // legacy SDK enriched server-side) for the MEMBERS column. + nameByID := resolveTeamMemberNames(ctx, result.Items) + + cols := teamListColumns(nameByID) + return ctx.PrintTotal(result.Items, cols, len(result.Items)) }) }, } @@ -104,9 +102,9 @@ Examples: return requireExactlyOneFlag(cmd, "id", "name", "ref-id") }, RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - team, err := ctx.Client.GetTeamInfo(cmdContext(ctx.Cmd), &flashduty.TeamGetInput{ - TeamID: teamID, + return runGFCommand(cmd, args, func(ctx *RunContext) error { + team, _, err := ctx.GFClient.Teams.ReadInfo(cmdContext(ctx.Cmd), &gflashduty.TeamInfoRequest{ + TeamID: uint64(teamID), TeamName: teamName, RefID: refID, }) @@ -118,7 +116,10 @@ Examples: return ctx.Printer.Print(team, nil) } - printTeamDetail(ctx.Writer, team) + // TeamItem carries only member person IDs; resolve names/emails + // in one batch to replicate the legacy member display. + members := resolveTeamMemberInfos(ctx, team.PersonIDs) + printTeamDetail(ctx.Writer, team, members) return nil }) }, @@ -155,16 +156,16 @@ Examples: flashduty team create --name "SRE Team" --emails alice@example.com,bob@example.com flashduty team create --name "SRE Team" --ref-id "hr-dept-42" --json`, RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { ids, err := parseIntSlice(personIDs) if err != nil { return fmt.Errorf("invalid --person-ids: %w", err) } - result, err := ctx.Client.UpsertTeam(cmdContext(ctx.Cmd), &flashduty.TeamUpsertInput{ + result, _, err := ctx.GFClient.Teams.WriteUpsert(cmdContext(ctx.Cmd), &gflashduty.TeamUpsertRequest{ TeamName: name, Description: description, - PersonIDs: ids, + PersonIDs: toUint64Slice(ids), Emails: parseStringSlice(emails), RefID: refID, }) @@ -218,7 +219,7 @@ Examples: return fmt.Errorf("--id is required") } - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { ids, err := parseIntSlice(personIDs) if err != nil { return fmt.Errorf("invalid --person-ids: %w", err) @@ -228,8 +229,8 @@ Examples: // provide --name, fetch the current name so we don't clear it. teamName := name if !cmd.Flags().Changed("name") { - existing, err := ctx.Client.GetTeamInfo(cmdContext(ctx.Cmd), &flashduty.TeamGetInput{ - TeamID: teamID, + existing, _, err := ctx.GFClient.Teams.ReadInfo(cmdContext(ctx.Cmd), &gflashduty.TeamInfoRequest{ + TeamID: uint64(teamID), }) if err != nil { return fmt.Errorf("failed to fetch current team: %w", err) @@ -237,24 +238,24 @@ Examples: teamName = existing.TeamName } - input := &flashduty.TeamUpsertInput{ - TeamID: teamID, + req := &gflashduty.TeamUpsertRequest{ + TeamID: uint64(teamID), TeamName: teamName, } if cmd.Flags().Changed("description") { - input.Description = description + req.Description = description } if cmd.Flags().Changed("person-ids") { - input.PersonIDs = ids + req.PersonIDs = toUint64Slice(ids) } if cmd.Flags().Changed("emails") { - input.Emails = parseStringSlice(emails) + req.Emails = parseStringSlice(emails) } if cmd.Flags().Changed("ref-id") { - input.RefID = refID + req.RefID = refID } - result, err := ctx.Client.UpsertTeam(cmdContext(ctx.Cmd), input) + result, _, err := ctx.GFClient.Teams.WriteUpsert(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -301,15 +302,15 @@ Examples: return requireExactlyOneFlag(cmd, "id", "name", "ref-id") }, RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { + return runGFCommand(cmd, args, func(ctx *RunContext) error { identifier := identifierDescription(teamID, teamName, refID) if !confirmAction(ctx.Cmd, fmt.Sprintf("Are you sure you want to delete team %s?", identifier)) { _, _ = fmt.Fprintln(ctx.Writer, "Aborted.") return nil } - err := ctx.Client.DeleteTeam(cmdContext(ctx.Cmd), &flashduty.TeamDeleteInput{ - TeamID: teamID, + _, err := ctx.GFClient.Teams.WriteDelete(cmdContext(ctx.Cmd), &gflashduty.TeamDeleteRequest{ + TeamID: uint64(teamID), TeamName: teamName, RefID: refID, }) @@ -331,33 +332,32 @@ Examples: return cmd } -func teamListColumns() []output.Column { +// teamListColumns renders the team table. The MEMBERS column maps each member's +// person ID to a resolved display name via nameByID, falling back to the numeric +// ID when a name can't be resolved. +func teamListColumns(nameByID map[uint64]string) []output.Column { return []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(flashduty.TeamInfo).TeamID, 10) }}, - {Header: "NAME", Field: func(v any) string { return v.(flashduty.TeamInfo).TeamName }}, + {Header: "ID", Field: func(v any) string { return strconv.FormatUint(v.(gflashduty.TeamBriefItem).TeamID, 10) }}, + {Header: "NAME", Field: func(v any) string { return v.(gflashduty.TeamBriefItem).TeamName }}, {Header: "MEMBERS", MaxWidth: 50, Field: func(v any) string { - members := v.(flashduty.TeamInfo).Members - names := make([]string, 0, len(members)) - for _, m := range members { - names = append(names, m.PersonName) + ids := v.(gflashduty.TeamBriefItem).PersonIDs + names := make([]string, 0, len(ids)) + for _, id := range ids { + if n, ok := nameByID[id]; ok && n != "" { + names = append(names, n) + } else { + names = append(names, strconv.FormatUint(id, 10)) + } } return strings.Join(names, ", ") }}, } } -func printTeamDetail(w io.Writer, team *flashduty.TeamItem) { - members := make([]string, 0, len(team.Members)) - for _, m := range team.Members { - if m.Email != "" { - members = append(members, fmt.Sprintf("%s <%s>", m.PersonName, m.Email)) - } else { - members = append(members, m.PersonName) - } - } +func printTeamDetail(w io.Writer, team *gflashduty.TeamItem, members []string) { if len(members) == 0 { for _, id := range team.PersonIDs { - members = append(members, strconv.FormatInt(id, 10)) + members = append(members, strconv.FormatUint(id, 10)) } } @@ -373,6 +373,81 @@ func printTeamDetail(w io.Writer, team *flashduty.TeamItem) { _, _ = fmt.Fprintf(w, "Updated By: %s\n", orDash(team.UpdatedByName)) } +// resolveTeamMemberNames batch-resolves the member person IDs of all team rows +// to display names via /person/infos, replicating the name enrichment the +// legacy SDK did server-side. Best-effort: a lookup failure yields a nil map and +// callers fall back to the numeric ID. +func resolveTeamMemberNames(rc *RunContext, items []gflashduty.TeamBriefItem) map[uint64]string { + seen := make(map[uint64]struct{}) + ids := make([]uint64, 0) + for _, it := range items { + for _, id := range it.PersonIDs { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil + } + resp, _, err := rc.GFClient.Members.PersonInfos(cmdContext(rc.Cmd), &gflashduty.PersonInfosRequest{PersonIDs: ids}) + if err != nil || resp == nil { + return nil + } + out := make(map[uint64]string, len(resp.Items)) + for _, p := range resp.Items { + out[p.PersonID] = p.PersonName + } + return out +} + +// resolveTeamMemberInfos resolves a team's member person IDs to display strings +// ("Name " when an email is present, otherwise the name), replicating the +// legacy member display for the team detail view. Best-effort: on lookup failure +// it returns nil and the caller falls back to numeric IDs. +func resolveTeamMemberInfos(rc *RunContext, personIDs []uint64) []string { + ids := make([]uint64, 0, len(personIDs)) + for _, id := range personIDs { + if id != 0 { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil + } + resp, _, err := rc.GFClient.Members.PersonInfos(cmdContext(rc.Cmd), &gflashduty.PersonInfosRequest{PersonIDs: ids}) + if err != nil || resp == nil { + return nil + } + members := make([]string, 0, len(resp.Items)) + for _, p := range resp.Items { + if p.Email != "" { + members = append(members, fmt.Sprintf("%s <%s>", p.PersonName, p.Email)) + } else { + members = append(members, p.PersonName) + } + } + return members +} + +// toUint64Slice converts a []int64 of person IDs to the []uint64 the +// go-flashduty team request structs expect. +func toUint64Slice(ids []int64) []uint64 { + if len(ids) == 0 { + return nil + } + out := make([]uint64, len(ids)) + for i, id := range ids { + out[i] = uint64(id) + } + return out +} + func identifierDescription(id int64, name, refID string) string { if id != 0 { return fmt.Sprintf("ID=%d", id) diff --git a/internal/cli/template.go b/internal/cli/template.go index 0de0b60..0298701 100644 --- a/internal/cli/template.go +++ b/internal/cli/template.go @@ -5,12 +5,50 @@ import ( "os" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" ) +// presetTemplateField returns the channel-specific source field from a +// go-flashduty TemplateItem. The field is selected by name out of the +// templateChannels map; TemplateItem exposes those same fields as named struct +// members, so this switch reproduces that selection with no behavior change. An +// unknown field name yields "". +func presetTemplateField(t *gflashduty.TemplateItem, fieldName string) string { + switch fieldName { + case "dingtalk": + return t.Dingtalk + case "dingtalk_app": + return t.DingtalkApp + case "feishu": + return t.Feishu + case "feishu_app": + return t.FeishuApp + case "wecom": + return t.Wecom + case "wecom_app": + return t.WecomApp + case "slack": + return t.Slack + case "slack_app": + return t.SlackApp + case "telegram": + return t.Telegram + case "teams_app": + return t.TeamsApp + case "email": + return t.Email + case "sms": + return t.SMS + case "zoom": + return t.Zoom + default: + return "" + } +} + func newTemplateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "template", @@ -30,14 +68,33 @@ func newTemplateGetPresetCmd() *cobra.Command { Use: "get-preset", Short: "Get the preset template for a channel", RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.GetPresetTemplate(cmdContext(ctx.Cmd), &flashduty.GetPresetTemplateInput{ - Channel: channel, + return runGFCommand(cmd, args, func(ctx *RunContext) error { + fieldName, ok := templateChannels[channel] + if !ok { + return fmt.Errorf("unknown channel: %s", channel) + } + + item, _, err := ctx.GFClient.NotificationTemplates.ReadInfo(cmdContext(ctx.Cmd), &gflashduty.TemplateIDRequest{ + TemplateID: presetTemplateID, }) if err != nil { return err } + templateCode := "" + if item != nil { + templateCode = presetTemplateField(item, fieldName) + } + if templateCode == "" { + return fmt.Errorf("no preset template found for channel: %s", channel) + } + + result := &presetTemplateResult{ + Channel: channel, + FieldName: fieldName, + TemplateCode: templateCode, + } + if ctx.Structured() { return ctx.Printer.Print(result, nil) } @@ -47,7 +104,7 @@ func newTemplateGetPresetCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&channel, "channel", "", "Notification channel (required). Values: "+strings.Join(flashduty.ChannelEnumValues(), ", ")) + cmd.Flags().StringVar(&channel, "channel", "", "Notification channel (required). Values: "+strings.Join(channelEnumValues(), ", ")) _ = cmd.MarkFlagRequired("channel") return cmd @@ -65,16 +122,71 @@ func newTemplateValidateCmd() *cobra.Command { return fmt.Errorf("failed to read template file: %w", err) } - return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.ValidateTemplate(cmdContext(ctx.Cmd), &flashduty.ValidateTemplateInput{ - Channel: channel, - TemplateCode: string(templateCode), - IncidentID: incidentID, + return runGFCommand(cmd, args, func(ctx *RunContext) error { + fieldName, ok := templateChannels[channel] + if !ok { + return fmt.Errorf("unknown channel: %s", channel) + } + + preview, _, err := ctx.GFClient.NotificationTemplates.ReadPreview(cmdContext(ctx.Cmd), &gflashduty.PreviewTemplateRequest{ + Content: string(templateCode), + Type: channel, + IncidentID: incidentID, }) if err != nil { return err } + // Reproduce the legacy SDK's client-side derivation of the + // validation result: the /template/preview endpoint only returns + // {success, content, message}; the size limit, errors, and + // warnings were all computed here from channelSizeLimits. + success := false + renderedPreview := "" + errorMessage := "" + if preview != nil { + success = preview.Success + renderedPreview = preview.Content + errorMessage = preview.Message + } + + renderedSize := len(renderedPreview) + sizeLimit := channelSizeLimits[channel] + + errs := []string{} + warnings := []string{} + + if !success { + errs = append(errs, errorMessage) + } + + if sizeLimit > 0 { + if renderedSize > sizeLimit { + sizeWarning := fmt.Sprintf("Rendered output is %d bytes, exceeding the %d byte limit for %s.", renderedSize, sizeLimit, channel) + switch channel { + case "telegram": + sizeWarning += " CRITICAL: Telegram will silently drop this message." + case "teams_app": + sizeWarning += " Teams will return an error for this message." + } + errs = append(errs, sizeWarning) + } else if renderedSize > int(float64(sizeLimit)*0.8) { + warnings = append(warnings, fmt.Sprintf("Rendered output is %d/%d bytes (%.0f%% of limit).", renderedSize, sizeLimit, float64(renderedSize)/float64(sizeLimit)*100)) + } + } + + result := &validateTemplateResult{ + Channel: channel, + FieldName: fieldName, + TemplateCode: string(templateCode), + Success: success && len(errs) == 0, + RenderedPreview: renderedPreview, + RenderedSize: renderedSize, + SizeLimit: sizeLimit, + Errors: errs, + Warnings: warnings, + } + if ctx.Structured() { return ctx.Printer.Print(result, nil) } @@ -100,7 +212,7 @@ func newTemplateValidateCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&channel, "channel", "", "Notification channel (required). Values: "+strings.Join(flashduty.ChannelEnumValues(), ", ")) + cmd.Flags().StringVar(&channel, "channel", "", "Notification channel (required). Values: "+strings.Join(channelEnumValues(), ", ")) cmd.Flags().StringVar(&file, "file", "", "Path to template file (required)") cmd.Flags().StringVar(&incidentID, "incident", "", "Real incident ID for preview (uses mock data if empty)") _ = cmd.MarkFlagRequired("channel") @@ -116,10 +228,10 @@ func newTemplateVariablesCmd() *cobra.Command { Use: "variables", Short: "List available template variables", RunE: func(cmd *cobra.Command, args []string) error { - vars := flashduty.TemplateVariables() + vars := templateVariables() if category != "" { - filtered := make([]flashduty.TemplateVariable, 0) + filtered := make([]templateVariable, 0) for _, v := range vars { if v.Category == category { filtered = append(filtered, v) @@ -129,10 +241,10 @@ func newTemplateVariablesCmd() *cobra.Command { } cols := []output.Column{ - {Header: "NAME", Field: func(v any) string { return v.(flashduty.TemplateVariable).Name }}, - {Header: "TYPE", Field: func(v any) string { return v.(flashduty.TemplateVariable).Type }}, - {Header: "DESCRIPTION", MaxWidth: 60, Field: func(v any) string { return v.(flashduty.TemplateVariable).Description }}, - {Header: "EXAMPLE", MaxWidth: 40, Field: func(v any) string { return v.(flashduty.TemplateVariable).Example }}, + {Header: "NAME", Field: func(v any) string { return v.(templateVariable).Name }}, + {Header: "TYPE", Field: func(v any) string { return v.(templateVariable).Type }}, + {Header: "DESCRIPTION", MaxWidth: 60, Field: func(v any) string { return v.(templateVariable).Description }}, + {Header: "EXAMPLE", MaxWidth: 40, Field: func(v any) string { return v.(templateVariable).Example }}, } return newPrinter(cmd.OutOrStdout()).Print(vars, cols) @@ -151,21 +263,21 @@ func newTemplateFunctionsCmd() *cobra.Command { Use: "functions", Short: "List available template functions", RunE: func(cmd *cobra.Command, args []string) error { - var funcs []flashduty.TemplateFunction + var funcs []templateFunction switch funcType { case "custom": - funcs = flashduty.TemplateCustomFunctions() + funcs = templateCustomFunctions() case "sprig": - funcs = flashduty.TemplateSprigFunctions() + funcs = templateSprigFunctions() default: - funcs = append(flashduty.TemplateCustomFunctions(), flashduty.TemplateSprigFunctions()...) + funcs = append(templateCustomFunctions(), templateSprigFunctions()...) } cols := []output.Column{ - {Header: "NAME", Field: func(v any) string { return v.(flashduty.TemplateFunction).Name }}, - {Header: "SYNTAX", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.TemplateFunction).Syntax }}, - {Header: "DESCRIPTION", MaxWidth: 60, Field: func(v any) string { return v.(flashduty.TemplateFunction).Description }}, + {Header: "NAME", Field: func(v any) string { return v.(templateFunction).Name }}, + {Header: "SYNTAX", MaxWidth: 50, Field: func(v any) string { return v.(templateFunction).Syntax }}, + {Header: "DESCRIPTION", MaxWidth: 60, Field: func(v any) string { return v.(templateFunction).Description }}, } return newPrinter(cmd.OutOrStdout()).Print(funcs, cols) diff --git a/internal/cli/templatemeta.go b/internal/cli/templatemeta.go new file mode 100644 index 0000000..5b2d5bf --- /dev/null +++ b/internal/cli/templatemeta.go @@ -0,0 +1,219 @@ +package cli + +import "slices" + +// Notification-template authoring reference data. +// +// This catalog (channels, size limits, variables, functions) describes the +// Flashduty template engine's capabilities. It is client-side reference data, +// not an API surface, so the generated go-flashduty SDK does not carry it — the +// CLI owns it directly. Platform-side additions require a CLI release. + +// presetTemplateID is the fixed MongoDB ObjectID for the system preset template. +const presetTemplateID = "6321aad26c12104586a88916" + +// templateChannels maps channel identifiers to TemplateItem field names. +var templateChannels = map[string]string{ + "dingtalk": "dingtalk", + "dingtalk_app": "dingtalk_app", + "feishu": "feishu", + "feishu_app": "feishu_app", + "wecom": "wecom", + "wecom_app": "wecom_app", + "slack": "slack", + "slack_app": "slack_app", + "telegram": "telegram", + "teams_app": "teams_app", + "email": "email", + "sms": "sms", + "zoom": "zoom", +} + +// channelSizeLimits defines the maximum rendered size per channel. +// 0 means no enforced limit. +var channelSizeLimits = map[string]int{ + "dingtalk": 4000, + "dingtalk_app": 0, + "feishu": 4000, + "feishu_app": 0, + "wecom": 4000, + "wecom_app": 0, + "slack": 15000, + "slack_app": 15000, + "telegram": 4096, + "teams_app": 28000, + "email": 0, + "sms": 0, + "zoom": 0, +} + +// channelEnumValues returns all valid notification channel identifiers, sorted. +func channelEnumValues() []string { + channels := make([]string, 0, len(templateChannels)) + for k := range templateChannels { + channels = append(channels, k) + } + slices.Sort(channels) + return channels +} + +// presetTemplateResult is the CLI output for `template get-preset`. +type presetTemplateResult struct { + Channel string `json:"channel"` + FieldName string `json:"field_name"` + TemplateCode string `json:"template_code"` +} + +// validateTemplateResult is the CLI output for `template validate`. +type validateTemplateResult struct { + Channel string `json:"channel"` + FieldName string `json:"field_name"` + TemplateCode string `json:"template_code"` + Success bool `json:"success"` + RenderedPreview string `json:"rendered_preview"` + RenderedSize int `json:"rendered_size"` + SizeLimit int `json:"size_limit"` + Errors []string `json:"errors"` + Warnings []string `json:"warnings"` +} + +// templateVariable describes one template variable for `template variables`. +type templateVariable struct { + Name string `json:"name" toon:"name"` + Type string `json:"type" toon:"type"` + Description string `json:"description" toon:"description"` + Example string `json:"example,omitempty" toon:"example,omitempty"` + Category string `json:"category" toon:"category"` +} + +// templateFunction describes one template function for `template functions`. +type templateFunction struct { + Name string `json:"name" toon:"name"` + Syntax string `json:"syntax" toon:"syntax"` + Description string `json:"description" toon:"description"` +} + +// templateVariables returns a copy of the available template variables. +func templateVariables() []templateVariable { + result := make([]templateVariable, len(templateVariableCatalog)) + copy(result, templateVariableCatalog) + return result +} + +// templateCustomFunctions returns a copy of the custom Flashduty template functions. +func templateCustomFunctions() []templateFunction { + result := make([]templateFunction, len(templateCustomFunctionCatalog)) + copy(result, templateCustomFunctionCatalog) + return result +} + +// templateSprigFunctions returns a copy of the commonly used Sprig template functions. +func templateSprigFunctions() []templateFunction { + result := make([]templateFunction, len(templateSprigFunctionCatalog)) + copy(result, templateSprigFunctionCatalog) + return result +} + +// --- Static Data --- + +var templateVariableCatalog = []templateVariable{ + // Core fields + {".Title", "string", "Incident title", "Order Message Failed", "core"}, + {".Description", "string", "Incident description", "Send order message failed too many times", "core"}, + {".Num", "string", "Short incident number", "ABC123", "core"}, + {".ID", "string", "Incident ID", "6321aad26c12104586a88916", "core"}, + {".IncidentSeverity", "string", "Severity level: Critical, Warning, Info, Ok", "Critical", "core"}, + {".IncidentStatus", "string", "Status code: Critical, Warning, Info, Ok", "Critical", "core"}, + {".Progress", "string", "Handling progress: Triggered, Processing, Closed", "Triggered", "core"}, + {".DetailUrl", "string", "Link to incident detail page", "https://console.flashcat.com/incident/detail/...", "core"}, + + // Time fields + {".StartTime", "int64", "Unix timestamp - incident start", "", "time"}, + {".LastTime", "int64", "Unix timestamp - last update", "", "time"}, + {".AckTime", "int64", "Unix timestamp - acknowledgement (0 if not acked)", "", "time"}, + {".CloseTime", "int64", "Unix timestamp - closure (0 if not closed)", "", "time"}, + {".SnoozedBefore", "int64", "Unix timestamp - snooze expiry", "", "time"}, + + // People fields + {".Creator", "*PersonItem", "Incident creator: {PersonID, PersonName, Email}", "", "people"}, + {".Closer", "*PersonItem", "Person who closed the incident", "", "people"}, + {".Owner", "*PersonItem", "Current incident owner", "", "people"}, + {".Responders", "[]*Responder", "List of responders: {PersonID, PersonName, Email, AssignedAt, AcknowledgedAt}", "", "people"}, + {".AssignedTo", "*AssignedTo", "Assignment info: {EscalateRuleID, EscalateRuleName, LayerIdx, Type}", "", "people"}, + + // Alert aggregation + {".AlertCnt", "int64", "Total associated alerts count", "10", "alerts"}, + {".ActiveAlertCnt", "int64", "Active (non-resolved) alerts count", "9", "alerts"}, + {".AlertEventCnt", "int64", "Total alert events count", "30", "alerts"}, + {".Alerts", "[]*AlertItem", "Alert list: {Title, Description, AlertSeverity, AlertStatus, StartTime, LastTime, EndTime, Labels}", "", "alerts"}, + + // Labels and custom data + {".Labels", "map[string]string", "Alert label key-value pairs. Access via .Labels.key or index .Labels \"dotted.key\"", "", "labels"}, + {".Fields", "map[string]interface{}", "Custom incident fields", "", "labels"}, + {".Images", "[]Image", "Associated images: {Src, Alt}", "", "labels"}, + + // Context fields + {".ChannelName", "string", "Collaboration space name", "Order system", "context"}, + {".ChannelID", "int64", "Collaboration space ID", "", "context"}, + {".AccountName", "string", "Account/organization name", "Flashduty", "context"}, + {".AccountLocale", "string", "Locale: zh-CN or en-US", "zh-CN", "context"}, + {".AccountTimeZone", "string", "Account timezone", "", "context"}, + + // Notification fields + {".FireType", "string", "Notification type: fire (initial) or refire (recurring)", "fire", "notification"}, + {".FireTimes", "int64", "Number of times notified", "", "notification"}, + {".IsFlapping", "bool", "Whether in flapping state", "true", "notification"}, + {".IsInStorm", "bool", "Whether in alert storm", "false", "notification"}, + {".Flapping", "*Flapping", "Flapping config: {MaxChanges, InMinutes, MuteMinutes}", "", "notification"}, + {".GroupMethod", "string", "Grouping method: n (none), p (by rule), i (intelligent)", "i", "notification"}, + + // Post-incident fields + {".Impact", "string", "Impact description", "", "post_incident"}, + {".RootCause", "string", "Root cause", "", "post_incident"}, + {".Resolution", "string", "Resolution description", "", "post_incident"}, + {".AISummary", "string", "AI-generated incident summary", "", "post_incident"}, +} + +var templateCustomFunctionCatalog = []templateFunction{ + {"date", `{{date "2006-01-02 15:04:05" .StartTime}}`, "Format Unix timestamp using Go time layout"}, + {"ago", `{{ago .StartTime}}`, "Human-readable duration since timestamp (e.g., '2 hours ago')"}, + {"toHtml", `{{toHtml .Title}}`, "HTML-escape special characters; accepts multiple args, uses first non-empty"}, + {"fireReason", `{{fireReason .}}`, "Returns notification type prefix: [REFIRE], [ESCALATE], etc."}, + {"colorSeverity", `{{colorSeverity .IncidentSeverity}}`, "Severity with markup for chat platforms"}, + {"colorBySeverity", `{{colorBySeverity .IncidentSeverity "text"}}`, "Color any text using severity-based color"}, + {"serverityToColor", `{{serverityToColor .IncidentSeverity}}`, "Returns hex color: #C80000 (Critical), #FA7D00 (Warning), #FABE00 (Info), #008800 (Ok)"}, + {"toSeverity", `{{toSeverity .IncidentSeverity}}`, "Severity to localized display string"}, + {"joinAlertLabels", `{{joinAlertLabels . "resource" ", "}}`, "Deduplicate and join a label's values from all alerts"}, + {"alertLabels", `{{alertLabels . "resource"}}`, "Return deduplicated label values as array"}, + {"maxAlertLabel", `{{maxAlertLabel . "trigger_value"}}`, "Max value of a label across alerts"}, + {"minAlertLabel", `{{minAlertLabel . "trigger_value"}}`, "Min value of a label across alerts"}, + {"in", `{{in $k "resource" "body_text"}}`, "Check if value is in a set of values"}, + {"mdToHtml", `{{mdToHtml .Description}}`, "Convert Markdown to sanitized HTML"}, + {"transferImage", `{{transferImage $root $v.Src}}`, "Upload image to Feishu (Feishu App only)"}, + {"imageSrcToURL", `{{imageSrcToURL $root $v.Src}}`, "Convert image key to accessible URL (DingTalk, Slack)"}, + {"imageAltToURL", `{{imageAltToURL $root $v.Alt}}`, "Get image URL by alt text"}, + {"jsonGet", `{{jsonGet .Labels.rule_note "detail_url"}}`, "Parse JSON string and extract via gjson path syntax"}, + {"index", `{{index .Labels "dotted.key"}}`, "Access map keys containing dots"}, +} + +var templateSprigFunctionCatalog = []templateFunction{ + {"trim", `{{trim .Title}}`, "Remove leading/trailing whitespace"}, + {"upper", `{{upper .IncidentSeverity}}`, "Convert to uppercase"}, + {"lower", `{{lower .IncidentSeverity}}`, "Convert to lowercase"}, + {"replace", `{{replace "old" "new" .Title}}`, "Replace all occurrences"}, + {"contains", `{{contains "error" .Title}}`, "Check if string contains substring"}, + {"default", `{{default "N/A" .Description}}`, "Return default value if empty"}, + {"ternary", `{{ternary "yes" "no" .IsFlapping}}`, "Ternary operator"}, + {"add", `{{add .AlertCnt 1}}`, "Add numbers"}, + {"sub", `{{sub .AlertCnt 1}}`, "Subtract numbers"}, + {"len", `{{len .Responders}}`, "Length of list/map/string"}, + {"list", `{{list "a" "b" "c"}}`, "Create a list"}, + {"dict", `{{dict "key" "value"}}`, "Create a dictionary"}, + {"hasKey", `{{hasKey .Labels "resource"}}`, "Check if map has key"}, + {"keys", `{{keys .Labels}}`, "Get map keys"}, + {"values", `{{values .Labels}}`, "Get map values"}, + {"empty", `{{empty .Description}}`, "Check if value is empty/zero"}, + {"coalesce", `{{coalesce .Description "No description"}}`, "Return first non-empty value"}, + {"toString", `{{toString .AlertCnt}}`, "Convert to string"}, + {"toInt64", `{{toInt64 "123"}}`, "Convert to int64"}, +} diff --git a/internal/cli/whoami.go b/internal/cli/whoami.go index c652035..ebaf3c3 100644 --- a/internal/cli/whoami.go +++ b/internal/cli/whoami.go @@ -11,7 +11,7 @@ func newWhoamiCmd() *cobra.Command { Use: "whoami", Short: "Show the current authenticated identity", RunE: func(cmd *cobra.Command, args []string) error { - client, err := newClient() + client, err := newGFClient() if err != nil { return err } diff --git a/internal/output/structured_time_test.go b/internal/output/structured_time_test.go index 6839b4b..66ef8aa 100644 --- a/internal/output/structured_time_test.go +++ b/internal/output/structured_time_test.go @@ -5,23 +5,23 @@ import ( "strings" "testing" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" ) -// row carries a typed flashduty.Timestamp field so we can prove the structured +// row carries a typed go-flashduty Timestamp field so we can prove the structured // printers render it as RFC3339 rather than a raw epoch integer. type row struct { - StartTime flashduty.Timestamp `json:"start_time" toon:"start_time"` + StartTime gflashduty.Timestamp `json:"start_time" toon:"start_time"` } // TestStructuredTimeIsRFC3339 is the regression guard for the typed-timestamp // SDK adoption: both the JSON and TOON printers must serialize a -// flashduty.Timestamp as a human-/LLM-readable RFC3339 string, never as the +// go-flashduty Timestamp as a human-/LLM-readable RFC3339 string, never as the // opaque Unix epoch integer. func TestStructuredTimeIsRFC3339(t *testing.T) { // 2026-05-28T08:00:00Z — fixed so we can assert the raw epoch is absent. const epochSec = 1779955200 - data := row{StartTime: flashduty.Timestamp(epochSec)} + data := row{StartTime: gflashduty.Timestamp(epochSec)} cases := []struct { name string diff --git a/internal/output/table.go b/internal/output/table.go index 8261438..077366c 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -90,7 +90,7 @@ func Truncate(s string, maxLen int) string { return runewidth.Truncate(s, maxLen, "...") } -// instant is satisfied by flashduty.Timestamp and flashduty.TimestampMilli. +// instant is satisfied by go-flashduty's Timestamp and TimestampMilli. type instant interface { Time() time.Time IsZero() bool diff --git a/internal/output/table_test.go b/internal/output/table_test.go index b31d22e..d9e890c 100644 --- a/internal/output/table_test.go +++ b/internal/output/table_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - flashduty "github.com/flashcatcloud/flashduty-sdk" + gflashduty "github.com/flashcatcloud/go-flashduty" ) // --------------------------------------------------------------------------- @@ -55,7 +55,7 @@ func TestTruncate(t *testing.T) { func TestFormatTime(t *testing.T) { t.Run("zero returns dash", func(t *testing.T) { // 25 - got := FormatTime(flashduty.Timestamp(0)) + got := FormatTime(gflashduty.Timestamp(0)) if got != "-" { t.Errorf("FormatTime(0) = %q, want %q", got, "-") } @@ -65,7 +65,7 @@ func TestFormatTime(t *testing.T) { // 26 const ts int64 = 1712000000 want := time.Unix(ts, 0).Local().Format("2006-01-02 15:04") - got := FormatTime(flashduty.Timestamp(ts)) + got := FormatTime(gflashduty.Timestamp(ts)) if got != want { t.Errorf("FormatTime(%d) = %q, want %q", ts, got, want) } From 95abf95aba7ef8c7991ecf393ff5ab94fa272128 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sun, 31 May 2026 16:06:45 +0800 Subject: [PATCH 07/10] fix(cli): drop unused boolPtr helper (golangci-lint unused) --- internal/cli/incident.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 5ae2413..05c62ff 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -578,9 +578,6 @@ func newIncidentSimilarCmd() *cobra.Command { return cmd } -// boolPtr returns a pointer to the given bool value. -func boolPtr(b bool) *bool { return &b } - // parseIntSlice converts a comma-separated string to []int64. func parseIntSlice(s string) ([]int64, error) { if s == "" { From 633b7ab8f691a02003b94f3e90ef6064e061e699 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sun, 31 May 2026 17:49:56 +0800 Subject: [PATCH 08/10] feat: adopt go-flashduty v0.5.0 pointer request fields go-flashduty v0.5.0 turns tri-state request fields into Go pointers so the zero value (false/0) reaches the wire instead of being dropped by omitempty. Adopt it in the CLI consumers the compiler flagged: - alert list: --active -> IsActive=Bool(true), --recovered -> IsActive=Bool(false), --muted -> EverMuted=Bool(true). --recovered (is_active=false) now actually filters instead of being silently dropped. Also drop the dead --title no-op flag (no title filter on the API; 'follow the API'). - incident similar: Limit -> Int64(limit) (flag default 5, always sent). - statuspage migrate structure: forward --url-name as URLName=String(...) when provided; nil reuses the source page's name. Was previously rejected because the SDK lacked the field; v0.5.0 adds it. Wire-body regression tests added for alert is_active/ever_muted, the no-filter omit case, incident similar limit, and migrate url_name forwarding. --- go.mod | 2 +- go.sum | 2 + internal/cli/alert.go | 18 ++----- internal/cli/alert_test.go | 62 ++++++++++++++++++++++++ internal/cli/incident.go | 2 +- internal/cli/incident_test.go | 28 +++++++++++ internal/cli/status_page_migrate.go | 16 +++--- internal/cli/status_page_migrate_test.go | 29 ++++++----- 8 files changed, 120 insertions(+), 39 deletions(-) create mode 100644 internal/cli/alert_test.go create mode 100644 internal/cli/incident_test.go diff --git a/go.mod b/go.mod index df8f71b..bca014b 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/go-flashduty v0.4.1 + github.com/flashcatcloud/go-flashduty v0.5.0 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 diff --git a/go.sum b/go.sum index 3fead38..64ccc87 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/flashcatcloud/go-flashduty v0.4.1 h1:W3DjcrClkkXB8D+cYVVn/Nt4g0S3KLBQwZ63uHawD/k= github.com/flashcatcloud/go-flashduty v0.4.1/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= +github.com/flashcatcloud/go-flashduty v0.5.0 h1:rQBuW8JytBRH+xtfiQLuRzcKOs96B2+vdCozuaCMlaI= +github.com/flashcatcloud/go-flashduty v0.5.0/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= diff --git a/internal/cli/alert.go b/internal/cli/alert.go index b9c8918..2e68136 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -27,7 +27,7 @@ func newAlertCmd() *cobra.Command { } func newAlertListCmd() *cobra.Command { - var severity, channel, title, since, until string + var severity, channel, since, until string var active, recovered, muted bool var limit, page int @@ -60,13 +60,13 @@ func newAlertListCmd() *cobra.Command { // Preserve legacy semantics: --active sends is_active=true, // --recovered sends is_active=false, neither omits the filter. if active { - req.IsActive = true + req.IsActive = gflashduty.Bool(true) } else if recovered { - req.IsActive = false + req.IsActive = gflashduty.Bool(false) } if muted { - req.EverMuted = true + req.EverMuted = gflashduty.Bool(true) } if channel != "" { @@ -77,15 +77,6 @@ func newAlertListCmd() *cobra.Command { req.ChannelIDs = channelIDs } - if title != "" { - // go-flashduty's AlertListRequest has no dedicated title - // filter; the legacy SDK's title search maps to nothing on - // the generated request. Title-only filtering is dropped in - // the migration (see migration notes). Kept here as a no-op - // to retain the flag for compatibility. - _ = title - } - result, _, err := ctx.GFClient.Alerts.ReadList(cmdContext(ctx.Cmd), req) if err != nil { return err @@ -111,7 +102,6 @@ func newAlertListCmd() *cobra.Command { cmd.Flags().BoolVar(&recovered, "recovered", false, "Show recovered only") cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs") cmd.Flags().BoolVar(&muted, "muted", false, "Show ever-muted only") - cmd.Flags().StringVar(&title, "title", "", "Search by title keyword") cmd.Flags().StringVar(&since, "since", "24h", "Start time") cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results") diff --git a/internal/cli/alert_test.go b/internal/cli/alert_test.go new file mode 100644 index 0000000..dedccdc --- /dev/null +++ b/internal/cli/alert_test.go @@ -0,0 +1,62 @@ +package cli + +import ( + "testing" +) + +// TestCommandAlertListActiveRecoveredReachWire is the regression guard for the +// nullable-pointer bug: is_active and ever_muted are *bool in the SDK, so the +// false value must reach the wire. Before the fix they were value+omitempty and +// --recovered (is_active=false) was silently dropped, turning the filter into a +// no-op that returned active alerts too. +func TestCommandAlertListActiveRecoveredReachWire(t *testing.T) { + cases := []struct { + name string + flag string + field string + wantBool bool + }{ + {"active sends is_active=true", "--active", "is_active", true}, + {"recovered sends is_active=false", "--recovered", "is_active", false}, + {"muted sends ever_muted=true", "--muted", "ever_muted", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + if _, err := execCommand("alert", "list", tc.flag); err != nil { + t.Fatalf("execCommand: %v", err) + } + got, ok := stub.lastBody[tc.field] + if !ok { + t.Fatalf("%s missing from wire body %#v", tc.field, stub.lastBody) + } + gotBool, isBool := got.(bool) + if !isBool { + t.Fatalf("%s = %#v (%T), want a JSON bool", tc.field, got, got) + } + if gotBool != tc.wantBool { + t.Errorf("%s = %v, want %v", tc.field, gotBool, tc.wantBool) + } + }) + } +} + +// TestCommandAlertListNoStatusFilterOmitsIsActive: with neither --active nor +// --recovered, is_active is a nil *bool and omitempty keeps it off the wire, so +// the server applies no status filter. +func TestCommandAlertListNoStatusFilterOmitsIsActive(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + if _, err := execCommand("alert", "list"); err != nil { + t.Fatalf("execCommand: %v", err) + } + if _, ok := stub.lastBody["is_active"]; ok { + t.Errorf("is_active should be omitted with no status filter, got %#v", stub.lastBody["is_active"]) + } + if _, ok := stub.lastBody["ever_muted"]; ok { + t.Errorf("ever_muted should be omitted without --muted, got %#v", stub.lastBody["ever_muted"]) + } +} diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 05c62ff..391f02c 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -558,7 +558,7 @@ func newIncidentSimilarCmd() *cobra.Command { return runGFCommand(cmd, args, func(ctx *RunContext) error { result, _, err := ctx.GFClient.Incidents.PastList(cmdContext(ctx.Cmd), &gflashduty.ListPastIncidentsRequest{ IncidentID: ctx.Args[0], - Limit: int64(limit), + Limit: gflashduty.Int64(int64(limit)), }) if err != nil { return err diff --git a/internal/cli/incident_test.go b/internal/cli/incident_test.go new file mode 100644 index 0000000..0b9a6cf --- /dev/null +++ b/internal/cli/incident_test.go @@ -0,0 +1,28 @@ +package cli + +import ( + "testing" +) + +// TestCommandIncidentSimilarLimitReachesWire guards the *int64 Limit field on +// ListPastIncidentsRequest: --limit must reach the wire body (it is wrapped +// with gflashduty.Int64). The command's --limit default is 5, never 0, so the +// value is always sent. +func TestCommandIncidentSimilarLimitReachesWire(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + if _, err := execCommand("incident", "similar", "inc-1", "--limit", "7"); err != nil { + t.Fatalf("execCommand: %v", err) + } + if stub.lastPath != "/incident/past/list" { + t.Fatalf("path = %q, want /incident/past/list", stub.lastPath) + } + // JSON numbers decode to float64 through the stub. + if got, _ := stub.lastBody["limit"].(float64); got != 7 { + t.Errorf("limit = %#v, want 7", stub.lastBody["limit"]) + } + if stub.lastBody["incident_id"] != "inc-1" { + t.Errorf("incident_id = %#v, want inc-1", stub.lastBody["incident_id"]) + } +} diff --git a/internal/cli/status_page_migrate.go b/internal/cli/status_page_migrate.go index 48c4237..b1d7309 100644 --- a/internal/cli/status_page_migrate.go +++ b/internal/cli/status_page_migrate.go @@ -34,17 +34,17 @@ func newStatusPageMigrateStructureCmd() *cobra.Command { if err := validateMigrationSource(source); err != nil { return err } - // go-flashduty's MigrateStatusPageStructureRequest carries no - // url_name field, so we cannot forward --url-name without silently - // dropping it. Reject the flag explicitly rather than ignore it. - if urlName != "" { - return fmt.Errorf("--url-name is not supported by this command") - } return runGFCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.GFClient.StatusPages.MigrateStructure(cmdContext(ctx.Cmd), &gflashduty.MigrateStatusPageStructureRequest{ + req := &gflashduty.MigrateStatusPageStructureRequest{ APIKey: sourceAPIKey, SourcePageID: sourcePageID, - }) + } + // url_name is *string (tri-state): set it only when the user + // provided one, so a nil pointer reuses the source page's name. + if urlName != "" { + req.URLName = gflashduty.String(urlName) + } + result, _, err := ctx.GFClient.StatusPages.MigrateStructure(cmdContext(ctx.Cmd), req) if err != nil { return err } diff --git a/internal/cli/status_page_migrate_test.go b/internal/cli/status_page_migrate_test.go index f2bd0f7..699318d 100644 --- a/internal/cli/status_page_migrate_test.go +++ b/internal/cli/status_page_migrate_test.go @@ -36,10 +36,10 @@ func TestCommandStatusPageMigrateStructureSendsSDKInput(t *testing.T) { if stub.lastBody["source_page_id"] != "src-1" { t.Errorf("source_page_id = %v, want src-1", stub.lastBody["source_page_id"]) } - // go-flashduty's request struct carries no url_name field, so the wire body - // must never contain one. + // url_name is an optional *string; when --url-name is not passed it stays + // nil and omitempty keeps it off the wire. if _, ok := stub.lastBody["url_name"]; ok { - t.Errorf("url_name should not be sent, got %#v", stub.lastBody["url_name"]) + t.Errorf("url_name should not be sent when --url-name is omitted, got %#v", stub.lastBody["url_name"]) } if !strings.Contains(out, "Job ID: job-1") { t.Errorf("missing job id in output:\n%s", out) @@ -49,14 +49,13 @@ func TestCommandStatusPageMigrateStructureSendsSDKInput(t *testing.T) { } } -// TestCommandStatusPageMigrateStructureRejectsURLName: go-flashduty's -// MigrateStatusPageStructureRequest has no url_name field, so the migrated -// command rejects --url-name rather than silently dropping it. (Legacy -// forwarded URLName to the SDK; the port cannot, so behavior changed from -// "forward" to "reject before the client call".) -func TestCommandStatusPageMigrateStructureRejectsURLName(t *testing.T) { +// TestCommandStatusPageMigrateStructureForwardsURLName: MigrateStatusPageStructureRequest +// now carries url_name (*string), so --url-name is forwarded to the SDK as the +// url_name wire field — matching legacy behavior. +func TestCommandStatusPageMigrateStructureForwardsURLName(t *testing.T) { saveAndResetGlobals(t) stub := newGFStub(t) + stub.data = map[string]any{"job_id": "job-2"} _, err := execCommand("statuspage", "migrate", "structure", "--from", "atlassian", @@ -64,14 +63,14 @@ func TestCommandStatusPageMigrateStructureRejectsURLName(t *testing.T) { "--api-key", "atlassian-secret", "--url-name", "customer-facing-status", ) - if err == nil { - t.Fatal("expected error rejecting --url-name") + if err != nil { + t.Fatalf("execCommand: %v", err) } - if !strings.Contains(err.Error(), "--url-name is not supported") { - t.Errorf("got %v; want --url-name rejection", err) + if stub.requests != 1 { + t.Errorf("expected exactly 1 request, got %d", stub.requests) } - if stub.requests != 0 { - t.Errorf("client must not be called when --url-name is rejected, got %d request(s)", stub.requests) + if stub.lastBody["url_name"] != "customer-facing-status" { + t.Errorf("url_name = %#v, want customer-facing-status", stub.lastBody["url_name"]) } } From a7307493b8af8a1b2344e4b86971a1dce8022d25 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sun, 31 May 2026 18:22:28 +0800 Subject: [PATCH 09/10] refactor: drop transitional gflashduty alias and GF-prefixed names The dual-client migration is complete; there is one client (go-flashduty). Import it un-aliased as `flashduty`; rename newGFClient/runGFCommand/ RunContext.GFClient to newClient/runCommand/Client. Pure rename, no behavior change. --- internal/cli/alert.go | 70 ++++---- internal/cli/alert_event.go | 20 +-- internal/cli/audit.go | 22 +-- internal/cli/change.go | 18 +- internal/cli/channel.go | 10 +- internal/cli/command.go | 16 +- internal/cli/command_test.go | 4 +- internal/cli/escalation_rule.go | 20 +-- internal/cli/field.go | 18 +- internal/cli/gfstub_test.go | 10 +- internal/cli/helpers.go | 8 +- internal/cli/identity.go | 4 +- internal/cli/incident.go | 216 +++++++++++------------ internal/cli/incident_test.go | 2 +- internal/cli/insight.go | 88 ++++----- internal/cli/login.go | 4 +- internal/cli/mcp.go | 8 +- internal/cli/member.go | 18 +- internal/cli/monit_agent.go | 14 +- internal/cli/monit_query.go | 18 +- internal/cli/oncall.go | 56 +++--- internal/cli/oncall_test.go | 12 +- internal/cli/postmortem.go | 18 +- internal/cli/root.go | 26 +-- internal/cli/status_page.go | 48 ++--- internal/cli/status_page_migrate.go | 26 +-- internal/cli/status_page_migrate_test.go | 8 +- internal/cli/team.go | 40 ++--- internal/cli/template.go | 12 +- internal/cli/whoami.go | 2 +- internal/output/structured_time_test.go | 6 +- internal/output/table_test.go | 6 +- 32 files changed, 424 insertions(+), 424 deletions(-) diff --git a/internal/cli/alert.go b/internal/cli/alert.go index 2e68136..85078a1 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -35,7 +35,7 @@ func newAlertListCmd() *cobra.Command { Use: "list", Short: "List alerts", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { if active && recovered { return fmt.Errorf("--active and --recovered are mutually exclusive") } @@ -49,7 +49,7 @@ func newAlertListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - req := &gflashduty.AlertListRequest{ + req := &flashduty.AlertListRequest{ StartTime: startTime, EndTime: endTime, AlertSeverity: severity, @@ -60,13 +60,13 @@ func newAlertListCmd() *cobra.Command { // Preserve legacy semantics: --active sends is_active=true, // --recovered sends is_active=false, neither omits the filter. if active { - req.IsActive = gflashduty.Bool(true) + req.IsActive = flashduty.Bool(true) } else if recovered { - req.IsActive = gflashduty.Bool(false) + req.IsActive = flashduty.Bool(false) } if muted { - req.EverMuted = gflashduty.Bool(true) + req.EverMuted = flashduty.Bool(true) } if channel != "" { @@ -77,19 +77,19 @@ func newAlertListCmd() *cobra.Command { req.ChannelIDs = channelIDs } - result, _, err := ctx.GFClient.Alerts.ReadList(cmdContext(ctx.Cmd), req) + result, _, err := ctx.Client.Alerts.ReadList(cmdContext(ctx.Cmd), req) if err != nil { return err } cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(gflashduty.AlertItem).AlertID }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.AlertItem).Title }}, - {Header: "SEVERITY", Field: func(v any) string { return v.(gflashduty.AlertItem).AlertSeverity }}, - {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.AlertItem).AlertStatus }}, - {Header: "EVENTS", Field: func(v any) string { return fmt.Sprintf("%d", v.(gflashduty.AlertItem).EventCnt) }}, - {Header: "CHANNEL", Field: func(v any) string { return v.(gflashduty.AlertItem).ChannelName }}, - {Header: "STARTED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.AlertItem).StartTime) }}, + {Header: "ID", Field: func(v any) string { return v.(flashduty.AlertItem).AlertID }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertItem).Title }}, + {Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.AlertItem).AlertSeverity }}, + {Header: "STATUS", Field: func(v any) string { return v.(flashduty.AlertItem).AlertStatus }}, + {Header: "EVENTS", Field: func(v any) string { return fmt.Sprintf("%d", v.(flashduty.AlertItem).EventCnt) }}, + {Header: "CHANNEL", Field: func(v any) string { return v.(flashduty.AlertItem).ChannelName }}, + {Header: "STARTED", Field: func(v any) string { return output.FormatTime(v.(flashduty.AlertItem).StartTime) }}, } return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) @@ -116,8 +116,8 @@ func newAlertGetCmd() *cobra.Command { Short: "Get alert detail", Args: requireArgs("alert_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.GFClient.Alerts.ReadInfo(cmdContext(ctx.Cmd), &gflashduty.AlertInfoRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.Client.Alerts.ReadInfo(cmdContext(ctx.Cmd), &flashduty.AlertInfoRequest{ AlertID: ctx.Args[0], }) if err != nil { @@ -135,7 +135,7 @@ func newAlertGetCmd() *cobra.Command { } } -func printAlertDetail(w io.Writer, a *gflashduty.AlertItem) { +func printAlertDetail(w io.Writer, a *flashduty.AlertItem) { if a == nil { return } @@ -178,8 +178,8 @@ func newAlertEventsCmd() *cobra.Command { Short: "List alert events", Args: requireArgs("alert_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.GFClient.Alerts.ReadEventList(cmdContext(ctx.Cmd), &gflashduty.AlertEventListRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.Client.Alerts.ReadEventList(cmdContext(ctx.Cmd), &flashduty.AlertEventListRequest{ AlertID: ctx.Args[0], }) if err != nil { @@ -192,11 +192,11 @@ func newAlertEventsCmd() *cobra.Command { } cols := []output.Column{ - {Header: "EVENT_ID", Field: func(v any) string { return v.(gflashduty.AlertEventItem).EventID }}, - {Header: "SEVERITY", Field: func(v any) string { return v.(gflashduty.AlertEventItem).EventSeverity }}, - {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.AlertEventItem).EventStatus }}, - {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(gflashduty.AlertEventItem).EventTime) }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.AlertEventItem).Title }}, + {Header: "EVENT_ID", Field: func(v any) string { return v.(flashduty.AlertEventItem).EventID }}, + {Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.AlertEventItem).EventSeverity }}, + {Header: "STATUS", Field: func(v any) string { return v.(flashduty.AlertEventItem).EventStatus }}, + {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.AlertEventItem).EventTime) }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEventItem).Title }}, } return ctx.PrintTotal(result.Items, cols, len(result.Items)) @@ -213,12 +213,12 @@ func newAlertTimelineCmd() *cobra.Command { Short: "View alert timeline", Args: requireArgs("alert_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - req := &gflashduty.AlertFeedRequest{AlertID: ctx.Args[0]} + return runCommand(cmd, args, func(ctx *RunContext) error { + req := &flashduty.AlertFeedRequest{AlertID: ctx.Args[0]} req.Limit = limit req.Page = page - result, _, err := ctx.GFClient.Alerts.ReadFeed(cmdContext(ctx.Cmd), req) + result, _, err := ctx.Client.Alerts.ReadFeed(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -236,10 +236,10 @@ func newAlertTimelineCmd() *cobra.Command { nameByID := resolveAlertFeedOperators(ctx, result.Items) cols := []output.Column{ - {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(gflashduty.FeedItem).CreatedAt) }}, - {Header: "TYPE", Field: func(v any) string { return string(v.(gflashduty.FeedItem).Type) }}, + {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.FeedItem).CreatedAt) }}, + {Header: "TYPE", Field: func(v any) string { return string(v.(flashduty.FeedItem).Type) }}, {Header: "OPERATOR", Field: func(v any) string { - it := v.(gflashduty.FeedItem) + it := v.(flashduty.FeedItem) if it.CreatorID == 0 { return "system" } @@ -249,7 +249,7 @@ func newAlertTimelineCmd() *cobra.Command { return strconv.FormatInt(it.CreatorID, 10) }}, {Header: "DETAIL", MaxWidth: 80, Field: func(v any) string { - d := v.(gflashduty.FeedItem).Detail + d := v.(flashduty.FeedItem).Detail if d == nil { return "-" } @@ -272,7 +272,7 @@ func newAlertTimelineCmd() *cobra.Command { // alert-feed items to display names via /person/infos, replicating the // operator-name enrichment the legacy SDK did server-side. Best-effort: a // lookup failure yields a nil map and callers fall back to the numeric ID. -func resolveAlertFeedOperators(rc *RunContext, items []gflashduty.FeedItem) map[int64]string { +func resolveAlertFeedOperators(rc *RunContext, items []flashduty.FeedItem) map[int64]string { seen := make(map[int64]struct{}, len(items)) ids := make([]uint64, 0, len(items)) for _, it := range items { @@ -288,7 +288,7 @@ func resolveAlertFeedOperators(rc *RunContext, items []gflashduty.FeedItem) map[ if len(ids) == 0 { return nil } - resp, _, err := rc.GFClient.Members.PersonInfos(cmdContext(rc.Cmd), &gflashduty.PersonInfosRequest{PersonIDs: ids}) + resp, _, err := rc.Client.Members.PersonInfos(cmdContext(rc.Cmd), &flashduty.PersonInfosRequest{PersonIDs: ids}) if err != nil || resp == nil { return nil } @@ -307,8 +307,8 @@ func newAlertMergeCmd() *cobra.Command { Short: "Merge alerts into an incident", Args: requireArgs("alert_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - if _, err := ctx.GFClient.Alerts.WriteMerge(cmdContext(ctx.Cmd), &gflashduty.AlertMergeRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.Client.Alerts.WriteMerge(cmdContext(ctx.Cmd), &flashduty.AlertMergeRequest{ AlertIDs: ctx.Args, IncidentID: incidentID, Comment: comment, diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index f93a7c6..c8e49c6 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -28,7 +28,7 @@ func newAlertEventListCmd() *cobra.Command { Use: "list", Short: "List alert events globally", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -38,7 +38,7 @@ func newAlertEventListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &gflashduty.AlertEventGlobalListRequest{ + input := &flashduty.AlertEventGlobalListRequest{ StartTime: startTime, EndTime: endTime, } @@ -62,18 +62,18 @@ func newAlertEventListCmd() *cobra.Command { input.IntegrationTypes = parseStringSlice(integrationType) } - result, _, err := ctx.GFClient.Alerts.EventReadList(cmdContext(ctx.Cmd), input) + result, _, err := ctx.Client.Alerts.EventReadList(cmdContext(ctx.Cmd), input) if err != nil { return err } cols := []output.Column{ - {Header: "EVENT_ID", Field: func(v any) string { return v.(gflashduty.AlertEventItem).EventID }}, - {Header: "ALERT_ID", Field: func(v any) string { return v.(gflashduty.AlertEventItem).AlertID }}, - {Header: "SEVERITY", Field: func(v any) string { return v.(gflashduty.AlertEventItem).EventSeverity }}, - {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.AlertEventItem).EventStatus }}, - {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(gflashduty.AlertEventItem).EventTime) }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.AlertEventItem).Title }}, + {Header: "EVENT_ID", Field: func(v any) string { return v.(flashduty.AlertEventItem).EventID }}, + {Header: "ALERT_ID", Field: func(v any) string { return v.(flashduty.AlertEventItem).AlertID }}, + {Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.AlertEventItem).EventSeverity }}, + {Header: "STATUS", Field: func(v any) string { return v.(flashduty.AlertEventItem).EventStatus }}, + {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.AlertEventItem).EventTime) }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEventItem).Title }}, } return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) diff --git a/internal/cli/audit.go b/internal/cli/audit.go index 8a10055..bc6c2ca 100644 --- a/internal/cli/audit.go +++ b/internal/cli/audit.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -28,7 +28,7 @@ func newAuditSearchCmd() *cobra.Command { Use: "search", Short: "Search audit logs", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -38,7 +38,7 @@ func newAuditSearchCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &gflashduty.AuditSearchRequest{ + input := &flashduty.AuditSearchRequest{ StartTime: startTime, EndTime: endTime, Limit: int64(limit), @@ -49,12 +49,12 @@ func newAuditSearchCmd() *cobra.Command { } var ( - result *gflashduty.AuditSearchResponse + result *flashduty.AuditSearchResponse cursor string ) for currentPage := 1; currentPage <= page; currentPage++ { input.SearchAfterCtx = cursor - result, _, err = ctx.GFClient.AuditLogs.Search(cmdContext(ctx.Cmd), input) + result, _, err = ctx.Client.AuditLogs.Search(cmdContext(ctx.Cmd), input) if err != nil { return err } @@ -62,8 +62,8 @@ func newAuditSearchCmd() *cobra.Command { break } if result.SearchAfterCtx == "" { - result = &gflashduty.AuditSearchResponse{ - Docs: []gflashduty.AuditLog{}, + result = &flashduty.AuditSearchResponse{ + Docs: []flashduty.AuditLog{}, Total: result.Total, } break @@ -73,24 +73,24 @@ func newAuditSearchCmd() *cobra.Command { cols := []output.Column{ {Header: "TIME", Field: func(v any) string { - return output.FormatTime(v.(gflashduty.AuditLog).CreatedAt) + return output.FormatTime(v.(flashduty.AuditLog).CreatedAt) }}, {Header: "PERSON", MaxWidth: 20, Field: func(v any) string { - r := v.(gflashduty.AuditLog) + r := v.(flashduty.AuditLog) if r.MemberName != "" { return r.MemberName } return fmt.Sprintf("%d", r.MemberID) }}, {Header: "OPERATION", MaxWidth: 30, Field: func(v any) string { - r := v.(gflashduty.AuditLog) + r := v.(flashduty.AuditLog) if r.OperationName != "" { return r.OperationName } return r.Operation }}, {Header: "DETAIL", MaxWidth: 50, Field: func(v any) string { - r := v.(gflashduty.AuditLog) + r := v.(flashduty.AuditLog) if r.Body != "" { return r.Body } diff --git a/internal/cli/change.go b/internal/cli/change.go index b094b58..de410d7 100644 --- a/internal/cli/change.go +++ b/internal/cli/change.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -28,7 +28,7 @@ func newChangeListCmd() *cobra.Command { Use: "list", Short: "List changes", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -51,7 +51,7 @@ func newChangeListCmd() *cobra.Command { reqPage = 1 } - input := &gflashduty.ListChangeRequest{ + input := &flashduty.ListChangeRequest{ StartTime: startTime, EndTime: endTime, } @@ -66,17 +66,17 @@ func newChangeListCmd() *cobra.Command { input.ChannelIDs = channelIDs } - result, _, err := ctx.GFClient.Changes.List(cmdContext(ctx.Cmd), input) + result, _, err := ctx.Client.Changes.List(cmdContext(ctx.Cmd), input) if err != nil { return err } cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(gflashduty.ChangeItem).ChangeID }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.ChangeItem).Title }}, - {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.ChangeItem).ChangeStatus }}, - {Header: "CHANNEL", Field: func(v any) string { return v.(gflashduty.ChangeItem).ChannelName }}, - {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(gflashduty.ChangeItem).StartTime) }}, + {Header: "ID", Field: func(v any) string { return v.(flashduty.ChangeItem).ChangeID }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.ChangeItem).Title }}, + {Header: "STATUS", Field: func(v any) string { return v.(flashduty.ChangeItem).ChangeStatus }}, + {Header: "CHANNEL", Field: func(v any) string { return v.(flashduty.ChangeItem).ChannelName }}, + {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.ChangeItem).StartTime) }}, } return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) diff --git a/internal/cli/channel.go b/internal/cli/channel.go index 9545431..2c38193 100644 --- a/internal/cli/channel.go +++ b/internal/cli/channel.go @@ -4,7 +4,7 @@ import ( "strconv" "strings" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -39,13 +39,13 @@ func newChannelListCmd() *cobra.Command { Use: "list", Short: "List channels", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) 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. - result, _, err := ctx.GFClient.Channels.ChannelList(cmdContext(ctx.Cmd), &gflashduty.ListChannelsRequest{}) + result, _, err := ctx.Client.Channels.ChannelList(cmdContext(ctx.Cmd), &flashduty.ListChannelsRequest{}) if err != nil { return err } @@ -115,7 +115,7 @@ func enrichChannelNames(ctx *RunContext, rows []channelRow) { teamNameByID := make(map[int64]string) if len(teamIDs) > 0 { - if resp, _, err := ctx.GFClient.Teams.ReadInfos(cmdContext(ctx.Cmd), &gflashduty.TeamInfosRequest{TeamIDs: teamIDs}); err == nil && resp != nil { + if resp, _, err := ctx.Client.Teams.ReadInfos(cmdContext(ctx.Cmd), &flashduty.TeamInfosRequest{TeamIDs: teamIDs}); err == nil && resp != nil { for _, t := range resp.Items { teamNameByID[int64(t.TeamID)] = t.TeamName } @@ -124,7 +124,7 @@ func enrichChannelNames(ctx *RunContext, rows []channelRow) { personNameByID := make(map[int64]string) if len(personIDs) > 0 { - if resp, _, err := ctx.GFClient.Members.PersonInfos(cmdContext(ctx.Cmd), &gflashduty.PersonInfosRequest{PersonIDs: personIDs}); err == nil && resp != nil { + if resp, _, err := ctx.Client.Members.PersonInfos(cmdContext(ctx.Cmd), &flashduty.PersonInfosRequest{PersonIDs: personIDs}); err == nil && resp != nil { for _, p := range resp.Items { personNameByID[int64(p.PersonID)] = p.PersonName } diff --git a/internal/cli/command.go b/internal/cli/command.go index ef46d47..245fdf6 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -4,17 +4,17 @@ import ( "fmt" "io" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" ) // RunContext provides helpers for command execution. It is created by -// runGFCommand and passed to the command's handler function. GFClient is the +// runCommand and passed to the command's handler function. Client is the // typed go-flashduty SDK every command calls through. type RunContext struct { - GFClient *gflashduty.Client + Client *flashduty.Client Cmd *cobra.Command Args []string Writer io.Writer @@ -27,16 +27,16 @@ type RunContext struct { // this to suppress detail views, footers, and interactive prompts. func (ctx *RunContext) Structured() bool { return ctx.Format.Structured() } -// runGFCommand creates a go-flashduty client and RunContext, then calls fn. It +// runCommand creates a go-flashduty client and RunContext, then calls fn. It // centralises the setup every API-backed command repeats; handlers reach the -// SDK through ctx.GFClient. -func runGFCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) error) error { - client, err := newGFClient() +// SDK through ctx.Client. +func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) error) error { + client, err := newClient() if err != nil { return err } ctx := &RunContext{ - GFClient: client, + Client: client, Cmd: cmd, Args: args, Writer: cmd.OutOrStdout(), diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 187562d..16f39e8 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -17,7 +17,7 @@ import ( func saveAndResetGlobals(t *testing.T) { t.Helper() - origNewGFClientFn := newGFClientFn + origNewClientFn := newClientFn origFlagJSON := flagJSON origFlagNoTrunc := flagNoTrunc origFlagAppKey := flagAppKey @@ -30,7 +30,7 @@ func saveAndResetGlobals(t *testing.T) { flagBaseURL = "" t.Cleanup(func() { - newGFClientFn = origNewGFClientFn + newClientFn = origNewClientFn flagJSON = origFlagJSON flagNoTrunc = origFlagNoTrunc flagAppKey = origFlagAppKey diff --git a/internal/cli/escalation_rule.go b/internal/cli/escalation_rule.go index 5335762..955f6fe 100644 --- a/internal/cli/escalation_rule.go +++ b/internal/cli/escalation_rule.go @@ -4,7 +4,7 @@ import ( "fmt" "strconv" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -27,7 +27,7 @@ func newEscalationRuleListCmd() *cobra.Command { Use: "list", Short: "List escalation rules for a channel", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { // Resolve channel name to ID if needed if channelID == 0 && channelName != "" { resolved, err := resolveChannelID(ctx, channelName) @@ -41,7 +41,7 @@ func newEscalationRuleListCmd() *cobra.Command { return fmt.Errorf("--channel or --channel-name is required") } - result, _, err := ctx.GFClient.Channels.ChannelEscalateRuleList(cmdContext(ctx.Cmd), &gflashduty.ChannelScopedListRequest{ + result, _, err := ctx.Client.Channels.ChannelEscalateRuleList(cmdContext(ctx.Cmd), &flashduty.ChannelScopedListRequest{ ChannelID: channelID, }) if err != nil { @@ -49,15 +49,15 @@ func newEscalationRuleListCmd() *cobra.Command { } cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(gflashduty.EscalateRuleItem).RuleID }}, - {Header: "NAME", Field: func(v any) string { return v.(gflashduty.EscalateRuleItem).RuleName }}, - {Header: "CHANNEL", Field: func(v any) string { return v.(gflashduty.EscalateRuleItem).ChannelName }}, - {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.EscalateRuleItem).Status }}, + {Header: "ID", Field: func(v any) string { return v.(flashduty.EscalateRuleItem).RuleID }}, + {Header: "NAME", Field: func(v any) string { return v.(flashduty.EscalateRuleItem).RuleName }}, + {Header: "CHANNEL", Field: func(v any) string { return v.(flashduty.EscalateRuleItem).ChannelName }}, + {Header: "STATUS", Field: func(v any) string { return v.(flashduty.EscalateRuleItem).Status }}, {Header: "PRIORITY", Field: func(v any) string { - return strconv.FormatInt(v.(gflashduty.EscalateRuleItem).Priority, 10) + return strconv.FormatInt(v.(flashduty.EscalateRuleItem).Priority, 10) }}, {Header: "LAYERS", Field: func(v any) string { - return strconv.Itoa(len(v.(gflashduty.EscalateRuleItem).Layers)) + return strconv.Itoa(len(v.(flashduty.EscalateRuleItem).Layers)) }}, } @@ -74,7 +74,7 @@ func newEscalationRuleListCmd() *cobra.Command { // resolveChannelID resolves a channel name to its ID. func resolveChannelID(ctx *RunContext, name string) (int64, error) { - result, _, err := ctx.GFClient.Channels.ChannelList(cmdContext(ctx.Cmd), &gflashduty.ListChannelsRequest{ + result, _, err := ctx.Client.Channels.ChannelList(cmdContext(ctx.Cmd), &flashduty.ListChannelsRequest{ ChannelName: name, }) if err != nil { diff --git a/internal/cli/field.go b/internal/cli/field.go index 43c9fff..33de606 100644 --- a/internal/cli/field.go +++ b/internal/cli/field.go @@ -3,7 +3,7 @@ package cli import ( "strings" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -25,8 +25,8 @@ func newFieldListCmd() *cobra.Command { Use: "list", Short: "List custom fields", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.GFClient.AlertEnrichment.FieldReadList(cmdContext(ctx.Cmd), &gflashduty.FieldListRequest{}) + return runCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.Client.AlertEnrichment.FieldReadList(cmdContext(ctx.Cmd), &flashduty.FieldListRequest{}) if err != nil { return err } @@ -37,7 +37,7 @@ func newFieldListCmd() *cobra.Command { // is unchanged. items := result.Items if name != "" { - filtered := make([]gflashduty.FieldItem, 0, len(items)) + filtered := make([]flashduty.FieldItem, 0, len(items)) for _, f := range items { if f.FieldName == name { filtered = append(filtered, f) @@ -47,12 +47,12 @@ func newFieldListCmd() *cobra.Command { } cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(gflashduty.FieldItem).FieldID }}, - {Header: "NAME", Field: func(v any) string { return v.(gflashduty.FieldItem).FieldName }}, - {Header: "DISPLAY_NAME", Field: func(v any) string { return v.(gflashduty.FieldItem).DisplayName }}, - {Header: "TYPE", Field: func(v any) string { return v.(gflashduty.FieldItem).FieldType }}, + {Header: "ID", Field: func(v any) string { return v.(flashduty.FieldItem).FieldID }}, + {Header: "NAME", Field: func(v any) string { return v.(flashduty.FieldItem).FieldName }}, + {Header: "DISPLAY_NAME", Field: func(v any) string { return v.(flashduty.FieldItem).DisplayName }}, + {Header: "TYPE", Field: func(v any) string { return v.(flashduty.FieldItem).FieldType }}, {Header: "OPTIONS", MaxWidth: 50, Field: func(v any) string { - return strings.Join(v.(gflashduty.FieldItem).Options, ", ") + return strings.Join(v.(flashduty.FieldItem).Options, ", ") }}, } diff --git a/internal/cli/gfstub_test.go b/internal/cli/gfstub_test.go index ac548c4..77afcf5 100644 --- a/internal/cli/gfstub_test.go +++ b/internal/cli/gfstub_test.go @@ -7,11 +7,11 @@ import ( "net/http/httptest" "testing" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" ) // gfStub is an httptest-backed stand-in for the go-flashduty API. Migrated -// commands build a *gflashduty.Client (a concrete type, not an interface), so +// commands build a *flashduty.Client (a concrete type, not an interface), so // they can't be mocked the way the legacy flashdutyClient interface is — they // are exercised against this stub server instead. The stub records every // request's path and decoded JSON body and replies with a canned envelope, so a @@ -45,7 +45,7 @@ type gfStub struct { dataForPath func(path string, body map[string]any) any } -// newGFStub starts a stub server and wires newGFClientFn to a client pointed at +// newGFStub starts a stub server and wires newClientFn to a client pointed at // it. It returns the stub so tests can inspect the captured request. The server // is torn down via t.Cleanup. func newGFStub(t *testing.T) *gfStub { @@ -81,8 +81,8 @@ func newGFStub(t *testing.T) *gfStub { })) t.Cleanup(s.server.Close) - newGFClientFn = func() (*gflashduty.Client, error) { - return gflashduty.NewClient("test-key", gflashduty.WithBaseURL(s.server.URL)) + newClientFn = func() (*flashduty.Client, error) { + return flashduty.NewClient("test-key", flashduty.WithBaseURL(s.server.URL)) } return s } diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go index 99f57be..9552e2b 100644 --- a/internal/cli/helpers.go +++ b/internal/cli/helpers.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" ) // parseKVSlice converts a slice of "KEY=VALUE" entries into a map. @@ -32,8 +32,8 @@ func parseKVSlice(entries []string) (map[string]string, error) { // first then on the first '=', mirroring parseKVSlice — that means params JSON // containing commas isn't supported; specs with complex params must keep their // objects single-keyed. -func parseToolSpecs(specs []string) ([]gflashduty.ToolInvokeRequestToolsItem, error) { - out := make([]gflashduty.ToolInvokeRequestToolsItem, 0, len(specs)) +func parseToolSpecs(specs []string) ([]flashduty.ToolInvokeRequestToolsItem, error) { + out := make([]flashduty.ToolInvokeRequestToolsItem, 0, len(specs)) for _, s := range specs { var name string var rawParams string @@ -63,7 +63,7 @@ func parseToolSpecs(specs []string) ([]gflashduty.ToolInvokeRequestToolsItem, er return nil, fmt.Errorf("invalid params JSON in spec %q: %w", s, err) } } - out = append(out, gflashduty.ToolInvokeRequestToolsItem{Tool: name, Params: params}) + out = append(out, flashduty.ToolInvokeRequestToolsItem{Tool: name, Params: params}) } return out, nil } diff --git a/internal/cli/identity.go b/internal/cli/identity.go index f42d534..d6ed763 100644 --- a/internal/cli/identity.go +++ b/internal/cli/identity.go @@ -6,7 +6,7 @@ import ( "fmt" "io" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" ) type identityResult struct { @@ -18,7 +18,7 @@ type identityResult struct { // resolveIdentity fetches the caller's identity, preferring member-level detail // (which carries the member name) and falling back to account-level info when // the app key is account-scoped rather than tied to a member. -func resolveIdentity(ctx context.Context, client *gflashduty.Client) (*identityResult, error) { +func resolveIdentity(ctx context.Context, client *flashduty.Client) (*identityResult, error) { member, _, memberErr := client.Members.MemberInfo(ctx) if memberErr == nil { return &identityResult{ diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 391f02c..9393abe 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -9,7 +9,7 @@ import ( "strings" "time" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "golang.org/x/term" @@ -49,12 +49,12 @@ func newIncidentCmd() *cobra.Command { func incidentColumns() []output.Column { return []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(gflashduty.IncidentInfo).IncidentID }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.IncidentInfo).Title }}, - {Header: "SEVERITY", Field: func(v any) string { return v.(gflashduty.IncidentInfo).IncidentSeverity }}, - {Header: "PROGRESS", Field: func(v any) string { return v.(gflashduty.IncidentInfo).Progress }}, - {Header: "CHANNEL", Field: func(v any) string { return v.(gflashduty.IncidentInfo).ChannelName }}, - {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.IncidentInfo).StartTime) }}, + {Header: "ID", Field: func(v any) string { return v.(flashduty.IncidentInfo).IncidentID }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.IncidentInfo).Title }}, + {Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.IncidentInfo).IncidentSeverity }}, + {Header: "PROGRESS", Field: func(v any) string { return v.(flashduty.IncidentInfo).Progress }}, + {Header: "CHANNEL", Field: func(v any) string { return v.(flashduty.IncidentInfo).ChannelName }}, + {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.IncidentInfo).StartTime) }}, } } @@ -63,12 +63,12 @@ func incidentColumns() []output.Column { // IncidentInfo. func pastIncidentColumns() []output.Column { return []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(gflashduty.PastIncidentItem).IncidentID }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.PastIncidentItem).Title }}, - {Header: "SEVERITY", Field: func(v any) string { return v.(gflashduty.PastIncidentItem).IncidentSeverity }}, - {Header: "PROGRESS", Field: func(v any) string { return v.(gflashduty.PastIncidentItem).Progress }}, - {Header: "CHANNEL", Field: func(v any) string { return v.(gflashduty.PastIncidentItem).ChannelName }}, - {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.PastIncidentItem).StartTime) }}, + {Header: "ID", Field: func(v any) string { return v.(flashduty.PastIncidentItem).IncidentID }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.PastIncidentItem).Title }}, + {Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.PastIncidentItem).IncidentSeverity }}, + {Header: "PROGRESS", Field: func(v any) string { return v.(flashduty.PastIncidentItem).Progress }}, + {Header: "CHANNEL", Field: func(v any) string { return v.(flashduty.PastIncidentItem).ChannelName }}, + {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.PastIncidentItem).StartTime) }}, } } @@ -81,7 +81,7 @@ func newIncidentListCmd() *cobra.Command { Use: "list", Short: "List incidents", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -91,7 +91,7 @@ func newIncidentListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - req := &gflashduty.ListIncidentsRequest{ + req := &flashduty.ListIncidentsRequest{ Progress: progress, IncidentSeverity: severity, StartTime: startTime, @@ -104,7 +104,7 @@ func newIncidentListCmd() *cobra.Command { req.ChannelIDs = []int64{channelID} } - result, _, err := ctx.GFClient.Incidents.List(cmdContext(ctx.Cmd), req) + result, _, err := ctx.Client.Incidents.List(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -132,8 +132,8 @@ func newIncidentGetCmd() *cobra.Command { Short: "Get incident details", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.GFClient.Incidents.List(cmdContext(ctx.Cmd), &gflashduty.ListIncidentsRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.Client.Incidents.List(cmdContext(ctx.Cmd), &flashduty.ListIncidentsRequest{ IncidentIDs: ctx.Args, }) if err != nil { @@ -157,7 +157,7 @@ func newIncidentGetCmd() *cobra.Command { } } -func printIncidentDetail(w io.Writer, inc gflashduty.IncidentInfo) { +func printIncidentDetail(w io.Writer, inc flashduty.IncidentInfo) { responders := make([]string, 0, len(inc.Responders)) for _, r := range inc.Responders { responders = append(responders, r.PersonName) @@ -227,8 +227,8 @@ func newIncidentCreateCmd() *cobra.Command { return fmt.Errorf("--severity is required (Critical, Warning, Info)") } - return runGFCommand(cmd, args, func(ctx *RunContext) error { - req := &gflashduty.CreateIncidentRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + req := &flashduty.CreateIncidentRequest{ Title: title, IncidentSeverity: severity, ChannelID: channelID, @@ -243,10 +243,10 @@ func newIncidentCreateCmd() *cobra.Command { // = "assign". On a brand-new incident the backend would default an // empty type to "assign" anyway, but we set it explicitly so the // migration is a pure no-drift refactor. - req.AssignedTo = gflashduty.CreateIncidentRequestAssignedTo{PersonIDs: personIDs, Type: "assign"} + req.AssignedTo = flashduty.CreateIncidentRequestAssignedTo{PersonIDs: personIDs, Type: "assign"} } - result, _, err := ctx.GFClient.Incidents.Create(cmdContext(ctx.Cmd), req) + result, _, err := ctx.Client.Incidents.Create(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -292,7 +292,7 @@ func newIncidentUpdateCmd() *cobra.Command { customFields = append(customFields, customField{name: parts[0], value: parts[1]}) } - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { incidentID := ctx.Args[0] updated := make([]string, 0) @@ -300,7 +300,7 @@ func newIncidentUpdateCmd() *cobra.Command { // SDK: only set fields the user supplied, and label severity as // "severity" (not the wire field "incident_severity") in the // summary line. - resetReq := &gflashduty.UpdateIncidentFieldsRequest{IncidentID: incidentID} + resetReq := &flashduty.UpdateIncidentFieldsRequest{IncidentID: incidentID} if title != "" { resetReq.Title = title updated = append(updated, "title") @@ -314,7 +314,7 @@ func newIncidentUpdateCmd() *cobra.Command { updated = append(updated, "severity") } if len(updated) > 0 { - if _, err := ctx.GFClient.Incidents.Reset(cmdContext(ctx.Cmd), resetReq); err != nil { + if _, err := ctx.Client.Incidents.Reset(cmdContext(ctx.Cmd), resetReq); err != nil { return err } } @@ -331,7 +331,7 @@ func newIncidentUpdateCmd() *cobra.Command { return fmt.Errorf("custom field name '%s' contains invalid characters (only alphanumeric and underscore allowed)", f.name) } } - if _, err := ctx.GFClient.Incidents.FieldReset(cmdContext(ctx.Cmd), &gflashduty.ResetIncidentFieldRequest{ + if _, err := ctx.Client.Incidents.FieldReset(cmdContext(ctx.Cmd), &flashduty.ResetIncidentFieldRequest{ IncidentID: incidentID, FieldName: f.name, FieldValue: map[string]any{"value": f.value}, @@ -365,8 +365,8 @@ func newIncidentAckCmd() *cobra.Command { Short: "Acknowledge incidents", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - if _, err := ctx.GFClient.Incidents.Ack(cmdContext(ctx.Cmd), &gflashduty.AckIncidentRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.Client.Incidents.Ack(cmdContext(ctx.Cmd), &flashduty.AckIncidentRequest{ IncidentIDs: ctx.Args, }); err != nil { return err @@ -393,8 +393,8 @@ unacknowledged state. The command accepts up to 100 incident IDs.`, if err := validateIncidentIDBatch(args); err != nil { return err } - return runGFCommand(cmd, args, func(ctx *RunContext) error { - if _, err := ctx.GFClient.Incidents.Unack(cmdContext(ctx.Cmd), &gflashduty.UnackIncidentRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.Client.Incidents.Unack(cmdContext(ctx.Cmd), &flashduty.UnackIncidentRequest{ IncidentIDs: ctx.Args, }); err != nil { return err @@ -412,8 +412,8 @@ func newIncidentCloseCmd() *cobra.Command { Short: "Close incidents", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - if _, err := ctx.GFClient.Incidents.Resolve(cmdContext(ctx.Cmd), &gflashduty.ResolveIncidentRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.Client.Incidents.Resolve(cmdContext(ctx.Cmd), &flashduty.ResolveIncidentRequest{ IncidentIDs: ctx.Args, }); err != nil { return err @@ -440,8 +440,8 @@ accepts up to 100 incident IDs.`, if err := validateIncidentIDBatch(args); err != nil { return err } - return runGFCommand(cmd, args, func(ctx *RunContext) error { - if _, err := ctx.GFClient.Incidents.Wake(cmdContext(ctx.Cmd), &gflashduty.WakeIncidentRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.Client.Incidents.Wake(cmdContext(ctx.Cmd), &flashduty.WakeIncidentRequest{ IncidentIDs: ctx.Args, }); err != nil { return err @@ -459,13 +459,13 @@ func newIncidentTimelineCmd() *cobra.Command { Short: "View incident timeline", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { // go-flashduty has no batched timeline endpoint, so fan out per // incident ID over /incident/feed and concatenate the entries, // replicating the legacy SDK's GetIncidentTimelines behavior. - var items []gflashduty.IncidentFeedItem + var items []flashduty.IncidentFeedItem for _, id := range ctx.Args { - result, _, err := ctx.GFClient.Incidents.Feed(cmdContext(ctx.Cmd), &gflashduty.ListIncidentFeedRequest{IncidentID: id}) + result, _, err := ctx.Client.Incidents.Feed(cmdContext(ctx.Cmd), &flashduty.ListIncidentFeedRequest{IncidentID: id}) if err != nil { return err } @@ -482,10 +482,10 @@ func newIncidentTimelineCmd() *cobra.Command { nameByID := resolveFeedOperators(ctx, items) cols := []output.Column{ - {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(gflashduty.IncidentFeedItem).CreatedAt) }}, - {Header: "TYPE", Field: func(v any) string { return string(v.(gflashduty.IncidentFeedItem).Type) }}, + {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.IncidentFeedItem).CreatedAt) }}, + {Header: "TYPE", Field: func(v any) string { return string(v.(flashduty.IncidentFeedItem).Type) }}, {Header: "OPERATOR", Field: func(v any) string { - it := v.(gflashduty.IncidentFeedItem) + it := v.(flashduty.IncidentFeedItem) if it.CreatorID == 0 { return "system" } @@ -495,7 +495,7 @@ func newIncidentTimelineCmd() *cobra.Command { return strconv.FormatInt(it.CreatorID, 10) }}, {Header: "DETAIL", MaxWidth: 80, Field: func(v any) string { - d := v.(gflashduty.IncidentFeedItem).Detail + d := v.(flashduty.IncidentFeedItem).Detail if d == nil { return "-" } @@ -517,10 +517,10 @@ func newIncidentAlertsCmd() *cobra.Command { Short: "View incident alerts", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - req := &gflashduty.ListIncidentAlertsRequest{IncidentID: ctx.Args[0]} + return runCommand(cmd, args, func(ctx *RunContext) error { + req := &flashduty.ListIncidentAlertsRequest{IncidentID: ctx.Args[0]} req.Limit = limit - result, _, err := ctx.GFClient.Incidents.AlertList(cmdContext(ctx.Cmd), req) + result, _, err := ctx.Client.Incidents.AlertList(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -531,11 +531,11 @@ func newIncidentAlertsCmd() *cobra.Command { } cols := []output.Column{ - {Header: "ALERT_ID", Field: func(v any) string { return v.(gflashduty.AlertInfo).AlertID }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.AlertInfo).Title }}, - {Header: "SEVERITY", Field: func(v any) string { return v.(gflashduty.AlertInfo).AlertSeverity }}, - {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.AlertInfo).AlertStatus }}, - {Header: "STARTED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.AlertInfo).StartTime) }}, + {Header: "ALERT_ID", Field: func(v any) string { return v.(flashduty.AlertInfo).AlertID }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertInfo).Title }}, + {Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.AlertInfo).AlertSeverity }}, + {Header: "STATUS", Field: func(v any) string { return v.(flashduty.AlertInfo).AlertStatus }}, + {Header: "STARTED", Field: func(v any) string { return output.FormatTime(v.(flashduty.AlertInfo).StartTime) }}, } return ctx.PrintTotal(result.Items, cols, int(result.Total)) @@ -555,10 +555,10 @@ func newIncidentSimilarCmd() *cobra.Command { Short: "Find similar incidents", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.GFClient.Incidents.PastList(cmdContext(ctx.Cmd), &gflashduty.ListPastIncidentsRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.Client.Incidents.PastList(cmdContext(ctx.Cmd), &flashduty.ListPastIncidentsRequest{ IncidentID: ctx.Args[0], - Limit: gflashduty.Int64(int64(limit)), + Limit: flashduty.Int64(int64(limit)), }) if err != nil { return err @@ -630,7 +630,7 @@ func newIncidentMergeCmd() *cobra.Command { Short: "Merge incidents into a target incident", Args: requireArgs("target_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { sourceIDs := parseStringSlice(source) if len(sourceIDs) == 0 { return fmt.Errorf("--source is required") @@ -639,7 +639,7 @@ func newIncidentMergeCmd() *cobra.Command { return fmt.Errorf("--source accepts at most 100 incident IDs") } - if _, err := ctx.GFClient.Incidents.Merge(cmdContext(ctx.Cmd), &gflashduty.MergeIncidentsRequest{ + if _, err := ctx.Client.Incidents.Merge(cmdContext(ctx.Cmd), &flashduty.MergeIncidentsRequest{ SourceIncidentIDs: sourceIDs, TargetIncidentID: ctx.Args[0], }); err != nil { @@ -666,7 +666,7 @@ func newIncidentSnoozeCmd() *cobra.Command { Short: "Snooze incidents", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { d, err := time.ParseDuration(duration) if err != nil { return fmt.Errorf("invalid --duration: %w", err) @@ -680,7 +680,7 @@ func newIncidentSnoozeCmd() *cobra.Command { minutes := int64(d / time.Minute) - if _, err := ctx.GFClient.Incidents.Snooze(cmdContext(ctx.Cmd), &gflashduty.SnoozeIncidentRequest{ + if _, err := ctx.Client.Incidents.Snooze(cmdContext(ctx.Cmd), &flashduty.SnoozeIncidentRequest{ IncidentIDs: ctx.Args, Minutes: minutes, }); err != nil { @@ -705,8 +705,8 @@ func newIncidentReopenCmd() *cobra.Command { Short: "Reopen closed incidents", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - if _, err := ctx.GFClient.Incidents.Reopen(cmdContext(ctx.Cmd), &gflashduty.ReopenIncidentRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.Client.Incidents.Reopen(cmdContext(ctx.Cmd), &flashduty.ReopenIncidentRequest{ IncidentIDs: ctx.Args, }); err != nil { return err @@ -726,7 +726,7 @@ func newIncidentReassignCmd() *cobra.Command { Short: "Reassign an incident to new responders", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { personIDs, err := parseIntSlice(person) if err != nil { return fmt.Errorf("invalid --person: %w", err) @@ -740,9 +740,9 @@ func newIncidentReassignCmd() *cobra.Command { // the backend relabel an already-assigned incident as "reassign" in the // feed/IM cards — a behavior change. Whether "reassign" is the more // correct label is a separate product decision, not a migration one. - if _, err := ctx.GFClient.Incidents.Assign(cmdContext(ctx.Cmd), &gflashduty.AssignIncidentRequest{ + if _, err := ctx.Client.Incidents.Assign(cmdContext(ctx.Cmd), &flashduty.AssignIncidentRequest{ IncidentIDs: []string{ctx.Args[0]}, - AssignedTo: gflashduty.AssignedTo{PersonIDs: personIDs, Type: "assign"}, + AssignedTo: flashduty.AssignedTo{PersonIDs: personIDs, Type: "assign"}, }); err != nil { return err } @@ -786,17 +786,17 @@ personal channels, or a template.`, return fmt.Errorf("--person is required") } - var notify gflashduty.AddIncidentResponderRequestNotify + var notify flashduty.AddIncidentResponderRequestNotify if followPreference || notifyChannel != "" || templateID != "" { - notify = gflashduty.AddIncidentResponderRequestNotify{ + notify = flashduty.AddIncidentResponderRequestNotify{ FollowPreference: followPreference, PersonalChannels: parseStringSlice(notifyChannel), TemplateID: templateID, } } - return runGFCommand(cmd, args, func(ctx *RunContext) error { - if _, err := ctx.GFClient.Incidents.ResponderAdd(cmdContext(ctx.Cmd), &gflashduty.AddIncidentResponderRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.Client.Incidents.ResponderAdd(cmdContext(ctx.Cmd), &flashduty.AddIncidentResponderRequest{ IncidentID: ctx.Args[0], PersonIDs: personIDs, Notify: notify, @@ -845,8 +845,8 @@ webhook reply behavior.`, return fmt.Errorf("--comment must be at most 1024 characters") } - return runGFCommand(cmd, args, func(ctx *RunContext) error { - if _, err := ctx.GFClient.Incidents.Comment(cmdContext(ctx.Cmd), &gflashduty.CommentIncidentRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.Client.Incidents.Comment(cmdContext(ctx.Cmd), &flashduty.CommentIncidentRequest{ IncidentIDs: ctx.Args, Comment: comment, MuteReply: muteReply, @@ -879,8 +879,8 @@ matching alerts automatically. The command accepts up to 100 incident IDs.`, flashduty incident disable-merge inc_123 inc_456`, Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - if _, err := ctx.GFClient.Incidents.DisableMerge(cmdContext(ctx.Cmd), &gflashduty.DisableIncidentMergeRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.Client.Incidents.DisableMerge(cmdContext(ctx.Cmd), &flashduty.DisableIncidentMergeRequest{ IncidentIDs: ctx.Args, }); err != nil { return err @@ -910,13 +910,13 @@ unless --force is provided. The command accepts up to 100 incident IDs.`, if err := validateIncidentIDBatch(args); err != nil { return err } - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { if !confirmAction(ctx.Cmd, fmt.Sprintf("Are you sure you want to remove %d incident(s)?", len(ctx.Args))) { _, _ = fmt.Fprintln(ctx.Writer, "Aborted.") return nil } - if _, err := ctx.GFClient.Incidents.Remove(cmdContext(ctx.Cmd), &gflashduty.RemoveIncidentRequest{ + if _, err := ctx.Client.Incidents.Remove(cmdContext(ctx.Cmd), &flashduty.RemoveIncidentRequest{ IncidentIDs: ctx.Args, }); err != nil { return err @@ -976,12 +976,12 @@ invite historical responders selected by FlashDuty.`, if err != nil { return fmt.Errorf("invalid --member: %w", err) } - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { resolvedIntegrationID, err := resolveWarRoomIntegrationID(ctx) if err != nil { return err } - warRoom, _, err := ctx.GFClient.Incidents.WarRoomCreate(cmdContext(ctx.Cmd), &gflashduty.CreateWarRoomRequest{ + warRoom, _, err := ctx.Client.Incidents.WarRoomCreate(cmdContext(ctx.Cmd), &flashduty.CreateWarRoomRequest{ IncidentID: ctx.Args[0], IntegrationID: resolvedIntegrationID, MemberIDs: memberIDs, @@ -1015,7 +1015,7 @@ func resolveWarRoomIntegrationID(ctx *RunContext) (int64, error) { return integrationID, nil } - result, _, err := ctx.GFClient.ImIntegrations.List(cmdContext(ctx.Cmd)) + result, _, err := ctx.Client.ImIntegrations.List(cmdContext(ctx.Cmd)) if err != nil { return 0, err } @@ -1039,8 +1039,8 @@ as get, delete, and add-member.`, flashduty incident war-room list inc_123 --integration 42`, Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.GFClient.Incidents.WarRoomList(cmdContext(ctx.Cmd), &gflashduty.ListWarRoomsRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.Client.Incidents.WarRoomList(cmdContext(ctx.Cmd), &flashduty.ListWarRoomsRequest{ IncidentID: ctx.Args[0], IntegrationID: integrationID, }) @@ -1071,8 +1071,8 @@ the chat ID and integration ID for an incident.`, flashduty incident war-room get chat_123 --integration 42`, Args: requireArgs("chat_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - warRoom, _, err := ctx.GFClient.Incidents.WarRoomDetail(cmdContext(ctx.Cmd), &gflashduty.GetWarRoomDetailRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + warRoom, _, err := ctx.Client.Incidents.WarRoomDetail(cmdContext(ctx.Cmd), &flashduty.GetWarRoomDetailRequest{ IntegrationID: integrationID, ChatID: ctx.Args[0], }) @@ -1111,12 +1111,12 @@ integration ID.`, flashduty incident war-room delete inc_123 --integration 42 --force`, Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { if !confirmAction(ctx.Cmd, fmt.Sprintf("Are you sure you want to delete the war room for incident %s?", ctx.Args[0])) { _, _ = fmt.Fprintln(ctx.Writer, "Aborted.") return nil } - if _, err := ctx.GFClient.Incidents.WarRoomDelete(cmdContext(ctx.Cmd), &gflashduty.DeleteWarRoomRequest{ + if _, err := ctx.Client.Incidents.WarRoomDelete(cmdContext(ctx.Cmd), &flashduty.DeleteWarRoomRequest{ IncidentID: ctx.Args[0], IntegrationID: integrationID, }); err != nil { @@ -1159,8 +1159,8 @@ IDs.`, if len(memberIDs) == 0 { return fmt.Errorf("--member is required") } - return runGFCommand(cmd, args, func(ctx *RunContext) error { - if _, _, err := ctx.GFClient.Incidents.WriteAddWarRoomMember(cmdContext(ctx.Cmd), &gflashduty.AddWarRoomMemberRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + if _, _, err := ctx.Client.Incidents.WriteAddWarRoomMember(cmdContext(ctx.Cmd), &flashduty.AddWarRoomMemberRequest{ IntegrationID: integrationID, ChatID: ctx.Args[0], MemberIDs: memberIDs, @@ -1192,8 +1192,8 @@ This is a read-only preview of the users FlashDuty would add when flashduty incident war-room create inc_123 --add-observers`, Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.GFClient.Incidents.ReadGetWarRoomDefaultObservers(cmdContext(ctx.Cmd), &gflashduty.GetWarRoomDefaultObserversRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.Client.Incidents.ReadGetWarRoomDefaultObservers(cmdContext(ctx.Cmd), &flashduty.GetWarRoomDefaultObserversRequest{ IncidentID: ctx.Args[0], }) if err != nil { @@ -1207,25 +1207,25 @@ This is a read-only preview of the users FlashDuty would add when func incidentWarRoomColumns() []output.Column { return []output.Column{ - {Header: "INTEGRATION", Field: func(v any) string { return fmt.Sprint(v.(gflashduty.WarRoomItem).IntegrationID) }}, - {Header: "CHAT_ID", Field: func(v any) string { return v.(gflashduty.WarRoomItem).ChatID }}, - {Header: "INCIDENT_ID", Field: func(v any) string { return v.(gflashduty.WarRoomItem).IncidentID }}, - {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.WarRoomItem).Status }}, - {Header: "PLUGIN", Field: func(v any) string { return v.(gflashduty.WarRoomItem).PluginType }}, - {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.WarRoomItem).CreatedAt) }}, + {Header: "INTEGRATION", Field: func(v any) string { return fmt.Sprint(v.(flashduty.WarRoomItem).IntegrationID) }}, + {Header: "CHAT_ID", Field: func(v any) string { return v.(flashduty.WarRoomItem).ChatID }}, + {Header: "INCIDENT_ID", Field: func(v any) string { return v.(flashduty.WarRoomItem).IncidentID }}, + {Header: "STATUS", Field: func(v any) string { return v.(flashduty.WarRoomItem).Status }}, + {Header: "PLUGIN", Field: func(v any) string { return v.(flashduty.WarRoomItem).PluginType }}, + {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.WarRoomItem).CreatedAt) }}, } } func incidentWarRoomObserverColumns() []output.Column { return []output.Column{ - {Header: "PERSON_ID", Field: func(v any) string { return fmt.Sprint(v.(gflashduty.WarRoomPersonItem).PersonID) }}, - {Header: "NAME", Field: func(v any) string { return v.(gflashduty.WarRoomPersonItem).PersonName }}, - {Header: "EMAIL", Field: func(v any) string { return v.(gflashduty.WarRoomPersonItem).Email }}, - {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.WarRoomPersonItem).Status }}, + {Header: "PERSON_ID", Field: func(v any) string { return fmt.Sprint(v.(flashduty.WarRoomPersonItem).PersonID) }}, + {Header: "NAME", Field: func(v any) string { return v.(flashduty.WarRoomPersonItem).PersonName }}, + {Header: "EMAIL", Field: func(v any) string { return v.(flashduty.WarRoomPersonItem).Email }}, + {Header: "STATUS", Field: func(v any) string { return v.(flashduty.WarRoomPersonItem).Status }}, } } -func printWarRoomDetail(w io.Writer, warRoom *gflashduty.WarRoom) { +func printWarRoomDetail(w io.Writer, warRoom *flashduty.WarRoom) { if warRoom == nil { return } @@ -1242,11 +1242,11 @@ func newIncidentFeedCmd() *cobra.Command { Short: "View incident feed (paginated timeline)", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - feedReq := &gflashduty.ListIncidentFeedRequest{IncidentID: ctx.Args[0]} + return runCommand(cmd, args, func(ctx *RunContext) error { + feedReq := &flashduty.ListIncidentFeedRequest{IncidentID: ctx.Args[0]} feedReq.Page = page feedReq.Limit = limit - result, _, err := ctx.GFClient.Incidents.Feed(cmdContext(ctx.Cmd), feedReq) + result, _, err := ctx.Client.Incidents.Feed(cmdContext(ctx.Cmd), feedReq) if err != nil { return err } @@ -1263,10 +1263,10 @@ func newIncidentFeedCmd() *cobra.Command { nameByID := resolveFeedOperators(ctx, result.Items) cols := []output.Column{ - {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(gflashduty.IncidentFeedItem).CreatedAt) }}, - {Header: "TYPE", Field: func(v any) string { return string(v.(gflashduty.IncidentFeedItem).Type) }}, + {Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.IncidentFeedItem).CreatedAt) }}, + {Header: "TYPE", Field: func(v any) string { return string(v.(flashduty.IncidentFeedItem).Type) }}, {Header: "OPERATOR", Field: func(v any) string { - it := v.(gflashduty.IncidentFeedItem) + it := v.(flashduty.IncidentFeedItem) if it.CreatorID == 0 { return "system" } @@ -1276,7 +1276,7 @@ func newIncidentFeedCmd() *cobra.Command { return strconv.FormatInt(it.CreatorID, 10) }}, {Header: "DETAIL", MaxWidth: 80, Field: func(v any) string { - d := v.(gflashduty.IncidentFeedItem).Detail + d := v.(flashduty.IncidentFeedItem).Detail if d == nil { return "-" } @@ -1299,7 +1299,7 @@ func newIncidentFeedCmd() *cobra.Command { // items to display names via /person/infos, replicating the operator-name // enrichment the legacy SDK did server-side. Best-effort: a lookup failure // yields a nil map and callers fall back to the numeric ID. -func resolveFeedOperators(rc *RunContext, items []gflashduty.IncidentFeedItem) map[int64]string { +func resolveFeedOperators(rc *RunContext, items []flashduty.IncidentFeedItem) map[int64]string { seen := make(map[int64]struct{}, len(items)) ids := make([]uint64, 0, len(items)) for _, it := range items { @@ -1315,7 +1315,7 @@ func resolveFeedOperators(rc *RunContext, items []gflashduty.IncidentFeedItem) m if len(ids) == 0 { return nil } - resp, _, err := rc.GFClient.Members.PersonInfos(cmdContext(rc.Cmd), &gflashduty.PersonInfosRequest{PersonIDs: ids}) + resp, _, err := rc.Client.Members.PersonInfos(cmdContext(rc.Cmd), &flashduty.PersonInfosRequest{PersonIDs: ids}) if err != nil || resp == nil { return nil } @@ -1332,8 +1332,8 @@ func newIncidentDetailCmd() *cobra.Command { Short: "View full incident detail with AI summary", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.GFClient.Incidents.Info(cmdContext(ctx.Cmd), &gflashduty.IncidentInfoRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.Client.Incidents.Info(cmdContext(ctx.Cmd), &flashduty.IncidentInfoRequest{ IncidentID: ctx.Args[0], }) if err != nil { @@ -1351,7 +1351,7 @@ func newIncidentDetailCmd() *cobra.Command { } } -func printIncidentFullDetail(w io.Writer, inc *gflashduty.IncidentInfo) { +func printIncidentFullDetail(w io.Writer, inc *flashduty.IncidentInfo) { if inc == nil { return } diff --git a/internal/cli/incident_test.go b/internal/cli/incident_test.go index 0b9a6cf..6345754 100644 --- a/internal/cli/incident_test.go +++ b/internal/cli/incident_test.go @@ -6,7 +6,7 @@ import ( // TestCommandIncidentSimilarLimitReachesWire guards the *int64 Limit field on // ListPastIncidentsRequest: --limit must reach the wire body (it is wrapped -// with gflashduty.Int64). The command's --limit default is 5, never 0, so the +// with flashduty.Int64). The command's --limit default is 5, never 0, so the // value is always sent. func TestCommandIncidentSimilarLimitReachesWire(t *testing.T) { saveAndResetGlobals(t) diff --git a/internal/cli/insight.go b/internal/cli/insight.go index 9baea13..a98ddf3 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -30,7 +30,7 @@ func newInsightTeamCmd() *cobra.Command { Use: "team", Short: "Query insights by team", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -40,7 +40,7 @@ func newInsightTeamCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, _, err := ctx.GFClient.Analytics.ByTeam(cmdContext(ctx.Cmd), &gflashduty.InsightQueryRequest{ + result, _, err := ctx.Client.Analytics.ByTeam(cmdContext(ctx.Cmd), &flashduty.InsightQueryRequest{ StartTime: startTime, EndTime: endTime, }) @@ -50,28 +50,28 @@ func newInsightTeamCmd() *cobra.Command { cols := []output.Column{ {Header: "TEAM", MaxWidth: 30, Field: func(v any) string { - return v.(gflashduty.DimensionInsightItem).TeamName + return v.(flashduty.DimensionInsightItem).TeamName }}, {Header: "INCIDENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(gflashduty.DimensionInsightItem).TotalIncidentCnt) + return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalIncidentCnt) }}, {Header: "ACK%", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(gflashduty.DimensionInsightItem).AcknowledgementPct*100) + return fmt.Sprintf("%.0f%%", v.(flashduty.DimensionInsightItem).AcknowledgementPct*100) }}, {Header: "MTTA", Field: func(v any) string { - return output.FormatDurationFloat(v.(gflashduty.DimensionInsightItem).MeanSecondsToAck) + return output.FormatDurationFloat(v.(flashduty.DimensionInsightItem).MeanSecondsToAck) }}, {Header: "MTTR", Field: func(v any) string { - return output.FormatDurationFloat(v.(gflashduty.DimensionInsightItem).MeanSecondsToClose) + return output.FormatDurationFloat(v.(flashduty.DimensionInsightItem).MeanSecondsToClose) }}, {Header: "NOISE_REDUCTION", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(gflashduty.DimensionInsightItem).NoiseReductionPct*100) + return fmt.Sprintf("%.0f%%", v.(flashduty.DimensionInsightItem).NoiseReductionPct*100) }}, {Header: "ALERTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(gflashduty.DimensionInsightItem).TotalAlertCnt) + return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalAlertCnt) }}, {Header: "EVENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(gflashduty.DimensionInsightItem).TotalAlertEventCnt) + return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalAlertEventCnt) }}, } @@ -93,7 +93,7 @@ func newInsightChannelCmd() *cobra.Command { Use: "channel", Short: "Query insights by channel", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -103,7 +103,7 @@ func newInsightChannelCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, _, err := ctx.GFClient.Analytics.ByChannel(cmdContext(ctx.Cmd), &gflashduty.InsightQueryRequest{ + result, _, err := ctx.Client.Analytics.ByChannel(cmdContext(ctx.Cmd), &flashduty.InsightQueryRequest{ StartTime: startTime, EndTime: endTime, }) @@ -113,28 +113,28 @@ func newInsightChannelCmd() *cobra.Command { cols := []output.Column{ {Header: "CHANNEL", MaxWidth: 30, Field: func(v any) string { - return v.(gflashduty.DimensionInsightItem).ChannelName + return v.(flashduty.DimensionInsightItem).ChannelName }}, {Header: "INCIDENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(gflashduty.DimensionInsightItem).TotalIncidentCnt) + return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalIncidentCnt) }}, {Header: "ACK%", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(gflashduty.DimensionInsightItem).AcknowledgementPct*100) + return fmt.Sprintf("%.0f%%", v.(flashduty.DimensionInsightItem).AcknowledgementPct*100) }}, {Header: "MTTA", Field: func(v any) string { - return output.FormatDurationFloat(v.(gflashduty.DimensionInsightItem).MeanSecondsToAck) + return output.FormatDurationFloat(v.(flashduty.DimensionInsightItem).MeanSecondsToAck) }}, {Header: "MTTR", Field: func(v any) string { - return output.FormatDurationFloat(v.(gflashduty.DimensionInsightItem).MeanSecondsToClose) + return output.FormatDurationFloat(v.(flashduty.DimensionInsightItem).MeanSecondsToClose) }}, {Header: "NOISE_REDUCTION", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(gflashduty.DimensionInsightItem).NoiseReductionPct*100) + return fmt.Sprintf("%.0f%%", v.(flashduty.DimensionInsightItem).NoiseReductionPct*100) }}, {Header: "ALERTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(gflashduty.DimensionInsightItem).TotalAlertCnt) + return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalAlertCnt) }}, {Header: "EVENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(gflashduty.DimensionInsightItem).TotalAlertEventCnt) + return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalAlertEventCnt) }}, } @@ -156,7 +156,7 @@ func newInsightResponderCmd() *cobra.Command { Use: "responder", Short: "Query insights by responder", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -166,7 +166,7 @@ func newInsightResponderCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, _, err := ctx.GFClient.Analytics.ByResponder(cmdContext(ctx.Cmd), &gflashduty.InsightQueryRequest{ + result, _, err := ctx.Client.Analytics.ByResponder(cmdContext(ctx.Cmd), &flashduty.InsightQueryRequest{ StartTime: startTime, EndTime: endTime, }) @@ -176,22 +176,22 @@ func newInsightResponderCmd() *cobra.Command { cols := []output.Column{ {Header: "RESPONDER", MaxWidth: 30, Field: func(v any) string { - return v.(gflashduty.ResponderInsightItem).ResponderName + return v.(flashduty.ResponderInsightItem).ResponderName }}, {Header: "INCIDENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(gflashduty.ResponderInsightItem).TotalIncidentCnt) + return fmt.Sprintf("%d", v.(flashduty.ResponderInsightItem).TotalIncidentCnt) }}, {Header: "ACK%", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(gflashduty.ResponderInsightItem).AcknowledgementPct*100) + return fmt.Sprintf("%.0f%%", v.(flashduty.ResponderInsightItem).AcknowledgementPct*100) }}, {Header: "MTTA", Field: func(v any) string { - return output.FormatDurationFloat(v.(gflashduty.ResponderInsightItem).MeanSecondsToAck) + return output.FormatDurationFloat(v.(flashduty.ResponderInsightItem).MeanSecondsToAck) }}, {Header: "INTERRUPTIONS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(gflashduty.ResponderInsightItem).TotalInterruptions) + return fmt.Sprintf("%d", v.(flashduty.ResponderInsightItem).TotalInterruptions) }}, {Header: "ENGAGED", Field: func(v any) string { - return output.FormatDuration(int(v.(gflashduty.ResponderInsightItem).TotalEngagedSeconds)) + return output.FormatDuration(int(v.(flashduty.ResponderInsightItem).TotalEngagedSeconds)) }}, } @@ -214,7 +214,7 @@ func newInsightTopAlertsCmd() *cobra.Command { Use: "top-alerts", Short: "Query top alert sources by label", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -224,7 +224,7 @@ func newInsightTopAlertsCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, _, err := ctx.GFClient.Analytics.TopkAlertsByLabel(cmdContext(ctx.Cmd), &gflashduty.InsightTopkAlertByLabelRequest{ + result, _, err := ctx.Client.Analytics.TopkAlertsByLabel(cmdContext(ctx.Cmd), &flashduty.InsightTopkAlertByLabelRequest{ StartTime: startTime, EndTime: endTime, Label: label, @@ -236,13 +236,13 @@ func newInsightTopAlertsCmd() *cobra.Command { cols := []output.Column{ {Header: "LABEL", MaxWidth: 50, Field: func(v any) string { - return v.(gflashduty.InsightAlertByLabelItem).Label + return v.(flashduty.InsightAlertByLabelItem).Label }}, {Header: "ALERTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(gflashduty.InsightAlertByLabelItem).TotalAlertCnt) + return fmt.Sprintf("%d", v.(flashduty.InsightAlertByLabelItem).TotalAlertCnt) }}, {Header: "EVENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(gflashduty.InsightAlertByLabelItem).TotalAlertEventCnt) + return fmt.Sprintf("%d", v.(flashduty.InsightAlertByLabelItem).TotalAlertEventCnt) }}, } @@ -268,7 +268,7 @@ func newInsightIncidentsCmd() *cobra.Command { Use: "incidents", Short: "Query incidents with performance metrics", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -278,39 +278,39 @@ func newInsightIncidentsCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - req := &gflashduty.InsightIncidentListRequest{ + req := &flashduty.InsightIncidentListRequest{ StartTime: startTime, EndTime: endTime, } req.Limit = limit req.Page = page - result, _, err := ctx.GFClient.Analytics.IncidentList(cmdContext(ctx.Cmd), req) + result, _, err := ctx.Client.Analytics.IncidentList(cmdContext(ctx.Cmd), req) if err != nil { return err } cols := []output.Column{ {Header: "ID", Field: func(v any) string { - return v.(gflashduty.IncidentRawItem).IncidentID + return v.(flashduty.IncidentRawItem).IncidentID }}, {Header: "TITLE", MaxWidth: 40, Field: func(v any) string { - return v.(gflashduty.IncidentRawItem).Title + return v.(flashduty.IncidentRawItem).Title }}, {Header: "SEVERITY", Field: func(v any) string { - return v.(gflashduty.IncidentRawItem).Severity + return v.(flashduty.IncidentRawItem).Severity }}, {Header: "CHANNEL", MaxWidth: 20, Field: func(v any) string { - return v.(gflashduty.IncidentRawItem).ChannelName + return v.(flashduty.IncidentRawItem).ChannelName }}, {Header: "MTTA", Field: func(v any) string { - return output.FormatDuration(int(v.(gflashduty.IncidentRawItem).SecondsToAck)) + return output.FormatDuration(int(v.(flashduty.IncidentRawItem).SecondsToAck)) }}, {Header: "MTTR", Field: func(v any) string { - return output.FormatDuration(int(v.(gflashduty.IncidentRawItem).SecondsToClose)) + return output.FormatDuration(int(v.(flashduty.IncidentRawItem).SecondsToClose)) }}, {Header: "NOTIFICATIONS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(gflashduty.IncidentRawItem).Notifications) + return fmt.Sprintf("%d", v.(flashduty.IncidentRawItem).Notifications) }}, } diff --git a/internal/cli/login.go b/internal/cli/login.go index 770171b..f5f39fe 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -6,7 +6,7 @@ import ( "os" "time" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "golang.org/x/term" @@ -30,7 +30,7 @@ func newLoginCmd() *cobra.Command { return fmt.Errorf("app key cannot be empty") } - client, err := gflashduty.NewClient(appKey, gflashduty.WithLogger(&silentLogger{})) + client, err := flashduty.NewClient(appKey, flashduty.WithLogger(&silentLogger{})) if err != nil { return fmt.Errorf("invalid app key: %w", err) } diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 86b2273..6f4347d 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" ) @@ -36,7 +36,7 @@ func newMCPCreateCmd() *cobra.Command { Use: "create", Short: "Register an MCP server", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { if strings.TrimSpace(serverName) == "" { return fmt.Errorf("--server-name is required") } @@ -48,7 +48,7 @@ func newMCPCreateCmd() *cobra.Command { if err != nil { return fmt.Errorf("invalid --headers: %w", err) } - input := &gflashduty.McpServerCreateRequest{ + input := &flashduty.McpServerCreateRequest{ ServerName: serverName, Description: description, Transport: transport, @@ -61,7 +61,7 @@ func newMCPCreateCmd() *cobra.Command { CallTimeout: int64(callTimeout), TeamID: teamID, } - result, _, err := ctx.GFClient.McpServers.WriteServerCreate(cmdContext(ctx.Cmd), input) + result, _, err := ctx.Client.McpServers.WriteServerCreate(cmdContext(ctx.Cmd), input) if err != nil { return err } diff --git a/internal/cli/member.go b/internal/cli/member.go index 4ab08fb..7dd0a21 100644 --- a/internal/cli/member.go +++ b/internal/cli/member.go @@ -4,7 +4,7 @@ import ( "fmt" "strconv" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -27,7 +27,7 @@ func newMemberListCmd() *cobra.Command { Use: "list", Short: "List members", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { // go-flashduty's MemberListRequest exposes a single search // keyword (Query); the legacy SDK split name/email into separate // filters. Both --name and --email are keyword searches against @@ -36,12 +36,12 @@ func newMemberListCmd() *cobra.Command { if query == "" { query = email } - req := &gflashduty.MemberListRequest{ + req := &flashduty.MemberListRequest{ Query: query, } req.Page = page - result, _, err := ctx.GFClient.Members.MemberList(cmdContext(ctx.Cmd), req) + result, _, err := ctx.Client.Members.MemberList(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -50,11 +50,11 @@ func newMemberListCmd() *cobra.Command { // "no members" path (structured: empty set; plain: a message). if len(result.Items) > 0 { cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.FormatUint(v.(gflashduty.MemberItem).MemberID, 10) }}, - {Header: "NAME", Field: func(v any) string { return v.(gflashduty.MemberItem).MemberName }}, - {Header: "EMAIL", Field: func(v any) string { return v.(gflashduty.MemberItem).Email }}, - {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.MemberItem).Status }}, - {Header: "TIMEZONE", Field: func(v any) string { return v.(gflashduty.MemberItem).TimeZone }}, + {Header: "ID", Field: func(v any) string { return strconv.FormatUint(v.(flashduty.MemberItem).MemberID, 10) }}, + {Header: "NAME", Field: func(v any) string { return v.(flashduty.MemberItem).MemberName }}, + {Header: "EMAIL", Field: func(v any) string { return v.(flashduty.MemberItem).Email }}, + {Header: "STATUS", Field: func(v any) string { return v.(flashduty.MemberItem).Status }}, + {Header: "TIMEZONE", Field: func(v any) string { return v.(flashduty.MemberItem).TimeZone }}, } if err := ctx.Printer.Print(result.Items, cols); err != nil { return err diff --git a/internal/cli/monit_agent.go b/internal/cli/monit_agent.go index fe205d1..f5dbc6f 100644 --- a/internal/cli/monit_agent.go +++ b/internal/cli/monit_agent.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" ) @@ -27,12 +27,12 @@ func newMonitAgentCatalogCmd() *cobra.Command { if targetLocator == "" { return fmt.Errorf("--target-locator is required") } - return runGFCommand(cmd, args, func(ctx *RunContext) error { - input := &gflashduty.ToolCatalogRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + input := &flashduty.ToolCatalogRequest{ TargetKind: targetKind, TargetLocator: targetLocator, } - result, _, err := ctx.GFClient.Diagnostics.ToolsCatalog(cmdContext(ctx.Cmd), input) + result, _, err := ctx.Client.Diagnostics.ToolsCatalog(cmdContext(ctx.Cmd), input) if err != nil { return err } @@ -71,13 +71,13 @@ func newMonitAgentInvokeCmd() *cobra.Command { return fmt.Errorf("invalid --tool-spec: %w", err) } - return runGFCommand(cmd, args, func(ctx *RunContext) error { - input := &gflashduty.ToolInvokeRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + input := &flashduty.ToolInvokeRequest{ TargetKind: targetKind, TargetLocator: targetLocator, Tools: parsed, } - result, _, err := ctx.GFClient.Diagnostics.ToolsInvoke(cmdContext(ctx.Cmd), input) + result, _, err := ctx.Client.Diagnostics.ToolsInvoke(cmdContext(ctx.Cmd), input) if err != nil { return err } diff --git a/internal/cli/monit_query.go b/internal/cli/monit_query.go index 15333d9..a37c40d 100644 --- a/internal/cli/monit_query.go +++ b/internal/cli/monit_query.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/timeutil" @@ -42,13 +42,13 @@ func newMonitQueryDiagnoseCmd() *cobra.Command { return fmt.Errorf("invalid --time-end: %w", err) } - return runGFCommand(cmd, args, func(ctx *RunContext) error { - input := &gflashduty.DiagnoseRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + input := &flashduty.DiagnoseRequest{ DsType: dsType, DsName: dsName, Operation: operation, - Input: gflashduty.DiagnoseRequestInput{Query: inputQuery}, - TimeRange: gflashduty.DiagnoseRequestTimeRange{Start: startTime, End: endTime}, + Input: flashduty.DiagnoseRequestInput{Query: inputQuery}, + TimeRange: flashduty.DiagnoseRequestTimeRange{Start: startTime, End: endTime}, } if maxLogs > 0 { input.Options.MaxLogsScanned = int64(maxLogs) @@ -60,7 +60,7 @@ func newMonitQueryDiagnoseCmd() *cobra.Command { input.Options.TimeoutSeconds = int64(timeoutSeconds) } - result, _, err := ctx.GFClient.Diagnostics.QueryDiagnose(cmdContext(ctx.Cmd), input) + result, _, err := ctx.Client.Diagnostics.QueryDiagnose(cmdContext(ctx.Cmd), input) if err != nil { return err } @@ -100,14 +100,14 @@ func newMonitQueryRowsCmd() *cobra.Command { return fmt.Errorf("invalid --args: %w", err) } - return runGFCommand(cmd, args, func(ctx *RunContext) error { - input := &gflashduty.QueryRowsRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + input := &flashduty.QueryRowsRequest{ DsType: dsType, DsName: dsName, Expr: expr, Args: argsMap, } - result, _, err := ctx.GFClient.Diagnostics.QueryRows(cmdContext(ctx.Cmd), input) + result, _, err := ctx.Client.Diagnostics.QueryRows(cmdContext(ctx.Cmd), input) if err != nil { return err } diff --git a/internal/cli/oncall.go b/internal/cli/oncall.go index c4b7b7b..dd1d2ee 100644 --- a/internal/cli/oncall.go +++ b/internal/cli/oncall.go @@ -5,7 +5,7 @@ import ( "strconv" "strings" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -40,7 +40,7 @@ func newOncallWhoCmd() *cobra.Command { Use: "who", Short: "Show who is currently on call", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -50,7 +50,7 @@ func newOncallWhoCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - req := &gflashduty.ScheduleListRequest{ + req := &flashduty.ScheduleListRequest{ Start: startTime, End: endTime, Query: query, @@ -66,7 +66,7 @@ func newOncallWhoCmd() *cobra.Command { req.TeamIDs = teamIDs } - result, _, err := ctx.GFClient.Schedules.List(cmdContext(ctx.Cmd), req) + result, _, err := ctx.Client.Schedules.List(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -76,17 +76,17 @@ func newOncallWhoCmd() *cobra.Command { cols := []output.Column{ {Header: "SCHEDULE", MaxWidth: 30, Field: func(v any) string { - return scheduleDisplayName(v.(gflashduty.ScheduleItem)) + return scheduleDisplayName(v.(flashduty.ScheduleItem)) }}, {Header: "ON_CALL", MaxWidth: 40, Field: func(v any) string { - s := v.(gflashduty.ScheduleItem) + s := v.(flashduty.ScheduleItem) return formatOncallMembers(&s.CurOncall, nameByID) }}, {Header: "UNTIL", Field: func(v any) string { - return output.FormatTime(v.(gflashduty.ScheduleItem).CurOncall.End) + return output.FormatTime(v.(flashduty.ScheduleItem).CurOncall.End) }}, {Header: "NEXT", MaxWidth: 40, Field: func(v any) string { - s := v.(gflashduty.ScheduleItem) + s := v.(flashduty.ScheduleItem) return formatOncallMembers(&s.NextOncall, nameByID) }}, } @@ -114,7 +114,7 @@ func newOncallScheduleListCmd() *cobra.Command { Use: "list", Short: "List schedules", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -124,7 +124,7 @@ func newOncallScheduleListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - req := &gflashduty.ScheduleListRequest{ + req := &flashduty.ScheduleListRequest{ Start: startTime, End: endTime, Query: query, @@ -140,27 +140,27 @@ func newOncallScheduleListCmd() *cobra.Command { req.TeamIDs = teamIDs } - result, _, err := ctx.GFClient.Schedules.List(cmdContext(ctx.Cmd), req) + result, _, err := ctx.Client.Schedules.List(cmdContext(ctx.Cmd), req) if err != nil { return err } cols := []output.Column{ {Header: "ID", Field: func(v any) string { - return strconv.FormatInt(scheduleID(v.(gflashduty.ScheduleItem)), 10) + return strconv.FormatInt(scheduleID(v.(flashduty.ScheduleItem)), 10) }}, {Header: "NAME", MaxWidth: 30, Field: func(v any) string { - return scheduleDisplayName(v.(gflashduty.ScheduleItem)) + return scheduleDisplayName(v.(flashduty.ScheduleItem)) }}, {Header: "STATUS", Field: func(v any) string { - s := v.(gflashduty.ScheduleItem) + s := v.(flashduty.ScheduleItem) if s.Disabled != 0 { return "disabled" } return "enabled" }}, {Header: "LAYERS", Field: func(v any) string { - return scheduleLayerCount(v.(gflashduty.ScheduleItem)) + return scheduleLayerCount(v.(flashduty.ScheduleItem)) }}, } @@ -187,7 +187,7 @@ func newOncallScheduleGetCmd() *cobra.Command { Short: "Get schedule detail", Args: requireArgs("schedule_id"), RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { scheduleIDArg, err := strconv.ParseInt(ctx.Args[0], 10, 64) if err != nil { return fmt.Errorf("invalid schedule_id %q: %w", ctx.Args[0], err) @@ -202,7 +202,7 @@ func newOncallScheduleGetCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - s, _, err := ctx.GFClient.Schedules.Info(cmdContext(ctx.Cmd), &gflashduty.ScheduleInfoRequest{ + s, _, err := ctx.Client.Schedules.Info(cmdContext(ctx.Cmd), &flashduty.ScheduleInfoRequest{ ScheduleID: scheduleIDArg, Start: startTime, End: endTime, @@ -216,7 +216,7 @@ func newOncallScheduleGetCmd() *cobra.Command { } // Resolve on-call person IDs to display names (best-effort). - nameByID := resolveScheduleOncallPeople(ctx, []gflashduty.ScheduleItem{*s}) + nameByID := resolveScheduleOncallPeople(ctx, []flashduty.ScheduleItem{*s}) status := "enabled" if s.Disabled != 0 { @@ -242,13 +242,13 @@ func newOncallScheduleGetCmd() *cobra.Command { cols := []output.Column{ {Header: "START", Field: func(v any) string { - return output.FormatTime(v.(gflashduty.ScheduleCalculatedSchedule).Start) + return output.FormatTime(v.(flashduty.ScheduleCalculatedSchedule).Start) }}, {Header: "END", Field: func(v any) string { - return output.FormatTime(v.(gflashduty.ScheduleCalculatedSchedule).End) + return output.FormatTime(v.(flashduty.ScheduleCalculatedSchedule).End) }}, {Header: "GROUP", MaxWidth: 30, Field: func(v any) string { - g := v.(gflashduty.ScheduleCalculatedSchedule).Group + g := v.(flashduty.ScheduleCalculatedSchedule).Group if g.GroupName != "" { return g.GroupName } @@ -272,7 +272,7 @@ func newOncallScheduleGetCmd() *cobra.Command { // scheduleID returns the schedule's numeric ID, preferring schedule_id and // falling back to the legacy id field. -func scheduleID(s gflashduty.ScheduleItem) int64 { +func scheduleID(s flashduty.ScheduleItem) int64 { if s.ScheduleID != 0 { return s.ScheduleID } @@ -281,7 +281,7 @@ func scheduleID(s gflashduty.ScheduleItem) int64 { // scheduleDisplayName returns the schedule's display name, preferring // schedule_name and falling back to the legacy name field. -func scheduleDisplayName(s gflashduty.ScheduleItem) string { +func scheduleDisplayName(s flashduty.ScheduleItem) string { if s.ScheduleName != "" { return s.ScheduleName } @@ -291,7 +291,7 @@ func scheduleDisplayName(s gflashduty.ScheduleItem) string { return "-" } -func scheduleLayerCount(s gflashduty.ScheduleItem) string { +func scheduleLayerCount(s flashduty.ScheduleItem) string { switch { case len(s.Layers) > 0: return fmt.Sprintf("%d", len(s.Layers)) @@ -307,7 +307,7 @@ func scheduleLayerCount(s gflashduty.ScheduleItem) string { // formatOncallMembers renders an on-call group's members as display names, // resolving person IDs through nameByID (best-effort, falling back to the // numeric ID), and finally to the group name when no members are present. -func formatOncallMembers(oncall *gflashduty.ScheduleOncallGroup, nameByID map[int64]string) string { +func formatOncallMembers(oncall *flashduty.ScheduleOncallGroup, nameByID map[int64]string) string { if oncall == nil { return "-" } @@ -339,10 +339,10 @@ func formatOncallMembers(oncall *gflashduty.ScheduleOncallGroup, nameByID map[in // via /person/infos, replicating the name lookup the legacy SDK fronted. // Best-effort: a lookup failure yields a nil map and callers fall back to the // numeric ID. -func resolveScheduleOncallPeople(rc *RunContext, items []gflashduty.ScheduleItem) map[int64]string { +func resolveScheduleOncallPeople(rc *RunContext, items []flashduty.ScheduleItem) map[int64]string { seen := make(map[int64]struct{}) ids := make([]uint64, 0) - collect := func(g gflashduty.ScheduleOncallGroup) { + collect := func(g flashduty.ScheduleOncallGroup) { for _, m := range g.Group.Members { for _, pid := range m.PersonIDs { if pid == 0 { @@ -363,7 +363,7 @@ func resolveScheduleOncallPeople(rc *RunContext, items []gflashduty.ScheduleItem if len(ids) == 0 { return nil } - resp, _, err := rc.GFClient.Members.PersonInfos(cmdContext(rc.Cmd), &gflashduty.PersonInfosRequest{PersonIDs: ids}) + resp, _, err := rc.Client.Members.PersonInfos(cmdContext(rc.Cmd), &flashduty.PersonInfosRequest{PersonIDs: ids}) if err != nil || resp == nil { return nil } diff --git a/internal/cli/oncall_test.go b/internal/cli/oncall_test.go index 5edeeac..0074a91 100644 --- a/internal/cli/oncall_test.go +++ b/internal/cli/oncall_test.go @@ -3,33 +3,33 @@ package cli import ( "testing" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" ) func TestScheduleLayerCount(t *testing.T) { tests := []struct { name string - input gflashduty.ScheduleItem + input flashduty.ScheduleItem want string }{ { name: "raw layers", - input: gflashduty.ScheduleItem{Layers: []gflashduty.ScheduleLayer{{}, {}}}, + input: flashduty.ScheduleItem{Layers: []flashduty.ScheduleLayer{{}, {}}}, want: "2", }, { name: "schedule layers fallback", - input: gflashduty.ScheduleItem{ScheduleLayers: []gflashduty.ScheduleCalculatedLayer{{}, {}, {}}}, + input: flashduty.ScheduleItem{ScheduleLayers: []flashduty.ScheduleCalculatedLayer{{}, {}, {}}}, want: "3", }, { name: "layer schedules fallback", - input: gflashduty.ScheduleItem{LayerSchedules: []gflashduty.ScheduleCalculatedLayer{{}, {}}}, + input: flashduty.ScheduleItem{LayerSchedules: []flashduty.ScheduleCalculatedLayer{{}, {}}}, want: "2", }, { name: "unknown when no layer arrays are present", - input: gflashduty.ScheduleItem{}, + input: flashduty.ScheduleItem{}, want: "-", }, } diff --git a/internal/cli/postmortem.go b/internal/cli/postmortem.go index a20b49f..0e9baac 100644 --- a/internal/cli/postmortem.go +++ b/internal/cli/postmortem.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -27,8 +27,8 @@ func newPostmortemListCmd() *cobra.Command { Use: "list", Short: "List post-mortem reports", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - req := &gflashduty.ListPostMortemsRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + req := &flashduty.ListPostMortemsRequest{ Status: status, } req.Page = page @@ -66,18 +66,18 @@ func newPostmortemListCmd() *cobra.Command { req.CreatedAtEndSeconds = endTime } - result, _, err := ctx.GFClient.Incidents.PostMortemList(cmdContext(ctx.Cmd), req) + result, _, err := ctx.Client.Incidents.PostMortemList(cmdContext(ctx.Cmd), req) if err != nil { return err } cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return v.(gflashduty.PostMortemMeta).PostMortemID }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.PostMortemMeta).Title }}, - {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.PostMortemMeta).Status }}, - {Header: "CHANNEL", Field: func(v any) string { return v.(gflashduty.PostMortemMeta).ChannelName }}, + {Header: "ID", Field: func(v any) string { return v.(flashduty.PostMortemMeta).PostMortemID }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.PostMortemMeta).Title }}, + {Header: "STATUS", Field: func(v any) string { return v.(flashduty.PostMortemMeta).Status }}, + {Header: "CHANNEL", Field: func(v any) string { return v.(flashduty.PostMortemMeta).ChannelName }}, {Header: "CREATED", Field: func(v any) string { - return output.FormatTime(v.(gflashduty.PostMortemMeta).CreatedAtSeconds) + return output.FormatTime(v.(flashduty.PostMortemMeta).CreatedAtSeconds) }}, } diff --git a/internal/cli/root.go b/internal/cli/root.go index e3cf418..a35dd87 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -8,7 +8,7 @@ import ( "os" "strings" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" toon "github.com/toon-format/toon-go" "golang.org/x/term" @@ -18,9 +18,9 @@ import ( "github.com/flashcatcloud/flashduty-cli/internal/update" ) -// newGFClientFn creates the go-flashduty client used by all commands. +// newClientFn creates the go-flashduty client used by all commands. // Override in tests to inject a stub server. -var newGFClientFn = defaultNewGFClient +var newClientFn = defaultNewClient var ( flagJSON bool @@ -115,14 +115,14 @@ func Execute() error { return rootCmd.Execute() } -// newGFClient creates a go-flashduty client using the current factory. -func newGFClient() (*gflashduty.Client, error) { - return newGFClientFn() +// newClient creates a go-flashduty client using the current factory. +func newClient() (*flashduty.Client, error) { + return newClientFn() } -// defaultNewGFClient creates a real go-flashduty client from resolved config + +// defaultNewClient creates a real go-flashduty client from resolved config + // flag overrides. This is the typed SDK every command uses. -func defaultNewGFClient() (*gflashduty.Client, error) { +func defaultNewClient() (*flashduty.Client, error) { cfg, err := loadResolvedConfig() if err != nil { return nil, err @@ -132,15 +132,15 @@ func defaultNewGFClient() (*gflashduty.Client, error) { return nil, fmt.Errorf("no app key configured. Run 'flashduty login' or set FLASHDUTY_APP_KEY") } - opts := []gflashduty.Option{ - gflashduty.WithUserAgent("flashduty-cli/" + versionStr), - gflashduty.WithLogger(&silentLogger{}), + opts := []flashduty.Option{ + flashduty.WithUserAgent("flashduty-cli/" + versionStr), + flashduty.WithLogger(&silentLogger{}), } if cfg.BaseURL != "" && cfg.BaseURL != config.DefaultBaseURL { - opts = append(opts, gflashduty.WithBaseURL(cfg.BaseURL)) + opts = append(opts, flashduty.WithBaseURL(cfg.BaseURL)) } - return gflashduty.NewClient(cfg.AppKey, opts...) + return flashduty.NewClient(cfg.AppKey, opts...) } func loadResolvedConfig() (*config.Config, error) { diff --git a/internal/cli/status_page.go b/internal/cli/status_page.go index 0ce9aaa..8571dc3 100644 --- a/internal/cli/status_page.go +++ b/internal/cli/status_page.go @@ -6,7 +6,7 @@ import ( "strings" "time" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -32,13 +32,13 @@ func newStatusPageListCmd() *cobra.Command { Use: "list", Short: "List status pages", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { pageIDs, err := parseIntSlice(ids) if err != nil { return fmt.Errorf("invalid --id: %w", err) } - result, _, err := ctx.GFClient.StatusPages.ReadPageList(cmdContext(ctx.Cmd)) + result, _, err := ctx.Client.StatusPages.ReadPageList(cmdContext(ctx.Cmd)) if err != nil { return err } @@ -51,7 +51,7 @@ func newStatusPageListCmd() *cobra.Command { for _, id := range pageIDs { want[id] = struct{}{} } - filtered := make([]gflashduty.StatusPageItem, 0, len(pages)) + filtered := make([]flashduty.StatusPageItem, 0, len(pages)) for _, p := range pages { if _, ok := want[p.PageID]; ok { filtered = append(filtered, p) @@ -61,16 +61,16 @@ func newStatusPageListCmd() *cobra.Command { } cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(gflashduty.StatusPageItem).PageID, 10) }}, - {Header: "NAME", Field: func(v any) string { return v.(gflashduty.StatusPageItem).Name }}, - {Header: "SLUG", Field: func(v any) string { return v.(gflashduty.StatusPageItem).URLName }}, + {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(flashduty.StatusPageItem).PageID, 10) }}, + {Header: "NAME", Field: func(v any) string { return v.(flashduty.StatusPageItem).Name }}, + {Header: "SLUG", Field: func(v any) string { return v.(flashduty.StatusPageItem).URLName }}, // STATUS reads the account's overall_status, which the // /status-page/list endpoint does not return. The legacy SDK // likewise never populated it, so this column stays empty — // preserved here to keep the table shape identical. {Header: "STATUS", Field: func(v any) string { return "" }}, {Header: "COMPONENTS", Field: func(v any) string { - comps := v.(gflashduty.StatusPageItem).Components + comps := v.(flashduty.StatusPageItem).Components names := make([]string, 0, len(comps)) for _, c := range comps { names = append(names, c.Name) @@ -97,8 +97,8 @@ func newStatusPageChangesCmd() *cobra.Command { Use: "changes", Short: "List active status page changes", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.GFClient.StatusPages.ChangeActiveList(cmdContext(ctx.Cmd), &gflashduty.StatusPagesChangeActiveListRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.Client.StatusPages.ChangeActiveList(cmdContext(ctx.Cmd), &flashduty.StatusPagesChangeActiveListRequest{ PageID: pageID, Type: changeType, }) @@ -107,17 +107,17 @@ func newStatusPageChangesCmd() *cobra.Command { } cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(gflashduty.StatusPageChangeItem).ChangeID, 10) }}, - {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(gflashduty.StatusPageChangeItem).Title }}, - {Header: "TYPE", Field: func(v any) string { return v.(gflashduty.StatusPageChangeItem).Type }}, - {Header: "STATUS", Field: func(v any) string { return v.(gflashduty.StatusPageChangeItem).Status }}, + {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(flashduty.StatusPageChangeItem).ChangeID, 10) }}, + {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.StatusPageChangeItem).Title }}, + {Header: "TYPE", Field: func(v any) string { return v.(flashduty.StatusPageChangeItem).Type }}, + {Header: "STATUS", Field: func(v any) string { return v.(flashduty.StatusPageChangeItem).Status }}, // The active-list endpoint returns the event's scheduled window // (start_at_seconds / close_at_seconds), not the row's created/ // updated timestamps the legacy SDK reported. The CREATED/UPDATED // headers are preserved to keep the table shape identical; they now // reflect the event start and (scheduled) close times. - {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.StatusPageChangeItem).StartAtSeconds) }}, - {Header: "UPDATED", Field: func(v any) string { return output.FormatTime(v.(gflashduty.StatusPageChangeItem).CloseAtSeconds) }}, + {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.StatusPageChangeItem).StartAtSeconds) }}, + {Header: "UPDATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.StatusPageChangeItem).CloseAtSeconds) }}, } return ctx.Printer.Print(result.Items, cols) @@ -142,7 +142,7 @@ func newStatusPageCreateIncidentCmd() *cobra.Command { Use: "create-incident", Short: "Create a status page incident", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { // Replicate the legacy SDK's request shaping exactly: default the // status to "investigating", build a single timeline update carrying // the message and any parsed component_changes, and fall back to the @@ -150,7 +150,7 @@ func newStatusPageCreateIncidentCmd() *cobra.Command { // byte-for-byte equivalent so the migration introduces no drift. const status = "investigating" - update := gflashduty.CreateStatusPageChangeRequestUpdatesItem{ + update := flashduty.CreateStatusPageChangeRequestUpdatesItem{ AtSeconds: time.Now().Unix(), Status: status, } @@ -161,12 +161,12 @@ func newStatusPageCreateIncidentCmd() *cobra.Command { for _, part := range parseStringSlice(components) { kv := strings.SplitN(part, ":", 2) if len(kv) == 2 { - update.ComponentChanges = append(update.ComponentChanges, gflashduty.CreateStatusPageChangeRequestUpdatesItemComponentChangesItem{ + update.ComponentChanges = append(update.ComponentChanges, flashduty.CreateStatusPageChangeRequestUpdatesItemComponentChangesItem{ ComponentID: strings.TrimSpace(kv[0]), Status: strings.TrimSpace(kv[1]), }) } else if len(kv) == 1 && kv[0] != "" { - update.ComponentChanges = append(update.ComponentChanges, gflashduty.CreateStatusPageChangeRequestUpdatesItemComponentChangesItem{ + update.ComponentChanges = append(update.ComponentChanges, flashduty.CreateStatusPageChangeRequestUpdatesItemComponentChangesItem{ ComponentID: strings.TrimSpace(kv[0]), Status: "partial_outage", }) @@ -179,13 +179,13 @@ func newStatusPageCreateIncidentCmd() *cobra.Command { description = title } - result, _, err := ctx.GFClient.StatusPages.ChangeCreate(cmdContext(ctx.Cmd), &gflashduty.CreateStatusPageChangeRequest{ + result, _, err := ctx.Client.StatusPages.ChangeCreate(cmdContext(ctx.Cmd), &flashduty.CreateStatusPageChangeRequest{ PageID: pageID, Title: title, Type: "incident", Status: status, Description: description, - Updates: []gflashduty.CreateStatusPageChangeRequestUpdatesItem{update}, + Updates: []flashduty.CreateStatusPageChangeRequestUpdatesItem{update}, NotifySubscribers: notify, }) if err != nil { @@ -221,8 +221,8 @@ func newStatusPageCreateTimelineCmd() *cobra.Command { Use: "create-timeline", Short: "Add a timeline update to a status page change", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - _, _, err := ctx.GFClient.StatusPages.ChangeTimelineCreate(cmdContext(ctx.Cmd), &gflashduty.CreateStatusPageChangeTimelineRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + _, _, err := ctx.Client.StatusPages.ChangeTimelineCreate(cmdContext(ctx.Cmd), &flashduty.CreateStatusPageChangeTimelineRequest{ PageID: pageID, ChangeID: changeID, Description: message, diff --git a/internal/cli/status_page_migrate.go b/internal/cli/status_page_migrate.go index b1d7309..f5a65bc 100644 --- a/internal/cli/status_page_migrate.go +++ b/internal/cli/status_page_migrate.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" ) @@ -34,17 +34,17 @@ func newStatusPageMigrateStructureCmd() *cobra.Command { if err := validateMigrationSource(source); err != nil { return err } - return runGFCommand(cmd, args, func(ctx *RunContext) error { - req := &gflashduty.MigrateStatusPageStructureRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + req := &flashduty.MigrateStatusPageStructureRequest{ APIKey: sourceAPIKey, SourcePageID: sourcePageID, } // url_name is *string (tri-state): set it only when the user // provided one, so a nil pointer reuses the source page's name. if urlName != "" { - req.URLName = gflashduty.String(urlName) + req.URLName = flashduty.String(urlName) } - result, _, err := ctx.GFClient.StatusPages.MigrateStructure(cmdContext(ctx.Cmd), req) + result, _, err := ctx.Client.StatusPages.MigrateStructure(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -78,8 +78,8 @@ func newStatusPageMigrateEmailSubscribersCmd() *cobra.Command { if err := validateMigrationSource(source); err != nil { return err } - return runGFCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.GFClient.StatusPages.MigrateEmailSubscribers(cmdContext(ctx.Cmd), &gflashduty.MigrateStatusPageEmailSubscribersRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.Client.StatusPages.MigrateEmailSubscribers(cmdContext(ctx.Cmd), &flashduty.MigrateStatusPageEmailSubscribersRequest{ APIKey: sourceAPIKey, SourcePageID: sourcePageID, TargetPageID: targetPageID, @@ -112,8 +112,8 @@ func newStatusPageMigrateStatusCmd() *cobra.Command { Use: "status", Short: "Show migration job status", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - job, _, err := ctx.GFClient.StatusPages.MigrationStatus(cmdContext(ctx.Cmd), &gflashduty.StatusPagesMigrationStatusRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + job, _, err := ctx.Client.StatusPages.MigrationStatus(cmdContext(ctx.Cmd), &flashduty.StatusPagesMigrationStatusRequest{ JobID: jobID, }) if err != nil { @@ -138,8 +138,8 @@ func newStatusPageMigrateCancelCmd() *cobra.Command { Use: "cancel", Short: "Cancel a running migration job", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - if _, err := ctx.GFClient.StatusPages.MigrationCancel(cmdContext(ctx.Cmd), &gflashduty.CancelStatusPageMigrationRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + if _, err := ctx.Client.StatusPages.MigrationCancel(cmdContext(ctx.Cmd), &flashduty.CancelStatusPageMigrationRequest{ JobID: jobID, }); err != nil { return err @@ -184,7 +184,7 @@ func validateMigrationSource(source string) error { return nil } -func printMigrationStart(ctx *RunContext, migrationType, source, sourcePageID string, targetPageID int64, result *gflashduty.StatusPageMigrationStartResponse) error { +func printMigrationStart(ctx *RunContext, migrationType, source, sourcePageID string, targetPageID int64, result *flashduty.StatusPageMigrationStartResponse) error { if ctx.Structured() { payload := map[string]any{ "type": migrationType, @@ -227,7 +227,7 @@ func printMigrationStart(ctx *RunContext, migrationType, source, sourcePageID st return err } -func printMigrationStatus(ctx *RunContext, job *gflashduty.StatusPageMigrationJob) error { +func printMigrationStatus(ctx *RunContext, job *flashduty.StatusPageMigrationJob) error { if ctx.Structured() { return ctx.Printer.Print(job, nil) } diff --git a/internal/cli/status_page_migrate_test.go b/internal/cli/status_page_migrate_test.go index 699318d..037da92 100644 --- a/internal/cli/status_page_migrate_test.go +++ b/internal/cli/status_page_migrate_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" ) // TestCommandStatusPageMigrateStructureSendsSDKInput asserts the structure @@ -366,7 +366,7 @@ func TestCommandStatusPageMigrateStatusPropagatesSDKError(t *testing.T) { // gfStub always replies with a success ("OK") envelope, so to exercise the // error path we stand up a tiny server that returns a failure envelope and - // wire newGFClientFn at it directly. The client surfaces the envelope's + // wire newClientFn at it directly. The client surfaces the envelope's // error.code/message in the returned error. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -376,8 +376,8 @@ func TestCommandStatusPageMigrateStatusPropagatesSDKError(t *testing.T) { }) })) t.Cleanup(srv.Close) - newGFClientFn = func() (*gflashduty.Client, error) { - return gflashduty.NewClient("test-key", gflashduty.WithBaseURL(srv.URL)) + newClientFn = func() (*flashduty.Client, error) { + return flashduty.NewClient("test-key", flashduty.WithBaseURL(srv.URL)) } _, err := execCommand("statuspage", "migrate", "status", "--job-id", "nope") diff --git a/internal/cli/team.go b/internal/cli/team.go index b0a1bd7..30519a5 100644 --- a/internal/cli/team.go +++ b/internal/cli/team.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -51,8 +51,8 @@ Examples: flashduty team list --person-id 12345 --limit 50 flashduty team list --orderby team_name --asc`, RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.GFClient.Teams.ReadInfos(cmdContext(ctx.Cmd), &gflashduty.TeamInfosRequest{}) + return runCommand(cmd, args, func(ctx *RunContext) error { + result, _, err := ctx.Client.Teams.ReadInfos(cmdContext(ctx.Cmd), &flashduty.TeamInfosRequest{}) if err != nil { return err } @@ -102,8 +102,8 @@ Examples: return requireExactlyOneFlag(cmd, "id", "name", "ref-id") }, RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { - team, _, err := ctx.GFClient.Teams.ReadInfo(cmdContext(ctx.Cmd), &gflashduty.TeamInfoRequest{ + return runCommand(cmd, args, func(ctx *RunContext) error { + team, _, err := ctx.Client.Teams.ReadInfo(cmdContext(ctx.Cmd), &flashduty.TeamInfoRequest{ TeamID: uint64(teamID), TeamName: teamName, RefID: refID, @@ -156,13 +156,13 @@ Examples: flashduty team create --name "SRE Team" --emails alice@example.com,bob@example.com flashduty team create --name "SRE Team" --ref-id "hr-dept-42" --json`, RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { ids, err := parseIntSlice(personIDs) if err != nil { return fmt.Errorf("invalid --person-ids: %w", err) } - result, _, err := ctx.GFClient.Teams.WriteUpsert(cmdContext(ctx.Cmd), &gflashduty.TeamUpsertRequest{ + result, _, err := ctx.Client.Teams.WriteUpsert(cmdContext(ctx.Cmd), &flashduty.TeamUpsertRequest{ TeamName: name, Description: description, PersonIDs: toUint64Slice(ids), @@ -219,7 +219,7 @@ Examples: return fmt.Errorf("--id is required") } - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { ids, err := parseIntSlice(personIDs) if err != nil { return fmt.Errorf("invalid --person-ids: %w", err) @@ -229,7 +229,7 @@ Examples: // provide --name, fetch the current name so we don't clear it. teamName := name if !cmd.Flags().Changed("name") { - existing, _, err := ctx.GFClient.Teams.ReadInfo(cmdContext(ctx.Cmd), &gflashduty.TeamInfoRequest{ + existing, _, err := ctx.Client.Teams.ReadInfo(cmdContext(ctx.Cmd), &flashduty.TeamInfoRequest{ TeamID: uint64(teamID), }) if err != nil { @@ -238,7 +238,7 @@ Examples: teamName = existing.TeamName } - req := &gflashduty.TeamUpsertRequest{ + req := &flashduty.TeamUpsertRequest{ TeamID: uint64(teamID), TeamName: teamName, } @@ -255,7 +255,7 @@ Examples: req.RefID = refID } - result, _, err := ctx.GFClient.Teams.WriteUpsert(cmdContext(ctx.Cmd), req) + result, _, err := ctx.Client.Teams.WriteUpsert(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -302,14 +302,14 @@ Examples: return requireExactlyOneFlag(cmd, "id", "name", "ref-id") }, RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { identifier := identifierDescription(teamID, teamName, refID) if !confirmAction(ctx.Cmd, fmt.Sprintf("Are you sure you want to delete team %s?", identifier)) { _, _ = fmt.Fprintln(ctx.Writer, "Aborted.") return nil } - _, err := ctx.GFClient.Teams.WriteDelete(cmdContext(ctx.Cmd), &gflashduty.TeamDeleteRequest{ + _, err := ctx.Client.Teams.WriteDelete(cmdContext(ctx.Cmd), &flashduty.TeamDeleteRequest{ TeamID: uint64(teamID), TeamName: teamName, RefID: refID, @@ -337,10 +337,10 @@ Examples: // ID when a name can't be resolved. func teamListColumns(nameByID map[uint64]string) []output.Column { return []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.FormatUint(v.(gflashduty.TeamBriefItem).TeamID, 10) }}, - {Header: "NAME", Field: func(v any) string { return v.(gflashduty.TeamBriefItem).TeamName }}, + {Header: "ID", Field: func(v any) string { return strconv.FormatUint(v.(flashduty.TeamBriefItem).TeamID, 10) }}, + {Header: "NAME", Field: func(v any) string { return v.(flashduty.TeamBriefItem).TeamName }}, {Header: "MEMBERS", MaxWidth: 50, Field: func(v any) string { - ids := v.(gflashduty.TeamBriefItem).PersonIDs + ids := v.(flashduty.TeamBriefItem).PersonIDs names := make([]string, 0, len(ids)) for _, id := range ids { if n, ok := nameByID[id]; ok && n != "" { @@ -354,7 +354,7 @@ func teamListColumns(nameByID map[uint64]string) []output.Column { } } -func printTeamDetail(w io.Writer, team *gflashduty.TeamItem, members []string) { +func printTeamDetail(w io.Writer, team *flashduty.TeamItem, members []string) { if len(members) == 0 { for _, id := range team.PersonIDs { members = append(members, strconv.FormatUint(id, 10)) @@ -377,7 +377,7 @@ func printTeamDetail(w io.Writer, team *gflashduty.TeamItem, members []string) { // to display names via /person/infos, replicating the name enrichment the // legacy SDK did server-side. Best-effort: a lookup failure yields a nil map and // callers fall back to the numeric ID. -func resolveTeamMemberNames(rc *RunContext, items []gflashduty.TeamBriefItem) map[uint64]string { +func resolveTeamMemberNames(rc *RunContext, items []flashduty.TeamBriefItem) map[uint64]string { seen := make(map[uint64]struct{}) ids := make([]uint64, 0) for _, it := range items { @@ -395,7 +395,7 @@ func resolveTeamMemberNames(rc *RunContext, items []gflashduty.TeamBriefItem) ma if len(ids) == 0 { return nil } - resp, _, err := rc.GFClient.Members.PersonInfos(cmdContext(rc.Cmd), &gflashduty.PersonInfosRequest{PersonIDs: ids}) + resp, _, err := rc.Client.Members.PersonInfos(cmdContext(rc.Cmd), &flashduty.PersonInfosRequest{PersonIDs: ids}) if err != nil || resp == nil { return nil } @@ -420,7 +420,7 @@ func resolveTeamMemberInfos(rc *RunContext, personIDs []uint64) []string { if len(ids) == 0 { return nil } - resp, _, err := rc.GFClient.Members.PersonInfos(cmdContext(rc.Cmd), &gflashduty.PersonInfosRequest{PersonIDs: ids}) + resp, _, err := rc.Client.Members.PersonInfos(cmdContext(rc.Cmd), &flashduty.PersonInfosRequest{PersonIDs: ids}) if err != nil || resp == nil { return nil } diff --git a/internal/cli/template.go b/internal/cli/template.go index 0298701..9861f2f 100644 --- a/internal/cli/template.go +++ b/internal/cli/template.go @@ -5,7 +5,7 @@ import ( "os" "strings" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -16,7 +16,7 @@ import ( // templateChannels map; TemplateItem exposes those same fields as named struct // members, so this switch reproduces that selection with no behavior change. An // unknown field name yields "". -func presetTemplateField(t *gflashduty.TemplateItem, fieldName string) string { +func presetTemplateField(t *flashduty.TemplateItem, fieldName string) string { switch fieldName { case "dingtalk": return t.Dingtalk @@ -68,13 +68,13 @@ func newTemplateGetPresetCmd() *cobra.Command { Use: "get-preset", Short: "Get the preset template for a channel", RunE: func(cmd *cobra.Command, args []string) error { - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { fieldName, ok := templateChannels[channel] if !ok { return fmt.Errorf("unknown channel: %s", channel) } - item, _, err := ctx.GFClient.NotificationTemplates.ReadInfo(cmdContext(ctx.Cmd), &gflashduty.TemplateIDRequest{ + item, _, err := ctx.Client.NotificationTemplates.ReadInfo(cmdContext(ctx.Cmd), &flashduty.TemplateIDRequest{ TemplateID: presetTemplateID, }) if err != nil { @@ -122,13 +122,13 @@ func newTemplateValidateCmd() *cobra.Command { return fmt.Errorf("failed to read template file: %w", err) } - return runGFCommand(cmd, args, func(ctx *RunContext) error { + return runCommand(cmd, args, func(ctx *RunContext) error { fieldName, ok := templateChannels[channel] if !ok { return fmt.Errorf("unknown channel: %s", channel) } - preview, _, err := ctx.GFClient.NotificationTemplates.ReadPreview(cmdContext(ctx.Cmd), &gflashduty.PreviewTemplateRequest{ + preview, _, err := ctx.Client.NotificationTemplates.ReadPreview(cmdContext(ctx.Cmd), &flashduty.PreviewTemplateRequest{ Content: string(templateCode), Type: channel, IncidentID: incidentID, diff --git a/internal/cli/whoami.go b/internal/cli/whoami.go index ebaf3c3..c652035 100644 --- a/internal/cli/whoami.go +++ b/internal/cli/whoami.go @@ -11,7 +11,7 @@ func newWhoamiCmd() *cobra.Command { Use: "whoami", Short: "Show the current authenticated identity", RunE: func(cmd *cobra.Command, args []string) error { - client, err := newGFClient() + client, err := newClient() if err != nil { return err } diff --git a/internal/output/structured_time_test.go b/internal/output/structured_time_test.go index 66ef8aa..b3a0af5 100644 --- a/internal/output/structured_time_test.go +++ b/internal/output/structured_time_test.go @@ -5,13 +5,13 @@ import ( "strings" "testing" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" ) // row carries a typed go-flashduty Timestamp field so we can prove the structured // printers render it as RFC3339 rather than a raw epoch integer. type row struct { - StartTime gflashduty.Timestamp `json:"start_time" toon:"start_time"` + StartTime flashduty.Timestamp `json:"start_time" toon:"start_time"` } // TestStructuredTimeIsRFC3339 is the regression guard for the typed-timestamp @@ -21,7 +21,7 @@ type row struct { func TestStructuredTimeIsRFC3339(t *testing.T) { // 2026-05-28T08:00:00Z — fixed so we can assert the raw epoch is absent. const epochSec = 1779955200 - data := row{StartTime: gflashduty.Timestamp(epochSec)} + data := row{StartTime: flashduty.Timestamp(epochSec)} cases := []struct { name string diff --git a/internal/output/table_test.go b/internal/output/table_test.go index d9e890c..4ebf8f2 100644 --- a/internal/output/table_test.go +++ b/internal/output/table_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - gflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/go-flashduty" ) // --------------------------------------------------------------------------- @@ -55,7 +55,7 @@ func TestTruncate(t *testing.T) { func TestFormatTime(t *testing.T) { t.Run("zero returns dash", func(t *testing.T) { // 25 - got := FormatTime(gflashduty.Timestamp(0)) + got := FormatTime(flashduty.Timestamp(0)) if got != "-" { t.Errorf("FormatTime(0) = %q, want %q", got, "-") } @@ -65,7 +65,7 @@ func TestFormatTime(t *testing.T) { // 26 const ts int64 = 1712000000 want := time.Unix(ts, 0).Local().Format("2006-01-02 15:04") - got := FormatTime(gflashduty.Timestamp(ts)) + got := FormatTime(flashduty.Timestamp(ts)) if got != want { t.Errorf("FormatTime(%d) = %q, want %q", ts, got, want) } From 090031f9c264db8f1ffbf9ce892aff47afcc6038 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sun, 31 May 2026 19:51:07 +0800 Subject: [PATCH 10/10] fix(cli): repair channel/team list regressions + adopt go-flashduty v0.5.2 - channel list: export channelRow fields with json tags so the json/toon printers (reflection-based, skip unexported fields) emit full rows instead of empty objects. - team list: call Teams.ReadList with the --name/--page/--limit/--orderby/ --asc/--person-id flags instead of Teams.ReadInfos with an empty body, which silently dropped every flag; use server-side Total for the footer. - go-flashduty v0.5.2: named string enums now implement fmt.Stringer, fixing --output-format toon for incident feed/timeline and alert timeline. --- go.mod | 2 +- go.sum | 4 ++-- internal/cli/channel.go | 53 ++++++++++++++++++++++------------------- internal/cli/team.go | 18 +++++++++----- 4 files changed, 44 insertions(+), 33 deletions(-) diff --git a/go.mod b/go.mod index 70bfa1a..4e86fb3 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/go-flashduty v0.5.1 + github.com/flashcatcloud/go-flashduty v0.5.2 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 8dad8dc..57e5863 100644 --- a/go.sum +++ b/go.sum @@ -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.1 h1:bLPRnTKdZOT+IPtJFHRS36TPLftLDBCUZqxtGSNg9ys= -github.com/flashcatcloud/go-flashduty v0.5.1/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= +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/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= diff --git a/internal/cli/channel.go b/internal/cli/channel.go index 2c38193..44f72d9 100644 --- a/internal/cli/channel.go +++ b/internal/cli/channel.go @@ -23,13 +23,18 @@ func newChannelCmd() *cobra.Command { // ChannelItem carries only TeamID/CreatorID, so we keep those IDs and resolve // the team and creator names here (replicating the legacy SDK's enrichChannels) // before rendering. +// Fields are exported with json tags so the json/toon printers (which marshal +// via reflection and skip unexported fields) emit the full row, not {}. The +// table printer uses the accessor funcs below. json keys mirror the legacy +// ChannelInfo contract (channel_id/channel_name/team_id/creator_id/...); TOON +// renders the Go field names, consistent with every other command's output. type channelRow struct { - channelID int64 - channelName string - teamID int64 - creatorID int64 - teamName string - creatorName string + ChannelID int64 `json:"channel_id"` + ChannelName string `json:"channel_name"` + TeamID int64 `json:"team_id"` + CreatorID int64 `json:"creator_id"` + TeamName string `json:"team_name"` + CreatorName string `json:"creator_name"` } func newChannelListCmd() *cobra.Command { @@ -56,10 +61,10 @@ func newChannelListCmd() *cobra.Command { continue } rows = append(rows, channelRow{ - channelID: ch.ChannelID, - channelName: ch.ChannelName, - teamID: ch.TeamID, - creatorID: ch.CreatorID, + ChannelID: ch.ChannelID, + ChannelName: ch.ChannelName, + TeamID: ch.TeamID, + CreatorID: ch.CreatorID, }) } @@ -69,10 +74,10 @@ func newChannelListCmd() *cobra.Command { enrichChannelNames(ctx, rows) cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(channelRow).channelID, 10) }}, - {Header: "NAME", Field: func(v any) string { return v.(channelRow).channelName }}, - {Header: "TEAM", Field: func(v any) string { return v.(channelRow).teamName }}, - {Header: "CREATOR", Field: func(v any) string { return v.(channelRow).creatorName }}, + {Header: "ID", Field: func(v any) string { return strconv.FormatInt(v.(channelRow).ChannelID, 10) }}, + {Header: "NAME", Field: func(v any) string { return v.(channelRow).ChannelName }}, + {Header: "TEAM", Field: func(v any) string { return v.(channelRow).TeamName }}, + {Header: "CREATOR", Field: func(v any) string { return v.(channelRow).CreatorName }}, } return ctx.PrintTotal(rows, cols, len(rows)) @@ -99,16 +104,16 @@ func enrichChannelNames(ctx *RunContext, rows []channelRow) { personSeen := make(map[int64]struct{}, len(rows)) personIDs := make([]uint64, 0, len(rows)) for _, r := range rows { - if r.teamID != 0 { - if _, ok := teamSeen[r.teamID]; !ok { - teamSeen[r.teamID] = struct{}{} - teamIDs = append(teamIDs, uint64(r.teamID)) + if r.TeamID != 0 { + if _, ok := teamSeen[r.TeamID]; !ok { + teamSeen[r.TeamID] = struct{}{} + teamIDs = append(teamIDs, uint64(r.TeamID)) } } - if r.creatorID != 0 { - if _, ok := personSeen[r.creatorID]; !ok { - personSeen[r.creatorID] = struct{}{} - personIDs = append(personIDs, uint64(r.creatorID)) + if r.CreatorID != 0 { + if _, ok := personSeen[r.CreatorID]; !ok { + personSeen[r.CreatorID] = struct{}{} + personIDs = append(personIDs, uint64(r.CreatorID)) } } } @@ -132,7 +137,7 @@ func enrichChannelNames(ctx *RunContext, rows []channelRow) { } for i := range rows { - rows[i].teamName = teamNameByID[rows[i].teamID] - rows[i].creatorName = personNameByID[rows[i].creatorID] + rows[i].TeamName = teamNameByID[rows[i].TeamID] + rows[i].CreatorName = personNameByID[rows[i].CreatorID] } } diff --git a/internal/cli/team.go b/internal/cli/team.go index 30519a5..bfce530 100644 --- a/internal/cli/team.go +++ b/internal/cli/team.go @@ -52,7 +52,13 @@ Examples: flashduty team list --orderby team_name --asc`, RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.Client.Teams.ReadInfos(cmdContext(ctx.Cmd), &flashduty.TeamInfosRequest{}) + result, _, err := ctx.Client.Teams.ReadList(cmdContext(ctx.Cmd), &flashduty.TeamListRequest{ + ListOptions: flashduty.ListOptions{Page: page, Limit: limit}, + Query: name, + Orderby: orderBy, + Asc: asc, + PersonID: uint64(personID), + }) if err != nil { return err } @@ -63,7 +69,7 @@ Examples: nameByID := resolveTeamMemberNames(ctx, result.Items) cols := teamListColumns(nameByID) - return ctx.PrintTotal(result.Items, cols, len(result.Items)) + return ctx.PrintTotal(result.Items, cols, int(result.Total)) }) }, } @@ -337,10 +343,10 @@ Examples: // ID when a name can't be resolved. func teamListColumns(nameByID map[uint64]string) []output.Column { return []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.FormatUint(v.(flashduty.TeamBriefItem).TeamID, 10) }}, - {Header: "NAME", Field: func(v any) string { return v.(flashduty.TeamBriefItem).TeamName }}, + {Header: "ID", Field: func(v any) string { return strconv.FormatUint(v.(flashduty.TeamItem).TeamID, 10) }}, + {Header: "NAME", Field: func(v any) string { return v.(flashduty.TeamItem).TeamName }}, {Header: "MEMBERS", MaxWidth: 50, Field: func(v any) string { - ids := v.(flashduty.TeamBriefItem).PersonIDs + ids := v.(flashduty.TeamItem).PersonIDs names := make([]string, 0, len(ids)) for _, id := range ids { if n, ok := nameByID[id]; ok && n != "" { @@ -377,7 +383,7 @@ func printTeamDetail(w io.Writer, team *flashduty.TeamItem, members []string) { // to display names via /person/infos, replicating the name enrichment the // legacy SDK did server-side. Best-effort: a lookup failure yields a nil map and // callers fall back to the numeric ID. -func resolveTeamMemberNames(rc *RunContext, items []flashduty.TeamBriefItem) map[uint64]string { +func resolveTeamMemberNames(rc *RunContext, items []flashduty.TeamItem) map[uint64]string { seen := make(map[uint64]struct{}) ids := make([]uint64, 0) for _, it := range items {