From f6e51eb6e981b5919d0a3e46afe35ae8e0516902 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Sat, 30 May 2026 17:50:37 +0800 Subject: [PATCH] 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) }