diff --git a/go.mod b/go.mod index 1bf83ce..4e86fb3 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,11 @@ 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.5.2 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 + 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 +15,5 @@ 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 d77f1e0..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/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.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= @@ -16,8 +16,6 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A 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 54d2287..85078a1 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -3,9 +3,10 @@ package cli import ( "fmt" "io" + "strconv" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -26,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 @@ -48,23 +49,24 @@ func newAlertListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &flashduty.ListAlertsInput{ + req := &flashduty.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 = flashduty.Bool(true) } else if recovered { - input.IsActive = boolPtr(false) + req.IsActive = flashduty.Bool(false) } if muted { - input.EverMuted = boolPtr(true) + req.EverMuted = flashduty.Bool(true) } if channel != "" { @@ -72,25 +74,25 @@ func newAlertListCmd() *cobra.Command { if err != nil { return fmt.Errorf("invalid --channel: %w", err) } - input.ChannelIDs = channelIDs + req.ChannelIDs = channelIDs } - result, err := ctx.Client.ListAlerts(cmdContext(ctx.Cmd), input) + 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.(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.(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.Alerts, cols, len(result.Alerts), page, result.Total) + return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) }) }, } @@ -100,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") @@ -116,7 +117,7 @@ func newAlertGetCmd() *cobra.Command { 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{ + result, _, err := ctx.Client.Alerts.ReadInfo(cmdContext(ctx.Cmd), &flashduty.AlertInfoRequest{ AlertID: ctx.Args[0], }) if err != nil { @@ -124,24 +125,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 *flashduty.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) } @@ -174,27 +179,27 @@ func newAlertEventsCmd() *cobra.Command { 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{ + result, _, err := ctx.Client.Alerts.ReadEventList(cmdContext(ctx.Cmd), &flashduty.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.(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.AlertEvents, cols, len(result.AlertEvents)) + return ctx.PrintTotal(result.Items, cols, len(result.Items)) }) }, } @@ -209,11 +214,11 @@ func newAlertTimelineCmd() *cobra.Command { 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, - }) + req := &flashduty.AlertFeedRequest{AlertID: ctx.Args[0]} + req.Limit = limit + req.Page = page + + result, _, err := ctx.Client.Alerts.ReadFeed(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -223,12 +228,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.(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.(flashduty.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.(flashduty.FeedItem).Detail if d == nil { return "-" } @@ -247,6 +268,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 []flashduty.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.Client.Members.PersonInfos(cmdContext(rc.Cmd), &flashduty.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 @@ -256,7 +308,7 @@ func newAlertMergeCmd() *cobra.Command { 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{ + 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 e99e482..c8e49c6 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" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -37,15 +38,16 @@ func newAlertEventListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &flashduty.ListAlertEventsGlobalInput{ + input := &flashduty.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.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.(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.(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.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/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/audit.go b/internal/cli/audit.go index 99dc5b9..bc6c2ca 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" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -38,23 +38,23 @@ func newAuditSearchCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &flashduty.SearchAuditLogsInput{ + input := &flashduty.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 *flashduty.AuditSearchResponse cursor string ) for currentPage := 1; currentPage <= page; currentPage++ { input.SearchAfterCtx = cursor - result, err = ctx.Client.SearchAuditLogs(cmdContext(ctx.Cmd), input) + result, _, err = ctx.Client.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 = &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.(flashduty.AuditLogRecord).CreatedAt) + return output.FormatTime(v.(flashduty.AuditLog).CreatedAt) }}, {Header: "PERSON", MaxWidth: 20, Field: func(v any) string { - r := v.(flashduty.AuditLogRecord) + 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.(flashduty.AuditLogRecord) + r := v.(flashduty.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.(flashduty.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/change.go b/internal/cli/change.go index 77964ba..de410d7 100644 --- a/internal/cli/change.go +++ b/internal/cli/change.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -16,7 +16,6 @@ func newChangeCmd() *cobra.Command { Short: "Manage changes", } cmd.AddCommand(newChangeListCmd()) - cmd.AddCommand(newChangeTrendCmd()) return cmd } @@ -39,12 +38,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 := &flashduty.ListChangeRequest{ StartTime: startTime, EndTime: endTime, - Limit: limit, - Page: page, } + input.Limit = reqLimit + input.Page = reqPage if channel != "" { channelIDs, err := parseIntSlice(channel) @@ -54,20 +66,20 @@ func newChangeListCmd() *cobra.Command { input.ChannelIDs = channelIDs } - result, err := ctx.Client.ListChanges(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.(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.(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.Changes, cols, len(result.Changes), page, result.Total) + return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) }) }, } @@ -80,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/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/channel.go b/internal/cli/channel.go index 34eee90..44f72d9 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" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -18,6 +19,24 @@ 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. +// 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 `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 { var name string @@ -26,21 +45,42 @@ func newChannelListCmd() *cobra.Command { 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, - }) + // 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.Client.Channels.ChannelList(cmdContext(ctx.Cmd), &flashduty.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 +89,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.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 + } + } + } + + personNameByID := make(map[int64]string) + if len(personIDs) > 0 { + 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 + } + } + } + + 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 bb93e17..245fdf6 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -4,20 +4,22 @@ import ( "fmt" "io" + "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 -// runCommand and passed to the command's handler function. +// runCommand and passed to the command's handler function. Client is the +// typed go-flashduty SDK every command calls through. type RunContext struct { - Client flashdutyClient - Cmd *cobra.Command - Args []string - Writer io.Writer - Printer output.Printer - Format output.Format + Client *flashduty.Client + Cmd *cobra.Command + Args []string + Writer io.Writer + Printer output.Printer + Format output.Format } // Structured reports whether output should be a machine-readable dump (JSON or @@ -25,20 +27,21 @@ 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. +// 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.Client. 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(), + Client: client, + Cmd: cmd, + Args: args, + Writer: cmd.OutOrStdout(), + Printer: newPrinter(cmd.OutOrStdout()), + Format: currentOutputFormat(), } return fn(ctx) } diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 1ef73b6..16f39e8 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -2,308 +2,15 @@ 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) 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") -} - -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) 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") -} - -func (m *mockClient) ValidateTemplate(context.Context, *flashduty.ValidateTemplateInput) (*flashduty.ValidateTemplateOutput, error) { - 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") -} - -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) 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") -} - -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) 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") -} - -func (m *mockClient) QueryInsightIncidentList(context.Context, *flashduty.QueryInsightIncidentListInput) (*flashduty.QueryInsightIncidentListOutput, error) { - 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") -} - -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") -} - -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") -} - -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. @@ -392,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 { @@ -433,16 +135,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 +154,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,21 +170,72 @@ func TestCommandIncidentCreateWithoutIncidentID_JSON(t *testing.T) { } } -// --------------------------------------------------------------------------- -// Test 223: incident timeline empty -// --------------------------------------------------------------------------- +// 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) + } +} -type mockTimelineEmpty struct{ mockClient } +func TestCommandIncidentReassignSetsAssignType(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) -func (m *mockTimelineEmpty) GetIncidentTimelines(_ context.Context, _ []string) ([]flashduty.IncidentTimelineOutput, error) { - return []flashduty.IncidentTimelineOutput{ - {IncidentID: "test", Timeline: nil}, - }, nil + _, 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 +// --------------------------------------------------------------------------- + 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 { @@ -504,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 { @@ -527,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 { @@ -547,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 { @@ -570,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 { @@ -590,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) } @@ -655,15 +368,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 { @@ -681,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 { @@ -694,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 { @@ -707,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 { @@ -782,66 +490,18 @@ 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) - 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 +511,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 +530,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 +552,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) } } @@ -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...) @@ -941,8 +600,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 +612,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 +639,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 +655,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 +674,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).") { @@ -1028,80 +691,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") { @@ -1111,22 +716,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) @@ -1135,33 +745,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) @@ -1170,15 +784,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 +808,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) @@ -1204,17 +828,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.") { @@ -1224,61 +852,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 +910,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/escalation_rule.go b/internal/cli/escalation_rule.go index 8825113..955f6fe 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" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -30,7 +30,7 @@ func newEscalationRuleListCmd() *cobra.Command { return runCommand(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.Client.Channels.ChannelEscalateRuleList(cmdContext(ctx.Cmd), &flashduty.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.(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.(flashduty.EscalateRuleItem).Priority, 10) + }}, + {Header: "LAYERS", Field: func(v any) string { + return strconv.Itoa(len(v.(flashduty.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.Client.Channels.ChannelList(cmdContext(ctx.Cmd), &flashduty.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..33de606 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" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -26,24 +26,37 @@ func newFieldListCmd() *cobra.Command { 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, - }) + result, _, err := ctx.Client.AlertEnrichment.FieldReadList(cmdContext(ctx.Cmd), &flashduty.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([]flashduty.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.(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.(flashduty.FieldInfo).Options, ", ") + return strings.Join(v.(flashduty.FieldItem).Options, ", ") }}, } - return ctx.PrintTotal(result.Fields, cols, result.Total) + return ctx.PrintTotal(items, cols, len(items)) }) }, } diff --git a/internal/cli/gfstub_test.go b/internal/cli/gfstub_test.go new file mode 100644 index 0000000..77afcf5 --- /dev/null +++ b/internal/cli/gfstub_test.go @@ -0,0 +1,103 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/flashcatcloud/go-flashduty" +) + +// gfStub is an httptest-backed stand-in for the go-flashduty API. Migrated +// 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 +// 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 + + // 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 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 { + 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.dataForPath != nil: + payload = s.dataForPath(s.lastPath, s.lastBody) + 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) + + newClientFn = func() (*flashduty.Client, error) { + return flashduty.NewClient("test-key", flashduty.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..9552e2b 100644 --- a/internal/cli/helpers.go +++ b/internal/cli/helpers.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "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) ([]flashduty.ToolInvokeRequestToolsItem, error) { + out := make([]flashduty.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, flashduty.ToolInvokeRequestToolsItem{Tool: name, Params: params}) } return out, nil } diff --git a/internal/cli/identity.go b/internal/cli/identity.go index 43f4e44..d6ed763 100644 --- a/internal/cli/identity.go +++ b/internal/cli/identity.go @@ -6,7 +6,7 @@ import ( "fmt" "io" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "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 *flashduty.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 8a22838..9393abe 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -9,7 +9,7 @@ import ( "strings" "time" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "golang.org/x/term" @@ -49,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.(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) }}, + } +} + +// 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.(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) }}, } } @@ -77,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 := &flashduty.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.Client.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,32 +133,31 @@ func newIncidentGetCmd() *cobra.Command { 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, + result, _, err := ctx.Client.Incidents.List(cmdContext(ctx.Cmd), &flashduty.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 flashduty.IncidentInfo) { responders := make([]string, 0, len(inc.Responders)) for _, r := range inc.Responders { responders = append(responders, r.PersonName) @@ -152,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 { @@ -212,13 +228,25 @@ func newIncidentCreateCmd() *cobra.Command { } 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, - }) + req := &flashduty.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 = flashduty.CreateIncidentRequestAssignedTo{PersonIDs: personIDs, Type: "assign"} + } + + result, _, err := ctx.Client.Incidents.Create(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -251,34 +279,73 @@ func newIncidentUpdateCmd() *cobra.Command { Short: "Update an incident", Args: requireArgs("incident_id"), 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, + 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 := &flashduty.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.Client.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.Client.Incidents.FieldReset(cmdContext(ctx.Cmd), &flashduty.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 }) }, @@ -299,7 +366,9 @@ func newIncidentAckCmd() *cobra.Command { 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 { + if _, err := ctx.Client.Incidents.Ack(cmdContext(ctx.Cmd), &flashduty.AckIncidentRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Acknowledged %d incident(s).", len(ctx.Args))) @@ -325,7 +394,9 @@ unacknowledged state. The command accepts up to 100 incident IDs.`, return err } return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.UnackIncidents(cmdContext(ctx.Cmd), ctx.Args); err != nil { + if _, err := ctx.Client.Incidents.Unack(cmdContext(ctx.Cmd), &flashduty.UnackIncidentRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Unacknowledged %d incident(s).", len(ctx.Args))) @@ -342,7 +413,9 @@ func newIncidentCloseCmd() *cobra.Command { 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 { + if _, err := ctx.Client.Incidents.Resolve(cmdContext(ctx.Cmd), &flashduty.ResolveIncidentRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Closed %d incident(s).", len(ctx.Args))) @@ -368,7 +441,9 @@ accepts up to 100 incident IDs.`, return err } return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.WakeIncidents(cmdContext(ctx.Cmd), ctx.Args); err != nil { + if _, err := ctx.Client.Incidents.Wake(cmdContext(ctx.Cmd), &flashduty.WakeIncidentRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Restored notifications for %d incident(s).", len(ctx.Args))) @@ -385,22 +460,42 @@ func newIncidentTimelineCmd() *cobra.Command { 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 + // 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 []flashduty.IncidentFeedItem + for _, id := range ctx.Args { + result, _, err := ctx.Client.Incidents.Feed(cmdContext(ctx.Cmd), &flashduty.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.(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.(flashduty.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.(flashduty.IncidentFeedItem).Detail if d == nil { return "-" } @@ -408,7 +503,7 @@ func newIncidentTimelineCmd() *cobra.Command { }}, } - return ctx.Printer.Print(results[0].Timeline, cols) + return ctx.Printer.Print(items, cols) }) }, } @@ -423,25 +518,27 @@ func newIncidentAlertsCmd() *cobra.Command { 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) + req := &flashduty.ListIncidentAlertsRequest{IncidentID: ctx.Args[0]} + req.Limit = limit + result, _, err := ctx.Client.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.(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(results[0].Alerts, cols, results[0].Total) + return ctx.PrintTotal(result.Items, cols, int(result.Total)) }) }, } @@ -459,17 +556,20 @@ func newIncidentSimilarCmd() *cobra.Command { 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) + result, _, err := ctx.Client.Incidents.PastList(cmdContext(ctx.Cmd), &flashduty.ListPastIncidentsRequest{ + IncidentID: ctx.Args[0], + Limit: flashduty.Int64(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()) }) }, } @@ -478,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 == "" { @@ -542,7 +639,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.Client.Incidents.Merge(cmdContext(ctx.Cmd), &flashduty.MergeIncidentsRequest{ SourceIncidentIDs: sourceIDs, TargetIncidentID: ctx.Args[0], }); err != nil { @@ -583,7 +680,7 @@ func newIncidentSnoozeCmd() *cobra.Command { minutes := int64(d / time.Minute) - if err := ctx.Client.SnoozeIncidents(cmdContext(ctx.Cmd), &flashduty.SnoozeIncidentsInput{ + if _, err := ctx.Client.Incidents.Snooze(cmdContext(ctx.Cmd), &flashduty.SnoozeIncidentRequest{ IncidentIDs: ctx.Args, Minutes: minutes, }); err != nil { @@ -609,7 +706,9 @@ func newIncidentReopenCmd() *cobra.Command { 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 { + if _, err := ctx.Client.Incidents.Reopen(cmdContext(ctx.Cmd), &flashduty.ReopenIncidentRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Reopened %d incident(s).", len(ctx.Args))) @@ -636,9 +735,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.Client.Incidents.Assign(cmdContext(ctx.Cmd), &flashduty.AssignIncidentRequest{ IncidentIDs: []string{ctx.Args[0]}, - PersonIDs: personIDs, + AssignedTo: flashduty.AssignedTo{PersonIDs: personIDs, Type: "assign"}, }); err != nil { return err } @@ -682,9 +786,9 @@ personal channels, or a template.`, return fmt.Errorf("--person is required") } - var notify *flashduty.IncidentNotifyInput + var notify flashduty.AddIncidentResponderRequestNotify if followPreference || notifyChannel != "" || templateID != "" { - notify = &flashduty.IncidentNotifyInput{ + notify = flashduty.AddIncidentResponderRequestNotify{ FollowPreference: followPreference, PersonalChannels: parseStringSlice(notifyChannel), TemplateID: templateID, @@ -692,7 +796,7 @@ personal channels, or a template.`, } return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.AddIncidentResponders(cmdContext(ctx.Cmd), &flashduty.IncidentAddResponderInput{ + if _, err := ctx.Client.Incidents.ResponderAdd(cmdContext(ctx.Cmd), &flashduty.AddIncidentResponderRequest{ IncidentID: ctx.Args[0], PersonIDs: personIDs, Notify: notify, @@ -742,7 +846,7 @@ webhook reply behavior.`, } return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.CommentIncidents(cmdContext(ctx.Cmd), &flashduty.IncidentCommentInput{ + if _, err := ctx.Client.Incidents.Comment(cmdContext(ctx.Cmd), &flashduty.CommentIncidentRequest{ IncidentIDs: ctx.Args, Comment: comment, MuteReply: muteReply, @@ -776,7 +880,9 @@ matching alerts automatically. The command accepts up to 100 incident IDs.`, 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 { + if _, err := ctx.Client.Incidents.DisableMerge(cmdContext(ctx.Cmd), &flashduty.DisableIncidentMergeRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Disabled auto-merge for %d incident(s).", len(ctx.Args))) @@ -810,7 +916,9 @@ unless --force is provided. The command accepts up to 100 incident IDs.`, return nil } - if err := ctx.Client.RemoveIncidents(cmdContext(ctx.Cmd), ctx.Args); err != nil { + if _, err := ctx.Client.Incidents.Remove(cmdContext(ctx.Cmd), &flashduty.RemoveIncidentRequest{ + IncidentIDs: ctx.Args, + }); err != nil { return err } ctx.WriteResult(fmt.Sprintf("Removed %d incident(s).", len(ctx.Args))) @@ -873,7 +981,7 @@ invite historical responders selected by FlashDuty.`, if err != nil { return err } - warRoom, err := ctx.Client.CreateIncidentWarRoom(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomCreateInput{ + warRoom, _, err := ctx.Client.Incidents.WarRoomCreate(cmdContext(ctx.Cmd), &flashduty.CreateWarRoomRequest{ IncidentID: ctx.Args[0], IntegrationID: resolvedIntegrationID, MemberIDs: memberIDs, @@ -907,7 +1015,7 @@ func resolveWarRoomIntegrationID(ctx *RunContext) (int64, error) { return integrationID, nil } - result, err := ctx.Client.ListWarRoomEnabledDataSources(cmdContext(ctx.Cmd)) + result, _, err := ctx.Client.ImIntegrations.List(cmdContext(ctx.Cmd)) if err != nil { return 0, err } @@ -932,7 +1040,7 @@ as get, delete, and add-member.`, 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{ + result, _, err := ctx.Client.Incidents.WarRoomList(cmdContext(ctx.Cmd), &flashduty.ListWarRoomsRequest{ IncidentID: ctx.Args[0], IntegrationID: integrationID, }) @@ -964,7 +1072,7 @@ the chat ID and integration ID for an incident.`, 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{ + warRoom, _, err := ctx.Client.Incidents.WarRoomDetail(cmdContext(ctx.Cmd), &flashduty.GetWarRoomDetailRequest{ IntegrationID: integrationID, ChatID: ctx.Args[0], }) @@ -1008,7 +1116,7 @@ integration ID.`, _, _ = fmt.Fprintln(ctx.Writer, "Aborted.") return nil } - if err := ctx.Client.DeleteIncidentWarRoom(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomDeleteInput{ + if _, err := ctx.Client.Incidents.WarRoomDelete(cmdContext(ctx.Cmd), &flashduty.DeleteWarRoomRequest{ IncidentID: ctx.Args[0], IntegrationID: integrationID, }); err != nil { @@ -1052,7 +1160,7 @@ IDs.`, return fmt.Errorf("--member is required") } return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.AddIncidentWarRoomMembers(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomAddMemberInput{ + if _, _, err := ctx.Client.Incidents.WriteAddWarRoomMember(cmdContext(ctx.Cmd), &flashduty.AddWarRoomMemberRequest{ IntegrationID: integrationID, ChatID: ctx.Args[0], MemberIDs: memberIDs, @@ -1085,11 +1193,13 @@ This is a read-only preview of the users FlashDuty would add when 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]) + result, _, err := ctx.Client.Incidents.ReadGetWarRoomDefaultObservers(cmdContext(ctx.Cmd), &flashduty.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)) }) }, } @@ -1097,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.(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.(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.(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.(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 *flashduty.IncidentWarRoom) { +func printWarRoomDetail(w io.Writer, warRoom *flashduty.WarRoom) { if warRoom == nil { return } @@ -1133,11 +1243,10 @@ func newIncidentFeedCmd() *cobra.Command { 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, - }) + feedReq := &flashduty.ListIncidentFeedRequest{IncidentID: ctx.Args[0]} + feedReq.Page = page + feedReq.Limit = limit + result, _, err := ctx.Client.Incidents.Feed(cmdContext(ctx.Cmd), feedReq) if err != nil { return err } @@ -1147,12 +1256,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.(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.(flashduty.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.(flashduty.IncidentFeedItem).Detail if d == nil { return "-" } @@ -1171,6 +1295,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 []flashduty.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.Client.Members.PersonInfos(cmdContext(rc.Cmd), &flashduty.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 ", @@ -1178,7 +1333,7 @@ func newIncidentDetailCmd() *cobra.Command { 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{ + result, _, err := ctx.Client.Incidents.Info(cmdContext(ctx.Cmd), &flashduty.IncidentInfoRequest{ IncidentID: ctx.Args[0], }) if err != nil { @@ -1186,17 +1341,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 *flashduty.IncidentInfo) { + if inc == nil { + return + } responders := make([]string, 0, len(inc.Responders)) for _, r := range inc.Responders { name := r.PersonName @@ -1218,7 +1376,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/incident_test.go b/internal/cli/incident_test.go new file mode 100644 index 0000000..6345754 --- /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 flashduty.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/insight.go b/internal/cli/insight.go index 2130c81..a98ddf3 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -20,7 +20,6 @@ func newInsightCmd() *cobra.Command { cmd.AddCommand(newInsightResponderCmd()) cmd.AddCommand(newInsightTopAlertsCmd()) cmd.AddCommand(newInsightIncidentsCmd()) - cmd.AddCommand(newInsightNotificationsCmd()) return cmd } @@ -41,7 +40,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.Client.Analytics.ByTeam(cmdContext(ctx.Cmd), &flashduty.InsightQueryRequest{ StartTime: startTime, EndTime: endTime, }) @@ -104,7 +103,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.Client.Analytics.ByChannel(cmdContext(ctx.Cmd), &flashduty.InsightQueryRequest{ StartTime: startTime, EndTime: endTime, }) @@ -167,7 +166,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.Client.Analytics.ByResponder(cmdContext(ctx.Cmd), &flashduty.InsightQueryRequest{ StartTime: startTime, EndTime: endTime, }) @@ -179,9 +178,6 @@ func newInsightResponderCmd() *cobra.Command { {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 - }}, {Header: "INCIDENTS", Field: func(v any) string { return fmt.Sprintf("%d", v.(flashduty.ResponderInsightItem).TotalIncidentCnt) }}, @@ -195,7 +191,7 @@ func newInsightResponderCmd() *cobra.Command { return fmt.Sprintf("%d", v.(flashduty.ResponderInsightItem).TotalInterruptions) }}, {Header: "ENGAGED", Field: func(v any) string { - return output.FormatDuration(v.(flashduty.ResponderInsightItem).TotalEngagedSeconds) + return output.FormatDuration(int(v.(flashduty.ResponderInsightItem).TotalEngagedSeconds)) }}, } @@ -228,13 +224,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.Client.Analytics.TopkAlertsByLabel(cmdContext(ctx.Cmd), &flashduty.InsightTopkAlertByLabelRequest{ + StartTime: startTime, + EndTime: endTime, + Label: label, + K: int64(limit), }) if err != nil { return err @@ -284,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 := &flashduty.InsightIncidentListRequest{ + StartTime: startTime, + EndTime: endTime, + } + req.Limit = limit + req.Page = page + + 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.(flashduty.InsightIncidentItem).IncidentID + return v.(flashduty.IncidentRawItem).IncidentID }}, {Header: "TITLE", MaxWidth: 40, Field: func(v any) string { - return v.(flashduty.InsightIncidentItem).Title + return v.(flashduty.IncidentRawItem).Title }}, {Header: "SEVERITY", Field: func(v any) string { - return v.(flashduty.InsightIncidentItem).Severity + return v.(flashduty.IncidentRawItem).Severity }}, {Header: "CHANNEL", MaxWidth: 20, Field: func(v any) string { - return v.(flashduty.InsightIncidentItem).ChannelName + return v.(flashduty.IncidentRawItem).ChannelName }}, {Header: "MTTA", Field: func(v any) string { - return output.FormatDuration(v.(flashduty.InsightIncidentItem).SecondsToAck) + return output.FormatDuration(int(v.(flashduty.IncidentRawItem).SecondsToAck)) }}, {Header: "MTTR", Field: func(v any) string { - return output.FormatDuration(v.(flashduty.InsightIncidentItem).SecondsToClose) + return output.FormatDuration(int(v.(flashduty.IncidentRawItem).SecondsToClose)) }}, {Header: "NOTIFICATIONS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.InsightIncidentItem).Notifications) + return fmt.Sprintf("%d", v.(flashduty.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)) }) }, } @@ -332,56 +326,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", - 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/login.go b/internal/cli/login.go index 7096a06..f5f39fe 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -6,7 +6,7 @@ import ( "os" "time" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "golang.org/x/term" diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 3db09af..6f4347d 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" ) @@ -48,7 +48,7 @@ func newMCPCreateCmd() *cobra.Command { if err != nil { return fmt.Errorf("invalid --headers: %w", err) } - input := &flashduty.CreateMCPServerInput{ + input := &flashduty.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.Client.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/member.go b/internal/cli/member.go index 55da0c9..7dd0a21 100644 --- a/internal/cli/member.go +++ b/internal/cli/member.go @@ -4,7 +4,7 @@ import ( "fmt" "strconv" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -28,34 +28,35 @@ func newMemberListCmd() *cobra.Command { 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, - }) + // 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 := &flashduty.MemberListRequest{ + Query: query, + } + req.Page = page + + result, _, err := ctx.Client.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 { + // 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.Itoa(v.(flashduty.MemberItem).MemberID) }}, + {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.Members, cols); err != nil { - return err - } - } else if len(result.PersonInfos) > 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 }}, - } - 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_agent.go b/internal/cli/monit_agent.go index cd1f258..f5dbc6f 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" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" ) @@ -28,11 +28,11 @@ func newMonitAgentCatalogCmd() *cobra.Command { return fmt.Errorf("--target-locator is required") } return runCommand(cmd, args, func(ctx *RunContext) error { - input := &flashduty.MonitAgentCatalogInput{ + input := &flashduty.ToolCatalogRequest{ TargetKind: targetKind, TargetLocator: targetLocator, } - result, err := ctx.Client.MonitAgentCatalog(cmdContext(ctx.Cmd), input) + result, _, err := ctx.Client.Diagnostics.ToolsCatalog(cmdContext(ctx.Cmd), input) if err != nil { return err } @@ -72,12 +72,12 @@ func newMonitAgentInvokeCmd() *cobra.Command { } return runCommand(cmd, args, func(ctx *RunContext) error { - input := &flashduty.MonitAgentInvokeInput{ + input := &flashduty.ToolInvokeRequest{ TargetKind: targetKind, TargetLocator: targetLocator, Tools: parsed, } - result, err := ctx.Client.MonitAgentInvoke(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_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..a37c40d 100644 --- a/internal/cli/monit_query.go +++ b/internal/cli/monit_query.go @@ -1,9 +1,10 @@ package cli import ( + "encoding/json" "fmt" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/timeutil" @@ -42,25 +43,24 @@ func newMonitQueryDiagnoseCmd() *cobra.Command { } return runCommand(cmd, args, func(ctx *RunContext) error { - input := &flashduty.MonitQueryDiagnoseInput{ + input := &flashduty.DiagnoseRequest{ DsType: dsType, DsName: dsName, - TimeStart: startTime, - TimeEnd: endTime, Operation: operation, - Input: flashduty.MonitQueryDiagnoseQuery{Query: inputQuery}, + Input: flashduty.DiagnoseRequestInput{Query: inputQuery}, + TimeRange: flashduty.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.Client.Diagnostics.QueryDiagnose(cmdContext(ctx.Cmd), input) if err != nil { return err } @@ -101,24 +101,32 @@ func newMonitQueryRowsCmd() *cobra.Command { } return runCommand(cmd, args, func(ctx *RunContext) error { - input := &flashduty.MonitQueryRowsInput{ + input := &flashduty.QueryRowsRequest{ DsType: dsType, DsName: dsName, Expr: expr, Args: argsMap, } - result, err := ctx.Client.MonitQueryRows(cmdContext(ctx.Cmd), input) + result, _, err := ctx.Client.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 c6fc388..70c56c2 100644 --- a/internal/cli/monit_query_test.go +++ b/internal/cli/monit_query_test.go @@ -1,11 +1,9 @@ package cli import ( - "context" + "fmt" "strings" "testing" - - flashduty "github.com/flashcatcloud/flashduty-sdk" ) func TestMonitQueryDiagnoseFlags(t *testing.T) { @@ -30,50 +28,12 @@ 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) { 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 +48,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 +108,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 +117,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 +126,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 +141,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) } } @@ -188,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", @@ -202,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) + } + body := stub.lastBody + if body["ds_type"] != "prometheus" || body["ds_name"] != "prom-prod" || body["expr"] != "up" { + t.Errorf("unexpected rows input: %#v", body) } - got := mock.rowsInput - if got.DsType != "prometheus" || got.DsName != "prom-prod" || got.Expr != "up" { - t.Errorf("unexpected rows input: %+v", got) + 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) } } @@ -247,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 { @@ -257,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) } }) } @@ -266,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", @@ -282,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..dd1d2ee 100644 --- a/internal/cli/oncall.go +++ b/internal/cli/oncall.go @@ -5,7 +5,7 @@ import ( "strconv" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -50,56 +50,48 @@ func newOncallWhoCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &flashduty.ListSchedulesWithSlotsInput{ + req := &flashduty.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.Client.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.(flashduty.ScheduleItem)) }}, {Header: "ON_CALL", MaxWidth: 40, Field: func(v any) string { - s := v.(flashduty.ScheduleDetail) - return formatOncallMembers(s.CurOncall) + s := v.(flashduty.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.(flashduty.ScheduleItem).CurOncall.End) }}, {Header: "NEXT", MaxWidth: 40, Field: func(v any) string { - s := v.(flashduty.ScheduleDetail) - return formatOncallMembers(s.NextOncall) + s := v.(flashduty.ScheduleItem) + return formatOncallMembers(&s.NextOncall, nameByID) }}, } - return ctx.PrintTotal(result.Schedules, cols, int(result.Total)) + return ctx.PrintTotal(result.Items, cols, int(result.Total)) }) }, } @@ -132,55 +124,47 @@ func newOncallScheduleListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - input := &flashduty.ListSchedulesWithSlotsInput{ + req := &flashduty.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.Client.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.(flashduty.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.(flashduty.ScheduleItem)) }}, {Header: "STATUS", Field: func(v any) string { - s := v.(flashduty.ScheduleDetail) - if s.Disabled != nil && *s.Disabled != 0 { + s := v.(flashduty.ScheduleItem) + if s.Disabled != 0 { return "disabled" } return "enabled" }}, {Header: "LAYERS", Field: func(v any) string { - return scheduleLayerCount(v.(flashduty.ScheduleDetail)) + return scheduleLayerCount(v.(flashduty.ScheduleItem)) }}, } - return ctx.PrintTotal(result.Schedules, cols, int(result.Total)) + return ctx.PrintTotal(result.Items, cols, int(result.Total)) }) }, } @@ -204,7 +188,7 @@ func newOncallScheduleGetCmd() *cobra.Command { 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) + 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.Client.Schedules.Info(cmdContext(ctx.Cmd), &flashduty.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, []flashduty.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 @@ -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 flashduty.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 flashduty.ScheduleItem) string { + if s.ScheduleName != "" { + return s.ScheduleName + } + if s.Name != "" { + return s.Name + } + return "-" +} + +func scheduleLayerCount(s flashduty.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 *flashduty.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 []flashduty.ScheduleItem) map[int64]string { + seen := make(map[int64]struct{}) + ids := make([]uint64, 0) + collect := func(g flashduty.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.Client.Members.PersonInfos(cmdContext(rc.Cmd), &flashduty.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..0074a91 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" + "github.com/flashcatcloud/go-flashduty" ) func TestScheduleLayerCount(t *testing.T) { tests := []struct { name string - input flashduty.ScheduleDetail + input flashduty.ScheduleItem want string }{ { name: "raw layers", - input: flashduty.ScheduleDetail{Layers: []flashduty.ScheduleLayer{{}, {}}}, + input: flashduty.ScheduleItem{Layers: []flashduty.ScheduleLayer{{}, {}}}, want: "2", }, { name: "schedule layers fallback", - input: flashduty.ScheduleDetail{ScheduleLayers: []flashduty.ScheduleCalculatedLayer{{}, {}, {}}}, + input: flashduty.ScheduleItem{ScheduleLayers: []flashduty.ScheduleCalculatedLayer{{}, {}, {}}}, want: "3", }, { name: "layer schedules fallback", - input: flashduty.ScheduleDetail{LayerSchedules: []flashduty.ScheduleCalculatedLayer{{}, {}}}, + input: flashduty.ScheduleItem{LayerSchedules: []flashduty.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: flashduty.ScheduleItem{}, want: "-", }, } diff --git a/internal/cli/postmortem.go b/internal/cli/postmortem.go index 8911257..0e9baac 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" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -28,18 +28,18 @@ func newPostmortemListCmd() *cobra.Command { Short: "List post-mortem reports", RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { - input := &flashduty.ListPostMortemsInput{ + req := &flashduty.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.Client.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.(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.(flashduty.PostMortem).CreatedAtSeconds) + return output.FormatTime(v.(flashduty.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 347c567..a35dd87 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -8,8 +8,9 @@ import ( "os" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "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" @@ -17,102 +18,8 @@ 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 - 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 - - // === 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 - 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) - 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 === - 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 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) - - // === 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. +// newClientFn creates the go-flashduty client used by all commands. +// Override in tests to inject a stub server. var newClientFn = defaultNewClient var ( @@ -208,13 +115,14 @@ func Execute() error { return rootCmd.Execute() } -// newClient creates a flashdutyClient using the current factory. -func newClient() (flashdutyClient, error) { +// newClient creates a go-flashduty client using the current factory. +func newClient() (*flashduty.Client, error) { return newClientFn() } -// defaultNewClient creates a real Flashduty SDK client from resolved config + flag overrides. -func defaultNewClient() (flashdutyClient, error) { +// defaultNewClient creates a real go-flashduty client from resolved config + +// flag overrides. This is the typed SDK every command uses. +func defaultNewClient() (*flashduty.Client, error) { cfg, err := loadResolvedConfig() if err != nil { return nil, err @@ -232,12 +140,7 @@ func defaultNewClient() (flashdutyClient, error) { opts = append(opts, flashduty.WithBaseURL(cfg.BaseURL)) } - sdkClient, err := flashduty.NewClient(cfg.AppKey, opts...) - if err != nil { - return nil, err - } - - return sdkClient, nil + return flashduty.NewClient(cfg.AppKey, opts...) } func loadResolvedConfig() (*config.Config, error) { @@ -287,11 +190,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..8571dc3 100644 --- a/internal/cli/status_page.go +++ b/internal/cli/status_page.go @@ -4,8 +4,9 @@ import ( "fmt" "strconv" "strings" + "time" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -37,21 +38,42 @@ func newStatusPageListCmd() *cobra.Command { return fmt.Errorf("invalid --id: %w", err) } - pages, err := ctx.Client.ListStatusPages(cmdContext(ctx.Cmd), pageIDs) + result, _, err := ctx.Client.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([]flashduty.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.(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.(flashduty.StatusPage).Components + comps := v.(flashduty.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, ", ") }}, @@ -76,24 +98,29 @@ func newStatusPageChangesCmd() *cobra.Command { Short: "List active status page changes", 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, + result, _, err := ctx.Client.StatusPages.ChangeActiveList(cmdContext(ctx.Cmd), &flashduty.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.(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.(flashduty.StatusPageChangeItem).StartAtSeconds) }}, + {Header: "UPDATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.StatusPageChangeItem).CloseAtSeconds) }}, } - return ctx.Printer.Print(result.Changes, cols) + return ctx.Printer.Print(result.Items, cols) }) }, } @@ -116,12 +143,50 @@ func newStatusPageCreateIncidentCmd() *cobra.Command { 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, + // 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 := flashduty.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, flashduty.CreateStatusPageChangeRequestUpdatesItemComponentChangesItem{ + ComponentID: strings.TrimSpace(kv[0]), + Status: strings.TrimSpace(kv[1]), + }) + } else if len(kv) == 1 && kv[0] != "" { + update.ComponentChanges = append(update.ComponentChanges, flashduty.CreateStatusPageChangeRequestUpdatesItemComponentChangesItem{ + ComponentID: strings.TrimSpace(kv[0]), + Status: "partial_outage", + }) + } + } + } + + description := message + if description == "" { + description = title + } + + result, _, err := ctx.Client.StatusPages.ChangeCreate(cmdContext(ctx.Cmd), &flashduty.CreateStatusPageChangeRequest{ + PageID: pageID, + Title: title, + Type: "incident", + Status: status, + Description: description, + Updates: []flashduty.CreateStatusPageChangeRequestUpdatesItem{update}, + NotifySubscribers: notify, }) if err != nil { return err @@ -157,11 +222,11 @@ func newStatusPageCreateTimelineCmd() *cobra.Command { 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, + _, _, err := ctx.Client.StatusPages.ChangeTimelineCreate(cmdContext(ctx.Cmd), &flashduty.CreateStatusPageChangeTimelineRequest{ + PageID: pageID, + ChangeID: changeID, + Description: message, + Status: status, }) if err != nil { return err diff --git a/internal/cli/status_page_migrate.go b/internal/cli/status_page_migrate.go index 5e5b4e4..f5a65bc 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" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" ) @@ -35,11 +35,16 @@ func newStatusPageMigrateStructureCmd() *cobra.Command { return err } return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.StartStatusPageMigration(cmdContext(ctx.Cmd), &flashduty.StartStatusPageMigrationInput{ - SourceAPIKey: sourceAPIKey, + req := &flashduty.MigrateStatusPageStructureRequest{ + APIKey: sourceAPIKey, SourcePageID: sourcePageID, - URLName: urlName, - }) + } + // 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 = flashduty.String(urlName) + } + result, _, err := ctx.Client.StatusPages.MigrateStructure(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -74,8 +79,8 @@ func newStatusPageMigrateEmailSubscribersCmd() *cobra.Command { return err } return runCommand(cmd, args, func(ctx *RunContext) error { - result, err := ctx.Client.StartStatusPageEmailSubscriberMigration(cmdContext(ctx.Cmd), &flashduty.StartStatusPageEmailSubscriberMigrationInput{ - SourceAPIKey: sourceAPIKey, + result, _, err := ctx.Client.StatusPages.MigrateEmailSubscribers(cmdContext(ctx.Cmd), &flashduty.MigrateStatusPageEmailSubscribersRequest{ + APIKey: sourceAPIKey, SourcePageID: sourcePageID, TargetPageID: targetPageID, }) @@ -108,7 +113,9 @@ func newStatusPageMigrateStatusCmd() *cobra.Command { 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) + job, _, err := ctx.Client.StatusPages.MigrationStatus(cmdContext(ctx.Cmd), &flashduty.StatusPagesMigrationStatusRequest{ + JobID: jobID, + }) if err != nil { return err } @@ -132,7 +139,9 @@ func newStatusPageMigrateCancelCmd() *cobra.Command { 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 { + if _, err := ctx.Client.StatusPages.MigrationCancel(cmdContext(ctx.Cmd), &flashduty.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 *flashduty.StatusPageMigrationStartResponse) error { if ctx.Structured() { payload := map[string]any{ "type": migrationType, diff --git a/internal/cli/status_page_migrate_test.go b/internal/cli/status_page_migrate_test.go index dc5430b..037da92 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" + "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) + // 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 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) @@ -88,17 +49,13 @@ func TestCommandStatusPageMigrateStructureSendsSDKInput(t *testing.T) { } } -func TestCommandStatusPageMigrateStructureSendsURLName(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) - - 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) + stub.data = map[string]any{"job_id": "job-2"} _, err := execCommand("statuspage", "migrate", "structure", "--from", "atlassian", @@ -109,12 +66,11 @@ func TestCommandStatusPageMigrateStructureSendsURLName(t *testing.T) { if err != nil { t.Fatalf("execCommand: %v", err) } - - if gotInput == nil { - t.Fatal("expected input to be captured") + if stub.requests != 1 { + t.Errorf("expected exactly 1 request, got %d", stub.requests) } - if gotInput.URLName != "customer-facing-status" { - t.Errorf("URLName = %q, want customer-facing-status", gotInput.URLName) + if stub.lastBody["url_name"] != "customer-facing-status" { + t.Errorf("url_name = %#v, want customer-facing-status", stub.lastBody["url_name"]) } } @@ -137,15 +93,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 +109,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 +119,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 +132,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 +141,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 +155,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 +195,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 +213,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 +236,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 +287,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 +313,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 +336,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 +364,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 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") + _ = 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) + newClientFn = func() (*flashduty.Client, error) { + return flashduty.NewClient("test-key", flashduty.WithBaseURL(srv.URL)) } - newClientFn = func() (flashdutyClient, error) { return mock, nil } _, err := execCommand("statuspage", "migrate", "status", "--job-id", "nope") if err == nil { diff --git a/internal/cli/team.go b/internal/cli/team.go index d07fa0a..bfce530 100644 --- a/internal/cli/team.go +++ b/internal/cli/team.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -52,20 +52,24 @@ 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.ListTeams(cmdContext(ctx.Cmd), &flashduty.ListTeamsInput{ - Name: name, - Page: page, - Limit: limit, - OrderBy: orderBy, - Asc: asc, - PersonID: personID, + 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 } - 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, int(result.Total)) }) }, } @@ -105,8 +109,8 @@ Examples: }, 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, + team, _, err := ctx.Client.Teams.ReadInfo(cmdContext(ctx.Cmd), &flashduty.TeamInfoRequest{ + TeamID: uint64(teamID), TeamName: teamName, RefID: refID, }) @@ -118,7 +122,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 }) }, @@ -161,10 +168,10 @@ Examples: return fmt.Errorf("invalid --person-ids: %w", err) } - result, err := ctx.Client.UpsertTeam(cmdContext(ctx.Cmd), &flashduty.TeamUpsertInput{ + result, _, err := ctx.Client.Teams.WriteUpsert(cmdContext(ctx.Cmd), &flashduty.TeamUpsertRequest{ TeamName: name, Description: description, - PersonIDs: ids, + PersonIDs: toUint64Slice(ids), Emails: parseStringSlice(emails), RefID: refID, }) @@ -228,8 +235,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.Client.Teams.ReadInfo(cmdContext(ctx.Cmd), &flashduty.TeamInfoRequest{ + TeamID: uint64(teamID), }) if err != nil { return fmt.Errorf("failed to fetch current team: %w", err) @@ -237,24 +244,24 @@ Examples: teamName = existing.TeamName } - input := &flashduty.TeamUpsertInput{ - TeamID: teamID, + req := &flashduty.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.Client.Teams.WriteUpsert(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -308,8 +315,8 @@ Examples: return nil } - err := ctx.Client.DeleteTeam(cmdContext(ctx.Cmd), &flashduty.TeamDeleteInput{ - TeamID: teamID, + _, err := ctx.Client.Teams.WriteDelete(cmdContext(ctx.Cmd), &flashduty.TeamDeleteRequest{ + TeamID: uint64(teamID), TeamName: teamName, RefID: refID, }) @@ -331,33 +338,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.(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 { - members := v.(flashduty.TeamInfo).Members - names := make([]string, 0, len(members)) - for _, m := range members { - names = append(names, m.PersonName) + ids := v.(flashduty.TeamItem).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 *flashduty.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 +379,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 []flashduty.TeamItem) 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.Client.Members.PersonInfos(cmdContext(rc.Cmd), &flashduty.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.Client.Members.PersonInfos(cmdContext(rc.Cmd), &flashduty.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..9861f2f 100644 --- a/internal/cli/template.go +++ b/internal/cli/template.go @@ -5,12 +5,50 @@ import ( "os" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "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 *flashduty.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", @@ -31,13 +69,32 @@ func newTemplateGetPresetCmd() *cobra.Command { 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, + fieldName, ok := templateChannels[channel] + if !ok { + return fmt.Errorf("unknown channel: %s", channel) + } + + item, _, err := ctx.Client.NotificationTemplates.ReadInfo(cmdContext(ctx.Cmd), &flashduty.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 @@ -66,15 +123,70 @@ func newTemplateValidateCmd() *cobra.Command { } 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, + fieldName, ok := templateChannels[channel] + if !ok { + return fmt.Errorf("unknown channel: %s", channel) + } + + preview, _, err := ctx.Client.NotificationTemplates.ReadPreview(cmdContext(ctx.Cmd), &flashduty.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/output/structured_time_test.go b/internal/output/structured_time_test.go index 6839b4b..b3a0af5 100644 --- a/internal/output/structured_time_test.go +++ b/internal/output/structured_time_test.go @@ -5,10 +5,10 @@ import ( "strings" "testing" - flashduty "github.com/flashcatcloud/flashduty-sdk" + "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"` @@ -16,7 +16,7 @@ type row struct { // 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. 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..4ebf8f2 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" + "github.com/flashcatcloud/go-flashduty" ) // --------------------------------------------------------------------------- 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) }