From cb17a7f12f0af047b09653daf5b72c87ced9fcc4 Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Wed, 20 May 2026 14:08:02 +0800 Subject: [PATCH 1/5] feat: add incident lifecycle and war-room commands --- OPENAPI_COMMAND_GAP.md | 354 ++++++++++++++ internal/cli/command_test.go | 462 ++++++++++++++++++ internal/cli/identity.go | 9 +- internal/cli/incident.go | 424 ++++++++++++++++ internal/cli/incident_lifecycle_client.go | 414 ++++++++++++++++ .../cli/incident_lifecycle_client_test.go | 250 ++++++++++ internal/cli/root.go | 22 +- 7 files changed, 1933 insertions(+), 2 deletions(-) create mode 100644 OPENAPI_COMMAND_GAP.md create mode 100644 internal/cli/incident_lifecycle_client.go create mode 100644 internal/cli/incident_lifecycle_client_test.go diff --git a/OPENAPI_COMMAND_GAP.md b/OPENAPI_COMMAND_GAP.md new file mode 100644 index 0000000..e9fbfc0 --- /dev/null +++ b/OPENAPI_COMMAND_GAP.md @@ -0,0 +1,354 @@ +# Flashduty CLI OpenAPI command gap + +Updated: 2026-05-19 + +Sources: + +- `https://docs.flashcat.cloud/api-reference/on-call.openapi.zh.json` +- `https://docs.flashcat.cloud/api-reference/platform.openapi.zh.json` +- `https://docs.flashcat.cloud/api-reference/monitors.openapi.zh.json` +- `https://docs.flashcat.cloud/api-reference/rum.openapi.zh.json` + +This document tracks official OpenAPI endpoints that are not yet fully exposed +as `flashduty` commands. It intentionally focuses on command coverage, not +internal SDK helper coverage. + +Legend: + +- `missing`: no first-class CLI command. +- `partial`: some behavior exists, but the CLI does not expose the full API + contract. +- `covered indirectly`: the CLI reaches the endpoint through another command or + SDK enrichment path, but there is no user-facing command for the endpoint. +- `implemented`: implemented after this tracking document was introduced. +- `defer`: command shape or side effects need a separate design pass. + +## Implemented incident lifecycle slice + +These endpoints were selected as the first implementation slice because they +are close to the existing `incident` command family and have simple +request/response contracts. + +| Endpoint | Status | Proposed command | Notes | +| --- | --- | --- | --- | +| `/incident/unack` | implemented | `incident unack [ ...]` | Inverse of `incident ack`; payload is `incident_ids`. | +| `/incident/wake` | implemented | `incident wake [ ...]` | Inverse of `incident snooze`; payload is `incident_ids`. | +| `/incident/comment` | implemented | `incident comment [ ...] --comment [--mute-reply]` | Adds timeline comments. | +| `/incident/responder/add` | implemented | `incident add-responder --person ` | Additive responder change; distinct from replacement-style assignment. | +| `/incident/remove` | implemented | `incident remove [ ...] --force` | Destructive; requires confirmation outside JSON mode. | +| `/incident/disable-merge` | implemented | `incident disable-merge [ ...]` | Present in current docs.flashcat.cloud OpenAPI. | +| `/incident/assign` | partial | keep `incident reassign`; consider richer `incident assign` later | Existing command covers direct person assignment only. | +| `/incident/field/reset` | covered indirectly | `incident update --field key=value` | Already called once per custom field. | + +Defer from the first slice: + +| Endpoint | Status | Reason | +| --- | --- | --- | +| `/incident/custom-action/do` | defer | Integration-specific side effect; needs output and failure semantics. | +| `/incident/war-room/create` | implemented | Implemented as `incident war-room create`; supports `--add-observers`. | +| `/incident/war-room/delete` | implemented | Implemented as `incident war-room delete --force`. | +| `/incident/war-room/list` | implemented | Implemented as `incident war-room list`. | +| `/incident/war-room/detail` | implemented | Implemented as `incident war-room get`. | +| `/incident/post-mortem/info` | missing | Fits postmortem slice, not lifecycle. | +| `/incident/post-mortem/delete` | missing | Destructive; fits postmortem slice. | + +## On-call gaps + +### Alert management + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/alert/list-by-ids` | partial | `alert get` already supports detail by ID, but no batch list-by-ids command. | +| `/alert/pipeline/list` | missing | `alert pipeline list` | +| `/alert/pipeline/info` | missing | `alert pipeline get` | +| `/alert/pipeline/upsert` | missing | `alert pipeline upsert --file` | + +### Calendar management + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/calendar/list` | missing | `calendar list` | +| `/calendar/info` | missing | `calendar get` | +| `/calendar/create` | missing | `calendar create --file` | +| `/calendar/update` | missing | `calendar update --file` | +| `/calendar/delete` | missing | `calendar delete --id --force` | +| `/calendar/event/list` | missing | `calendar event list` | +| `/calendar/event/upsert` | missing | `calendar event upsert --file` | +| `/calendar/event/delete` | missing | `calendar event delete --id --force` | + +### Collaboration spaces + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/channel/info` | missing | `channel get` | +| `/channel/infos` | covered indirectly | Used for enrichment; no command. | +| `/channel/create` | missing | `channel create --file` | +| `/channel/update` | missing | `channel update --file` | +| `/channel/delete` | missing | `channel delete --id --force` | +| `/channel/enable` | missing | `channel enable --id` | +| `/channel/disable` | missing | `channel disable --id` | +| `/channel/escalate/rule/info` | missing | `escalation-rule get` | +| `/channel/escalate/rule/create` | missing | `escalation-rule create --file` | +| `/channel/escalate/rule/update` | missing | `escalation-rule update --file` | +| `/channel/escalate/rule/delete` | missing | `escalation-rule delete --id --force` | +| `/channel/escalate/rule/enable` | missing | `escalation-rule enable --id` | +| `/channel/escalate/rule/disable` | missing | `escalation-rule disable --id` | +| `/channel/notify/rule/list` | missing | `channel notify-rule list` | +| `/channel/notify/rule/create` | missing | `channel notify-rule create --file` | +| `/channel/notify/rule/update` | missing | `channel notify-rule update --file` | +| `/channel/notify/rule/delete` | missing | `channel notify-rule delete --id --force` | +| `/channel/notify/rule/enable` | missing | `channel notify-rule enable --id` | +| `/channel/notify/rule/disable` | missing | `channel notify-rule disable --id` | +| `/channel/silence/rule/list` | missing | `channel silence-rule list` | +| `/channel/silence/rule/create` | missing | `channel silence-rule create --file` | +| `/channel/silence/rule/update` | missing | `channel silence-rule update --file` | +| `/channel/silence/rule/delete` | missing | `channel silence-rule delete --id --force` | +| `/channel/silence/rule/enable` | missing | `channel silence-rule enable --id` | +| `/channel/silence/rule/disable` | missing | `channel silence-rule disable --id` | +| `/channel/inhibit/rule/list` | missing | `channel inhibit-rule list` | +| `/channel/inhibit/rule/create` | missing | `channel inhibit-rule create --file` | +| `/channel/inhibit/rule/update` | missing | `channel inhibit-rule update --file` | +| `/channel/inhibit/rule/delete` | missing | `channel inhibit-rule delete --id --force` | +| `/channel/inhibit/rule/enable` | missing | `channel inhibit-rule enable --id` | +| `/channel/inhibit/rule/disable` | missing | `channel inhibit-rule disable --id` | +| `/channel/unsubscribe/rule/list` | missing | `channel unsubscribe-rule list` | +| `/channel/unsubscribe/rule/create` | missing | `channel unsubscribe-rule create --file` | +| `/channel/unsubscribe/rule/update` | missing | `channel unsubscribe-rule update --file` | +| `/channel/unsubscribe/rule/delete` | missing | `channel unsubscribe-rule delete --id --force` | +| `/channel/unsubscribe/rule/enable` | missing | `channel unsubscribe-rule enable --id` | +| `/channel/unsubscribe/rule/disable` | missing | `channel unsubscribe-rule disable --id` | + +### Label enrichment + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/enrichment/list` | missing | `enrichment list` | +| `/enrichment/info` | missing | `enrichment get` | +| `/enrichment/upsert` | missing | `enrichment upsert --file` | +| `/enrichment/mapping/api/list` | missing | `enrichment mapping-api list` | +| `/enrichment/mapping/api/info` | missing | `enrichment mapping-api get` | +| `/enrichment/mapping/api/create` | missing | `enrichment mapping-api create --file` | +| `/enrichment/mapping/api/update` | missing | `enrichment mapping-api update --file` | +| `/enrichment/mapping/api/delete` | missing | `enrichment mapping-api delete --id --force` | +| `/enrichment/mapping/schema/list` | missing | `enrichment mapping-schema list` | +| `/enrichment/mapping/schema/info` | missing | `enrichment mapping-schema get` | +| `/enrichment/mapping/schema/create` | missing | `enrichment mapping-schema create --file` | +| `/enrichment/mapping/schema/update` | missing | `enrichment mapping-schema update --file` | +| `/enrichment/mapping/schema/delete` | missing | `enrichment mapping-schema delete --id --force` | +| `/enrichment/mapping/data/list` | missing | `enrichment mapping-data list` | +| `/enrichment/mapping/data/upsert` | missing | `enrichment mapping-data upsert --file` | +| `/enrichment/mapping/data/delete` | missing | `enrichment mapping-data delete --file` | +| `/enrichment/mapping/data/download` | missing | `enrichment mapping-data download` | +| `/enrichment/mapping/data/upload` | missing | `enrichment mapping-data upload --file` | +| `/enrichment/mapping/data/truncate` | defer | Destructive bulk operation. | + +### Incident management + +See the first implementation slice above for lifecycle gaps. + +Additional incident gaps: + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/incident/custom-action/do` | defer | `incident custom-action do` | +| `/incident/war-room/create` | implemented | `incident war-room create` | +| `/incident/war-room/delete` | implemented | `incident war-room delete` | +| `/incident/war-room/list` | implemented | `incident war-room list` | +| `/incident/war-room/detail` | implemented | `incident war-room get` | +| `/incident/post-mortem/info` | missing | `postmortem get` | +| `/incident/post-mortem/delete` | missing | `postmortem delete --id --force` | + +### Insights + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/insight/account` | missing | `insight account` | +| `/insight/team/export` | missing | `insight team export` | +| `/insight/channel/export` | missing | `insight channel export` | +| `/insight/responder/export` | missing | `insight responder export` | +| `/insight/incident/export` | missing | `insight incidents export` | + +### Routes + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/route/list` | missing | `route list` | +| `/route/info` | missing | `route get` | +| `/route/upsert` | missing | `route upsert --file` | + +### Schedules + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/schedule/infos` | covered indirectly | Used for enrichment; no command. | +| `/schedule/self` | missing | `oncall schedule self` | +| `/schedule/preview` | missing | `oncall schedule preview --file` | +| `/schedule/create` | missing | `oncall schedule create --file` | +| `/schedule/update` | missing | `oncall schedule update --file` | +| `/schedule/delete` | missing | `oncall schedule delete --id --force` | + +### Status page + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/status-page/change/list` | partial | Current `statuspage changes` uses active-list behavior, not this endpoint. | +| `/status-page/change/info` | missing | `statuspage change get` | +| `/status-page/change/update` | missing | `statuspage change update` | +| `/status-page/change/delete` | missing | `statuspage change delete --force` | +| `/status-page/change/timeline/update` | missing | `statuspage timeline update` | +| `/status-page/change/timeline/delete` | missing | `statuspage timeline delete --force` | +| `/status-page/subscriber/list` | missing | `statuspage subscriber list` | +| `/status-page/subscriber/export` | missing | `statuspage subscriber export` | +| `/status-page/subscriber/import` | missing | `statuspage subscriber import --file` | + +### Templates + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/template/list` | missing | `template list` | +| `/template/info` | partial | `template get-preset` fetches only the system preset template. | +| `/template/create` | missing | `template create --file` | +| `/template/update` | missing | `template update --file` | +| `/template/delete` | missing | `template delete --id --force` | + +### Webhook history + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/webhook/history/list` | missing | `webhook history list` | +| `/webhook/history/detail` | missing | `webhook history get` | + +## Platform gaps + +### Audit logs + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/audit/operation/list` | missing | `audit operations` | + +### Members + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/member/info` | partial | `whoami` calls current member info; no `member info` command. | +| `/person/infos` | covered indirectly | Used for enrichment; no direct lookup command. | +| `/member/invite` | missing | `member invite` | +| `/member/info/reset` | missing | `member update` | +| `/member/delete` | missing | `member delete --id --force` | +| `/member/role/grant` | missing | `member role grant` | +| `/member/role/revoke` | missing | `member role revoke` | +| `/member/role/update` | missing | `member role update` | + +### Roles and permissions + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/role/list` | missing | `role list` | +| `/role/info` | missing | `role get` | +| `/role/upsert` | missing | `role upsert --file` | +| `/role/delete` | missing | `role delete --id --force` | +| `/role/enable` | missing | `role enable --id` | +| `/role/disable` | missing | `role disable --id` | +| `/role/permission/list` | missing | `role permissions` | +| `/role/permission/factor/list` | missing | `role permission-factors` | +| `/role/member/grant` | missing | `role member grant` | +| `/role/member/revoke` | missing | `role member revoke` | + +## Monitors gaps + +The CLI has no first-class `monit` or `monitor` command family yet. Treat the +whole Monitors OpenAPI as missing except for internal SDK support for +`/monit/rule/counter/status`, which currently has no command. + +### Datasources + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/monit/datasource/list` | missing | `monit datasource list` | +| `/monit/datasource/info` | missing | `monit datasource get` | +| `/monit/datasource/create` | missing | `monit datasource create --file` | +| `/monit/datasource/update` | missing | `monit datasource update --file` | +| `/monit/datasource/delete` | missing | `monit datasource delete --id --force` | +| `/monit/datasource/sls/projects` | missing | `monit datasource sls-projects` | +| `/monit/datasource/sls/logstores` | missing | `monit datasource sls-logstores` | + +### Rules + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/monit/rule/list/basic` | missing | `monit rule list` | +| `/monit/rule/info` | missing | `monit rule get` | +| `/monit/rule/create` | missing | `monit rule create --file` | +| `/monit/rule/update` | missing | `monit rule update --file` | +| `/monit/rule/update/fields` | missing | `monit rule update-fields --file` | +| `/monit/rule/delete` | missing | `monit rule delete --id --force` | +| `/monit/rule/delete/batch` | missing | `monit rule delete-batch --file --force` | +| `/monit/rule/move` | missing | `monit rule move` | +| `/monit/rule/import` | missing | `monit rule import --file` | +| `/monit/rule/export` | missing | `monit rule export` | +| `/monit/rule/status` | missing | `monit rule status` | +| `/monit/rule/dstypes` | missing | `monit rule dstypes` | +| `/monit/rule/audits` | missing | `monit rule audits` | +| `/monit/rule/audit/detail` | missing | `monit rule audit get` | +| `/monit/rule/counter/status` | missing | `monit rule counter status` | +| `/monit/rule/counter/total` | missing | `monit rule counter total` | +| `/monit/rule/counter/channel` | missing | `monit rule counter channel` | +| `/monit/rule/counter/node` | missing | `monit rule counter node` | + +### Rulesets + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/monit/store/ruleset/list` | missing | `monit ruleset list` | +| `/monit/store/ruleset/info` | missing | `monit ruleset get` | +| `/monit/store/ruleset/create` | missing | `monit ruleset create --file` | +| `/monit/store/ruleset/update` | missing | `monit ruleset update --file` | +| `/monit/store/ruleset/delete` | missing | `monit ruleset delete --id --force` | + +## RUM gaps + +The CLI has no first-class `rum` command family yet. + +### Applications + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/rum/application/list` | missing | `rum application list` | +| `/rum/application/info` | missing | `rum application get` | +| `/rum/application/infos` | missing | `rum application get-batch` | +| `/rum/application/create` | missing | `rum application create --file` | +| `/rum/application/update` | missing | `rum application update --file` | +| `/rum/application/delete` | missing | `rum application delete --id --force` | + +### Issues + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/rum/issue/list` | missing | `rum issue list` | +| `/rum/issue/info` | missing | `rum issue get` | +| `/rum/issue/update` | missing | `rum issue update` | + +### Sourcemaps + +| Endpoint | Status | Suggested command family | +| --- | --- | --- | +| `/sourcemap/list` | missing | `rum sourcemap list` | + +## Current non-catalog commands + +These CLI commands use endpoints or static SDK data that are not present in the +current four official OpenAPI specs above. Keep them, but do not use them as +evidence that official OpenAPI coverage is complete. + +| Command | Backing behavior | +| --- | --- | +| `change list` | `/change/list` | +| `change trend` | report endpoint for change trend | +| `insight notifications` | report endpoint for notification trend | +| `statuspage list` | `/status-page/list` | +| `statuspage changes` | `/status-page/change/active/list` | +| `template validate` | `/template/preview` | +| `template variables` | SDK static metadata | +| `template functions` | SDK static metadata | +| `field list` | `/field/list` | +| `whoami` | `/account/info` plus `/member/info` | diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index d8a609e..c0e5560 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -51,10 +51,58 @@ 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, *IncidentCommentInput) error { + return fmt.Errorf("mockClient: CommentIncidents not implemented") +} + +func (m *mockClient) AddIncidentResponders(context.Context, *IncidentAddResponderInput) error { + return fmt.Errorf("mockClient: AddIncidentResponders not implemented") +} + +func (m *mockClient) CreateIncidentWarRoom(context.Context, *IncidentWarRoomCreateInput) (*IncidentWarRoom, error) { + return nil, fmt.Errorf("mockClient: CreateIncidentWarRoom not implemented") +} + +func (m *mockClient) ListIncidentWarRooms(context.Context, *IncidentWarRoomListInput) (*IncidentWarRoomListOutput, error) { + return nil, fmt.Errorf("mockClient: ListIncidentWarRooms not implemented") +} + +func (m *mockClient) GetIncidentWarRoom(context.Context, *IncidentWarRoomDetailInput) (*IncidentWarRoom, error) { + return nil, fmt.Errorf("mockClient: GetIncidentWarRoom not implemented") +} + +func (m *mockClient) DeleteIncidentWarRoom(context.Context, *IncidentWarRoomDeleteInput) error { + return fmt.Errorf("mockClient: DeleteIncidentWarRoom not implemented") +} + +func (m *mockClient) AddIncidentWarRoomMembers(context.Context, *IncidentWarRoomAddMemberInput) error { + return fmt.Errorf("mockClient: AddIncidentWarRoomMembers not implemented") +} + +func (m *mockClient) GetIncidentWarRoomDefaultObservers(context.Context, string) ([]IncidentWarRoomObserver, error) { + return nil, fmt.Errorf("mockClient: GetIncidentWarRoomDefaultObservers not implemented") +} + func (m *mockClient) ListChannels(context.Context, *flashduty.ListChannelsInput) (*flashduty.ListChannelsOutput, error) { return nil, fmt.Errorf("mockClient: ListChannels not implemented") } @@ -610,6 +658,420 @@ func TestCommandIncidentMergeRejectsMoreThan100Sources(t *testing.T) { } } +type mockIncidentLifecycle struct { + mockClient + + unackIDs []string + wakeIDs []string + removeIDs []string + disableMergeIDs []string + commentInput *IncidentCommentInput + responderInput *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 *IncidentCommentInput) error { + copied := *input + copied.IncidentIDs = append([]string(nil), input.IncidentIDs...) + m.commentInput = &copied + return nil +} + +func (m *mockIncidentLifecycle) AddIncidentResponders(_ context.Context, input *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 *IncidentWarRoomCreateInput + listInput *IncidentWarRoomListInput + getInput *IncidentWarRoomDetailInput + deleteInput *IncidentWarRoomDeleteInput + addMemberInput *IncidentWarRoomAddMemberInput + defaultObserverIncID string + defaultObserverOutput []IncidentWarRoomObserver +} + +func (m *mockIncidentWarRoom) CreateIncidentWarRoom(_ context.Context, input *IncidentWarRoomCreateInput) (*IncidentWarRoom, error) { + copied := *input + copied.MemberIDs = append([]int64(nil), input.MemberIDs...) + m.createInput = &copied + return &IncidentWarRoom{ChatID: "chat-1", ChatName: "INC outage", ShareLink: "https://chat.example/1"}, nil +} + +func (m *mockIncidentWarRoom) ListIncidentWarRooms(_ context.Context, input *IncidentWarRoomListInput) (*IncidentWarRoomListOutput, error) { + copied := *input + m.listInput = &copied + return &IncidentWarRoomListOutput{ + Items: []IncidentWarRoomItem{ + {IntegrationID: 42, ChatID: "chat-1", IncidentID: "inc-1", Status: "enabled", PluginType: "feishu"}, + }, + }, nil +} + +func (m *mockIncidentWarRoom) GetIncidentWarRoom(_ context.Context, input *IncidentWarRoomDetailInput) (*IncidentWarRoom, error) { + copied := *input + m.getInput = &copied + return &IncidentWarRoom{ChatID: "chat-1", ChatName: "INC outage", ShareLink: "https://chat.example/1"}, nil +} + +func (m *mockIncidentWarRoom) DeleteIncidentWarRoom(_ context.Context, input *IncidentWarRoomDeleteInput) error { + copied := *input + m.deleteInput = &copied + return nil +} + +func (m *mockIncidentWarRoom) AddIncidentWarRoomMembers(_ context.Context, input *IncidentWarRoomAddMemberInput) error { + copied := *input + copied.MemberIDs = append([]int64(nil), input.MemberIDs...) + m.addMemberInput = &copied + return nil +} + +func (m *mockIncidentWarRoom) GetIncidentWarRoomDefaultObservers(_ context.Context, incidentID string) ([]IncidentWarRoomObserver, error) { + m.defaultObserverIncID = incidentID + return m.defaultObserverOutput, 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 TestCommandIncidentWarRoomDefaultObservers(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockIncidentWarRoom{ + defaultObserverOutput: []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..06dc40f 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,26 @@ func newIncidentAckCmd() *cobra.Command { } } +func newIncidentUnackCmd() *cobra.Command { + return &cobra.Command{ + Use: "unack [ ...]", + Short: "Cancel incident acknowledgement", + 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 +348,26 @@ func newIncidentCloseCmd() *cobra.Command { } } +func newIncidentWakeCmd() *cobra.Command { + return &cobra.Command{ + Use: "wake [ ...]", + Short: "Restore notifications for snoozed incidents", + 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 +508,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 +645,376 @@ 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", + 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 *IncidentNotifyInput + if followPreference || notifyChannel != "" || templateID != "" { + notify = &IncidentNotifyInput{ + FollowPreference: followPreference, + PersonalChannels: parseStringSlice(notifyChannel), + TemplateID: templateID, + } + } + + return runCommand(cmd, args, func(ctx *RunContext) error { + if err := ctx.Client.AddIncidentResponders(cmdContext(ctx.Cmd), &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", + 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), &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", + 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", + 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", + } + 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", + 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 { + warRoom, err := ctx.Client.CreateIncidentWarRoom(cmdContext(ctx.Cmd), &IncidentWarRoomCreateInput{ + IncidentID: ctx.Args[0], + IntegrationID: integrationID, + 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") + 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") + _ = cmd.MarkFlagRequired("integration") + return cmd +} + +func newIncidentWarRoomListCmd() *cobra.Command { + var integrationID int64 + + cmd := &cobra.Command{ + Use: "list ", + Short: "List incident war rooms", + 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), &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", + 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), &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") + _ = 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", + 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), &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") + 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", + 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), &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") + cmd.Flags().StringVar(&member, "member", "", "Comma-separated member person IDs") + _ = 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", + 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.(IncidentWarRoomItem).IntegrationID) }}, + {Header: "CHAT_ID", Field: func(v any) string { return v.(IncidentWarRoomItem).ChatID }}, + {Header: "INCIDENT_ID", Field: func(v any) string { return v.(IncidentWarRoomItem).IncidentID }}, + {Header: "STATUS", Field: func(v any) string { return v.(IncidentWarRoomItem).Status }}, + {Header: "PLUGIN", Field: func(v any) string { return v.(IncidentWarRoomItem).PluginType }}, + {Header: "CREATED", Field: func(v any) string { return formatWarRoomCreatedAt(v.(IncidentWarRoomItem).CreatedAt) }}, + } +} + +func incidentWarRoomObserverColumns() []output.Column { + return []output.Column{ + {Header: "PERSON_ID", Field: func(v any) string { return fmt.Sprint(v.(IncidentWarRoomObserver).PersonID) }}, + {Header: "NAME", Field: func(v any) string { return v.(IncidentWarRoomObserver).DisplayName() }}, + {Header: "EMAIL", Field: func(v any) string { return v.(IncidentWarRoomObserver).Email }}, + {Header: "STATUS", Field: func(v any) string { return v.(IncidentWarRoomObserver).Status }}, + } +} + +func printWarRoomDetail(w io.Writer, warRoom *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/incident_lifecycle_client.go b/internal/cli/incident_lifecycle_client.go new file mode 100644 index 0000000..62e9be8 --- /dev/null +++ b/internal/cli/incident_lifecycle_client.go @@ -0,0 +1,414 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + flashduty "github.com/flashcatcloud/flashduty-sdk" +) + +const incidentAPIResponseLimit = 10 * 1024 * 1024 + +type flashdutyCLIClient struct { + *flashduty.Client + incident *incidentAPIClient +} + +func (c *flashdutyCLIClient) UnackIncidents(ctx context.Context, incidentIDs []string) error { + return c.incident.UnackIncidents(ctx, incidentIDs) +} + +func (c *flashdutyCLIClient) WakeIncidents(ctx context.Context, incidentIDs []string) error { + return c.incident.WakeIncidents(ctx, incidentIDs) +} + +func (c *flashdutyCLIClient) RemoveIncidents(ctx context.Context, incidentIDs []string) error { + return c.incident.RemoveIncidents(ctx, incidentIDs) +} + +func (c *flashdutyCLIClient) DisableIncidentMerge(ctx context.Context, incidentIDs []string) error { + return c.incident.DisableIncidentMerge(ctx, incidentIDs) +} + +func (c *flashdutyCLIClient) CommentIncidents(ctx context.Context, input *IncidentCommentInput) error { + return c.incident.CommentIncidents(ctx, input) +} + +func (c *flashdutyCLIClient) AddIncidentResponders(ctx context.Context, input *IncidentAddResponderInput) error { + return c.incident.AddIncidentResponders(ctx, input) +} + +func (c *flashdutyCLIClient) CreateIncidentWarRoom(ctx context.Context, input *IncidentWarRoomCreateInput) (*IncidentWarRoom, error) { + return c.incident.CreateIncidentWarRoom(ctx, input) +} + +func (c *flashdutyCLIClient) ListIncidentWarRooms(ctx context.Context, input *IncidentWarRoomListInput) (*IncidentWarRoomListOutput, error) { + return c.incident.ListIncidentWarRooms(ctx, input) +} + +func (c *flashdutyCLIClient) GetIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDetailInput) (*IncidentWarRoom, error) { + return c.incident.GetIncidentWarRoom(ctx, input) +} + +func (c *flashdutyCLIClient) DeleteIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDeleteInput) error { + return c.incident.DeleteIncidentWarRoom(ctx, input) +} + +func (c *flashdutyCLIClient) AddIncidentWarRoomMembers(ctx context.Context, input *IncidentWarRoomAddMemberInput) error { + return c.incident.AddIncidentWarRoomMembers(ctx, input) +} + +func (c *flashdutyCLIClient) GetIncidentWarRoomDefaultObservers(ctx context.Context, incidentID string) ([]IncidentWarRoomObserver, error) { + return c.incident.GetIncidentWarRoomDefaultObservers(ctx, incidentID) +} + +type IncidentCommentInput struct { + IncidentIDs []string + Comment string + MuteReply bool +} + +type IncidentNotifyInput struct { + FollowPreference bool + PersonalChannels []string + TemplateID string +} + +type IncidentAddResponderInput struct { + IncidentID string + PersonIDs []int64 + Notify *IncidentNotifyInput +} + +type IncidentWarRoomCreateInput struct { + IncidentID string + IntegrationID int64 + MemberIDs []int64 + AddObservers bool +} + +type IncidentWarRoomListInput struct { + IncidentID string + IntegrationID int64 +} + +type IncidentWarRoomDetailInput struct { + IntegrationID int64 + ChatID string +} + +type IncidentWarRoomDeleteInput struct { + IncidentID string + IntegrationID int64 +} + +type IncidentWarRoomAddMemberInput struct { + IntegrationID int64 + ChatID string + MemberIDs []int64 +} + +type IncidentWarRoom struct { + ChatID string `json:"chat_id"` + ChatName string `json:"chat_name"` + ShareLink string `json:"share_link"` +} + +type IncidentWarRoomItem struct { + AccountID int64 `json:"account_id"` + IntegrationID int64 `json:"integration_id"` + CreatedBy int64 `json:"created_by"` + ChatID string `json:"chat_id"` + IncidentID string `json:"incident_id"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` + PluginType string `json:"plugin_type"` +} + +type IncidentWarRoomListOutput struct { + Items []IncidentWarRoomItem `json:"items"` +} + +type IncidentWarRoomObserver struct { + PersonID int64 `json:"person_id"` + PersonName string `json:"person_name"` + Name string `json:"name"` + Email string `json:"email"` + Status string `json:"status"` +} + +func (o IncidentWarRoomObserver) DisplayName() string { + if o.PersonName != "" { + return o.PersonName + } + return o.Name +} + +type incidentAPIClient struct { + appKey string + baseURL *url.URL + userAgent string + httpClient *http.Client +} + +func newIncidentAPIClient(appKey, baseURL, userAgent string) *incidentAPIClient { + parsed, err := url.Parse(baseURL) + if err != nil || parsed == nil { + parsed, _ = url.Parse("https://api.flashcat.cloud") + } + return &incidentAPIClient{ + appKey: appKey, + baseURL: parsed, + userAgent: userAgent, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *incidentAPIClient) UnackIncidents(ctx context.Context, incidentIDs []string) error { + return c.postEmpty(ctx, "/incident/unack", map[string]any{"incident_ids": incidentIDs}) +} + +func (c *incidentAPIClient) WakeIncidents(ctx context.Context, incidentIDs []string) error { + return c.postEmpty(ctx, "/incident/wake", map[string]any{"incident_ids": incidentIDs}) +} + +func (c *incidentAPIClient) RemoveIncidents(ctx context.Context, incidentIDs []string) error { + return c.postEmpty(ctx, "/incident/remove", map[string]any{"incident_ids": incidentIDs}) +} + +func (c *incidentAPIClient) DisableIncidentMerge(ctx context.Context, incidentIDs []string) error { + return c.postEmpty(ctx, "/incident/disable-merge", map[string]any{"incident_ids": incidentIDs}) +} + +func (c *incidentAPIClient) CommentIncidents(ctx context.Context, input *IncidentCommentInput) error { + if input == nil { + return fmt.Errorf("incident comment input is required") + } + body := map[string]any{ + "incident_ids": input.IncidentIDs, + "comment": input.Comment, + } + if input.MuteReply { + body["mute_reply"] = true + } + return c.postEmpty(ctx, "/incident/comment", body) +} + +func (c *incidentAPIClient) AddIncidentResponders(ctx context.Context, input *IncidentAddResponderInput) error { + if input == nil { + return fmt.Errorf("incident responder input is required") + } + body := map[string]any{ + "incident_id": input.IncidentID, + "person_ids": input.PersonIDs, + } + if input.Notify != nil { + notify := map[string]any{} + if input.Notify.FollowPreference { + notify["follow_preference"] = true + } + if len(input.Notify.PersonalChannels) > 0 { + notify["personal_channels"] = input.Notify.PersonalChannels + } + if input.Notify.TemplateID != "" { + notify["template_id"] = input.Notify.TemplateID + } + if len(notify) > 0 { + body["notify"] = notify + } + } + return c.postEmpty(ctx, "/incident/responder/add", body) +} + +func (c *incidentAPIClient) CreateIncidentWarRoom(ctx context.Context, input *IncidentWarRoomCreateInput) (*IncidentWarRoom, error) { + if input == nil { + return nil, fmt.Errorf("incident war-room create input is required") + } + body := map[string]any{ + "incident_id": input.IncidentID, + "integration_id": input.IntegrationID, + } + if len(input.MemberIDs) > 0 { + body["member_ids"] = input.MemberIDs + } + if input.AddObservers { + body["add_observers"] = true + } + var out IncidentWarRoom + if err := c.postData(ctx, "/incident/war-room/create", body, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *incidentAPIClient) ListIncidentWarRooms(ctx context.Context, input *IncidentWarRoomListInput) (*IncidentWarRoomListOutput, error) { + if input == nil { + return nil, fmt.Errorf("incident war-room list input is required") + } + body := map[string]any{"incident_id": input.IncidentID} + if input.IntegrationID > 0 { + body["integration_id"] = input.IntegrationID + } + var out IncidentWarRoomListOutput + if err := c.postData(ctx, "/incident/war-room/list", body, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *incidentAPIClient) GetIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDetailInput) (*IncidentWarRoom, error) { + if input == nil { + return nil, fmt.Errorf("incident war-room detail input is required") + } + var out IncidentWarRoom + if err := c.postData(ctx, "/incident/war-room/detail", map[string]any{ + "integration_id": input.IntegrationID, + "chat_id": input.ChatID, + }, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *incidentAPIClient) DeleteIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDeleteInput) error { + if input == nil { + return fmt.Errorf("incident war-room delete input is required") + } + return c.postEmpty(ctx, "/incident/war-room/delete", map[string]any{ + "incident_id": input.IncidentID, + "integration_id": input.IntegrationID, + }) +} + +func (c *incidentAPIClient) AddIncidentWarRoomMembers(ctx context.Context, input *IncidentWarRoomAddMemberInput) error { + if input == nil { + return fmt.Errorf("incident war-room add-member input is required") + } + return c.postEmpty(ctx, "/incident/war-room/add-member", map[string]any{ + "integration_id": input.IntegrationID, + "chat_id": input.ChatID, + "member_ids": input.MemberIDs, + }) +} + +func (c *incidentAPIClient) GetIncidentWarRoomDefaultObservers(ctx context.Context, incidentID string) ([]IncidentWarRoomObserver, error) { + var out struct { + Observers []IncidentWarRoomObserver `json:"observers"` + } + if err := c.postData(ctx, "/incident/war-room/default-observers", map[string]any{ + "incident_id": incidentID, + }, &out); err != nil { + return nil, err + } + return out.Observers, nil +} + +type incidentAPIEnvelope struct { + Error *flashduty.DutyError `json:"error,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +func (c *incidentAPIClient) postEmpty(ctx context.Context, path string, body any) error { + resp, err := c.post(ctx, path, body) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + limited := io.LimitReader(resp.Body, incidentAPIResponseLimit) + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(limited) + return fmt.Errorf("API request failed (HTTP %d): %s", resp.StatusCode, c.redactAppKey(string(data))) + } + + var result flashduty.FlashdutyResponse + if err := json.NewDecoder(limited).Decode(&result); err != nil { + return fmt.Errorf("invalid API response: %w", err) + } + if result.Error != nil { + return result.Error + } + return nil +} + +func (c *incidentAPIClient) postData(ctx context.Context, path string, body any, out any) error { + resp, err := c.post(ctx, path, body) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + limited := io.LimitReader(resp.Body, incidentAPIResponseLimit) + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(limited) + return fmt.Errorf("API request failed (HTTP %d): %s", resp.StatusCode, c.redactAppKey(string(data))) + } + + var envelope incidentAPIEnvelope + if err := json.NewDecoder(limited).Decode(&envelope); err != nil { + return fmt.Errorf("invalid API response: %w", err) + } + if envelope.Error != nil { + return envelope.Error + } + if out == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" { + return nil + } + if err := json.Unmarshal(envelope.Data, out); err != nil { + return fmt.Errorf("invalid API data: %w", err) + } + return nil +} + +func (c *incidentAPIClient) post(ctx context.Context, path string, body any) (*http.Response, error) { + reqBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("invalid request body: %w", err) + } + + parsedPath, err := url.Parse(strings.TrimPrefix(path, "/")) + if err != nil { + return nil, fmt.Errorf("invalid request path: %w", err) + } + fullURL := c.baseURL.ResolveReference(parsedPath) + query := fullURL.Query() + query.Set("app_key", c.appKey) + fullURL.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL.String(), bytes.NewReader(reqBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + if c.userAgent != "" { + req.Header.Set("User-Agent", c.userAgent) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %s", c.redactAppKey(err.Error())) + } + return resp, nil +} + +func (c *incidentAPIClient) redactAppKey(s string) string { + if c.appKey == "" || s == "" { + return s + } + redacted := strings.ReplaceAll(s, c.appKey, "[REDACTED]") + escaped := url.QueryEscape(c.appKey) + if escaped != c.appKey { + redacted = strings.ReplaceAll(redacted, escaped, "[REDACTED]") + } + return redacted +} diff --git a/internal/cli/incident_lifecycle_client_test.go b/internal/cli/incident_lifecycle_client_test.go new file mode 100644 index 0000000..29c11b7 --- /dev/null +++ b/internal/cli/incident_lifecycle_client_test.go @@ -0,0 +1,250 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type capturedIncidentRequest struct { + path string + appKey string + body map[string]any +} + +func newIncidentLifecycleTestClient(t *testing.T, capture *capturedIncidentRequest) *incidentAPIClient { + t.Helper() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capture.path = r.URL.Path + capture.appKey = r.URL.Query().Get("app_key") + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&capture.body); err != nil { + t.Fatalf("decode request body: %v", err) + } + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}}) + })) + t.Cleanup(ts.Close) + + return newIncidentAPIClient("test-key", ts.URL, "flashduty-cli/test") +} + +func TestIncidentAPIClientSimpleLifecycleRequests(t *testing.T) { + tests := []struct { + name string + call func(context.Context, *incidentAPIClient) error + path string + }{ + { + name: "unack", + call: func(ctx context.Context, c *incidentAPIClient) error { + return c.UnackIncidents(ctx, []string{"inc-1", "inc-2"}) + }, + path: "/incident/unack", + }, + { + name: "wake", + call: func(ctx context.Context, c *incidentAPIClient) error { + return c.WakeIncidents(ctx, []string{"inc-1", "inc-2"}) + }, + path: "/incident/wake", + }, + { + name: "remove", + call: func(ctx context.Context, c *incidentAPIClient) error { + return c.RemoveIncidents(ctx, []string{"inc-1", "inc-2"}) + }, + path: "/incident/remove", + }, + { + name: "disable merge", + call: func(ctx context.Context, c *incidentAPIClient) error { + return c.DisableIncidentMerge(ctx, []string{"inc-1", "inc-2"}) + }, + path: "/incident/disable-merge", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + capture := &capturedIncidentRequest{} + client := newIncidentLifecycleTestClient(t, capture) + + if err := tc.call(context.Background(), client); err != nil { + t.Fatalf("call returned error: %v", err) + } + + if capture.path != tc.path { + t.Fatalf("expected path %q, got %q", tc.path, capture.path) + } + if capture.appKey != "test-key" { + t.Fatalf("expected app_key test-key, got %q", capture.appKey) + } + gotIDs, ok := capture.body["incident_ids"].([]any) + if !ok || len(gotIDs) != 2 || gotIDs[0] != "inc-1" || gotIDs[1] != "inc-2" { + t.Fatalf("unexpected body: %#v", capture.body) + } + }) + } +} + +func TestIncidentAPIClientCommentRequest(t *testing.T) { + capture := &capturedIncidentRequest{} + client := newIncidentLifecycleTestClient(t, capture) + + err := client.CommentIncidents(context.Background(), &IncidentCommentInput{ + IncidentIDs: []string{"inc-1"}, + Comment: "rollback started", + MuteReply: true, + }) + if err != nil { + t.Fatalf("CommentIncidents returned error: %v", err) + } + + if capture.path != "/incident/comment" { + t.Fatalf("expected comment path, got %q", capture.path) + } + if capture.body["comment"] != "rollback started" || capture.body["mute_reply"] != true { + t.Fatalf("unexpected body: %#v", capture.body) + } +} + +func TestIncidentAPIClientAddResponderRequest(t *testing.T) { + capture := &capturedIncidentRequest{} + client := newIncidentLifecycleTestClient(t, capture) + + err := client.AddIncidentResponders(context.Background(), &IncidentAddResponderInput{ + IncidentID: "inc-1", + PersonIDs: []int64{101, 202}, + Notify: &IncidentNotifyInput{ + FollowPreference: true, + PersonalChannels: []string{"voice", "sms"}, + TemplateID: "6321aad26c12104586a88916", + }, + }) + if err != nil { + t.Fatalf("AddIncidentResponders returned error: %v", err) + } + + if capture.path != "/incident/responder/add" { + t.Fatalf("expected responder add path, got %q", capture.path) + } + if capture.body["incident_id"] != "inc-1" { + t.Fatalf("unexpected body: %#v", capture.body) + } + notify, ok := capture.body["notify"].(map[string]any) + if !ok { + t.Fatalf("expected notify body, got %#v", capture.body) + } + if notify["follow_preference"] != true || notify["template_id"] != "6321aad26c12104586a88916" { + t.Fatalf("unexpected notify body: %#v", notify) + } +} + +func TestIncidentAPIClientWarRoomCreateRequest(t *testing.T) { + capture := &capturedIncidentRequest{} + client := newIncidentLifecycleTestClient(t, capture) + + warRoom, err := client.CreateIncidentWarRoom(context.Background(), &IncidentWarRoomCreateInput{ + IncidentID: "inc-1", + IntegrationID: 42, + MemberIDs: []int64{101, 202}, + AddObservers: true, + }) + if err != nil { + t.Fatalf("CreateIncidentWarRoom returned error: %v", err) + } + + if capture.path != "/incident/war-room/create" { + t.Fatalf("expected war-room create path, got %q", capture.path) + } + if capture.body["incident_id"] != "inc-1" || capture.body["add_observers"] != true { + t.Fatalf("unexpected body: %#v", capture.body) + } + if warRoom == nil { + t.Fatal("expected war room output") + } +} + +func TestIncidentAPIClientWarRoomListDecodesItems(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/incident/war-room/list" { + t.Fatalf("expected list path, got %q", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "items": []map[string]any{ + { + "integration_id": float64(42), + "chat_id": "chat-1", + "incident_id": "inc-1", + "status": "enabled", + "plugin_type": "feishu", + }, + }, + }, + }) + })) + t.Cleanup(ts.Close) + + client := newIncidentAPIClient("test-key", ts.URL, "flashduty-cli/test") + result, err := client.ListIncidentWarRooms(context.Background(), &IncidentWarRoomListInput{IncidentID: "inc-1"}) + if err != nil { + t.Fatalf("ListIncidentWarRooms returned error: %v", err) + } + if len(result.Items) != 1 || result.Items[0].ChatID != "chat-1" { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestIncidentAPIClientWarRoomDefaultObserversDecodesObservers(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/incident/war-room/default-observers" { + t.Fatalf("expected default observers path, got %q", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "observers": []map[string]any{ + {"person_id": float64(101), "person_name": "Alice", "email": "alice@example.com"}, + }, + }, + }) + })) + t.Cleanup(ts.Close) + + client := newIncidentAPIClient("test-key", ts.URL, "flashduty-cli/test") + observers, err := client.GetIncidentWarRoomDefaultObservers(context.Background(), "inc-1") + if err != nil { + t.Fatalf("GetIncidentWarRoomDefaultObservers returned error: %v", err) + } + if len(observers) != 1 || observers[0].DisplayName() != "Alice" { + t.Fatalf("unexpected observers: %#v", observers) + } +} + +type failingRoundTripper struct{} + +func (f failingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp " + req.URL.String()) +} + +func TestIncidentAPIClientRedactsAppKeyOnTransportError(t *testing.T) { + client := newIncidentAPIClient("secret-app-key", "https://api.flashcat.cloud", "flashduty-cli/test") + client.httpClient = &http.Client{Transport: failingRoundTripper{}} + + err := client.UnackIncidents(context.Background(), []string{"inc-1"}) + if err == nil { + t.Fatal("expected transport error, got nil") + } + if strings.Contains(err.Error(), "secret-app-key") { + t.Fatalf("transport error leaked app key: %v", err) + } + if !strings.Contains(err.Error(), "[REDACTED]") { + t.Fatalf("expected redacted marker in error, got: %v", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 3b76af9..356fe57 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -30,7 +30,19 @@ 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 *IncidentCommentInput) error + AddIncidentResponders(ctx context.Context, input *IncidentAddResponderInput) error + CreateIncidentWarRoom(ctx context.Context, input *IncidentWarRoomCreateInput) (*IncidentWarRoom, error) + ListIncidentWarRooms(ctx context.Context, input *IncidentWarRoomListInput) (*IncidentWarRoomListOutput, error) + GetIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDetailInput) (*IncidentWarRoom, error) + DeleteIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDeleteInput) error + AddIncidentWarRoomMembers(ctx context.Context, input *IncidentWarRoomAddMemberInput) error + GetIncidentWarRoomDefaultObservers(ctx context.Context, incidentID string) ([]IncidentWarRoomObserver, 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 +206,15 @@ 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 &flashdutyCLIClient{ + Client: sdkClient, + incident: newIncidentAPIClient(cfg.AppKey, cfg.BaseURL, "flashduty-cli/"+versionStr), + }, nil } func loadResolvedConfig() (*config.Config, error) { From f787a9db407d50c87c9c7f35ff0327c1960c5f52 Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Wed, 20 May 2026 14:41:31 +0800 Subject: [PATCH 2/5] refactor: move incident adapters to sdk --- AGENTS.md | 8 + go.mod | 4 +- go.sum | 4 +- internal/cli/command_test.go | 134 ++++-- internal/cli/incident.go | 66 ++- internal/cli/incident_lifecycle_client.go | 414 ------------------ .../cli/incident_lifecycle_client_test.go | 250 ----------- internal/cli/root.go | 22 +- todos/2026-05-20-cli-sdk-cleanup.md | 17 + todos/2026-05-20-sdk-api-adapter-migration.md | 30 ++ 10 files changed, 218 insertions(+), 731 deletions(-) create mode 100644 AGENTS.md delete mode 100644 internal/cli/incident_lifecycle_client.go delete mode 100644 internal/cli/incident_lifecycle_client_test.go create mode 100644 todos/2026-05-20-cli-sdk-cleanup.md create mode 100644 todos/2026-05-20-sdk-api-adapter-migration.md 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..91f26ec 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.8.1-0.20260520063928-ea6a81de4c95 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..403907b 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.8.1-0.20260520063928-ea6a81de4c95 h1:b7O4LtOfecgmuq2Ipv6vkssHNFzqfUbo5utBuR8X4kI= +github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260520063928-ea6a81de4c95/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 c0e5560..7cdedc9 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 @@ -71,38 +73,42 @@ func (m *mockClient) DisableIncidentMerge(context.Context, []string) error { return fmt.Errorf("mockClient: DisableIncidentMerge not implemented") } -func (m *mockClient) CommentIncidents(context.Context, *IncidentCommentInput) error { +func (m *mockClient) CommentIncidents(context.Context, *flashduty.IncidentCommentInput) error { return fmt.Errorf("mockClient: CommentIncidents not implemented") } -func (m *mockClient) AddIncidentResponders(context.Context, *IncidentAddResponderInput) error { +func (m *mockClient) AddIncidentResponders(context.Context, *flashduty.IncidentAddResponderInput) error { return fmt.Errorf("mockClient: AddIncidentResponders not implemented") } -func (m *mockClient) CreateIncidentWarRoom(context.Context, *IncidentWarRoomCreateInput) (*IncidentWarRoom, error) { +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, *IncidentWarRoomListInput) (*IncidentWarRoomListOutput, error) { +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, *IncidentWarRoomDetailInput) (*IncidentWarRoom, error) { +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, *IncidentWarRoomDeleteInput) error { +func (m *mockClient) DeleteIncidentWarRoom(context.Context, *flashduty.IncidentWarRoomDeleteInput) error { return fmt.Errorf("mockClient: DeleteIncidentWarRoom not implemented") } -func (m *mockClient) AddIncidentWarRoomMembers(context.Context, *IncidentWarRoomAddMemberInput) error { +func (m *mockClient) AddIncidentWarRoomMembers(context.Context, *flashduty.IncidentWarRoomAddMemberInput) error { return fmt.Errorf("mockClient: AddIncidentWarRoomMembers not implemented") } -func (m *mockClient) GetIncidentWarRoomDefaultObservers(context.Context, string) ([]IncidentWarRoomObserver, error) { +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") } @@ -307,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) @@ -319,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 // --------------------------------------------------------------------------- @@ -665,8 +698,8 @@ type mockIncidentLifecycle struct { wakeIDs []string removeIDs []string disableMergeIDs []string - commentInput *IncidentCommentInput - responderInput *IncidentAddResponderInput + commentInput *flashduty.IncidentCommentInput + responderInput *flashduty.IncidentAddResponderInput } func (m *mockIncidentLifecycle) UnackIncidents(_ context.Context, incidentIDs []string) error { @@ -689,14 +722,14 @@ func (m *mockIncidentLifecycle) DisableIncidentMerge(_ context.Context, incident return nil } -func (m *mockIncidentLifecycle) CommentIncidents(_ context.Context, input *IncidentCommentInput) error { +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 *IncidentAddResponderInput) error { +func (m *mockIncidentLifecycle) AddIncidentResponders(_ context.Context, input *flashduty.IncidentAddResponderInput) error { copied := *input copied.PersonIDs = append([]int64(nil), input.PersonIDs...) if input.Notify != nil { @@ -907,56 +940,61 @@ func TestCommandIncidentDisableMerge(t *testing.T) { type mockIncidentWarRoom struct { mockClient - createInput *IncidentWarRoomCreateInput - listInput *IncidentWarRoomListInput - getInput *IncidentWarRoomDetailInput - deleteInput *IncidentWarRoomDeleteInput - addMemberInput *IncidentWarRoomAddMemberInput + createInput *flashduty.IncidentWarRoomCreateInput + listInput *flashduty.IncidentWarRoomListInput + getInput *flashduty.IncidentWarRoomDetailInput + deleteInput *flashduty.IncidentWarRoomDeleteInput + addMemberInput *flashduty.IncidentWarRoomAddMemberInput defaultObserverIncID string - defaultObserverOutput []IncidentWarRoomObserver + defaultObserverOutput []flashduty.IncidentWarRoomObserver + enabledDataSources []flashduty.DataSourceIntegration } -func (m *mockIncidentWarRoom) CreateIncidentWarRoom(_ context.Context, input *IncidentWarRoomCreateInput) (*IncidentWarRoom, error) { +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 &IncidentWarRoom{ChatID: "chat-1", ChatName: "INC outage", ShareLink: "https://chat.example/1"}, nil + return &flashduty.IncidentWarRoom{ChatID: "chat-1", ChatName: "INC outage", ShareLink: "https://chat.example/1"}, nil } -func (m *mockIncidentWarRoom) ListIncidentWarRooms(_ context.Context, input *IncidentWarRoomListInput) (*IncidentWarRoomListOutput, error) { +func (m *mockIncidentWarRoom) ListIncidentWarRooms(_ context.Context, input *flashduty.IncidentWarRoomListInput) (*flashduty.IncidentWarRoomListOutput, error) { copied := *input m.listInput = &copied - return &IncidentWarRoomListOutput{ - Items: []IncidentWarRoomItem{ + 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 *IncidentWarRoomDetailInput) (*IncidentWarRoom, error) { +func (m *mockIncidentWarRoom) GetIncidentWarRoom(_ context.Context, input *flashduty.IncidentWarRoomDetailInput) (*flashduty.IncidentWarRoom, error) { copied := *input m.getInput = &copied - return &IncidentWarRoom{ChatID: "chat-1", ChatName: "INC outage", ShareLink: "https://chat.example/1"}, nil + return &flashduty.IncidentWarRoom{ChatID: "chat-1", ChatName: "INC outage", ShareLink: "https://chat.example/1"}, nil } -func (m *mockIncidentWarRoom) DeleteIncidentWarRoom(_ context.Context, input *IncidentWarRoomDeleteInput) error { +func (m *mockIncidentWarRoom) DeleteIncidentWarRoom(_ context.Context, input *flashduty.IncidentWarRoomDeleteInput) error { copied := *input m.deleteInput = &copied return nil } -func (m *mockIncidentWarRoom) AddIncidentWarRoomMembers(_ context.Context, input *IncidentWarRoomAddMemberInput) error { +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) ([]IncidentWarRoomObserver, error) { +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{} @@ -980,10 +1018,48 @@ func TestCommandIncidentWarRoomCreateWithObservers(t *testing.T) { } } +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: []IncidentWarRoomObserver{ + defaultObserverOutput: []flashduty.IncidentWarRoomObserver{ {PersonID: 101, PersonName: "Alice", Email: "alice@example.com"}, }, } diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 06dc40f..fd08bf1 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -662,9 +662,9 @@ func newIncidentAddResponderCmd() *cobra.Command { return fmt.Errorf("--person is required") } - var notify *IncidentNotifyInput + var notify *flashduty.IncidentNotifyInput if followPreference || notifyChannel != "" || templateID != "" { - notify = &IncidentNotifyInput{ + notify = &flashduty.IncidentNotifyInput{ FollowPreference: followPreference, PersonalChannels: parseStringSlice(notifyChannel), TemplateID: templateID, @@ -672,7 +672,7 @@ func newIncidentAddResponderCmd() *cobra.Command { } return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.AddIncidentResponders(cmdContext(ctx.Cmd), &IncidentAddResponderInput{ + if err := ctx.Client.AddIncidentResponders(cmdContext(ctx.Cmd), &flashduty.IncidentAddResponderInput{ IncidentID: ctx.Args[0], PersonIDs: personIDs, Notify: notify, @@ -715,7 +715,7 @@ func newIncidentCommentCmd() *cobra.Command { } return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.CommentIncidents(cmdContext(ctx.Cmd), &IncidentCommentInput{ + if err := ctx.Client.CommentIncidents(cmdContext(ctx.Cmd), &flashduty.IncidentCommentInput{ IncidentIDs: ctx.Args, Comment: comment, MuteReply: muteReply, @@ -812,9 +812,13 @@ func newIncidentWarRoomCreateCmd() *cobra.Command { return fmt.Errorf("invalid --member: %w", err) } return runCommand(cmd, args, func(ctx *RunContext) error { - warRoom, err := ctx.Client.CreateIncidentWarRoom(cmdContext(ctx.Cmd), &IncidentWarRoomCreateInput{ + resolvedIntegrationID, err := resolveWarRoomIntegrationID(ctx) + if err != nil { + return err + } + warRoom, err := ctx.Client.CreateIncidentWarRoom(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomCreateInput{ IncidentID: ctx.Args[0], - IntegrationID: integrationID, + IntegrationID: resolvedIntegrationID, MemberIDs: memberIDs, AddObservers: addObservers, }) @@ -834,10 +838,28 @@ func newIncidentWarRoomCreateCmd() *cobra.Command { cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID") 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") - _ = cmd.MarkFlagRequired("integration") 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 @@ -847,7 +869,7 @@ func newIncidentWarRoomListCmd() *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.ListIncidentWarRooms(cmdContext(ctx.Cmd), &IncidentWarRoomListInput{ + result, err := ctx.Client.ListIncidentWarRooms(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomListInput{ IncidentID: ctx.Args[0], IntegrationID: integrationID, }) @@ -872,7 +894,7 @@ func newIncidentWarRoomGetCmd() *cobra.Command { 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), &IncidentWarRoomDetailInput{ + warRoom, err := ctx.Client.GetIncidentWarRoom(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomDetailInput{ IntegrationID: integrationID, ChatID: ctx.Args[0], }) @@ -907,7 +929,7 @@ func newIncidentWarRoomDeleteCmd() *cobra.Command { _, _ = fmt.Fprintln(ctx.Writer, "Aborted.") return nil } - if err := ctx.Client.DeleteIncidentWarRoom(cmdContext(ctx.Cmd), &IncidentWarRoomDeleteInput{ + if err := ctx.Client.DeleteIncidentWarRoom(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomDeleteInput{ IncidentID: ctx.Args[0], IntegrationID: integrationID, }); err != nil { @@ -942,7 +964,7 @@ func newIncidentWarRoomAddMemberCmd() *cobra.Command { return fmt.Errorf("--member is required") } return runCommand(cmd, args, func(ctx *RunContext) error { - if err := ctx.Client.AddIncidentWarRoomMembers(cmdContext(ctx.Cmd), &IncidentWarRoomAddMemberInput{ + if err := ctx.Client.AddIncidentWarRoomMembers(cmdContext(ctx.Cmd), &flashduty.IncidentWarRoomAddMemberInput{ IntegrationID: integrationID, ChatID: ctx.Args[0], MemberIDs: memberIDs, @@ -981,25 +1003,25 @@ func newIncidentWarRoomDefaultObserversCmd() *cobra.Command { func incidentWarRoomColumns() []output.Column { return []output.Column{ - {Header: "INTEGRATION", Field: func(v any) string { return fmt.Sprint(v.(IncidentWarRoomItem).IntegrationID) }}, - {Header: "CHAT_ID", Field: func(v any) string { return v.(IncidentWarRoomItem).ChatID }}, - {Header: "INCIDENT_ID", Field: func(v any) string { return v.(IncidentWarRoomItem).IncidentID }}, - {Header: "STATUS", Field: func(v any) string { return v.(IncidentWarRoomItem).Status }}, - {Header: "PLUGIN", Field: func(v any) string { return v.(IncidentWarRoomItem).PluginType }}, - {Header: "CREATED", Field: func(v any) string { return formatWarRoomCreatedAt(v.(IncidentWarRoomItem).CreatedAt) }}, + {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.(IncidentWarRoomObserver).PersonID) }}, - {Header: "NAME", Field: func(v any) string { return v.(IncidentWarRoomObserver).DisplayName() }}, - {Header: "EMAIL", Field: func(v any) string { return v.(IncidentWarRoomObserver).Email }}, - {Header: "STATUS", Field: func(v any) string { return v.(IncidentWarRoomObserver).Status }}, + {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 *IncidentWarRoom) { +func printWarRoomDetail(w io.Writer, warRoom *flashduty.IncidentWarRoom) { if warRoom == nil { return } diff --git a/internal/cli/incident_lifecycle_client.go b/internal/cli/incident_lifecycle_client.go deleted file mode 100644 index 62e9be8..0000000 --- a/internal/cli/incident_lifecycle_client.go +++ /dev/null @@ -1,414 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - flashduty "github.com/flashcatcloud/flashduty-sdk" -) - -const incidentAPIResponseLimit = 10 * 1024 * 1024 - -type flashdutyCLIClient struct { - *flashduty.Client - incident *incidentAPIClient -} - -func (c *flashdutyCLIClient) UnackIncidents(ctx context.Context, incidentIDs []string) error { - return c.incident.UnackIncidents(ctx, incidentIDs) -} - -func (c *flashdutyCLIClient) WakeIncidents(ctx context.Context, incidentIDs []string) error { - return c.incident.WakeIncidents(ctx, incidentIDs) -} - -func (c *flashdutyCLIClient) RemoveIncidents(ctx context.Context, incidentIDs []string) error { - return c.incident.RemoveIncidents(ctx, incidentIDs) -} - -func (c *flashdutyCLIClient) DisableIncidentMerge(ctx context.Context, incidentIDs []string) error { - return c.incident.DisableIncidentMerge(ctx, incidentIDs) -} - -func (c *flashdutyCLIClient) CommentIncidents(ctx context.Context, input *IncidentCommentInput) error { - return c.incident.CommentIncidents(ctx, input) -} - -func (c *flashdutyCLIClient) AddIncidentResponders(ctx context.Context, input *IncidentAddResponderInput) error { - return c.incident.AddIncidentResponders(ctx, input) -} - -func (c *flashdutyCLIClient) CreateIncidentWarRoom(ctx context.Context, input *IncidentWarRoomCreateInput) (*IncidentWarRoom, error) { - return c.incident.CreateIncidentWarRoom(ctx, input) -} - -func (c *flashdutyCLIClient) ListIncidentWarRooms(ctx context.Context, input *IncidentWarRoomListInput) (*IncidentWarRoomListOutput, error) { - return c.incident.ListIncidentWarRooms(ctx, input) -} - -func (c *flashdutyCLIClient) GetIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDetailInput) (*IncidentWarRoom, error) { - return c.incident.GetIncidentWarRoom(ctx, input) -} - -func (c *flashdutyCLIClient) DeleteIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDeleteInput) error { - return c.incident.DeleteIncidentWarRoom(ctx, input) -} - -func (c *flashdutyCLIClient) AddIncidentWarRoomMembers(ctx context.Context, input *IncidentWarRoomAddMemberInput) error { - return c.incident.AddIncidentWarRoomMembers(ctx, input) -} - -func (c *flashdutyCLIClient) GetIncidentWarRoomDefaultObservers(ctx context.Context, incidentID string) ([]IncidentWarRoomObserver, error) { - return c.incident.GetIncidentWarRoomDefaultObservers(ctx, incidentID) -} - -type IncidentCommentInput struct { - IncidentIDs []string - Comment string - MuteReply bool -} - -type IncidentNotifyInput struct { - FollowPreference bool - PersonalChannels []string - TemplateID string -} - -type IncidentAddResponderInput struct { - IncidentID string - PersonIDs []int64 - Notify *IncidentNotifyInput -} - -type IncidentWarRoomCreateInput struct { - IncidentID string - IntegrationID int64 - MemberIDs []int64 - AddObservers bool -} - -type IncidentWarRoomListInput struct { - IncidentID string - IntegrationID int64 -} - -type IncidentWarRoomDetailInput struct { - IntegrationID int64 - ChatID string -} - -type IncidentWarRoomDeleteInput struct { - IncidentID string - IntegrationID int64 -} - -type IncidentWarRoomAddMemberInput struct { - IntegrationID int64 - ChatID string - MemberIDs []int64 -} - -type IncidentWarRoom struct { - ChatID string `json:"chat_id"` - ChatName string `json:"chat_name"` - ShareLink string `json:"share_link"` -} - -type IncidentWarRoomItem struct { - AccountID int64 `json:"account_id"` - IntegrationID int64 `json:"integration_id"` - CreatedBy int64 `json:"created_by"` - ChatID string `json:"chat_id"` - IncidentID string `json:"incident_id"` - Status string `json:"status"` - CreatedAt int64 `json:"created_at"` - PluginType string `json:"plugin_type"` -} - -type IncidentWarRoomListOutput struct { - Items []IncidentWarRoomItem `json:"items"` -} - -type IncidentWarRoomObserver struct { - PersonID int64 `json:"person_id"` - PersonName string `json:"person_name"` - Name string `json:"name"` - Email string `json:"email"` - Status string `json:"status"` -} - -func (o IncidentWarRoomObserver) DisplayName() string { - if o.PersonName != "" { - return o.PersonName - } - return o.Name -} - -type incidentAPIClient struct { - appKey string - baseURL *url.URL - userAgent string - httpClient *http.Client -} - -func newIncidentAPIClient(appKey, baseURL, userAgent string) *incidentAPIClient { - parsed, err := url.Parse(baseURL) - if err != nil || parsed == nil { - parsed, _ = url.Parse("https://api.flashcat.cloud") - } - return &incidentAPIClient{ - appKey: appKey, - baseURL: parsed, - userAgent: userAgent, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -func (c *incidentAPIClient) UnackIncidents(ctx context.Context, incidentIDs []string) error { - return c.postEmpty(ctx, "/incident/unack", map[string]any{"incident_ids": incidentIDs}) -} - -func (c *incidentAPIClient) WakeIncidents(ctx context.Context, incidentIDs []string) error { - return c.postEmpty(ctx, "/incident/wake", map[string]any{"incident_ids": incidentIDs}) -} - -func (c *incidentAPIClient) RemoveIncidents(ctx context.Context, incidentIDs []string) error { - return c.postEmpty(ctx, "/incident/remove", map[string]any{"incident_ids": incidentIDs}) -} - -func (c *incidentAPIClient) DisableIncidentMerge(ctx context.Context, incidentIDs []string) error { - return c.postEmpty(ctx, "/incident/disable-merge", map[string]any{"incident_ids": incidentIDs}) -} - -func (c *incidentAPIClient) CommentIncidents(ctx context.Context, input *IncidentCommentInput) error { - if input == nil { - return fmt.Errorf("incident comment input is required") - } - body := map[string]any{ - "incident_ids": input.IncidentIDs, - "comment": input.Comment, - } - if input.MuteReply { - body["mute_reply"] = true - } - return c.postEmpty(ctx, "/incident/comment", body) -} - -func (c *incidentAPIClient) AddIncidentResponders(ctx context.Context, input *IncidentAddResponderInput) error { - if input == nil { - return fmt.Errorf("incident responder input is required") - } - body := map[string]any{ - "incident_id": input.IncidentID, - "person_ids": input.PersonIDs, - } - if input.Notify != nil { - notify := map[string]any{} - if input.Notify.FollowPreference { - notify["follow_preference"] = true - } - if len(input.Notify.PersonalChannels) > 0 { - notify["personal_channels"] = input.Notify.PersonalChannels - } - if input.Notify.TemplateID != "" { - notify["template_id"] = input.Notify.TemplateID - } - if len(notify) > 0 { - body["notify"] = notify - } - } - return c.postEmpty(ctx, "/incident/responder/add", body) -} - -func (c *incidentAPIClient) CreateIncidentWarRoom(ctx context.Context, input *IncidentWarRoomCreateInput) (*IncidentWarRoom, error) { - if input == nil { - return nil, fmt.Errorf("incident war-room create input is required") - } - body := map[string]any{ - "incident_id": input.IncidentID, - "integration_id": input.IntegrationID, - } - if len(input.MemberIDs) > 0 { - body["member_ids"] = input.MemberIDs - } - if input.AddObservers { - body["add_observers"] = true - } - var out IncidentWarRoom - if err := c.postData(ctx, "/incident/war-room/create", body, &out); err != nil { - return nil, err - } - return &out, nil -} - -func (c *incidentAPIClient) ListIncidentWarRooms(ctx context.Context, input *IncidentWarRoomListInput) (*IncidentWarRoomListOutput, error) { - if input == nil { - return nil, fmt.Errorf("incident war-room list input is required") - } - body := map[string]any{"incident_id": input.IncidentID} - if input.IntegrationID > 0 { - body["integration_id"] = input.IntegrationID - } - var out IncidentWarRoomListOutput - if err := c.postData(ctx, "/incident/war-room/list", body, &out); err != nil { - return nil, err - } - return &out, nil -} - -func (c *incidentAPIClient) GetIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDetailInput) (*IncidentWarRoom, error) { - if input == nil { - return nil, fmt.Errorf("incident war-room detail input is required") - } - var out IncidentWarRoom - if err := c.postData(ctx, "/incident/war-room/detail", map[string]any{ - "integration_id": input.IntegrationID, - "chat_id": input.ChatID, - }, &out); err != nil { - return nil, err - } - return &out, nil -} - -func (c *incidentAPIClient) DeleteIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDeleteInput) error { - if input == nil { - return fmt.Errorf("incident war-room delete input is required") - } - return c.postEmpty(ctx, "/incident/war-room/delete", map[string]any{ - "incident_id": input.IncidentID, - "integration_id": input.IntegrationID, - }) -} - -func (c *incidentAPIClient) AddIncidentWarRoomMembers(ctx context.Context, input *IncidentWarRoomAddMemberInput) error { - if input == nil { - return fmt.Errorf("incident war-room add-member input is required") - } - return c.postEmpty(ctx, "/incident/war-room/add-member", map[string]any{ - "integration_id": input.IntegrationID, - "chat_id": input.ChatID, - "member_ids": input.MemberIDs, - }) -} - -func (c *incidentAPIClient) GetIncidentWarRoomDefaultObservers(ctx context.Context, incidentID string) ([]IncidentWarRoomObserver, error) { - var out struct { - Observers []IncidentWarRoomObserver `json:"observers"` - } - if err := c.postData(ctx, "/incident/war-room/default-observers", map[string]any{ - "incident_id": incidentID, - }, &out); err != nil { - return nil, err - } - return out.Observers, nil -} - -type incidentAPIEnvelope struct { - Error *flashduty.DutyError `json:"error,omitempty"` - Data json.RawMessage `json:"data,omitempty"` -} - -func (c *incidentAPIClient) postEmpty(ctx context.Context, path string, body any) error { - resp, err := c.post(ctx, path, body) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() - - limited := io.LimitReader(resp.Body, incidentAPIResponseLimit) - if resp.StatusCode >= 400 { - data, _ := io.ReadAll(limited) - return fmt.Errorf("API request failed (HTTP %d): %s", resp.StatusCode, c.redactAppKey(string(data))) - } - - var result flashduty.FlashdutyResponse - if err := json.NewDecoder(limited).Decode(&result); err != nil { - return fmt.Errorf("invalid API response: %w", err) - } - if result.Error != nil { - return result.Error - } - return nil -} - -func (c *incidentAPIClient) postData(ctx context.Context, path string, body any, out any) error { - resp, err := c.post(ctx, path, body) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() - - limited := io.LimitReader(resp.Body, incidentAPIResponseLimit) - if resp.StatusCode >= 400 { - data, _ := io.ReadAll(limited) - return fmt.Errorf("API request failed (HTTP %d): %s", resp.StatusCode, c.redactAppKey(string(data))) - } - - var envelope incidentAPIEnvelope - if err := json.NewDecoder(limited).Decode(&envelope); err != nil { - return fmt.Errorf("invalid API response: %w", err) - } - if envelope.Error != nil { - return envelope.Error - } - if out == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" { - return nil - } - if err := json.Unmarshal(envelope.Data, out); err != nil { - return fmt.Errorf("invalid API data: %w", err) - } - return nil -} - -func (c *incidentAPIClient) post(ctx context.Context, path string, body any) (*http.Response, error) { - reqBody, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("invalid request body: %w", err) - } - - parsedPath, err := url.Parse(strings.TrimPrefix(path, "/")) - if err != nil { - return nil, fmt.Errorf("invalid request path: %w", err) - } - fullURL := c.baseURL.ResolveReference(parsedPath) - query := fullURL.Query() - query.Set("app_key", c.appKey) - fullURL.RawQuery = query.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL.String(), bytes.NewReader(reqBody)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - if c.userAgent != "" { - req.Header.Set("User-Agent", c.userAgent) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to make request: %s", c.redactAppKey(err.Error())) - } - return resp, nil -} - -func (c *incidentAPIClient) redactAppKey(s string) string { - if c.appKey == "" || s == "" { - return s - } - redacted := strings.ReplaceAll(s, c.appKey, "[REDACTED]") - escaped := url.QueryEscape(c.appKey) - if escaped != c.appKey { - redacted = strings.ReplaceAll(redacted, escaped, "[REDACTED]") - } - return redacted -} diff --git a/internal/cli/incident_lifecycle_client_test.go b/internal/cli/incident_lifecycle_client_test.go deleted file mode 100644 index 29c11b7..0000000 --- a/internal/cli/incident_lifecycle_client_test.go +++ /dev/null @@ -1,250 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -type capturedIncidentRequest struct { - path string - appKey string - body map[string]any -} - -func newIncidentLifecycleTestClient(t *testing.T, capture *capturedIncidentRequest) *incidentAPIClient { - t.Helper() - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capture.path = r.URL.Path - capture.appKey = r.URL.Query().Get("app_key") - if r.Body != nil { - if err := json.NewDecoder(r.Body).Decode(&capture.body); err != nil { - t.Fatalf("decode request body: %v", err) - } - } - _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}}) - })) - t.Cleanup(ts.Close) - - return newIncidentAPIClient("test-key", ts.URL, "flashduty-cli/test") -} - -func TestIncidentAPIClientSimpleLifecycleRequests(t *testing.T) { - tests := []struct { - name string - call func(context.Context, *incidentAPIClient) error - path string - }{ - { - name: "unack", - call: func(ctx context.Context, c *incidentAPIClient) error { - return c.UnackIncidents(ctx, []string{"inc-1", "inc-2"}) - }, - path: "/incident/unack", - }, - { - name: "wake", - call: func(ctx context.Context, c *incidentAPIClient) error { - return c.WakeIncidents(ctx, []string{"inc-1", "inc-2"}) - }, - path: "/incident/wake", - }, - { - name: "remove", - call: func(ctx context.Context, c *incidentAPIClient) error { - return c.RemoveIncidents(ctx, []string{"inc-1", "inc-2"}) - }, - path: "/incident/remove", - }, - { - name: "disable merge", - call: func(ctx context.Context, c *incidentAPIClient) error { - return c.DisableIncidentMerge(ctx, []string{"inc-1", "inc-2"}) - }, - path: "/incident/disable-merge", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - capture := &capturedIncidentRequest{} - client := newIncidentLifecycleTestClient(t, capture) - - if err := tc.call(context.Background(), client); err != nil { - t.Fatalf("call returned error: %v", err) - } - - if capture.path != tc.path { - t.Fatalf("expected path %q, got %q", tc.path, capture.path) - } - if capture.appKey != "test-key" { - t.Fatalf("expected app_key test-key, got %q", capture.appKey) - } - gotIDs, ok := capture.body["incident_ids"].([]any) - if !ok || len(gotIDs) != 2 || gotIDs[0] != "inc-1" || gotIDs[1] != "inc-2" { - t.Fatalf("unexpected body: %#v", capture.body) - } - }) - } -} - -func TestIncidentAPIClientCommentRequest(t *testing.T) { - capture := &capturedIncidentRequest{} - client := newIncidentLifecycleTestClient(t, capture) - - err := client.CommentIncidents(context.Background(), &IncidentCommentInput{ - IncidentIDs: []string{"inc-1"}, - Comment: "rollback started", - MuteReply: true, - }) - if err != nil { - t.Fatalf("CommentIncidents returned error: %v", err) - } - - if capture.path != "/incident/comment" { - t.Fatalf("expected comment path, got %q", capture.path) - } - if capture.body["comment"] != "rollback started" || capture.body["mute_reply"] != true { - t.Fatalf("unexpected body: %#v", capture.body) - } -} - -func TestIncidentAPIClientAddResponderRequest(t *testing.T) { - capture := &capturedIncidentRequest{} - client := newIncidentLifecycleTestClient(t, capture) - - err := client.AddIncidentResponders(context.Background(), &IncidentAddResponderInput{ - IncidentID: "inc-1", - PersonIDs: []int64{101, 202}, - Notify: &IncidentNotifyInput{ - FollowPreference: true, - PersonalChannels: []string{"voice", "sms"}, - TemplateID: "6321aad26c12104586a88916", - }, - }) - if err != nil { - t.Fatalf("AddIncidentResponders returned error: %v", err) - } - - if capture.path != "/incident/responder/add" { - t.Fatalf("expected responder add path, got %q", capture.path) - } - if capture.body["incident_id"] != "inc-1" { - t.Fatalf("unexpected body: %#v", capture.body) - } - notify, ok := capture.body["notify"].(map[string]any) - if !ok { - t.Fatalf("expected notify body, got %#v", capture.body) - } - if notify["follow_preference"] != true || notify["template_id"] != "6321aad26c12104586a88916" { - t.Fatalf("unexpected notify body: %#v", notify) - } -} - -func TestIncidentAPIClientWarRoomCreateRequest(t *testing.T) { - capture := &capturedIncidentRequest{} - client := newIncidentLifecycleTestClient(t, capture) - - warRoom, err := client.CreateIncidentWarRoom(context.Background(), &IncidentWarRoomCreateInput{ - IncidentID: "inc-1", - IntegrationID: 42, - MemberIDs: []int64{101, 202}, - AddObservers: true, - }) - if err != nil { - t.Fatalf("CreateIncidentWarRoom returned error: %v", err) - } - - if capture.path != "/incident/war-room/create" { - t.Fatalf("expected war-room create path, got %q", capture.path) - } - if capture.body["incident_id"] != "inc-1" || capture.body["add_observers"] != true { - t.Fatalf("unexpected body: %#v", capture.body) - } - if warRoom == nil { - t.Fatal("expected war room output") - } -} - -func TestIncidentAPIClientWarRoomListDecodesItems(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/incident/war-room/list" { - t.Fatalf("expected list path, got %q", r.URL.Path) - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "items": []map[string]any{ - { - "integration_id": float64(42), - "chat_id": "chat-1", - "incident_id": "inc-1", - "status": "enabled", - "plugin_type": "feishu", - }, - }, - }, - }) - })) - t.Cleanup(ts.Close) - - client := newIncidentAPIClient("test-key", ts.URL, "flashduty-cli/test") - result, err := client.ListIncidentWarRooms(context.Background(), &IncidentWarRoomListInput{IncidentID: "inc-1"}) - if err != nil { - t.Fatalf("ListIncidentWarRooms returned error: %v", err) - } - if len(result.Items) != 1 || result.Items[0].ChatID != "chat-1" { - t.Fatalf("unexpected result: %#v", result) - } -} - -func TestIncidentAPIClientWarRoomDefaultObserversDecodesObservers(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/incident/war-room/default-observers" { - t.Fatalf("expected default observers path, got %q", r.URL.Path) - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "observers": []map[string]any{ - {"person_id": float64(101), "person_name": "Alice", "email": "alice@example.com"}, - }, - }, - }) - })) - t.Cleanup(ts.Close) - - client := newIncidentAPIClient("test-key", ts.URL, "flashduty-cli/test") - observers, err := client.GetIncidentWarRoomDefaultObservers(context.Background(), "inc-1") - if err != nil { - t.Fatalf("GetIncidentWarRoomDefaultObservers returned error: %v", err) - } - if len(observers) != 1 || observers[0].DisplayName() != "Alice" { - t.Fatalf("unexpected observers: %#v", observers) - } -} - -type failingRoundTripper struct{} - -func (f failingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - return nil, errors.New("dial tcp " + req.URL.String()) -} - -func TestIncidentAPIClientRedactsAppKeyOnTransportError(t *testing.T) { - client := newIncidentAPIClient("secret-app-key", "https://api.flashcat.cloud", "flashduty-cli/test") - client.httpClient = &http.Client{Transport: failingRoundTripper{}} - - err := client.UnackIncidents(context.Background(), []string{"inc-1"}) - if err == nil { - t.Fatal("expected transport error, got nil") - } - if strings.Contains(err.Error(), "secret-app-key") { - t.Fatalf("transport error leaked app key: %v", err) - } - if !strings.Contains(err.Error(), "[REDACTED]") { - t.Fatalf("expected redacted marker in error, got: %v", err) - } -} diff --git a/internal/cli/root.go b/internal/cli/root.go index 356fe57..a191d9a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -35,14 +35,15 @@ type flashdutyClient interface { 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 *IncidentCommentInput) error - AddIncidentResponders(ctx context.Context, input *IncidentAddResponderInput) error - CreateIncidentWarRoom(ctx context.Context, input *IncidentWarRoomCreateInput) (*IncidentWarRoom, error) - ListIncidentWarRooms(ctx context.Context, input *IncidentWarRoomListInput) (*IncidentWarRoomListOutput, error) - GetIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDetailInput) (*IncidentWarRoom, error) - DeleteIncidentWarRoom(ctx context.Context, input *IncidentWarRoomDeleteInput) error - AddIncidentWarRoomMembers(ctx context.Context, input *IncidentWarRoomAddMemberInput) error - GetIncidentWarRoomDefaultObservers(ctx context.Context, incidentID string) ([]IncidentWarRoomObserver, 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) @@ -211,10 +212,7 @@ func defaultNewClient() (flashdutyClient, error) { return nil, err } - return &flashdutyCLIClient{ - Client: sdkClient, - incident: newIncidentAPIClient(cfg.AppKey, cfg.BaseURL, "flashduty-cli/"+versionStr), - }, nil + return sdkClient, nil } func loadResolvedConfig() (*config.Config, error) { diff --git a/todos/2026-05-20-cli-sdk-cleanup.md b/todos/2026-05-20-cli-sdk-cleanup.md new file mode 100644 index 0000000..05a1925 --- /dev/null +++ b/todos/2026-05-20-cli-sdk-cleanup.md @@ -0,0 +1,17 @@ +# TODO: Remove CLI Raw API Client + +## Goal + +Update `flashduty-cli` to consume SDK methods for incident lifecycle and war-room commands. + +## Tasks + +- [x] Replace CLI-local incident lifecycle client methods with calls to `flashduty-sdk`. +- [x] Remove duplicate CLI-local request/response types where SDK types can be used directly. +- [x] Remove the CLI raw HTTP client after SDK migration. +- [x] Change `incident war-room create` so `--integration` is optional. +- [x] When `--integration` is omitted, call SDK datasource discovery for enabled war-room IM integrations and use the first returned `data_source_id` as `integration_id`. +- [x] Keep `--integration` as an override for explicit selection. +- [x] Add focused CLI command tests for auto-discovery and explicit override behavior. +- [x] Replace the temporary local SDK module replacement with a real SDK pseudo-version after the SDK branch is pushed. +- [x] Run only task-relevant SDK and CLI tests before publishing. diff --git a/todos/2026-05-20-sdk-api-adapter-migration.md b/todos/2026-05-20-sdk-api-adapter-migration.md new file mode 100644 index 0000000..f3bf5ab --- /dev/null +++ b/todos/2026-05-20-sdk-api-adapter-migration.md @@ -0,0 +1,30 @@ +# TODO: Move Missing API Adapters To SDK + +## Goal + +Move Flashduty API endpoint adapters out of `flashduty-cli` and into `github.com/flashcatcloud/flashduty-sdk`. + +## Tasks + +- [x] Confirm every CLI method that bypasses `flashduty-sdk`. +- [x] Add SDK typed inputs, outputs, and methods for incident lifecycle endpoints currently implemented by CLI raw HTTP code. +- [x] Add SDK typed inputs, outputs, and methods for incident war-room endpoints. +- [x] Add SDK datasource discovery support for `POST /datasource/im/war-room-enabled/list`. +- [x] Add focused SDK tests for request body encoding and response decoding. +- [x] Keep SDK API names stable and simple enough for CLI consumption. + +## Candidate Endpoints + +- `/incident/unack` +- `/incident/wake` +- `/incident/remove` +- `/incident/disable-merge` +- `/incident/comment` +- `/incident/responder/add` +- `/incident/war-room/create` +- `/incident/war-room/list` +- `/incident/war-room/detail` +- `/incident/war-room/delete` +- `/incident/war-room/add-member` +- `/incident/war-room/default-observers` +- `/datasource/im/war-room-enabled/list` From 0501984d25c6be8852857dce941a356d3220d574 Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Wed, 20 May 2026 16:40:47 +0800 Subject: [PATCH 3/5] chore: bump flashduty sdk --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 91f26ec..61e813a 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260520063928-ea6a81de4c95 + github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260520083924-30036c193a14 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 diff --git a/go.sum b/go.sum index 403907b..0dea0eb 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.20260520063928-ea6a81de4c95 h1:b7O4LtOfecgmuq2Ipv6vkssHNFzqfUbo5utBuR8X4kI= -github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260520063928-ea6a81de4c95/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= +github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260520083924-30036c193a14 h1:8g++Me/F+wQo39vBizTxBAnbTuf87/CUIWHA4q+nXCo= +github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260520083924-30036c193a14/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= From 99241fd32e80380926c81048839a365c56ff2a17 Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Fri, 22 May 2026 15:30:39 +0800 Subject: [PATCH 4/5] chore: use flashduty sdk v0.9.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 61e813a..990455b 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260520083924-30036c193a14 + 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 diff --git a/go.sum b/go.sum index 0dea0eb..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.20260520083924-30036c193a14 h1:8g++Me/F+wQo39vBizTxBAnbTuf87/CUIWHA4q+nXCo= -github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260520083924-30036c193a14/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= From 3c4a3ee10d544d5a70347ffdcfa1dedbcb336306 Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Fri, 22 May 2026 16:16:30 +0800 Subject: [PATCH 5/5] docs: improve incident command help --- OPENAPI_COMMAND_GAP.md | 354 ------------------ internal/cli/command_test.go | 59 +++ internal/cli/incident.go | 130 ++++++- todos/2026-05-20-cli-sdk-cleanup.md | 17 - todos/2026-05-20-sdk-api-adapter-migration.md | 30 -- 5 files changed, 172 insertions(+), 418 deletions(-) delete mode 100644 OPENAPI_COMMAND_GAP.md delete mode 100644 todos/2026-05-20-cli-sdk-cleanup.md delete mode 100644 todos/2026-05-20-sdk-api-adapter-migration.md diff --git a/OPENAPI_COMMAND_GAP.md b/OPENAPI_COMMAND_GAP.md deleted file mode 100644 index e9fbfc0..0000000 --- a/OPENAPI_COMMAND_GAP.md +++ /dev/null @@ -1,354 +0,0 @@ -# Flashduty CLI OpenAPI command gap - -Updated: 2026-05-19 - -Sources: - -- `https://docs.flashcat.cloud/api-reference/on-call.openapi.zh.json` -- `https://docs.flashcat.cloud/api-reference/platform.openapi.zh.json` -- `https://docs.flashcat.cloud/api-reference/monitors.openapi.zh.json` -- `https://docs.flashcat.cloud/api-reference/rum.openapi.zh.json` - -This document tracks official OpenAPI endpoints that are not yet fully exposed -as `flashduty` commands. It intentionally focuses on command coverage, not -internal SDK helper coverage. - -Legend: - -- `missing`: no first-class CLI command. -- `partial`: some behavior exists, but the CLI does not expose the full API - contract. -- `covered indirectly`: the CLI reaches the endpoint through another command or - SDK enrichment path, but there is no user-facing command for the endpoint. -- `implemented`: implemented after this tracking document was introduced. -- `defer`: command shape or side effects need a separate design pass. - -## Implemented incident lifecycle slice - -These endpoints were selected as the first implementation slice because they -are close to the existing `incident` command family and have simple -request/response contracts. - -| Endpoint | Status | Proposed command | Notes | -| --- | --- | --- | --- | -| `/incident/unack` | implemented | `incident unack [ ...]` | Inverse of `incident ack`; payload is `incident_ids`. | -| `/incident/wake` | implemented | `incident wake [ ...]` | Inverse of `incident snooze`; payload is `incident_ids`. | -| `/incident/comment` | implemented | `incident comment [ ...] --comment [--mute-reply]` | Adds timeline comments. | -| `/incident/responder/add` | implemented | `incident add-responder --person ` | Additive responder change; distinct from replacement-style assignment. | -| `/incident/remove` | implemented | `incident remove [ ...] --force` | Destructive; requires confirmation outside JSON mode. | -| `/incident/disable-merge` | implemented | `incident disable-merge [ ...]` | Present in current docs.flashcat.cloud OpenAPI. | -| `/incident/assign` | partial | keep `incident reassign`; consider richer `incident assign` later | Existing command covers direct person assignment only. | -| `/incident/field/reset` | covered indirectly | `incident update --field key=value` | Already called once per custom field. | - -Defer from the first slice: - -| Endpoint | Status | Reason | -| --- | --- | --- | -| `/incident/custom-action/do` | defer | Integration-specific side effect; needs output and failure semantics. | -| `/incident/war-room/create` | implemented | Implemented as `incident war-room create`; supports `--add-observers`. | -| `/incident/war-room/delete` | implemented | Implemented as `incident war-room delete --force`. | -| `/incident/war-room/list` | implemented | Implemented as `incident war-room list`. | -| `/incident/war-room/detail` | implemented | Implemented as `incident war-room get`. | -| `/incident/post-mortem/info` | missing | Fits postmortem slice, not lifecycle. | -| `/incident/post-mortem/delete` | missing | Destructive; fits postmortem slice. | - -## On-call gaps - -### Alert management - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/alert/list-by-ids` | partial | `alert get` already supports detail by ID, but no batch list-by-ids command. | -| `/alert/pipeline/list` | missing | `alert pipeline list` | -| `/alert/pipeline/info` | missing | `alert pipeline get` | -| `/alert/pipeline/upsert` | missing | `alert pipeline upsert --file` | - -### Calendar management - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/calendar/list` | missing | `calendar list` | -| `/calendar/info` | missing | `calendar get` | -| `/calendar/create` | missing | `calendar create --file` | -| `/calendar/update` | missing | `calendar update --file` | -| `/calendar/delete` | missing | `calendar delete --id --force` | -| `/calendar/event/list` | missing | `calendar event list` | -| `/calendar/event/upsert` | missing | `calendar event upsert --file` | -| `/calendar/event/delete` | missing | `calendar event delete --id --force` | - -### Collaboration spaces - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/channel/info` | missing | `channel get` | -| `/channel/infos` | covered indirectly | Used for enrichment; no command. | -| `/channel/create` | missing | `channel create --file` | -| `/channel/update` | missing | `channel update --file` | -| `/channel/delete` | missing | `channel delete --id --force` | -| `/channel/enable` | missing | `channel enable --id` | -| `/channel/disable` | missing | `channel disable --id` | -| `/channel/escalate/rule/info` | missing | `escalation-rule get` | -| `/channel/escalate/rule/create` | missing | `escalation-rule create --file` | -| `/channel/escalate/rule/update` | missing | `escalation-rule update --file` | -| `/channel/escalate/rule/delete` | missing | `escalation-rule delete --id --force` | -| `/channel/escalate/rule/enable` | missing | `escalation-rule enable --id` | -| `/channel/escalate/rule/disable` | missing | `escalation-rule disable --id` | -| `/channel/notify/rule/list` | missing | `channel notify-rule list` | -| `/channel/notify/rule/create` | missing | `channel notify-rule create --file` | -| `/channel/notify/rule/update` | missing | `channel notify-rule update --file` | -| `/channel/notify/rule/delete` | missing | `channel notify-rule delete --id --force` | -| `/channel/notify/rule/enable` | missing | `channel notify-rule enable --id` | -| `/channel/notify/rule/disable` | missing | `channel notify-rule disable --id` | -| `/channel/silence/rule/list` | missing | `channel silence-rule list` | -| `/channel/silence/rule/create` | missing | `channel silence-rule create --file` | -| `/channel/silence/rule/update` | missing | `channel silence-rule update --file` | -| `/channel/silence/rule/delete` | missing | `channel silence-rule delete --id --force` | -| `/channel/silence/rule/enable` | missing | `channel silence-rule enable --id` | -| `/channel/silence/rule/disable` | missing | `channel silence-rule disable --id` | -| `/channel/inhibit/rule/list` | missing | `channel inhibit-rule list` | -| `/channel/inhibit/rule/create` | missing | `channel inhibit-rule create --file` | -| `/channel/inhibit/rule/update` | missing | `channel inhibit-rule update --file` | -| `/channel/inhibit/rule/delete` | missing | `channel inhibit-rule delete --id --force` | -| `/channel/inhibit/rule/enable` | missing | `channel inhibit-rule enable --id` | -| `/channel/inhibit/rule/disable` | missing | `channel inhibit-rule disable --id` | -| `/channel/unsubscribe/rule/list` | missing | `channel unsubscribe-rule list` | -| `/channel/unsubscribe/rule/create` | missing | `channel unsubscribe-rule create --file` | -| `/channel/unsubscribe/rule/update` | missing | `channel unsubscribe-rule update --file` | -| `/channel/unsubscribe/rule/delete` | missing | `channel unsubscribe-rule delete --id --force` | -| `/channel/unsubscribe/rule/enable` | missing | `channel unsubscribe-rule enable --id` | -| `/channel/unsubscribe/rule/disable` | missing | `channel unsubscribe-rule disable --id` | - -### Label enrichment - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/enrichment/list` | missing | `enrichment list` | -| `/enrichment/info` | missing | `enrichment get` | -| `/enrichment/upsert` | missing | `enrichment upsert --file` | -| `/enrichment/mapping/api/list` | missing | `enrichment mapping-api list` | -| `/enrichment/mapping/api/info` | missing | `enrichment mapping-api get` | -| `/enrichment/mapping/api/create` | missing | `enrichment mapping-api create --file` | -| `/enrichment/mapping/api/update` | missing | `enrichment mapping-api update --file` | -| `/enrichment/mapping/api/delete` | missing | `enrichment mapping-api delete --id --force` | -| `/enrichment/mapping/schema/list` | missing | `enrichment mapping-schema list` | -| `/enrichment/mapping/schema/info` | missing | `enrichment mapping-schema get` | -| `/enrichment/mapping/schema/create` | missing | `enrichment mapping-schema create --file` | -| `/enrichment/mapping/schema/update` | missing | `enrichment mapping-schema update --file` | -| `/enrichment/mapping/schema/delete` | missing | `enrichment mapping-schema delete --id --force` | -| `/enrichment/mapping/data/list` | missing | `enrichment mapping-data list` | -| `/enrichment/mapping/data/upsert` | missing | `enrichment mapping-data upsert --file` | -| `/enrichment/mapping/data/delete` | missing | `enrichment mapping-data delete --file` | -| `/enrichment/mapping/data/download` | missing | `enrichment mapping-data download` | -| `/enrichment/mapping/data/upload` | missing | `enrichment mapping-data upload --file` | -| `/enrichment/mapping/data/truncate` | defer | Destructive bulk operation. | - -### Incident management - -See the first implementation slice above for lifecycle gaps. - -Additional incident gaps: - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/incident/custom-action/do` | defer | `incident custom-action do` | -| `/incident/war-room/create` | implemented | `incident war-room create` | -| `/incident/war-room/delete` | implemented | `incident war-room delete` | -| `/incident/war-room/list` | implemented | `incident war-room list` | -| `/incident/war-room/detail` | implemented | `incident war-room get` | -| `/incident/post-mortem/info` | missing | `postmortem get` | -| `/incident/post-mortem/delete` | missing | `postmortem delete --id --force` | - -### Insights - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/insight/account` | missing | `insight account` | -| `/insight/team/export` | missing | `insight team export` | -| `/insight/channel/export` | missing | `insight channel export` | -| `/insight/responder/export` | missing | `insight responder export` | -| `/insight/incident/export` | missing | `insight incidents export` | - -### Routes - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/route/list` | missing | `route list` | -| `/route/info` | missing | `route get` | -| `/route/upsert` | missing | `route upsert --file` | - -### Schedules - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/schedule/infos` | covered indirectly | Used for enrichment; no command. | -| `/schedule/self` | missing | `oncall schedule self` | -| `/schedule/preview` | missing | `oncall schedule preview --file` | -| `/schedule/create` | missing | `oncall schedule create --file` | -| `/schedule/update` | missing | `oncall schedule update --file` | -| `/schedule/delete` | missing | `oncall schedule delete --id --force` | - -### Status page - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/status-page/change/list` | partial | Current `statuspage changes` uses active-list behavior, not this endpoint. | -| `/status-page/change/info` | missing | `statuspage change get` | -| `/status-page/change/update` | missing | `statuspage change update` | -| `/status-page/change/delete` | missing | `statuspage change delete --force` | -| `/status-page/change/timeline/update` | missing | `statuspage timeline update` | -| `/status-page/change/timeline/delete` | missing | `statuspage timeline delete --force` | -| `/status-page/subscriber/list` | missing | `statuspage subscriber list` | -| `/status-page/subscriber/export` | missing | `statuspage subscriber export` | -| `/status-page/subscriber/import` | missing | `statuspage subscriber import --file` | - -### Templates - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/template/list` | missing | `template list` | -| `/template/info` | partial | `template get-preset` fetches only the system preset template. | -| `/template/create` | missing | `template create --file` | -| `/template/update` | missing | `template update --file` | -| `/template/delete` | missing | `template delete --id --force` | - -### Webhook history - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/webhook/history/list` | missing | `webhook history list` | -| `/webhook/history/detail` | missing | `webhook history get` | - -## Platform gaps - -### Audit logs - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/audit/operation/list` | missing | `audit operations` | - -### Members - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/member/info` | partial | `whoami` calls current member info; no `member info` command. | -| `/person/infos` | covered indirectly | Used for enrichment; no direct lookup command. | -| `/member/invite` | missing | `member invite` | -| `/member/info/reset` | missing | `member update` | -| `/member/delete` | missing | `member delete --id --force` | -| `/member/role/grant` | missing | `member role grant` | -| `/member/role/revoke` | missing | `member role revoke` | -| `/member/role/update` | missing | `member role update` | - -### Roles and permissions - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/role/list` | missing | `role list` | -| `/role/info` | missing | `role get` | -| `/role/upsert` | missing | `role upsert --file` | -| `/role/delete` | missing | `role delete --id --force` | -| `/role/enable` | missing | `role enable --id` | -| `/role/disable` | missing | `role disable --id` | -| `/role/permission/list` | missing | `role permissions` | -| `/role/permission/factor/list` | missing | `role permission-factors` | -| `/role/member/grant` | missing | `role member grant` | -| `/role/member/revoke` | missing | `role member revoke` | - -## Monitors gaps - -The CLI has no first-class `monit` or `monitor` command family yet. Treat the -whole Monitors OpenAPI as missing except for internal SDK support for -`/monit/rule/counter/status`, which currently has no command. - -### Datasources - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/monit/datasource/list` | missing | `monit datasource list` | -| `/monit/datasource/info` | missing | `monit datasource get` | -| `/monit/datasource/create` | missing | `monit datasource create --file` | -| `/monit/datasource/update` | missing | `monit datasource update --file` | -| `/monit/datasource/delete` | missing | `monit datasource delete --id --force` | -| `/monit/datasource/sls/projects` | missing | `monit datasource sls-projects` | -| `/monit/datasource/sls/logstores` | missing | `monit datasource sls-logstores` | - -### Rules - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/monit/rule/list/basic` | missing | `monit rule list` | -| `/monit/rule/info` | missing | `monit rule get` | -| `/monit/rule/create` | missing | `monit rule create --file` | -| `/monit/rule/update` | missing | `monit rule update --file` | -| `/monit/rule/update/fields` | missing | `monit rule update-fields --file` | -| `/monit/rule/delete` | missing | `monit rule delete --id --force` | -| `/monit/rule/delete/batch` | missing | `monit rule delete-batch --file --force` | -| `/monit/rule/move` | missing | `monit rule move` | -| `/monit/rule/import` | missing | `monit rule import --file` | -| `/monit/rule/export` | missing | `monit rule export` | -| `/monit/rule/status` | missing | `monit rule status` | -| `/monit/rule/dstypes` | missing | `monit rule dstypes` | -| `/monit/rule/audits` | missing | `monit rule audits` | -| `/monit/rule/audit/detail` | missing | `monit rule audit get` | -| `/monit/rule/counter/status` | missing | `monit rule counter status` | -| `/monit/rule/counter/total` | missing | `monit rule counter total` | -| `/monit/rule/counter/channel` | missing | `monit rule counter channel` | -| `/monit/rule/counter/node` | missing | `monit rule counter node` | - -### Rulesets - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/monit/store/ruleset/list` | missing | `monit ruleset list` | -| `/monit/store/ruleset/info` | missing | `monit ruleset get` | -| `/monit/store/ruleset/create` | missing | `monit ruleset create --file` | -| `/monit/store/ruleset/update` | missing | `monit ruleset update --file` | -| `/monit/store/ruleset/delete` | missing | `monit ruleset delete --id --force` | - -## RUM gaps - -The CLI has no first-class `rum` command family yet. - -### Applications - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/rum/application/list` | missing | `rum application list` | -| `/rum/application/info` | missing | `rum application get` | -| `/rum/application/infos` | missing | `rum application get-batch` | -| `/rum/application/create` | missing | `rum application create --file` | -| `/rum/application/update` | missing | `rum application update --file` | -| `/rum/application/delete` | missing | `rum application delete --id --force` | - -### Issues - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/rum/issue/list` | missing | `rum issue list` | -| `/rum/issue/info` | missing | `rum issue get` | -| `/rum/issue/update` | missing | `rum issue update` | - -### Sourcemaps - -| Endpoint | Status | Suggested command family | -| --- | --- | --- | -| `/sourcemap/list` | missing | `rum sourcemap list` | - -## Current non-catalog commands - -These CLI commands use endpoints or static SDK data that are not present in the -current four official OpenAPI specs above. Keep them, but do not use them as -evidence that official OpenAPI coverage is complete. - -| Command | Backing behavior | -| --- | --- | -| `change list` | `/change/list` | -| `change trend` | report endpoint for change trend | -| `insight notifications` | report endpoint for notification trend | -| `statuspage list` | `/status-page/list` | -| `statuspage changes` | `/status-page/change/active/list` | -| `template validate` | `/template/preview` | -| `template variables` | SDK static metadata | -| `template functions` | SDK static metadata | -| `field list` | `/field/list` | -| `whoami` | `/account/info` plus `/member/info` | diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 7cdedc9..96e6c2f 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -691,6 +691,65 @@ 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 diff --git a/internal/cli/incident.go b/internal/cli/incident.go index fd08bf1..56747c0 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -315,7 +315,13 @@ func newIncidentUnackCmd() *cobra.Command { return &cobra.Command{ Use: "unack [ ...]", Short: "Cancel incident acknowledgement", - Args: requireArgs("incident_id"), + 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 @@ -352,7 +358,13 @@ func newIncidentWakeCmd() *cobra.Command { return &cobra.Command{ Use: "wake [ ...]", Short: "Restore notifications for snoozed incidents", - Args: requireArgs("incident_id"), + 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 @@ -652,7 +664,17 @@ func newIncidentAddResponderCmd() *cobra.Command { cmd := &cobra.Command{ Use: "add-responder ", Short: "Add responders to an incident", - Args: requireArgs("incident_id"), + 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 { @@ -702,7 +724,14 @@ func newIncidentCommentCmd() *cobra.Command { cmd := &cobra.Command{ Use: "comment [ ...]", Short: "Add a comment to incident timelines", - Args: requireArgs("incident_id"), + 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 @@ -740,7 +769,13 @@ func newIncidentDisableMergeCmd() *cobra.Command { return &cobra.Command{ Use: "disable-merge [ ...]", Short: "Disable automatic merging for incidents", - Args: requireArgs("incident_id"), + 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 { @@ -759,7 +794,14 @@ func newIncidentRemoveCmd() *cobra.Command { cmd := &cobra.Command{ Use: "remove [ ...]", Short: "Permanently remove incidents", - Args: requireArgs("incident_id"), + 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 @@ -787,6 +829,14 @@ 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()) @@ -805,7 +855,16 @@ func newIncidentWarRoomCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create ", Short: "Create an incident war room", - Args: requireArgs("incident_id"), + 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 { @@ -835,7 +894,7 @@ func newIncidentWarRoomCreateCmd() *cobra.Command { }, } - cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID") + 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 @@ -866,7 +925,13 @@ func newIncidentWarRoomListCmd() *cobra.Command { cmd := &cobra.Command{ Use: "list ", Short: "List incident war rooms", - Args: requireArgs("incident_id"), + 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{ @@ -891,7 +956,14 @@ func newIncidentWarRoomGetCmd() *cobra.Command { cmd := &cobra.Command{ Use: "get ", Short: "Get incident war room details", - Args: requireArgs("chat_id"), + 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{ @@ -910,7 +982,7 @@ func newIncidentWarRoomGetCmd() *cobra.Command { }, } - cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID") + cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID (required)") _ = cmd.MarkFlagRequired("integration") return cmd } @@ -922,7 +994,16 @@ func newIncidentWarRoomDeleteCmd() *cobra.Command { cmd := &cobra.Command{ Use: "delete ", Short: "Delete an incident war room", - Args: requireArgs("incident_id"), + 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])) { @@ -941,7 +1022,7 @@ func newIncidentWarRoomDeleteCmd() *cobra.Command { }, } - cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID") + cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID (required)") cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") _ = cmd.MarkFlagRequired("integration") return cmd @@ -954,7 +1035,16 @@ func newIncidentWarRoomAddMemberCmd() *cobra.Command { cmd := &cobra.Command{ Use: "add-member ", Short: "Add members to an incident war room", - Args: requireArgs("chat_id"), + 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 { @@ -977,8 +1067,8 @@ func newIncidentWarRoomAddMemberCmd() *cobra.Command { }, } - cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID") - cmd.Flags().StringVar(&member, "member", "", "Comma-separated member person IDs") + 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 @@ -988,7 +1078,13 @@ func newIncidentWarRoomDefaultObserversCmd() *cobra.Command { return &cobra.Command{ Use: "default-observers ", Short: "Preview historical responders for war-room observer invitation", - Args: requireArgs("incident_id"), + 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]) diff --git a/todos/2026-05-20-cli-sdk-cleanup.md b/todos/2026-05-20-cli-sdk-cleanup.md deleted file mode 100644 index 05a1925..0000000 --- a/todos/2026-05-20-cli-sdk-cleanup.md +++ /dev/null @@ -1,17 +0,0 @@ -# TODO: Remove CLI Raw API Client - -## Goal - -Update `flashduty-cli` to consume SDK methods for incident lifecycle and war-room commands. - -## Tasks - -- [x] Replace CLI-local incident lifecycle client methods with calls to `flashduty-sdk`. -- [x] Remove duplicate CLI-local request/response types where SDK types can be used directly. -- [x] Remove the CLI raw HTTP client after SDK migration. -- [x] Change `incident war-room create` so `--integration` is optional. -- [x] When `--integration` is omitted, call SDK datasource discovery for enabled war-room IM integrations and use the first returned `data_source_id` as `integration_id`. -- [x] Keep `--integration` as an override for explicit selection. -- [x] Add focused CLI command tests for auto-discovery and explicit override behavior. -- [x] Replace the temporary local SDK module replacement with a real SDK pseudo-version after the SDK branch is pushed. -- [x] Run only task-relevant SDK and CLI tests before publishing. diff --git a/todos/2026-05-20-sdk-api-adapter-migration.md b/todos/2026-05-20-sdk-api-adapter-migration.md deleted file mode 100644 index f3bf5ab..0000000 --- a/todos/2026-05-20-sdk-api-adapter-migration.md +++ /dev/null @@ -1,30 +0,0 @@ -# TODO: Move Missing API Adapters To SDK - -## Goal - -Move Flashduty API endpoint adapters out of `flashduty-cli` and into `github.com/flashcatcloud/flashduty-sdk`. - -## Tasks - -- [x] Confirm every CLI method that bypasses `flashduty-sdk`. -- [x] Add SDK typed inputs, outputs, and methods for incident lifecycle endpoints currently implemented by CLI raw HTTP code. -- [x] Add SDK typed inputs, outputs, and methods for incident war-room endpoints. -- [x] Add SDK datasource discovery support for `POST /datasource/im/war-room-enabled/list`. -- [x] Add focused SDK tests for request body encoding and response decoding. -- [x] Keep SDK API names stable and simple enough for CLI consumption. - -## Candidate Endpoints - -- `/incident/unack` -- `/incident/wake` -- `/incident/remove` -- `/incident/disable-merge` -- `/incident/comment` -- `/incident/responder/add` -- `/incident/war-room/create` -- `/incident/war-room/list` -- `/incident/war-room/detail` -- `/incident/war-room/delete` -- `/incident/war-room/add-member` -- `/incident/war-room/default-observers` -- `/datasource/im/war-room-enabled/list`