diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1d96cb7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# Agent Instructions + +## Flashduty SDK Boundary + +- Do not implement Flashduty public API endpoint clients directly in this CLI repository. +- If a CLI command needs an endpoint that is missing from `github.com/flashcatcloud/flashduty-sdk`, add the typed adapter to `flashduty-sdk` first, with focused SDK tests. +- The CLI should consume SDK methods and keep only command parsing, output formatting, and CLI-specific orchestration. +- Existing raw HTTP adapters in the CLI are migration debt. Prefer removing them as SDK coverage catches up. diff --git a/go.mod b/go.mod index df9c943..990455b 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,10 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260514094839-5405a3ab38b1 + github.com/flashcatcloud/flashduty-sdk v0.9.0 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -13,7 +14,6 @@ require ( require ( github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.9 // 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 ade3d54..62f1eab 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.8.1-0.20260514094839-5405a3ab38b1 h1:Q9FJkGSAQCXCjjnjS18QYMNpHT8O27oRj9kd13tUeiI= -github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260514094839-5405a3ab38b1/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= +github.com/flashcatcloud/flashduty-sdk v0.9.0 h1:gEBt9ZJ8HbDc22U1V4cWPitxlPxfztqKIe2x6TyRqJw= +github.com/flashcatcloud/flashduty-sdk v0.9.0/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index d8a609e..96e6c2f 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -9,6 +9,8 @@ import ( "testing" flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/spf13/cobra" + "github.com/spf13/pflag" ) // mockClient provides default "not implemented" stubs for all flashdutyClient @@ -51,10 +53,62 @@ 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") } @@ -259,6 +313,8 @@ func saveAndResetGlobals(t *testing.T) { // and returns (stdout string, error). It also resets cobra flag state after // execution. func execCommand(args ...string) (string, error) { + resetCommandFlags(rootCmd) + buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -271,10 +327,35 @@ func execCommand(args ...string) (string, error) { rootCmd.SetArgs(nil) rootCmd.SetOut(nil) rootCmd.SetErr(nil) + resetCommandFlags(rootCmd) return buf.String(), err } +func resetCommandFlags(cmd *cobra.Command) { + if cmd == nil { + return + } + resetFlagSet(cmd.Flags()) + resetFlagSet(cmd.PersistentFlags()) + for _, child := range cmd.Commands() { + resetCommandFlags(child) + } +} + +func resetFlagSet(flags *pflag.FlagSet) { + if flags == nil { + return + } + flags.VisitAll(func(flag *pflag.Flag) { + switch flag.Value.Type() { + case "bool", "int", "int64", "string": + _ = flag.Value.Set(flag.DefValue) + flag.Changed = false + } + }) +} + // --------------------------------------------------------------------------- // Test 191: incident get returns empty results // --------------------------------------------------------------------------- @@ -610,6 +691,522 @@ func TestCommandIncidentMergeRejectsMoreThan100Sources(t *testing.T) { } } +func TestCommandIncidentLifecycleHelpDocumentsSafetyAndLookupHints(t *testing.T) { + saveAndResetGlobals(t) + + tests := []struct { + name string + args []string + want []string + }{ + { + name: "war-room create integration discovery", + args: []string{"incident", "war-room", "create", "--help"}, + want: []string{ + "If --integration is omitted", + "first war-room-enabled IM integration", + "Use 'flashduty member list'", + }, + }, + { + name: "war-room get required integration", + args: []string{"incident", "war-room", "get", "--help"}, + want: []string{ + "requires --integration", + "Use 'flashduty incident war-room list'", + }, + }, + { + name: "remove destructive behavior", + args: []string{"incident", "remove", "--help"}, + want: []string{ + "Permanently removes incidents", + "Prompts for confirmation", + "--force", + }, + }, + { + name: "comment limit", + args: []string{"incident", "comment", "--help"}, + want: []string{ + "up to 100 incidents", + "1024 characters", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := execCommand(tt.args...) + if err != nil { + t.Fatalf("help command returned error: %v", err) + } + for _, want := range tt.want { + if !strings.Contains(out, want) { + t.Fatalf("help output missing %q:\n%s", want, out) + } + } + }) + } +} + +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 } + + 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 { + t.Fatalf("[incident-unack] expected ids %q, got %q", want, got) + } + if !strings.Contains(out, "Unacknowledged 2 incident(s).") { + t.Fatalf("[incident-unack] unexpected output:\n%s", out) + } +} + +func TestCommandIncidentWake(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentLifecycle{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + 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 { + t.Fatalf("[incident-wake] expected ids %q, got %q", want, got) + } + if !strings.Contains(out, "Restored notifications for 1 incident(s).") { + t.Fatalf("[incident-wake] unexpected output:\n%s", out) + } +} + +func TestCommandIncidentComment(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentLifecycle{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + 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 got, want := strings.Join(mock.commentInput.IncidentIDs, ","), "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 !strings.Contains(out, "Commented on 2 incident(s).") { + t.Fatalf("[incident-comment] unexpected output:\n%s", out) + } +} + +func TestCommandIncidentCommentAllows1024UnicodeRunes(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentLifecycle{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + 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) + } +} + +func TestCommandIncidentLifecycleRejectsMoreThan100IDs(t *testing.T) { + commands := []struct { + name string + args []string + }{ + {name: "unack", args: []string{"incident", "unack"}}, + {name: "wake", args: []string{"incident", "wake"}}, + {name: "comment", args: []string{"incident", "comment", "--comment", "too many"}}, + {name: "remove", args: []string{"incident", "remove"}}, + } + + incidentIDs := make([]string, 101) + for i := range incidentIDs { + incidentIDs[i] = fmt.Sprintf("inc-%d", i+1) + } + + for _, tc := range commands { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentLifecycle{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + args := append([]string(nil), tc.args...) + args = append(args, incidentIDs...) + _, err := execCommand(args...) + if err == nil { + t.Fatal("expected too-many-ids error, got nil") + } + if !strings.Contains(err.Error(), "at most 100 incident IDs") { + t.Fatalf("expected max-id error, got %q", err.Error()) + } + }) + } +} + +func TestCommandIncidentAddResponder(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentLifecycle{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + out, err := execCommand( + "incident", "add-responder", "inc-1", + "--person", "101,202", + "--follow-preference", + "--notify-channel", "voice,sms", + "--template-id", "6321aad26c12104586a88916", + ) + 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 mock.responderInput.IncidentID != "inc-1" { + t.Fatalf("[incident-add-responder] expected incident inc-1, got %q", mock.responderInput.IncidentID) + } + if got, want := fmt.Sprint(mock.responderInput.PersonIDs), "[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) + } + if got, want := strings.Join(mock.responderInput.Notify.PersonalChannels, ","), "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 !strings.Contains(out, "Added 2 responder(s) to incident inc-1.") { + t.Fatalf("[incident-add-responder] unexpected output:\n%s", out) + } +} + +func TestCommandIncidentRemoveRequiresForceWhenNonInteractive(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentLifecycle{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + 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 !strings.Contains(out, "Aborted.") { + t.Fatalf("[incident-remove-abort] unexpected output:\n%s", out) + } +} + +func TestCommandIncidentRemoveWithForce(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentLifecycle{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + 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 { + t.Fatalf("[incident-remove-force] expected ids %q, got %q", want, got) + } + if !strings.Contains(out, "Removed 2 incident(s).") { + t.Fatalf("[incident-remove-force] unexpected output:\n%s", out) + } +} + +func TestCommandIncidentDisableMerge(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentLifecycle{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + 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 { + t.Fatalf("[incident-disable-merge] expected ids %q, got %q", want, got) + } + if !strings.Contains(out, "Disabled auto-merge for 2 incident(s).") { + t.Fatalf("[incident-disable-merge] unexpected output:\n%s", out) + } +} + +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 } + + 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 mock.createInput.IncidentID != "inc-1" || mock.createInput.IntegrationID != 42 || !mock.createInput.AddObservers { + t.Fatalf("[incident-war-room-create] unexpected input: %#v", mock.createInput) + } + if got, want := fmt.Sprint(mock.createInput.MemberIDs), "[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") { + t.Fatalf("[incident-war-room-create] unexpected output:\n%s", out) + } +} + +func TestCommandIncidentWarRoomCreateAutoDiscoversIntegration(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentWarRoom{ + enabledDataSources: []flashduty.DataSourceIntegration{ + {DataSourceID: 42, Name: "Feishu", PluginType: "feishu_app"}, + }, + } + 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 mock.createInput.IntegrationID != 42 { + t.Fatalf("[incident-war-room-create-autodiscover] expected integration 42, got %#v", mock.createInput) + } + if !strings.Contains(out, "War room created: chat-1") { + t.Fatalf("[incident-war-room-create-autodiscover] unexpected output:\n%s", out) + } +} + +func TestCommandIncidentWarRoomCreateRequiresEnabledIntegration(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentWarRoom{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, 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) + } +} + +func TestCommandIncidentWarRoomDefaultObservers(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentWarRoom{ + defaultObserverOutput: []flashduty.IncidentWarRoomObserver{ + {PersonID: 101, PersonName: "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 !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) + } +} + +func TestCommandIncidentWarRoomList(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentWarRoom{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + 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 !strings.Contains(out, "chat-1") || !strings.Contains(out, "Total: 1") { + t.Fatalf("[incident-war-room-list] unexpected output:\n%s", out) + } +} + +func TestCommandIncidentWarRoomGet(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentWarRoom{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + 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 !strings.Contains(out, "Chat ID:") || !strings.Contains(out, "chat-1") { + t.Fatalf("[incident-war-room-get] unexpected output:\n%s", out) + } +} + +func TestCommandIncidentWarRoomAddMember(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentWarRoom{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + 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 got, want := fmt.Sprint(mock.addMemberInput.MemberIDs), "[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.") { + t.Fatalf("[incident-war-room-add-member] unexpected output:\n%s", out) + } +} + +func TestCommandIncidentWarRoomDeleteWithForce(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentWarRoom{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + 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 !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 diff --git a/internal/cli/identity.go b/internal/cli/identity.go index 369f6ad..43f4e44 100644 --- a/internal/cli/identity.go +++ b/internal/cli/identity.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "io" + + flashduty "github.com/flashcatcloud/flashduty-sdk" ) type identityResult struct { @@ -13,7 +15,12 @@ type identityResult struct { Email string `json:"email,omitempty"` } -func resolveIdentity(ctx context.Context, client flashdutyClient) (*identityResult, error) { +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) if memberErr == nil { return &identityResult{ diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 1fbc5c7..56747c0 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -27,7 +27,9 @@ func newIncidentCmd() *cobra.Command { cmd.AddCommand(newIncidentCreateCmd()) cmd.AddCommand(newIncidentUpdateCmd()) cmd.AddCommand(newIncidentAckCmd()) + cmd.AddCommand(newIncidentUnackCmd()) cmd.AddCommand(newIncidentCloseCmd()) + cmd.AddCommand(newIncidentWakeCmd()) cmd.AddCommand(newIncidentTimelineCmd()) cmd.AddCommand(newIncidentAlertsCmd()) cmd.AddCommand(newIncidentSimilarCmd()) @@ -35,6 +37,11 @@ func newIncidentCmd() *cobra.Command { cmd.AddCommand(newIncidentSnoozeCmd()) cmd.AddCommand(newIncidentReopenCmd()) cmd.AddCommand(newIncidentReassignCmd()) + cmd.AddCommand(newIncidentAddResponderCmd()) + cmd.AddCommand(newIncidentCommentCmd()) + cmd.AddCommand(newIncidentDisableMergeCmd()) + cmd.AddCommand(newIncidentRemoveCmd()) + cmd.AddCommand(newIncidentWarRoomCmd()) cmd.AddCommand(newIncidentFeedCmd()) cmd.AddCommand(newIncidentDetailCmd()) return cmd @@ -304,6 +311,32 @@ func newIncidentAckCmd() *cobra.Command { } } +func newIncidentUnackCmd() *cobra.Command { + return &cobra.Command{ + Use: "unack [ ...]", + Short: "Cancel incident acknowledgement", + Long: `Cancel acknowledgement for one or more incidents. + +Use this when an incident was acknowledged by mistake and should return to the +unacknowledged state. The command accepts up to 100 incident IDs.`, + Example: ` flashduty incident unack inc_123 + flashduty incident unack inc_123 inc_456`, + Args: requireArgs("incident_id"), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateIncidentIDBatch(args); err != nil { + return err + } + return runCommand(cmd, args, func(ctx *RunContext) error { + if err := ctx.Client.UnackIncidents(cmdContext(ctx.Cmd), ctx.Args); err != nil { + return err + } + ctx.WriteResult(fmt.Sprintf("Unacknowledged %d incident(s).", len(ctx.Args))) + return nil + }) + }, + } +} + func newIncidentCloseCmd() *cobra.Command { return &cobra.Command{ Use: "close [ ...]", @@ -321,6 +354,32 @@ func newIncidentCloseCmd() *cobra.Command { } } +func newIncidentWakeCmd() *cobra.Command { + return &cobra.Command{ + Use: "wake [ ...]", + Short: "Restore notifications for snoozed incidents", + Long: `Wake one or more snoozed incidents. + +This cancels snooze and restores normal incident notifications. The command +accepts up to 100 incident IDs.`, + Example: ` flashduty incident wake inc_123 + flashduty incident wake inc_123 inc_456`, + Args: requireArgs("incident_id"), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateIncidentIDBatch(args); err != nil { + return err + } + return runCommand(cmd, args, func(ctx *RunContext) error { + if err := ctx.Client.WakeIncidents(cmdContext(ctx.Cmd), ctx.Args); err != nil { + return err + } + ctx.WriteResult(fmt.Sprintf("Restored notifications for %d incident(s).", len(ctx.Args))) + return nil + }) + }, + } +} + func newIncidentTimelineCmd() *cobra.Command { return &cobra.Command{ Use: "timeline ", @@ -461,6 +520,13 @@ func parseStringSlice(s string) []string { return result } +func validateIncidentIDBatch(incidentIDs []string) error { + if len(incidentIDs) > 100 { + return fmt.Errorf("command accepts at most 100 incident IDs") + } + return nil +} + func newIncidentMergeCmd() *cobra.Command { var source string @@ -591,6 +657,482 @@ func newIncidentReassignCmd() *cobra.Command { return cmd } +func newIncidentAddResponderCmd() *cobra.Command { + var person, notifyChannel, templateID string + var followPreference bool + + cmd := &cobra.Command{ + Use: "add-responder ", + Short: "Add responders to an incident", + Long: `Add one or more responders to an incident. + +Responder IDs are person IDs. Use 'flashduty member list' to find the right +person ID before running this command. Optional notification flags let you ask +FlashDuty to notify added responders through their preferences, explicit +personal channels, or a template.`, + Example: ` flashduty member list --name "Ada" + flashduty incident add-responder inc_123 --person 101,202 + flashduty incident add-responder inc_123 --person 101 --follow-preference + flashduty incident add-responder inc_123 --person 101 --notify-channel voice,sms,email`, + Args: requireArgs("incident_id"), + RunE: func(cmd *cobra.Command, args []string) error { + personIDs, err := parseIntSlice(person) + if err != nil { + return fmt.Errorf("invalid --person: %w", err) + } + if len(personIDs) == 0 { + return fmt.Errorf("--person is required") + } + + var notify *flashduty.IncidentNotifyInput + if followPreference || notifyChannel != "" || templateID != "" { + notify = &flashduty.IncidentNotifyInput{ + FollowPreference: followPreference, + PersonalChannels: parseStringSlice(notifyChannel), + TemplateID: templateID, + } + } + + return runCommand(cmd, args, func(ctx *RunContext) error { + if err := ctx.Client.AddIncidentResponders(cmdContext(ctx.Cmd), &flashduty.IncidentAddResponderInput{ + IncidentID: ctx.Args[0], + PersonIDs: personIDs, + Notify: notify, + }); err != nil { + return err + } + + ctx.WriteResult(fmt.Sprintf("Added %d responder(s) to incident %s.", len(personIDs), ctx.Args[0])) + return nil + }) + }, + } + + cmd.Flags().StringVar(&person, "person", "", "Comma-separated person IDs to add") + cmd.Flags().BoolVar(&followPreference, "follow-preference", false, "Follow each responder's notification preferences") + cmd.Flags().StringVar(¬ifyChannel, "notify-channel", "", "Comma-separated notification channels, e.g. voice,sms,email") + cmd.Flags().StringVar(&templateID, "template-id", "", "Notification template ID") + _ = cmd.MarkFlagRequired("person") + + return cmd +} + +func newIncidentCommentCmd() *cobra.Command { + var comment string + var muteReply bool + + cmd := &cobra.Command{ + Use: "comment [ ...]", + Short: "Add a comment to incident timelines", + Long: `Add a comment to one or more incident timelines. + +The command accepts up to 100 incidents. Comment text is required and must be +at most 1024 characters. Use --mute-reply when the comment should not trigger +webhook reply behavior.`, + Example: ` flashduty incident comment inc_123 --comment "Rollback started" + flashduty incident comment inc_123 inc_456 --comment "Mitigation deployed" --mute-reply`, + Args: requireArgs("incident_id"), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateIncidentIDBatch(args); err != nil { + return err + } + if strings.TrimSpace(comment) == "" { + return fmt.Errorf("--comment is required") + } + if len([]rune(comment)) > 1024 { + return fmt.Errorf("--comment must be at most 1024 characters") + } + + return runCommand(cmd, args, func(ctx *RunContext) error { + if err := ctx.Client.CommentIncidents(cmdContext(ctx.Cmd), &flashduty.IncidentCommentInput{ + IncidentIDs: ctx.Args, + Comment: comment, + MuteReply: muteReply, + }); err != nil { + return err + } + + ctx.WriteResult(fmt.Sprintf("Commented on %d incident(s).", len(ctx.Args))) + return nil + }) + }, + } + + cmd.Flags().StringVar(&comment, "comment", "", "Comment text") + cmd.Flags().BoolVar(&muteReply, "mute-reply", false, "Do not trigger webhook reply behavior for this comment") + _ = cmd.MarkFlagRequired("comment") + + return cmd +} + +func newIncidentDisableMergeCmd() *cobra.Command { + return &cobra.Command{ + Use: "disable-merge [ ...]", + Short: "Disable automatic merging for incidents", + Long: `Disable automatic alert merging for one or more incidents. + +Use this when an incident should stay isolated and must not absorb additional +matching alerts automatically. The command accepts up to 100 incident IDs.`, + Example: ` flashduty incident disable-merge inc_123 + flashduty incident disable-merge inc_123 inc_456`, + Args: requireArgs("incident_id"), + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + if err := ctx.Client.DisableIncidentMerge(cmdContext(ctx.Cmd), ctx.Args); err != nil { + return err + } + ctx.WriteResult(fmt.Sprintf("Disabled auto-merge for %d incident(s).", len(ctx.Args))) + return nil + }) + }, + } +} + +func newIncidentRemoveCmd() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "remove [ ...]", + Short: "Permanently remove incidents", + Long: `Permanently removes incidents from FlashDuty. + +This is a destructive operation. Prompts for confirmation in an interactive +terminal unless --force is set. In non-interactive mode the command aborts +unless --force is provided. The command accepts up to 100 incident IDs.`, + Example: ` flashduty incident remove inc_123 + flashduty incident remove inc_123 inc_456 --force`, + Args: requireArgs("incident_id"), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateIncidentIDBatch(args); err != nil { + return err + } + return runCommand(cmd, args, func(ctx *RunContext) error { + if !confirmAction(ctx.Cmd, fmt.Sprintf("Are you sure you want to remove %d incident(s)?", len(ctx.Args))) { + _, _ = fmt.Fprintln(ctx.Writer, "Aborted.") + return nil + } + + if err := ctx.Client.RemoveIncidents(cmdContext(ctx.Cmd), ctx.Args); err != nil { + return err + } + ctx.WriteResult(fmt.Sprintf("Removed %d incident(s).", len(ctx.Args))) + return nil + }) + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + return cmd +} + +func newIncidentWarRoomCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "war-room", + Short: "Manage incident war rooms", + Long: `Manage incident war rooms. + +War rooms are IM chats attached to incidents. Creating a war room can invite +explicit members and, when requested, historical responders as observers. +Commands that operate on an existing IM chat require the IM integration ID.`, + Example: ` flashduty incident war-room create inc_123 --add-observers + flashduty incident war-room list inc_123 + flashduty incident war-room get chat_123 --integration 42`, + } + cmd.AddCommand(newIncidentWarRoomCreateCmd()) + cmd.AddCommand(newIncidentWarRoomListCmd()) + cmd.AddCommand(newIncidentWarRoomGetCmd()) + cmd.AddCommand(newIncidentWarRoomDeleteCmd()) + cmd.AddCommand(newIncidentWarRoomAddMemberCmd()) + cmd.AddCommand(newIncidentWarRoomDefaultObserversCmd()) + return cmd +} + +func newIncidentWarRoomCreateCmd() *cobra.Command { + var integrationID int64 + var member string + var addObservers bool + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create an incident war room", + Long: `Create an incident war room in a configured IM integration. + +If --integration is omitted, the CLI uses the first war-room-enabled IM +integration returned by FlashDuty. Use --member to invite person IDs directly. +Use 'flashduty member list' to find person IDs. Use --add-observers to also +invite historical responders selected by FlashDuty.`, + Example: ` flashduty incident war-room create inc_123 + flashduty incident war-room create inc_123 --integration 42 --member 101,202 + flashduty incident war-room create inc_123 --add-observers`, + Args: requireArgs("incident_id"), + RunE: func(cmd *cobra.Command, args []string) error { + memberIDs, err := parseIntSlice(member) + if err != nil { + return fmt.Errorf("invalid --member: %w", err) + } + return runCommand(cmd, args, func(ctx *RunContext) error { + resolvedIntegrationID, err := resolveWarRoomIntegrationID(ctx) + if err != nil { + return err + } + warRoom, err := ctx.Client.CreateIncidentWarRoom(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomCreateInput{ + IncidentID: ctx.Args[0], + IntegrationID: resolvedIntegrationID, + MemberIDs: memberIDs, + AddObservers: addObservers, + }) + if err != nil { + return err + } + + message := fmt.Sprintf("War room created: %s", warRoom.ChatID) + if warRoom.ShareLink != "" { + message += fmt.Sprintf("\nShare link: %s", warRoom.ShareLink) + } + return ctx.WriteResultJSON(warRoom, message) + }) + }, + } + + cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID; if omitted, first war-room-enabled IM integration is used") + cmd.Flags().StringVar(&member, "member", "", "Comma-separated member person IDs to invite") + cmd.Flags().BoolVar(&addObservers, "add-observers", false, "Invite historical responders as extra war-room members") + return cmd +} + +func resolveWarRoomIntegrationID(ctx *RunContext) (int64, error) { + integrationID, err := ctx.Cmd.Flags().GetInt64("integration") + if err != nil { + return 0, err + } + if integrationID > 0 { + return integrationID, nil + } + + result, err := ctx.Client.ListWarRoomEnabledDataSources(cmdContext(ctx.Cmd)) + if err != nil { + return 0, err + } + if result == nil || len(result.Items) == 0 { + return 0, fmt.Errorf("no IM integration has war-room enabled; enable one in integration settings or pass --integration") + } + return result.Items[0].DataSourceID, nil +} + +func newIncidentWarRoomListCmd() *cobra.Command { + var integrationID int64 + + cmd := &cobra.Command{ + Use: "list ", + Short: "List incident war rooms", + Long: `List war rooms attached to an incident. + +Use this to discover chat IDs and integration IDs for follow-up commands such +as get, delete, and add-member.`, + Example: ` flashduty incident war-room list inc_123 + flashduty incident war-room list inc_123 --integration 42`, + Args: requireArgs("incident_id"), + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + result, err := ctx.Client.ListIncidentWarRooms(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomListInput{ + IncidentID: ctx.Args[0], + IntegrationID: integrationID, + }) + if err != nil { + return err + } + return ctx.PrintTotal(result.Items, incidentWarRoomColumns(), len(result.Items)) + }) + }, + } + + cmd.Flags().Int64Var(&integrationID, "integration", 0, "Filter by IM integration ID") + return cmd +} + +func newIncidentWarRoomGetCmd() *cobra.Command { + var integrationID int64 + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get incident war room details", + Long: `Get incident war room details by IM chat ID. + +This command requires --integration because chat IDs are scoped to an IM +integration. Use 'flashduty incident war-room list' with an incident ID to find +the chat ID and integration ID for an incident.`, + Example: ` flashduty incident war-room list inc_123 + flashduty incident war-room get chat_123 --integration 42`, + Args: requireArgs("chat_id"), + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + warRoom, err := ctx.Client.GetIncidentWarRoom(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomDetailInput{ + IntegrationID: integrationID, + ChatID: ctx.Args[0], + }) + if err != nil { + return err + } + if ctx.JSON { + return ctx.Printer.Print(warRoom, nil) + } + printWarRoomDetail(ctx.Writer, warRoom) + return nil + }) + }, + } + + cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID (required)") + _ = cmd.MarkFlagRequired("integration") + return cmd +} + +func newIncidentWarRoomDeleteCmd() *cobra.Command { + var integrationID int64 + var force bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an incident war room", + Long: `Delete the war room attached to an incident for an IM integration. + +This is a destructive operation. Prompts for confirmation in an interactive +terminal unless --force is set. In non-interactive mode the command aborts +unless --force is provided. Use 'flashduty incident war-room list' to find the +integration ID.`, + Example: ` flashduty incident war-room list inc_123 + flashduty incident war-room delete inc_123 --integration 42 + flashduty incident war-room delete inc_123 --integration 42 --force`, + Args: requireArgs("incident_id"), + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + if !confirmAction(ctx.Cmd, fmt.Sprintf("Are you sure you want to delete the war room for incident %s?", ctx.Args[0])) { + _, _ = fmt.Fprintln(ctx.Writer, "Aborted.") + return nil + } + if err := ctx.Client.DeleteIncidentWarRoom(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomDeleteInput{ + IncidentID: ctx.Args[0], + IntegrationID: integrationID, + }); err != nil { + return err + } + ctx.WriteResult(fmt.Sprintf("Deleted war room for incident %s.", ctx.Args[0])) + return nil + }) + }, + } + + cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID (required)") + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + _ = cmd.MarkFlagRequired("integration") + return cmd +} + +func newIncidentWarRoomAddMemberCmd() *cobra.Command { + var integrationID int64 + var member string + + cmd := &cobra.Command{ + Use: "add-member ", + Short: "Add members to an incident war room", + Long: `Add members to an existing incident war room by IM chat ID. + +This command requires --integration because chat IDs are scoped to an IM +integration. Member IDs are person IDs. Use 'flashduty member list' to find +person IDs, and 'flashduty incident war-room list' to find chat and integration +IDs.`, + Example: ` flashduty member list --name "Ada" + flashduty incident war-room list inc_123 + flashduty incident war-room add-member chat_123 --integration 42 --member 101,202`, + Args: requireArgs("chat_id"), + RunE: func(cmd *cobra.Command, args []string) error { + memberIDs, err := parseIntSlice(member) + if err != nil { + return fmt.Errorf("invalid --member: %w", err) + } + if len(memberIDs) == 0 { + return fmt.Errorf("--member is required") + } + return runCommand(cmd, args, func(ctx *RunContext) error { + if err := ctx.Client.AddIncidentWarRoomMembers(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomAddMemberInput{ + IntegrationID: integrationID, + ChatID: ctx.Args[0], + MemberIDs: memberIDs, + }); err != nil { + return err + } + ctx.WriteResult(fmt.Sprintf("Added %d member(s) to war room %s.", len(memberIDs), ctx.Args[0])) + return nil + }) + }, + } + + cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID (required)") + cmd.Flags().StringVar(&member, "member", "", "Comma-separated member person IDs (required)") + _ = cmd.MarkFlagRequired("integration") + _ = cmd.MarkFlagRequired("member") + return cmd +} + +func newIncidentWarRoomDefaultObserversCmd() *cobra.Command { + return &cobra.Command{ + Use: "default-observers ", + Short: "Preview historical responders for war-room observer invitation", + Long: `Preview historical responders eligible for war-room observer invitation. + +This is a read-only preview of the users FlashDuty would add when +--add-observers is used during war-room creation.`, + Example: ` flashduty incident war-room default-observers inc_123 + flashduty incident war-room create inc_123 --add-observers`, + Args: requireArgs("incident_id"), + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + observers, err := ctx.Client.GetIncidentWarRoomDefaultObservers(cmdContext(ctx.Cmd), ctx.Args[0]) + if err != nil { + return err + } + return ctx.PrintTotal(observers, incidentWarRoomObserverColumns(), len(observers)) + }) + }, + } +} + +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 formatWarRoomCreatedAt(v.(flashduty.IncidentWarRoomItem).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 }}, + } +} + +func printWarRoomDetail(w io.Writer, warRoom *flashduty.IncidentWarRoom) { + if warRoom == nil { + return + } + _, _ = fmt.Fprintf(w, "Chat ID: %s\n", warRoom.ChatID) + _, _ = fmt.Fprintf(w, "Chat Name: %s\n", orDash(warRoom.ChatName)) + _, _ = fmt.Fprintf(w, "Share Link: %s\n", orDash(warRoom.ShareLink)) +} + +func formatWarRoomCreatedAt(ts int64) string { + if ts > 1_000_000_000_000 { + ts /= 1000 + } + return output.FormatTime(ts) +} + func newIncidentFeedCmd() *cobra.Command { var limit, page int diff --git a/internal/cli/root.go b/internal/cli/root.go index 3b76af9..a191d9a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -30,7 +30,20 @@ type flashdutyClient interface { CreateIncident(ctx context.Context, input *flashduty.CreateIncidentInput) (any, 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) @@ -194,7 +207,12 @@ func defaultNewClient() (flashdutyClient, error) { opts = append(opts, flashduty.WithBaseURL(cfg.BaseURL)) } - return flashduty.NewClient(cfg.AppKey, opts...) + sdkClient, err := flashduty.NewClient(cfg.AppKey, opts...) + if err != nil { + return nil, err + } + + return sdkClient, nil } func loadResolvedConfig() (*config.Config, error) {