From 099408f21ba728db0bb396798a11af3a97cf2e00 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 19:57:14 -0700 Subject: [PATCH 1/9] feat(incident): add --remove-source-incidents to incident merge POST /incident/merge accepts remove_source_incidents (default false: the source incidents are closed and kept with a merge timeline entry; true: they are deleted). The hand-written `incident merge` command shadows the generated one and only exposed --source, so the field was unreachable from the CLI. Expose it as an opt-in bool flag; the default request body is unchanged. --- internal/cli/command_test.go | 28 ++++++++++++++++++++++++++ internal/cli/incident.go | 7 +++++-- skills/flashduty/reference/incident.md | 1 + 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 9187b25..80371b7 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -403,6 +403,34 @@ func TestCommandIncidentMergeRejectsMoreThan100Sources(t *testing.T) { } } +func TestCommandIncidentMergeRemoveSourceIncidents(t *testing.T) { + tests := []struct { + name string + extra []string + want any + }{ + {name: "default keeps sources", want: nil}, + {name: "flag removes sources", extra: []string{"--remove-source-incidents"}, want: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + args := append([]string{"incident", "merge", "target-1", "--source", "inc-1,inc-2"}, tc.extra...) + if _, err := execCommand(args...); err != nil { + t.Fatalf("[incident-merge-remove-source] unexpected error: %v", err) + } + if stub.lastPath != "/incident/merge" { + t.Fatalf("[incident-merge-remove-source] expected /incident/merge, got %q", stub.lastPath) + } + if got := stub.lastBody["remove_source_incidents"]; got != tc.want { + t.Fatalf("[incident-merge-remove-source] remove_source_incidents: want %#v, got %#v", tc.want, got) + } + }) + } +} + func TestCommandIncidentLifecycleHelpDocumentsSafetyAndLookupHints(t *testing.T) { saveAndResetGlobals(t) diff --git a/internal/cli/incident.go b/internal/cli/incident.go index cd7851f..ee55c59 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -705,6 +705,7 @@ func validateIncidentIDBatch(incidentIDs []string) error { func newIncidentMergeCmd() *cobra.Command { var source string + var removeSource bool cmd := &cobra.Command{ Use: "merge ", @@ -721,8 +722,9 @@ func newIncidentMergeCmd() *cobra.Command { } if _, err := ctx.Client.Incidents.Merge(cmdContext(ctx.Cmd), &flashduty.MergeIncidentsRequest{ - SourceIncidentIDs: sourceIDs, - TargetIncidentID: ctx.Args[0], + SourceIncidentIDs: sourceIDs, + TargetIncidentID: ctx.Args[0], + RemoveSourceIncidents: removeSource, }); err != nil { return err } @@ -734,6 +736,7 @@ func newIncidentMergeCmd() *cobra.Command { } cmd.Flags().StringVar(&source, "source", "", "Comma-separated source incident IDs (max 100)") + cmd.Flags().BoolVar(&removeSource, "remove-source-incidents", false, "Delete the source incidents after merging; by default they are closed and kept") _ = cmd.MarkFlagRequired("source") return cmd diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index b718f11..1d91c3a 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -258,6 +258,7 @@ List incidents by IDs ### merge Merge incidents into a target incident +- `--remove-source-incidents` bool - `--source` string ### past-list From 4d0981ed9bedce9e5e671df77a4d5d4928ca9897 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 20:15:08 -0700 Subject: [PATCH 2/9] test(cli): require curated commands to cover every request field A curated command that takes a generated command's name drops the generated twin, and with it every flag the curated one does not re-declare. Check each shadowed operation's top-level request fields against the curated flags, with explicit routes for renamed flags, positional arguments, and deliberately omitted fields. --- internal/cli/curated_fields_test.go | 194 ++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 internal/cli/curated_fields_test.go diff --git a/internal/cli/curated_fields_test.go b/internal/cli/curated_fields_test.go new file mode 100644 index 0000000..df8eee0 --- /dev/null +++ b/internal/cli/curated_fields_test.go @@ -0,0 +1,194 @@ +package cli + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// curatedFieldRoutes declares how a curated command that shadows a generated +// twin (see genAddLeaf) reaches each top-level request field of its operation +// when no flag of the same kebab-case name carries it. Values: +// +// "--flag" a curated flag with a different name sets the field +// "args" positional arguments set the field +// "omit: " the field is deliberately not exposed, with the reason +// +// A field with a same-named curated flag needs no entry. Every other field +// must be routed here, so a field the API adds later fails +// TestCuratedCommandsCoverRequestFields instead of silently vanishing behind +// the curated command. +var curatedFieldRoutes = map[string]map[string]string{ + "incident merge": { + "source_incident_ids": "--source", + "target_incident_id": "args", + "owner_id": "omit: ignored by the server; the merge never changes the target owner", + }, +} + +var apiLineRe = regexp.MustCompile(`(?m)^API: [A-Z]+ \S+ \(([^)]+)\)$`) + +// shadowedTwins pairs every curated command that took a generated leaf's name +// with the generated twin genAddLeaf dropped, keyed by command path (without +// the root prefix). The twin tree is rebuilt on a bare root so each generated +// leaf is present, then matched by path against the real rootCmd. +func shadowedTwins(t *testing.T) map[string][2]*cobra.Command { + t.Helper() + twinRoot := &cobra.Command{Use: rootCmd.Use} + registerGenerated(twinRoot) + + out := map[string][2]*cobra.Command{} + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + for _, twin := range c.Commands() { + walk(twin) + if twin.HasSubCommands() { + continue + } + path := strings.TrimPrefix(twin.CommandPath(), twinRoot.Name()+" ") + real, _, err := rootCmd.Find(strings.Fields(path)) + if err != nil || real == nil { + t.Fatalf("generated command %q missing from the real tree", path) + } + if real.Long != twin.Long { + out[path] = [2]*cobra.Command{real, twin} + } + } + } + walk(twinRoot) + return out +} + +// specRequestFields maps operationId to the top-level request-body property +// names, following $ref and allOf/oneOf/anyOf composition. +func specRequestFields(t *testing.T) map[string][]string { + t.Helper() + out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/flashcatcloud/go-flashduty").Output() + if err != nil { + t.Fatalf("locate go-flashduty module: %v", err) + } + data, err := os.ReadFile(filepath.Join(strings.TrimSpace(string(out)), "openapi", "openapi.en.json")) + if err != nil { + t.Fatalf("read spec: %v", err) + } + type schema struct { + Ref string `json:"$ref"` + Properties map[string]json.RawMessage `json:"properties"` + AllOf []schema `json:"allOf"` + OneOf []schema `json:"oneOf"` + AnyOf []schema `json:"anyOf"` + } + var spec struct { + Paths map[string]map[string]struct { + OperationID string `json:"operationId"` + RequestBody struct { + Content map[string]struct { + Schema schema `json:"schema"` + } `json:"content"` + } `json:"requestBody"` + } `json:"paths"` + Components struct { + Schemas map[string]schema `json:"schemas"` + } `json:"components"` + } + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("parse spec: %v", err) + } + + var collect func(s schema, into map[string]bool) + collect = func(s schema, into map[string]bool) { + if s.Ref != "" { + collect(spec.Components.Schemas[strings.TrimPrefix(s.Ref, "#/components/schemas/")], into) + } + for name := range s.Properties { + into[name] = true + } + for _, group := range [][]schema{s.AllOf, s.OneOf, s.AnyOf} { + for _, sub := range group { + collect(sub, into) + } + } + } + + fields := map[string][]string{} + for _, methods := range spec.Paths { + for _, op := range methods { + body, ok := op.RequestBody.Content["application/json"] + if !ok || op.OperationID == "" { + continue + } + set := map[string]bool{} + collect(body.Schema, set) + for name := range set { + fields[op.OperationID] = append(fields[op.OperationID], name) + } + sort.Strings(fields[op.OperationID]) + } + } + return fields +} + +// TestCuratedCommandsCoverRequestFields guards curated commands against +// silently hiding request fields: every top-level field of a shadowed +// operation must be reachable through a same-named flag or be routed in +// curatedFieldRoutes, and every route must still point at a live flag and a +// field the spec still has. +func TestCuratedCommandsCoverRequestFields(t *testing.T) { + twins := shadowedTwins(t) + fields := specRequestFields(t) + + var problems []string + for path, pair := range twins { + real, twin := pair[0], pair[1] + m := apiLineRe.FindStringSubmatch(twin.Long) + if m == nil { + t.Fatalf("generated command %q has no API line in its help", path) + } + routes := curatedFieldRoutes[path] + inSpec := map[string]bool{} + for _, field := range fields[m[1]] { + inSpec[field] = true + same := strings.ReplaceAll(field, "_", "-") + route, routed := routes[field] + switch { + case !routed: + if real.Flags().Lookup(same) == nil { + problems = append(problems, path+": request field "+field+" is not reachable (add --"+same+" or route it in curatedFieldRoutes)") + } + case route == "--"+same: + problems = append(problems, path+": route for "+field+" is redundant, the same-named flag already covers it") + case strings.HasPrefix(route, "--"): + if real.Flags().Lookup(strings.TrimPrefix(route, "--")) == nil { + problems = append(problems, path+": route "+field+" -> "+route+" names a flag the command does not have") + } + case route == "args": + case strings.HasPrefix(route, "omit: ") && strings.TrimSpace(strings.TrimPrefix(route, "omit: ")) != "": + default: + problems = append(problems, path+": route "+field+" -> "+route+` must be "--flag", "args", or "omit: "`) + } + } + for field := range routes { + if !inSpec[field] { + problems = append(problems, path+": route for "+field+" is stale, the spec no longer has that field") + } + } + } + for path := range curatedFieldRoutes { + if _, ok := twins[path]; !ok { + problems = append(problems, path+": curatedFieldRoutes entry for a command that no longer shadows a generated twin") + } + } + + sort.Strings(problems) + if len(problems) > 0 { + t.Errorf("%d curated request-field gaps:\n %s", len(problems), strings.Join(problems, "\n ")) + } + t.Logf("checked %d curated commands that shadow generated twins", len(twins)) +} From a4509ef2dbb5ecd2be7a1b71cb92f2e27bc0eb60 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 20:17:18 -0700 Subject: [PATCH 3/9] feat(alert): expose request filters on curated alert list commands Flags added: - alert list: --alert-ids, --alert-keys, --asc, --by-updated-at - alert-event list: --asc Remaining request fields are routed to the existing flags that already set them (--severity, --channel, --integration, --since, --until, --page, --active, --muted, --integration-type, --comment-file), or omitted with a stated reason (page cursor, single-valued orderby). --- internal/cli/alert.go | 14 ++++- internal/cli/alert_event.go | 3 + internal/cli/alert_test.go | 85 +++++++++++++++++++++++++++++ internal/cli/curated_fields_test.go | 25 +++++++++ skills/flashduty/reference/alert.md | 4 ++ 5 files changed, 128 insertions(+), 3 deletions(-) diff --git a/internal/cli/alert.go b/internal/cli/alert.go index 7ccf0eb..fba4669 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -24,14 +24,14 @@ func newAlertCmd() *cobra.Command { } func newAlertListCmd() *cobra.Command { - var severity, channel, integration, since, until, fields string - var active, recovered, muted bool + var severity, channel, integration, since, until, fields, alertIDs, alertKeys string + var active, recovered, muted, asc, byUpdatedAt bool var limit, page int cmd := &cobra.Command{ Use: "list", Short: "List alerts", - Long: curatedLong("List alerts within a time window, optionally filtered by severity, channel, active/recovered/muted state. No server-side title/text filter — to search by title, pipe --json to jq: 'select(.title|test(\"pat\";\"i\"))'. In json/toon mode, --fields projects each row to just the named fields (e.g. --fields alert_id,title,alert_severity,created_at) so you get a compact record without piping to jq. --limit max 100; --since/--until window must be < 31 days.", "Alerts", "ReadList"), + Long: curatedLong("List alerts within a time window, optionally filtered by severity, channel, integration, alert ID/key, active/recovered/muted state. --asc sorts oldest first; --by-updated-at applies the window to last-updated time. No server-side title/text filter — to search by title, pipe --json to jq: 'select(.title|test(\"pat\";\"i\"))'. In json/toon mode, --fields projects each row to just the named fields (e.g. --fields alert_id,title,alert_severity,created_at) so you get a compact record without piping to jq. --limit max 100; --since/--until window must be < 31 days.", "Alerts", "ReadList"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { if active && recovered { @@ -54,6 +54,10 @@ func newAlertListCmd() *cobra.Command { } req.Limit = limit req.Page = page + req.Asc = asc + req.ByUpdatedAt = byUpdatedAt + req.AlertIDs = parseStringSlice(alertIDs) + req.AlertKeys = parseStringSlice(alertKeys) // Preserve legacy semantics: --active sends is_active=true, // --recovered sends is_active=false, neither omits the filter. @@ -118,6 +122,10 @@ func newAlertListCmd() *cobra.Command { cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs") cmd.Flags().StringVar(&integration, "integration", "", "Comma-separated integration IDs") cmd.Flags().BoolVar(&muted, "muted", false, "Show ever-muted only") + cmd.Flags().StringVar(&alertIDs, "alert-ids", "", "Comma-separated alert IDs to return") + cmd.Flags().StringVar(&alertKeys, "alert-keys", "", "Comma-separated alert deduplication keys") + cmd.Flags().BoolVar(&asc, "asc", false, "Sort oldest first by start time (default newest first)") + cmd.Flags().BoolVar(&byUpdatedAt, "by-updated-at", false, "Apply --since/--until to the last-updated time instead of the start time") cmd.Flags().StringVar(&since, "since", "24h", "Start time") cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index 7abb232..968f8b9 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -20,6 +20,7 @@ func newAlertEventCmd() *cobra.Command { func newAlertEventListCmd() *cobra.Command { var severity, channel, integration, integrationType, since, until, fields string var limit, page int + var asc bool cmd := &cobra.Command{ Use: "list", @@ -46,6 +47,7 @@ func newAlertEventListCmd() *cobra.Command { } input.Limit = limit input.Page = page + input.Asc = asc if severity != "" { // go-flashduty takes severities as a comma-separated string. @@ -120,6 +122,7 @@ func newAlertEventListCmd() *cobra.Command { registerEnumFlag(cmd, "severity", severityEnum...) cmd.Flags().StringVar(&integration, "integration", "", "Comma-separated integration IDs") cmd.Flags().StringVar(&integrationType, "integration-type", "", "Comma-separated integration types (plugin keys, e.g. AliCloud,Prometheus) — not integration IDs; use --integration for that") + cmd.Flags().BoolVar(&asc, "asc", false, "Sort oldest first (default newest first)") cmd.Flags().StringVar(&since, "since", "1h", "Start time") cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") diff --git a/internal/cli/alert_test.go b/internal/cli/alert_test.go index f26638d..d22a2e8 100644 --- a/internal/cli/alert_test.go +++ b/internal/cli/alert_test.go @@ -2,6 +2,8 @@ package cli import ( "fmt" + "os" + "path/filepath" "strings" "testing" ) @@ -214,3 +216,86 @@ func TestCommandAlertMergeDataAndCommentFileBothStdinErrors(t *testing.T) { t.Fatalf("[alert-merge-double-stdin] expected the double-stdin-read error naming both flags, got: %v", err) } } + +func TestCommandAlertListFilterFlagsReachWire(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + if _, err := execCommand("alert", "list", "--alert-ids", "a1,a2", "--alert-keys", "k1, k2", "--asc", "--by-updated-at"); err != nil { + t.Fatalf("[alert-list-filters] unexpected error: %v", err) + } + if stub.lastPath != "/alert/list" { + t.Fatalf("[alert-list-filters] expected /alert/list, got %q", stub.lastPath) + } + for _, f := range []string{"asc", "by_updated_at"} { + if got := stub.lastBody[f]; got != true { + t.Errorf("[alert-list-filters] %s: want true, got %#v", f, got) + } + } + for field, want := range map[string]string{"alert_ids": "a1,a2", "alert_keys": "k1,k2"} { + got, _ := stub.lastBody[field].([]any) + var parts []string + for _, v := range got { + parts = append(parts, fmt.Sprint(v)) + } + if strings.Join(parts, ",") != want { + t.Errorf("[alert-list-filters] %s: want %q, got %#v", field, want, stub.lastBody[field]) + } + } +} + +func TestCommandAlertListFilterFlagsDefaultOmitted(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + if _, err := execCommand("alert", "list"); err != nil { + t.Fatalf("[alert-list-filters-default] unexpected error: %v", err) + } + for _, f := range []string{"alert_ids", "alert_keys", "asc", "by_updated_at"} { + if _, ok := stub.lastBody[f]; ok { + t.Errorf("[alert-list-filters-default] %s should be omitted by default, got %#v", f, stub.lastBody[f]) + } + } +} + +func TestCommandAlertEventListAsc(t *testing.T) { + for _, tc := range []struct { + name string + extra []string + want any + }{ + {name: "default omits asc", want: nil}, + {name: "flag sends asc", extra: []string{"--asc"}, want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + if _, err := execCommand(append([]string{"alert-event", "list"}, tc.extra...)...); err != nil { + t.Fatalf("[alert-event-list-asc] unexpected error: %v", err) + } + if stub.lastPath != "/alert-event/list" { + t.Fatalf("[alert-event-list-asc] expected /alert-event/list, got %q", stub.lastPath) + } + if got := stub.lastBody["asc"]; got != tc.want { + t.Fatalf("[alert-event-list-asc] asc: want %#v, got %#v", tc.want, got) + } + }) + } +} + +func TestCommandAlertMergeCommentFileReachesWire(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + path := filepath.Join(t.TempDir(), "comment.txt") + if err := os.WriteFile(path, []byte("merge `reason` $(x)"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := execCommand("alert", "merge", "a1", "--incident-id", "i1", "--comment-file", path); err != nil { + t.Fatalf("[alert-merge-comment] unexpected error: %v", err) + } + if got := stub.lastBody["comment"]; got != "merge `reason` $(x)" { + t.Fatalf("[alert-merge-comment] comment: got %#v", got) + } +} diff --git a/internal/cli/curated_fields_test.go b/internal/cli/curated_fields_test.go index df8eee0..82de95f 100644 --- a/internal/cli/curated_fields_test.go +++ b/internal/cli/curated_fields_test.go @@ -26,6 +26,31 @@ import ( // TestCuratedCommandsCoverRequestFields instead of silently vanishing behind // the curated command. var curatedFieldRoutes = map[string]map[string]string{ + "alert list": { + "alert_severity": "--severity", + "channel_ids": "--channel", + "integration_ids": "--integration", + "start_time": "--since", + "end_time": "--until", + "p": "--page", + "is_active": "--active", + "ever_muted": "--muted", + "search_after_ctx": "omit: cursor returned by the previous page; the curated list pages with --page and does not print the next-page cursor", + }, + "alert merge": { + "comment": "--comment-file", + }, + "alert-event list": { + "severities": "--severity", + "channel_ids": "--channel", + "integration_ids": "--integration", + "integration_types": "--integration-type", + "start_time": "--since", + "end_time": "--until", + "p": "--page", + "orderby": "omit: event_time is the only supported value and the server default", + "search_after_ctx": "omit: cursor returned by the previous page; the curated list pages with --page and does not print the next-page cursor", + }, "incident merge": { "source_incident_ids": "--source", "target_incident_id": "args", diff --git a/skills/flashduty/reference/alert.md b/skills/flashduty/reference/alert.md index bd808fb..09f26d8 100644 --- a/skills/flashduty/reference/alert.md +++ b/skills/flashduty/reference/alert.md @@ -88,6 +88,10 @@ Get alert detail ### list List alerts - `--active` bool +- `--alert-ids` string +- `--alert-keys` string +- `--asc` bool +- `--by-updated-at` bool - `--channel` string - `--fields` string - `--integration` string From a9b9907b3c8a6dbc59dbfd3391eda0d0bd6148e5 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 20:17:51 -0700 Subject: [PATCH 4/9] feat(misc): expose filter and sort fields on curated list/search commands Flags added: - audit search: --is-write, --is-dangerous, --request-id - change list: --orderby, --asc, --include-events, --filters (JSON array of key/oper/vals) - channel list: --channel-ids, --channel-name, --query, --is-my-team, --is-my-starred, --is-my-managed, --is-brief, --orderby, --asc, --page, --limit - field list: --query, --creator-id, --orderby, --asc Every new flag is sent only when set, so default request bodies are unchanged. Differently named existing flags are routed to their wire fields for team list/delete, change list, audit search and channel list. --- internal/cli/audit.go | 15 ++- internal/cli/change.go | 17 +++- internal/cli/channel.go | 37 ++++++- internal/cli/curated_fields_test.go | 32 ++++++ internal/cli/curated_misc_fields_test.go | 119 +++++++++++++++++++++++ internal/cli/field.go | 14 ++- skills/flashduty/reference/change.md | 4 + skills/flashduty/reference/channel.md | 11 +++ skills/flashduty/reference/field.md | 4 + 9 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 internal/cli/curated_misc_fields_test.go diff --git a/internal/cli/audit.go b/internal/cli/audit.go index 127846e..6845508 100644 --- a/internal/cli/audit.go +++ b/internal/cli/audit.go @@ -17,14 +17,15 @@ func newAuditCmd() *cobra.Command { } func newAuditSearchCmd() *cobra.Command { - var since, until, operation string + var since, until, operation, requestID string + var isWrite, isDangerous bool var person int64 var limit, page int cmd := &cobra.Command{ Use: "search", Short: "Search audit logs", - Long: curatedLong("Search audit logs within a time window, optionally filtered by person and operation type. The --since/--until window must be < 90 days; --limit max is 99.", "AuditLogs", "Search"), + Long: curatedLong("Search audit logs within a time window, optionally filtered by person, operation type, write/read, risk level and request ID. The --since/--until window must be < 90 days; --limit max is 99.", "AuditLogs", "Search"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) @@ -41,6 +42,13 @@ func newAuditSearchCmd() *cobra.Command { EndTime: endTime, Limit: int64(limit), PersonID: uint64(person), + RequestID: requestID, + } + if cmd.Flags().Changed("is-write") { + input.IsWrite = &isWrite + } + if cmd.Flags().Changed("is-dangerous") { + input.IsDangerous = &isDangerous } if operation != "" { input.Operations = parseStringSlice(operation) @@ -105,6 +113,9 @@ func newAuditSearchCmd() *cobra.Command { cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().Int64Var(&person, "person", 0, "Filter by person ID") cmd.Flags().StringVar(&operation, "operation", "", "Filter by exact operation name(s) from 'flashduty audit operation-list' (e.g. monitRule:write:update); comma-separate to match several in one call. Prefixes do NOT match (\"monitRule\" returns nothing).") + cmd.Flags().StringVar(&requestID, "request-id", "", "Filter to a single request by its request ID") + cmd.Flags().BoolVar(&isWrite, "is-write", false, "Only write operations; --is-write=false returns only read operations") + cmd.Flags().BoolVar(&isDangerous, "is-dangerous", false, "Only high-risk (dangerous) operations") cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 99)") cmd.Flags().IntVar(&page, "page", 1, "Page number") diff --git a/internal/cli/change.go b/internal/cli/change.go index 425b7e0..37ee1c0 100644 --- a/internal/cli/change.go +++ b/internal/cli/change.go @@ -1,6 +1,7 @@ package cli import ( + "encoding/json" "fmt" "github.com/flashcatcloud/go-flashduty" @@ -20,7 +21,9 @@ func newChangeListCmd() *cobra.Command { var channel string var since, until string var limit, page int - var query, integration string + var query, integration, filters string + var orderby string + var asc, includeEvents bool cmd := &cobra.Command{ Use: "list", @@ -72,6 +75,14 @@ func newChangeListCmd() *cobra.Command { input.IntegrationIDs = integrationIDs } input.Query = query + input.Orderby = orderby + input.Asc = asc + input.IncludeEvents = includeEvents + if filters != "" { + if err := json.Unmarshal([]byte(filters), &input.Filters); err != nil { + return fmt.Errorf("invalid --filters: %w", err) + } + } result, _, err := ctx.Client.Changes.List(cmdContext(ctx.Cmd), input) if err != nil { @@ -94,6 +105,10 @@ func newChangeListCmd() *cobra.Command { cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs") cmd.Flags().StringVar(&query, "query", "", "Free-text/regex search over change fields") cmd.Flags().StringVar(&integration, "integration", "", "Comma-separated reporting integration IDs") + cmd.Flags().StringVar(&filters, "filters", "", `Structured filters ANDed onto the query, as a JSON array of {"key","oper","vals"} (oper IN or NOTIN; key like labels.env). Keys starting with "incident" are ignored`) + cmd.Flags().StringVar(&orderby, "orderby", "", "Sort field: start_time (default) or last_time") + cmd.Flags().BoolVar(&asc, "asc", false, "Sort in ascending order") + cmd.Flags().BoolVar(&includeEvents, "include-events", false, "Include the underlying change events for each change") cmd.Flags().StringVar(&since, "since", "24h", "Start time (accepts 7d/24h/now, RFC3339, or Unix epoch; window must be < 31 days)") cmd.Flags().StringVar(&until, "until", "now", "End time (accepts 7d/24h/now, RFC3339, or Unix epoch)") cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") diff --git a/internal/cli/channel.go b/internal/cli/channel.go index 0355d15..5c0050e 100644 --- a/internal/cli/channel.go +++ b/internal/cli/channel.go @@ -37,8 +37,10 @@ type channelRow struct { } func newChannelListCmd() *cobra.Command { - var name string - var teamIDs []int64 + var name, channelName, query, orderby string + var teamIDs, channelIDs []int64 + var page, limit int + var asc, isBrief, isMyManaged, isMyStarred, isMyTeam bool cmd := &cobra.Command{ Use: "list", @@ -52,7 +54,25 @@ func newChannelListCmd() *cobra.Command { // is an exact-match server filter, so we keep the client-side filter // to preserve behavior. --team-ids, by contrast, is a server-side // filter on the channel's owning team (empty = all teams, unchanged). - result, _, err := ctx.Client.Channels.ChannelList(cmdContext(ctx.Cmd), &flashduty.ListChannelsRequest{TeamIDs: teamIDs}) + req := &flashduty.ListChannelsRequest{ + TeamIDs: teamIDs, + ChannelIDs: channelIDs, + ChannelName: channelName, + Query: query, + Orderby: orderby, + Asc: asc, + IsBrief: isBrief, + IsMyManaged: isMyManaged, + IsMyStarred: isMyStarred, + IsMyTeam: isMyTeam, + } + if cmd.Flags().Changed("page") { + req.Page = page + } + if cmd.Flags().Changed("limit") { + req.Limit = limit + } + result, _, err := ctx.Client.Channels.ChannelList(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -88,6 +108,17 @@ func newChannelListCmd() *cobra.Command { } cmd.Flags().StringVar(&name, "name", "", "Search by name") + cmd.Flags().StringVar(&channelName, "channel-name", "", "Exact channel name, server-side") + cmd.Flags().StringVar(&query, "query", "", "Case-insensitive regex over channel name and description, server-side") + cmd.Flags().Int64SliceVar(&channelIDs, "channel-ids", nil, "Filter by channel ID(s) (repeatable or comma-separated)") + cmd.Flags().BoolVar(&isMyTeam, "is-my-team", false, "Only channels owned by your teams (exclusive with --is-my-starred)") + cmd.Flags().BoolVar(&isMyStarred, "is-my-starred", false, "Only channels you starred (exclusive with --is-my-team)") + cmd.Flags().BoolVar(&isMyManaged, "is-my-managed", false, "Only channels you manage") + cmd.Flags().BoolVar(&isBrief, "is-brief", false, "Return only id, name, description and status, all matches without pagination") + cmd.Flags().StringVar(&orderby, "orderby", "", "Sort field (server default: created_at)") + cmd.Flags().BoolVar(&asc, "asc", false, "Sort in ascending order") + cmd.Flags().IntVar(&page, "page", 1, "Page number") + cmd.Flags().IntVar(&limit, "limit", 100, "Page size (server default 100)") cmd.Flags().Int64SliceVar(&teamIDs, "team-ids", nil, "Filter by owning team ID(s), server-side (repeatable or comma-separated)") return cmd diff --git a/internal/cli/curated_fields_test.go b/internal/cli/curated_fields_test.go index df8eee0..b55dfdf 100644 --- a/internal/cli/curated_fields_test.go +++ b/internal/cli/curated_fields_test.go @@ -31,6 +31,38 @@ var curatedFieldRoutes = map[string]map[string]string{ "target_incident_id": "args", "owner_id": "omit: ignored by the server; the merge never changes the target owner", }, + "audit search": { + "start_time": "--since", + "end_time": "--until", + "person_id": "--person", + "operations": "--operation", + "search_after_ctx": "omit: the cursor is managed by the command; --page walks it", + }, + "change list": { + "start_time": "--since", + "end_time": "--until", + "p": "--page", + "channel_ids": "--channel", + "integration_ids": "--integration", + "integration_id": "omit: deprecated single-value alias of integration_ids, which --integration sets", + "data_source_id": "omit: deprecated single-value alias of integration_ids, which --integration sets", + "data_source_ids": "omit: deprecated alias of integration_ids, which --integration sets", + }, + "channel list": { + "p": "--page", + }, + "team list": { + "p": "--page", + "query": "--name", + }, + "team delete": { + "team_id": "--id", + "team_name": "--name", + }, + "insight incident-export": { + "fields": "omit: object field; pass it through --data", + "labels": "omit: object field; pass it through --data", + }, } var apiLineRe = regexp.MustCompile(`(?m)^API: [A-Z]+ \S+ \(([^)]+)\)$`) diff --git a/internal/cli/curated_misc_fields_test.go b/internal/cli/curated_misc_fields_test.go new file mode 100644 index 0000000..1fbdd0e --- /dev/null +++ b/internal/cli/curated_misc_fields_test.go @@ -0,0 +1,119 @@ +package cli + +import ( + "reflect" + "testing" +) + +// TestCuratedListFilterFlagsReachWireBody checks that each filter/sort flag on +// the audit, change, channel and field list commands lands on its wire field, +// and that omitting every new flag leaves those fields out of the body. +func TestCuratedListFilterFlagsReachWireBody(t *testing.T) { + tests := []struct { + name string + args []string + path string + want map[string]any + absent []string + wantErr bool + }{ + { + name: "audit defaults omit new filters", + args: []string{"audit", "search"}, + path: "/audit/search", + absent: []string{"is_write", "is_dangerous", "request_id"}, + }, + { + name: "audit filters", + args: []string{"audit", "search", "--is-write=false", "--is-dangerous", "--request-id", "req-1"}, + path: "/audit/search", + want: map[string]any{"is_write": false, "is_dangerous": true, "request_id": "req-1"}, + }, + { + name: "change defaults omit new fields", + args: []string{"change", "list"}, + path: "/change/list", + absent: []string{"asc", "orderby", "include_events", "filters"}, + }, + { + name: "change sort, events and filters", + args: []string{"change", "list", "--orderby", "last_time", "--asc", "--include-events", "--filters", `[{"key":"labels.env","oper":"IN","vals":["prod"]}]`}, + path: "/change/list", + want: map[string]any{ + "orderby": "last_time", + "asc": true, + "include_events": true, + "filters": []any{map[string]any{"key": "labels.env", "oper": "IN", "vals": []any{"prod"}}}, + }, + }, + { + name: "change filters rejects bad JSON", + args: []string{"change", "list", "--filters", "not-json"}, + wantErr: true, + }, + { + name: "channel defaults omit new fields", + args: []string{"channel", "list"}, + path: "/channel/list", + absent: []string{"p", "limit", "query", "channel_name", "channel_ids", "is_brief", "is_my_team", "is_my_starred", "is_my_managed", "orderby", "asc"}, + }, + { + name: "channel filters and paging", + args: []string{"channel", "list", "--query", "^pay", "--channel-name", "payments", "--channel-ids", "1,2", "--is-my-team", "--is-my-managed", "--is-brief", "--orderby", "channel_name", "--asc", "--page", "2", "--limit", "10"}, + path: "/channel/list", + want: map[string]any{ + "query": "^pay", "channel_name": "payments", "channel_ids": []any{float64(1), float64(2)}, + "is_my_team": true, "is_my_managed": true, "is_brief": true, + "orderby": "channel_name", "asc": true, "p": float64(2), "limit": float64(10), + }, + }, + { + name: "channel starred", + args: []string{"channel", "list", "--is-my-starred"}, + path: "/channel/list", + want: map[string]any{"is_my_starred": true}, + }, + { + name: "field defaults omit new fields", + args: []string{"field", "list"}, + path: "/field/list", + absent: []string{"query", "creator_id", "orderby", "asc"}, + }, + { + name: "field filters and sort", + args: []string{"field", "list", "--query", "^env", "--creator-id", "42", "--orderby", "field_name", "--asc"}, + path: "/field/list", + want: map[string]any{"query": "^env", "creator_id": float64(42), "orderby": "field_name", "asc": true}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + _, err := execCommand(tc.args...) + if tc.wantErr { + if err == nil { + t.Fatalf("[misc-list-flags] expected an error for %v", tc.args) + } + return + } + if err != nil { + t.Fatalf("[misc-list-flags] unexpected error: %v", err) + } + if stub.lastPath != tc.path { + t.Fatalf("[misc-list-flags] expected %s, got %q", tc.path, stub.lastPath) + } + for k, want := range tc.want { + if got := stub.lastBody[k]; !reflect.DeepEqual(got, want) { + t.Errorf("[misc-list-flags] %s: want %#v, got %#v", k, want, got) + } + } + for _, k := range tc.absent { + if v, ok := stub.lastBody[k]; ok { + t.Errorf("[misc-list-flags] %s should be absent by default, got %#v", k, v) + } + } + }) + } +} diff --git a/internal/cli/field.go b/internal/cli/field.go index c77a390..2f980fe 100644 --- a/internal/cli/field.go +++ b/internal/cli/field.go @@ -16,7 +16,9 @@ func newFieldCmd() *cobra.Command { } func newFieldListCmd() *cobra.Command { - var name string + var name, query, orderby string + var creatorID int64 + var asc bool cmd := &cobra.Command{ Use: "list", @@ -24,7 +26,11 @@ func newFieldListCmd() *cobra.Command { Long: curatedLong("List custom fields, optionally filtered by exact field name.", "AlertEnrichment", "FieldReadList"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { - result, _, err := ctx.Client.AlertEnrichment.FieldReadList(cmdContext(ctx.Cmd), &flashduty.FieldListRequest{}) + req := &flashduty.FieldListRequest{Query: query, Orderby: orderby, Asc: asc} + if cmd.Flags().Changed("creator-id") { + req.CreatorID = &creatorID + } + result, _, err := ctx.Client.AlertEnrichment.FieldReadList(cmdContext(ctx.Cmd), req) if err != nil { return err } @@ -60,6 +66,10 @@ func newFieldListCmd() *cobra.Command { } cmd.Flags().StringVar(&name, "name", "", "Filter by field name") + cmd.Flags().StringVar(&query, "query", "", "Regex over field_name, server-side") + cmd.Flags().Int64Var(&creatorID, "creator-id", 0, "Filter by creator member ID") + cmd.Flags().StringVar(&orderby, "orderby", "", "Sort key (server default: created_at)") + cmd.Flags().BoolVar(&asc, "asc", false, "Sort in ascending order") return cmd } diff --git a/skills/flashduty/reference/change.md b/skills/flashduty/reference/change.md index ee398f7..59e3174 100644 --- a/skills/flashduty/reference/change.md +++ b/skills/flashduty/reference/change.md @@ -26,9 +26,13 @@ flashduty change list --since 48h --integration --query "deploy ### list List changes +- `--asc` bool - `--channel` string +- `--filters` string +- `--include-events` bool - `--integration` string - `--limit` int +- `--orderby` string - `--page` int - `--query` string - `--since` string diff --git a/skills/flashduty/reference/channel.md b/skills/flashduty/reference/channel.md index ca1e7a4..e64eca9 100644 --- a/skills/flashduty/reference/channel.md +++ b/skills/flashduty/reference/channel.md @@ -78,7 +78,18 @@ Batch get channels ### list List channels +- `--asc` bool +- `--channel-ids` int64Slice +- `--channel-name` string +- `--is-brief` bool +- `--is-my-managed` bool +- `--is-my-starred` bool +- `--is-my-team` bool +- `--limit` int - `--name` string +- `--orderby` string +- `--page` int +- `--query` string - `--team-ids` int64Slice - response: TOP-LEVEL array — pipe `--json | jq '.[]'` (NOT `.items[]`) — fields: account_id (integer); active_incident_highest_severity (string); auto_resolve_mode (string); auto_resolve_timeout (integer); channel_id (integer); channel_name (string); created_at (string); creator_id (integer); creator_name (string); deleted_at (string); description (string); disable_auto_close (boolean); disable_outlier_detection (boolean); event_group (object); external_report_token (string); flapping (object); group (object); is_external_report_enabled (boolean); is_private (boolean); is_starred (boolean); last_incident_at (string); managing_team_ids (array); progress_to_incident_cnts (object); status (string); team_id (integer); team_name (string); updated_at (string) diff --git a/skills/flashduty/reference/field.md b/skills/flashduty/reference/field.md index 306c2e9..512852b 100644 --- a/skills/flashduty/reference/field.md +++ b/skills/flashduty/reference/field.md @@ -63,7 +63,11 @@ Get field detail ### list List custom fields +- `--asc` bool +- `--creator-id` int64 - `--name` string +- `--orderby` string +- `--query` string - response: TOP-LEVEL array — pipe `--json | jq '.[]'` (NOT `.items[]`) — fields: account_id (integer); created_at (string); creator_id (integer); default_value (any); deleted_at (string); description (string); display_name (string); field_id (string); field_name (string); field_type (string); options (array); status (string); updated_at (string); updated_by (integer); value_type (string) ### update From fd3839272c10ec7aa267223d4434c8d4d98d3623 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 20:18:04 -0700 Subject: [PATCH 5/9] feat(incident): expose request fields on curated incident commands Flags added: - incident list: --incident-ids, --acker-ids, --closer-ids, --creator-ids, --responder-ids, --team-ids, --asc, --ever-muted, --is-my-channel, --is-my-team, --is-rare, --is-snoozed - incident feed: --asc, --types - incident merge: --title, --comment-file - incident comment: --comment-type-id Fields already carried by an existing flag or positional argument are declared in curatedFieldRoutes; the two fields that cannot be sent are recorded there with the reason. --- internal/cli/curated_fields_test.go | 34 +++++++ internal/cli/incident.go | 74 ++++++++++++-- internal/cli/incident_fields_test.go | 131 +++++++++++++++++++++++++ skills/flashduty/reference/incident.md | 17 ++++ 4 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 internal/cli/incident_fields_test.go diff --git a/internal/cli/curated_fields_test.go b/internal/cli/curated_fields_test.go index df8eee0..7cda91a 100644 --- a/internal/cli/curated_fields_test.go +++ b/internal/cli/curated_fields_test.go @@ -29,8 +29,42 @@ var curatedFieldRoutes = map[string]map[string]string{ "incident merge": { "source_incident_ids": "--source", "target_incident_id": "args", + "comment": "--comment-file", "owner_id": "omit: ignored by the server; the merge never changes the target owner", }, + "incident list": { + "start_time": "--since", + "end_time": "--until", + "p": "--page", + "channel_ids": "--channel", + "incident_severity": "--severity", + "search_after_ctx": "omit: pagination cursor; the command pages with --page", + }, + "incident create": { + "channel_id": "--channel", + "incident_severity": "--severity", + "assigned_to": "--assign", + "fields": "omit: the SDK models custom field values as an empty struct, so the typed request cannot carry them; set them after creation with incident update --field", + }, + "incident feed": { + "incident_id": "args", + "p": "--page", + }, + "incident comment": { + "incident_ids": "args", + "comment": "--comment-file", + }, + "incident remove": { + "incident_ids": "args", + }, + "incident snooze": { + "incident_ids": "args", + "minutes": "--duration", + }, + "incident post-mortem-content-reset": { + "post_mortem_id": "args", + "markdown": "--markdown-file", + }, } var apiLineRe = regexp.MustCompile(`(?m)^API: [A-Z]+ \S+ \(([^)]+)\)$`) diff --git a/internal/cli/incident.go b/internal/cli/incident.go index ee55c59..1067e24 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -73,6 +73,8 @@ func pastIncidentColumns() []output.Column { func newIncidentListCmd() *cobra.Command { var progress, severity, query, since, until, nums, fields, channel string + var incidentIDs, ackerIDs, closerIDs, creatorIDs, responderIDs, teamIDs string + var asc, everMuted, isMyChannel, isMyTeam, isRare, isSnoozed bool var channelID int64 var limit, page int defaultStructuredFields := []string{"incident_id", "num", "title", "incident_severity", "progress", "start_time", "channel_id", "detail_url"} @@ -117,6 +119,30 @@ func newIncidentListCmd() *cobra.Command { if nums != "" { req.Nums = parseStringSlice(nums) } + req.IncidentIDs = parseStringSlice(incidentIDs) + for _, f := range []struct { + flag string + raw string + dst *[]int64 + }{ + {"acker-ids", ackerIDs, &req.AckerIDs}, + {"closer-ids", closerIDs, &req.CloserIDs}, + {"creator-ids", creatorIDs, &req.CreatorIDs}, + {"responder-ids", responderIDs, &req.ResponderIDs}, + {"team-ids", teamIDs, &req.TeamIDs}, + } { + ids, err := parseIntSlice(f.raw) + if err != nil { + return fmt.Errorf("invalid --%s: %w", f.flag, err) + } + *f.dst = ids + } + req.Asc = asc + req.EverMuted = everMuted + req.IsMyChannel = isMyChannel + req.IsMyTeam = isMyTeam + req.IsRare = isRare + req.IsSnoozed = isSnoozed result, _, err := ctx.Client.Incidents.List(cmdContext(ctx.Cmd), req) if err != nil { @@ -171,6 +197,18 @@ func newIncidentListCmd() *cobra.Command { _ = cmd.Flags().MarkDeprecated("channel-id", "use --channel instead") cmd.Flags().StringVar(&query, "query", "", "Free-text search across title/labels/content (also resolves a 24-char incident ID or 6-char incident num to a direct lookup)") cmd.Flags().StringVar(&nums, "nums", "", "Comma-separated short incident ids (num, the 6-char id shown in the UI) to filter by") + cmd.Flags().StringVar(&incidentIDs, "incident-ids", "", "Comma-separated full incident IDs to restrict to") + cmd.Flags().StringVar(&ackerIDs, "acker-ids", "", "Comma-separated acker member IDs to filter by") + cmd.Flags().StringVar(&closerIDs, "closer-ids", "", "Comma-separated closer member IDs to filter by (0 = closed automatically)") + cmd.Flags().StringVar(&creatorIDs, "creator-ids", "", "Comma-separated creator member IDs to filter by (0 = created automatically)") + cmd.Flags().StringVar(&responderIDs, "responder-ids", "", "Comma-separated responder member IDs to filter by") + cmd.Flags().StringVar(&teamIDs, "team-ids", "", "Comma-separated team IDs; resolved to the channels those teams own") + cmd.Flags().BoolVar(&asc, "asc", false, "Sort ascending (oldest first)") + cmd.Flags().BoolVar(&everMuted, "ever-muted", false, "Only incidents that were ever silenced") + cmd.Flags().BoolVar(&isMyChannel, "is-my-channel", false, "Only incidents in channels you own") + cmd.Flags().BoolVar(&isMyTeam, "is-my-team", false, "Only incidents in channels owned by your teams") + cmd.Flags().BoolVar(&isRare, "is-rare", false, "Only outlier (rare) incidents") + cmd.Flags().BoolVar(&isSnoozed, "is-snoozed", false, "Only snoozed incidents") cmd.Flags().StringVar(&since, "since", "24h", "Start time (duration, date, datetime, or unix timestamp; --since→--until window must be < 31 days)") cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") @@ -704,7 +742,7 @@ func validateIncidentIDBatch(incidentIDs []string) error { } func newIncidentMergeCmd() *cobra.Command { - var source string + var source, title, commentFile string var removeSource bool cmd := &cobra.Command{ @@ -721,11 +759,20 @@ func newIncidentMergeCmd() *cobra.Command { return fmt.Errorf("--source accepts at most 100 incident IDs") } - if _, err := ctx.Client.Incidents.Merge(cmdContext(ctx.Cmd), &flashduty.MergeIncidentsRequest{ + req := &flashduty.MergeIncidentsRequest{ SourceIncidentIDs: sourceIDs, TargetIncidentID: ctx.Args[0], RemoveSourceIncidents: removeSource, - }); err != nil { + Title: title, + } + if cmd.Flags().Changed("comment-file") { + comment, err := resolveCommentFile(commentFile) + if err != nil { + return err + } + req.Comment = comment + } + if _, err := ctx.Client.Incidents.Merge(cmdContext(ctx.Cmd), req); err != nil { return err } @@ -737,6 +784,8 @@ func newIncidentMergeCmd() *cobra.Command { cmd.Flags().StringVar(&source, "source", "", "Comma-separated source incident IDs (max 100)") cmd.Flags().BoolVar(&removeSource, "remove-source-incidents", false, "Delete the source incidents after merging; by default they are closed and kept") + cmd.Flags().StringVar(&title, "title", "", "New title for the target incident") + cmd.Flags().StringVar(&commentFile, "comment-file", "", "Path to a file containing an optional comment for the merge timeline entry (- reads stdin)") _ = cmd.MarkFlagRequired("source") return cmd @@ -891,7 +940,7 @@ personal channels, or a template.`, } func newIncidentCommentCmd() *cobra.Command { - var commentFile string + var commentFile, commentTypeID string var muteReply bool cmd := &cobra.Command{ @@ -944,11 +993,15 @@ success.`, } return runCommand(cmd, args, func(ctx *RunContext) error { - if _, err := ctx.Client.Incidents.Comment(cmdContext(ctx.Cmd), &flashduty.CommentIncidentRequest{ + req := &flashduty.CommentIncidentRequest{ IncidentIDs: ctx.Args, Comment: comment, MuteReply: muteReply, - }); err != nil { + } + if cmd.Flags().Changed("comment-type-id") { + req.CommentTypeID = flashduty.String(commentTypeID) + } + if _, err := ctx.Client.Incidents.Comment(cmdContext(ctx.Cmd), req); err != nil { return err } @@ -963,6 +1016,7 @@ success.`, } cmd.Flags().StringVar(&commentFile, "comment-file", "", "Path to a file containing the comment text (- reads stdin)") + cmd.Flags().StringVar(&commentTypeID, "comment-type-id", "", "ID of an account-level comment type to attach to the comment") cmd.Flags().BoolVar(&muteReply, "mute-reply", false, "Do not trigger webhook reply behavior for this comment") _ = cmd.MarkFlagRequired("comment-file") @@ -1492,6 +1546,8 @@ func printWarRoomDetail(w io.Writer, warRoom *flashduty.WarRoom) { func newIncidentFeedCmd() *cobra.Command { var limit, page int + var asc bool + var types string cmd := &cobra.Command{ Use: "feed ", @@ -1503,6 +1559,10 @@ func newIncidentFeedCmd() *cobra.Command { feedReq := &flashduty.ListIncidentFeedRequest{IncidentID: ctx.Args[0]} feedReq.Page = page feedReq.Limit = limit + feedReq.Asc = asc + for _, t := range parseStringSlice(types) { + feedReq.Types = append(feedReq.Types, flashduty.IncidentFeedType(t)) + } result, _, err := ctx.Client.Incidents.Feed(cmdContext(ctx.Cmd), feedReq) if err != nil { return err @@ -1548,6 +1608,8 @@ func newIncidentFeedCmd() *cobra.Command { cmd.Flags().IntVar(&limit, "limit", 20, "Max events (max 100)") cmd.Flags().IntVar(&page, "page", 1, "Page number") + cmd.Flags().BoolVar(&asc, "asc", false, "Oldest entries first") + cmd.Flags().StringVar(&types, "types", "", "Comma-separated entry types to keep (e.g. i_comm,i_assign)") return cmd } diff --git a/internal/cli/incident_fields_test.go b/internal/cli/incident_fields_test.go new file mode 100644 index 0000000..6136bd6 --- /dev/null +++ b/internal/cli/incident_fields_test.go @@ -0,0 +1,131 @@ +package cli + +import ( + "reflect" + "testing" +) + +func TestCommandIncidentListFilterFlagsReachWire(t *testing.T) { + tests := []struct { + name string + extra []string + field string + want any + }{ + {"incident-ids", []string{"--incident-ids", "a1,b2"}, "incident_ids", []any{"a1", "b2"}}, + {"acker-ids", []string{"--acker-ids", "1,2"}, "acker_ids", []any{float64(1), float64(2)}}, + {"closer-ids", []string{"--closer-ids", "0"}, "closer_ids", []any{float64(0)}}, + {"creator-ids", []string{"--creator-ids", "3"}, "creator_ids", []any{float64(3)}}, + {"responder-ids", []string{"--responder-ids", "4,5"}, "responder_ids", []any{float64(4), float64(5)}}, + {"team-ids", []string{"--team-ids", "6"}, "team_ids", []any{float64(6)}}, + {"asc", []string{"--asc"}, "asc", true}, + {"ever-muted", []string{"--ever-muted"}, "ever_muted", true}, + {"is-my-channel", []string{"--is-my-channel"}, "is_my_channel", true}, + {"is-my-team", []string{"--is-my-team"}, "is_my_team", true}, + {"is-rare", []string{"--is-rare"}, "is_rare", true}, + {"is-snoozed", []string{"--is-snoozed"}, "is_snoozed", true}, + {"default omits", nil, "is_snoozed", nil}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + args := append([]string{"incident", "list"}, tc.extra...) + if _, err := execCommand(args...); err != nil { + t.Fatalf("[incident-list-filters] unexpected error: %v", err) + } + if stub.lastPath != "/incident/list" { + t.Fatalf("[incident-list-filters] expected /incident/list, got %q", stub.lastPath) + } + if got := stub.lastBody[tc.field]; !reflect.DeepEqual(got, tc.want) { + t.Fatalf("[incident-list-filters] %s: want %#v, got %#v", tc.field, tc.want, got) + } + }) + } +} + +func TestCommandIncidentListRejectsBadMemberID(t *testing.T) { + saveAndResetGlobals(t) + newGFStub(t) + + _, err := execCommand("incident", "list", "--acker-ids", "x") + if err == nil { + t.Fatal("[incident-list-bad-id] expected an error, got nil") + } +} + +func TestCommandIncidentFeedFlagsReachWire(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + if _, err := execCommand("incident", "feed", "inc-1", "--asc", "--types", "i_comm,i_assign"); err != nil { + t.Fatalf("[incident-feed-flags] unexpected error: %v", err) + } + if stub.lastPath != "/incident/feed" { + t.Fatalf("[incident-feed-flags] expected /incident/feed, got %q", stub.lastPath) + } + if stub.lastBody["asc"] != true { + t.Fatalf("[incident-feed-flags] asc: got %#v", stub.lastBody["asc"]) + } + if want := []any{"i_comm", "i_assign"}; !reflect.DeepEqual(stub.lastBody["types"], want) { + t.Fatalf("[incident-feed-flags] types: want %#v, got %#v", want, stub.lastBody["types"]) + } +} + +func TestCommandIncidentMergeTitleAndCommentFile(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + commentFile := writeCommentFile(t, "merged: same root cause `db`") + if _, err := execCommand("incident", "merge", "target-1", "--source", "inc-1", + "--title", "DB outage", "--comment-file", commentFile); err != nil { + t.Fatalf("[incident-merge-title-comment] unexpected error: %v", err) + } + if stub.lastBody["title"] != "DB outage" { + t.Fatalf("[incident-merge-title-comment] title: got %#v", stub.lastBody["title"]) + } + if stub.lastBody["comment"] != "merged: same root cause `db`" { + t.Fatalf("[incident-merge-title-comment] comment: got %#v", stub.lastBody["comment"]) + } +} + +func TestCommandIncidentMergeOmitsTitleAndCommentByDefault(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + if _, err := execCommand("incident", "merge", "target-1", "--source", "inc-1"); err != nil { + t.Fatalf("[incident-merge-default] unexpected error: %v", err) + } + for _, k := range []string{"title", "comment"} { + if _, ok := stub.lastBody[k]; ok { + t.Fatalf("[incident-merge-default] %s should be omitted, got %#v", k, stub.lastBody[k]) + } + } +} + +func TestCommandIncidentCommentTypeID(t *testing.T) { + tests := []struct { + name string + extra []string + want any + }{ + {"default omits", nil, nil}, + {"flag sets", []string{"--comment-type-id", "65f0c0ffee0000000000abcd"}, "65f0c0ffee0000000000abcd"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newIncidentCommentEchoStub(t) + commentFile := writeCommentFile(t, "note") + + args := append([]string{"incident", "comment", "inc-1", "--comment-file", commentFile}, tc.extra...) + if _, err := execCommand(args...); err != nil { + t.Fatalf("[incident-comment-type] unexpected error: %v", err) + } + if got := stub.bodies[0]["comment_type_id"]; got != tc.want { + t.Fatalf("[incident-comment-type] comment_type_id: want %#v, got %#v", tc.want, got) + } + }) + } +} diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 1d91c3a..6e3f8cb 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -166,6 +166,7 @@ Close incidents ### comment [ ...] Add a comment to incident timelines - `--comment-file` string +- `--comment-type-id` string - `--mute-reply` bool ### comment-type-create @@ -217,8 +218,10 @@ Disable incident merge ### feed View incident feed (paginated timeline) +- `--asc` bool - `--limit` int - `--page` int +- `--types` string - response: TOP-LEVEL array — pipe `--json | jq '.[]'` (NOT `.items[]`) — fields: account_id (integer); created_at (string); creator_id (integer); deleted_at (string); detail (object); ref_id (string); type (string); updated_at (string) ### field-reset @@ -239,15 +242,27 @@ Get incident detail ### list List incidents +- `--acker-ids` string +- `--asc` bool - `--channel` string +- `--closer-ids` string +- `--creator-ids` string +- `--ever-muted` bool - `--fields` string +- `--incident-ids` string +- `--is-my-channel` bool +- `--is-my-team` bool +- `--is-rare` bool +- `--is-snoozed` bool - `--limit` int - `--nums` string - `--page` int - `--progress` string - `--query` string +- `--responder-ids` string - `--severity` string - `--since` string +- `--team-ids` string - `--until` string - response: same shape as `get [ ...]` above @@ -258,8 +273,10 @@ List incidents by IDs ### merge Merge incidents into a target incident +- `--comment-file` string - `--remove-source-incidents` bool - `--source` string +- `--title` string ### past-list List past incidents From 95116db43af7ab7aa729ccbf0f56d8abc6ce5b54 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 20:23:14 -0700 Subject: [PATCH 6/9] fix(channel): filter list --name server-side channel list --name matched names client-side on the returned page only, so with paging it missed channels on other pages, and it duplicated the server-side query filter. Send the escaped text as the server's query regex instead (same case-insensitive substring semantics, now also over the description), drop the separate --query flag, and print the server's total. Also list the valid --orderby values, and state precisely why the audit and alert-event cursor/order fields are not exposed. --- internal/cli/channel.go | 25 +++++++++--------------- internal/cli/curated_fields_test.go | 7 ++++--- internal/cli/curated_misc_fields_test.go | 4 ++-- internal/cli/field.go | 2 +- skills/flashduty/reference/channel.md | 1 - 5 files changed, 16 insertions(+), 23 deletions(-) diff --git a/internal/cli/channel.go b/internal/cli/channel.go index 5c0050e..7223b8e 100644 --- a/internal/cli/channel.go +++ b/internal/cli/channel.go @@ -2,8 +2,8 @@ package cli import ( "fmt" + "regexp" "strconv" - "strings" "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" @@ -37,7 +37,7 @@ type channelRow struct { } func newChannelListCmd() *cobra.Command { - var name, channelName, query, orderby string + var name, channelName, orderby string var teamIDs, channelIDs []int64 var page, limit int var asc, isBrief, isMyManaged, isMyStarred, isMyTeam bool @@ -48,17 +48,14 @@ func newChannelListCmd() *cobra.Command { Long: curatedLong("List channels in the account, optionally filtered by name or owning team.", "Channels", "ChannelList"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { - // Legacy parity: the hand-written SDK called /channel/list with an - // empty body and applied the --name filter client-side as a - // case-insensitive substring match. go-flashduty's ChannelName field - // is an exact-match server filter, so we keep the client-side filter - // to preserve behavior. --team-ids, by contrast, is a server-side - // filter on the channel's owning team (empty = all teams, unchanged). + // --name keeps its case-insensitive substring semantics by sending + // the escaped text as the server's query regex, so the match runs + // across every page instead of only the page returned. req := &flashduty.ListChannelsRequest{ TeamIDs: teamIDs, ChannelIDs: channelIDs, ChannelName: channelName, - Query: query, + Query: regexp.QuoteMeta(name), Orderby: orderby, Asc: asc, IsBrief: isBrief, @@ -79,9 +76,6 @@ func newChannelListCmd() *cobra.Command { rows := make([]channelRow, 0, len(result.Items)) for _, ch := range result.Items { - if name != "" && !strings.Contains(strings.ToLower(ch.ChannelName), strings.ToLower(name)) { - continue - } rows = append(rows, channelRow{ ChannelID: ch.ChannelID, ChannelName: ch.ChannelName, @@ -102,20 +96,19 @@ func newChannelListCmd() *cobra.Command { {Header: "CREATOR", Field: func(v any) string { return v.(channelRow).CreatorName }}, } - return ctx.PrintTotal(rows, cols, len(rows)) + return ctx.PrintTotal(rows, cols, int(result.Total)) }) }, } - cmd.Flags().StringVar(&name, "name", "", "Search by name") + cmd.Flags().StringVar(&name, "name", "", "Case-insensitive substring of the channel name or description, server-side") cmd.Flags().StringVar(&channelName, "channel-name", "", "Exact channel name, server-side") - cmd.Flags().StringVar(&query, "query", "", "Case-insensitive regex over channel name and description, server-side") cmd.Flags().Int64SliceVar(&channelIDs, "channel-ids", nil, "Filter by channel ID(s) (repeatable or comma-separated)") cmd.Flags().BoolVar(&isMyTeam, "is-my-team", false, "Only channels owned by your teams (exclusive with --is-my-starred)") cmd.Flags().BoolVar(&isMyStarred, "is-my-starred", false, "Only channels you starred (exclusive with --is-my-team)") cmd.Flags().BoolVar(&isMyManaged, "is-my-managed", false, "Only channels you manage") cmd.Flags().BoolVar(&isBrief, "is-brief", false, "Return only id, name, description and status, all matches without pagination") - cmd.Flags().StringVar(&orderby, "orderby", "", "Sort field (server default: created_at)") + cmd.Flags().StringVar(&orderby, "orderby", "", "Sort field: ranking, created_at, updated_at, channel_name, or last_incident_at (server default: created_at)") cmd.Flags().BoolVar(&asc, "asc", false, "Sort in ascending order") cmd.Flags().IntVar(&page, "page", 1, "Page number") cmd.Flags().IntVar(&limit, "limit", 100, "Page size (server default 100)") diff --git a/internal/cli/curated_fields_test.go b/internal/cli/curated_fields_test.go index 66692a5..d9f8228 100644 --- a/internal/cli/curated_fields_test.go +++ b/internal/cli/curated_fields_test.go @@ -48,7 +48,7 @@ var curatedFieldRoutes = map[string]map[string]string{ "start_time": "--since", "end_time": "--until", "p": "--page", - "orderby": "omit: event_time is the only supported value and the server default", + "orderby": "omit: event_time is the server default and the only order the server's paging cursor supports", "search_after_ctx": "omit: cursor returned by the previous page; the curated list pages with --page and does not print the next-page cursor", }, "incident merge": { @@ -62,7 +62,7 @@ var curatedFieldRoutes = map[string]map[string]string{ "end_time": "--until", "person_id": "--person", "operations": "--operation", - "search_after_ctx": "omit: the cursor is managed by the command; --page walks it", + "search_after_ctx": "omit: --page walks the cursor by re-sending each response's search_after_ctx", }, "change list": { "start_time": "--since", @@ -75,7 +75,8 @@ var curatedFieldRoutes = map[string]map[string]string{ "data_source_ids": "omit: deprecated alias of integration_ids, which --integration sets", }, "channel list": { - "p": "--page", + "p": "--page", + "query": "--name", }, "team list": { "p": "--page", diff --git a/internal/cli/curated_misc_fields_test.go b/internal/cli/curated_misc_fields_test.go index 1fbdd0e..7cd23d3 100644 --- a/internal/cli/curated_misc_fields_test.go +++ b/internal/cli/curated_misc_fields_test.go @@ -59,10 +59,10 @@ func TestCuratedListFilterFlagsReachWireBody(t *testing.T) { }, { name: "channel filters and paging", - args: []string{"channel", "list", "--query", "^pay", "--channel-name", "payments", "--channel-ids", "1,2", "--is-my-team", "--is-my-managed", "--is-brief", "--orderby", "channel_name", "--asc", "--page", "2", "--limit", "10"}, + args: []string{"channel", "list", "--name", "pay.ments", "--channel-name", "payments", "--channel-ids", "1,2", "--is-my-team", "--is-my-managed", "--is-brief", "--orderby", "channel_name", "--asc", "--page", "2", "--limit", "10"}, path: "/channel/list", want: map[string]any{ - "query": "^pay", "channel_name": "payments", "channel_ids": []any{float64(1), float64(2)}, + "query": `pay\.ments`, "channel_name": "payments", "channel_ids": []any{float64(1), float64(2)}, "is_my_team": true, "is_my_managed": true, "is_brief": true, "orderby": "channel_name", "asc": true, "p": float64(2), "limit": float64(10), }, diff --git a/internal/cli/field.go b/internal/cli/field.go index 2f980fe..9f2e408 100644 --- a/internal/cli/field.go +++ b/internal/cli/field.go @@ -66,7 +66,7 @@ func newFieldListCmd() *cobra.Command { } cmd.Flags().StringVar(&name, "name", "", "Filter by field name") - cmd.Flags().StringVar(&query, "query", "", "Regex over field_name, server-side") + cmd.Flags().StringVar(&query, "query", "", "Case-insensitive regex over the field name, server-side") cmd.Flags().Int64Var(&creatorID, "creator-id", 0, "Filter by creator member ID") cmd.Flags().StringVar(&orderby, "orderby", "", "Sort key (server default: created_at)") cmd.Flags().BoolVar(&asc, "asc", false, "Sort in ascending order") diff --git a/skills/flashduty/reference/channel.md b/skills/flashduty/reference/channel.md index e64eca9..521a937 100644 --- a/skills/flashduty/reference/channel.md +++ b/skills/flashduty/reference/channel.md @@ -89,7 +89,6 @@ List channels - `--name` string - `--orderby` string - `--page` int -- `--query` string - `--team-ids` int64Slice - response: TOP-LEVEL array — pipe `--json | jq '.[]'` (NOT `.items[]`) — fields: account_id (integer); active_incident_highest_severity (string); auto_resolve_mode (string); auto_resolve_timeout (integer); channel_id (integer); channel_name (string); created_at (string); creator_id (integer); creator_name (string); deleted_at (string); description (string); disable_auto_close (boolean); disable_outlier_detection (boolean); event_group (object); external_report_token (string); flapping (object); group (object); is_external_report_enabled (boolean); is_private (boolean); is_starred (boolean); last_incident_at (string); managing_team_ids (array); progress_to_incident_cnts (object); status (string); team_id (integer); team_name (string); updated_at (string) From cd04f6378484cd19be3e09ebd344c2f33805f4d9 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 21:04:24 -0700 Subject: [PATCH 7/9] chore(deps): bump go-flashduty to v0.15.12 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a8b3db0..2236542 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.26.0 require ( - github.com/flashcatcloud/go-flashduty v0.15.10 + github.com/flashcatcloud/go-flashduty v0.15.12 github.com/mattn/go-runewidth v0.0.30 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 864bd3c..e4fc4de 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/flashcatcloud/go-flashduty v0.15.10 h1:OfCx6EpF5tv+qSXGPZcEwEjXbo0KvQBOaw3ggWj+6Hs= -github.com/flashcatcloud/go-flashduty v0.15.10/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= +github.com/flashcatcloud/go-flashduty v0.15.12 h1:+x+wSCcVKpxlBfGBMU5vftwk6ducoumYKmlmyDhEO8c= +github.com/flashcatcloud/go-flashduty v0.15.12/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= 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.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= From 858208ffe78fb989aa1a55c4b1931a6876a118d6 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 21:08:06 -0700 Subject: [PATCH 8/9] feat(incident): typed custom fields and full assignment on create - incident create: --field key=value (now sendable with go-flashduty v0.15.12), --assign-emails, --escalate-rule-id, --escalate-layer. - --field values are converted by the field's type from /field/list: checkbox -> bool, multi_select -> string array (comma-separated or a JSON array), single_select/text -> string. Unknown names are rejected. - incident update --field sent field_value as {"value": v}; the server takes the raw value, so every update was rejected. Send the typed value directly. - Test helper: reset every slice-valued flag between runs, not only string slices, so int slices no longer leak across tests. --- internal/cli/command_test.go | 20 ++-- internal/cli/curated_fields_test.go | 2 +- internal/cli/incident.go | 141 ++++++++++++++++++++----- internal/cli/incident_fields_test.go | 100 ++++++++++++++++++ skills/flashduty/reference/incident.md | 4 + 5 files changed, 227 insertions(+), 40 deletions(-) diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 80371b7..e366006 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -125,21 +125,15 @@ func resetFlagSet(flags *pflag.FlagSet) { return } flags.VisitAll(func(flag *pflag.Flag) { - switch flag.Value.Type() { - case "bool", "int", "int64", "string": + // Slice-valued flags (string, int, int64, ...) accumulate across + // Parse() calls, and Set("") would append an empty entry, so empty + // them with Replace. Every other flag goes back to its default. + if sv, ok := flag.Value.(pflag.SliceValue); ok { + _ = sv.Replace([]string{}) + } else { _ = flag.Value.Set(flag.DefValue) - flag.Changed = false - case "stringSlice", "stringArray": - // Slice-valued flags accumulate across Parse() calls; clear them - // explicitly so a later test isn't observing the previous test's - // repeated --flag entries. pflag's SliceValue / Append interfaces - // don't expose a "reset to default" — Set("") would append an - // empty entry, so we use Replace([]) to truly empty the slice. - if sv, ok := flag.Value.(pflag.SliceValue); ok { - _ = sv.Replace([]string{}) - flag.Changed = false - } } + flag.Changed = false }) } diff --git a/internal/cli/curated_fields_test.go b/internal/cli/curated_fields_test.go index d9f8228..4e1950f 100644 --- a/internal/cli/curated_fields_test.go +++ b/internal/cli/curated_fields_test.go @@ -102,7 +102,7 @@ var curatedFieldRoutes = map[string]map[string]string{ "channel_id": "--channel", "incident_severity": "--severity", "assigned_to": "--assign", - "fields": "omit: the SDK models custom field values as an empty struct, so the typed request cannot carry them; set them after creation with incident update --field", + "fields": "--field", }, "incident feed": { "incident_id": "args", diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 1067e24..2653135 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -2,6 +2,7 @@ package cli import ( "bufio" + "encoding/json" "errors" "fmt" "io" @@ -357,9 +358,10 @@ func orDash(s string) string { } func newIncidentCreateCmd() *cobra.Command { - var title, severity, description string - var channelID int64 + var title, severity, description, escalateRuleID string + var channelID, escalateLayer int64 var assign []int + var assignEmails, fieldFlags []string cmd := &cobra.Command{ Use: "create", @@ -388,15 +390,29 @@ func newIncidentCreateCmd() *cobra.Command { if severity == "" { return fmt.Errorf("--severity is required (Critical, Warning, Info)") } + customFields, err := parseCustomFieldFlags(fieldFlags) + if err != nil { + return err + } return runCommand(cmd, args, func(ctx *RunContext) error { + customFields, err := typedCustomFields(ctx, customFields) + if err != nil { + return err + } req := &flashduty.CreateIncidentRequest{ Title: title, IncidentSeverity: severity, ChannelID: channelID, Description: description, } - if len(assign) > 0 { + if len(customFields) > 0 { + req.Fields = flashduty.CustomFieldValues{} + for _, f := range customFields { + req.Fields[f.name] = f.value + } + } + if len(assign) > 0 || len(assignEmails) > 0 || escalateRuleID != "" { personIDs := make([]int64, len(assign)) for i, id := range assign { personIDs[i] = int64(id) @@ -405,7 +421,13 @@ func newIncidentCreateCmd() *cobra.Command { // = "assign". On a brand-new incident the backend would default an // empty type to "assign" anyway, but we set it explicitly so the // migration is a pure no-drift refactor. - req.AssignedTo = flashduty.CreateIncidentRequestAssignedTo{PersonIDs: personIDs, Type: "assign"} + req.AssignedTo = flashduty.CreateIncidentRequestAssignedTo{ + PersonIDs: personIDs, + Emails: assignEmails, + EscalateRuleID: escalateRuleID, + LayerIdx: escalateLayer, + Type: "assign", + } } result, _, err := ctx.Client.Incidents.Create(cmdContext(ctx.Cmd), req) @@ -429,10 +451,90 @@ func newIncidentCreateCmd() *cobra.Command { registerEnumFlag(cmd, "severity", severityEnum...) cmd.Flags().StringVar(&description, "description", "", "Description (max 6144 chars)") cmd.Flags().IntSliceVar(&assign, "assign", nil, "Member IDs to assign directly (use 'flashduty member list' to look up member IDs)") + cmd.Flags().StringSliceVar(&assignEmails, "assign-emails", nil, "Member emails to assign (comma-separated); emails with no matching member are ignored") + cmd.Flags().StringVar(&escalateRuleID, "escalate-rule-id", "", "Escalation rule ID; assigns the people at --escalate-layer of that rule") + cmd.Flags().Int64Var(&escalateLayer, "escalate-layer", 0, "Zero-based escalation rule layer to start from (with --escalate-rule-id)") + cmd.Flags().StringArrayVar(&fieldFlags, "field", nil, customFieldFlagHelp) return cmd } +const customFieldFlagHelp = "Custom field: key=value (repeatable). Checkbox takes true/false; multi-select takes comma-separated options or a JSON array" + +type customFieldFlag struct { + name string + value any +} + +// parseCustomFieldFlags splits and validates --field key=value flags. Values +// stay raw strings until typedCustomFields converts them by field type. +func parseCustomFieldFlags(flags []string) ([]customFieldFlag, error) { + fields := make([]customFieldFlag, 0, len(flags)) + for _, f := range flags { + name, raw, ok := strings.Cut(f, "=") + if !ok { + return nil, fmt.Errorf("invalid --field format %q, expected key=value", f) + } + if name == "" { + return nil, fmt.Errorf("custom field name must not be empty") + } + for _, ch := range name { + isValid := (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' + if !isValid { + return nil, fmt.Errorf("custom field name '%s' contains invalid characters (only alphanumeric and underscore allowed)", name) + } + } + fields = append(fields, customFieldFlag{name: name, value: raw}) + } + return fields, nil +} + +// typedCustomFields converts each raw --field value to the type its field +// definition requires: checkbox fields take a bool, multi-select fields a +// string array, and single-select and text fields a string. +func typedCustomFields(ctx *RunContext, fields []customFieldFlag) ([]customFieldFlag, error) { + if len(fields) == 0 { + return fields, nil + } + defs, _, err := ctx.Client.AlertEnrichment.FieldReadList(cmdContext(ctx.Cmd), &flashduty.FieldListRequest{}) + if err != nil { + return nil, fmt.Errorf("unable to look up custom field types: %w", err) + } + fieldTypes := make(map[string]string, len(defs.Items)) + for _, d := range defs.Items { + fieldTypes[d.FieldName] = d.FieldType + } + typed := make([]customFieldFlag, 0, len(fields)) + for _, f := range fields { + raw := f.value.(string) + fieldType, ok := fieldTypes[f.name] + if !ok { + return nil, fmt.Errorf("unknown custom field %q (see 'flashduty field list')", f.name) + } + var value any = raw + switch fieldType { + case "checkbox": + b, err := strconv.ParseBool(raw) + if err != nil { + return nil, fmt.Errorf("custom field %q is a checkbox: value must be true or false, got %q", f.name, raw) + } + value = b + case "multi_select": + var options []string + if strings.HasPrefix(strings.TrimSpace(raw), "[") { + if err := json.Unmarshal([]byte(raw), &options); err != nil { + return nil, fmt.Errorf("custom field %q is multi-select: invalid JSON array %q: %w", f.name, raw, err) + } + } else { + options = parseStringSlice(raw) + } + value = options + } + typed = append(typed, customFieldFlag{name: f.name, value: value}) + } + return typed, nil +} + func newIncidentUpdateCmd() *cobra.Command { var title, description, severity string var fieldFlags []string @@ -442,21 +544,17 @@ func newIncidentUpdateCmd() *cobra.Command { Short: "Update an incident", Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { - type customField struct { - name string - value string - } - customFields := make([]customField, 0, len(fieldFlags)) - for _, f := range fieldFlags { - parts := strings.SplitN(f, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid --field format %q, expected key=value", f) - } - customFields = append(customFields, customField{name: parts[0], value: parts[1]}) + customFields, err := parseCustomFieldFlags(fieldFlags) + if err != nil { + return err } return runCommand(cmd, args, func(ctx *RunContext) error { incidentID := ctx.Args[0] + customFields, err := typedCustomFields(ctx, customFields) + if err != nil { + return err + } updated := make([]string, 0) // Standard fields go through /incident/reset. Mirror the legacy @@ -485,19 +583,10 @@ func newIncidentUpdateCmd() *cobra.Command { // Custom fields go through /incident/field/reset, one call per // field, preserving the legacy per-field semantics. for _, f := range customFields { - if f.name == "" { - return fmt.Errorf("custom field name must not be empty") - } - for _, ch := range f.name { - isValid := (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' - if !isValid { - return fmt.Errorf("custom field name '%s' contains invalid characters (only alphanumeric and underscore allowed)", f.name) - } - } if _, err := ctx.Client.Incidents.FieldReset(cmdContext(ctx.Cmd), &flashduty.ResetIncidentFieldRequest{ IncidentID: incidentID, FieldName: f.name, - FieldValue: map[string]any{"value": f.value}, + FieldValue: f.value, }); err != nil { return fmt.Errorf("unable to update custom field '%s': %w", f.name, err) } @@ -517,7 +606,7 @@ func newIncidentUpdateCmd() *cobra.Command { cmd.Flags().StringVar(&title, "title", "", "New title") cmd.Flags().StringVar(&description, "description", "", "New description") cmd.Flags().StringVar(&severity, "severity", "", "New severity: Critical, Warning, Info") - cmd.Flags().StringArrayVar(&fieldFlags, "field", nil, "Custom field: key=value (repeatable)") + cmd.Flags().StringArrayVar(&fieldFlags, "field", nil, customFieldFlagHelp) registerEnumFlag(cmd, "severity", severityEnum...) return cmd diff --git a/internal/cli/incident_fields_test.go b/internal/cli/incident_fields_test.go index 6136bd6..900fea1 100644 --- a/internal/cli/incident_fields_test.go +++ b/internal/cli/incident_fields_test.go @@ -2,6 +2,7 @@ package cli import ( "reflect" + "strings" "testing" ) @@ -129,3 +130,102 @@ func TestCommandIncidentCommentTypeID(t *testing.T) { }) } } + +func stubCustomFieldDefs(stub *gfStub) { + stub.dataForPath = func(path string, _ map[string]any) any { + if path == "/field/list" { + return map[string]any{"items": []any{ + map[string]any{"field_name": "region", "field_type": "text"}, + map[string]any{"field_name": "urgent", "field_type": "checkbox"}, + map[string]any{"field_name": "tags", "field_type": "multi_select"}, + map[string]any{"field_name": "tier", "field_type": "single_select"}, + }} + } + return map[string]any{} + } +} + +func TestCommandIncidentCreateCustomFieldsAndAssignment(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stubCustomFieldDefs(stub) + + _, err := execCommand("incident", "create", "--title", "db down", "--severity", "Critical", + "--field", "region=123", "--field", "urgent=true", "--field", "tags=db,core", "--field", `tier=["x"]`, + "--assign-emails", "a@example.com,b@example.com", "--escalate-rule-id", "0123456789abcdef01234567", "--escalate-layer", "1") + if err != nil { + t.Fatalf("[incident-create-fields] unexpected error: %v", err) + } + if stub.lastPath != "/incident/create" { + t.Fatalf("[incident-create-fields] expected /incident/create, got %q", stub.lastPath) + } + wantFields := map[string]any{"region": "123", "urgent": true, "tags": []any{"db", "core"}, "tier": `["x"]`} + if got := stub.lastBody["fields"]; !reflect.DeepEqual(got, wantFields) { + t.Fatalf("[incident-create-fields] fields: want %#v, got %#v", wantFields, got) + } + wantAssigned := map[string]any{ + "emails": []any{"a@example.com", "b@example.com"}, + "escalate_rule_id": "0123456789abcdef01234567", + "layer_idx": float64(1), + "type": "assign", + } + if got := stub.lastBody["assigned_to"]; !reflect.DeepEqual(got, wantAssigned) { + t.Fatalf("[incident-create-fields] assigned_to: want %#v, got %#v", wantAssigned, got) + } +} + +func TestCommandIncidentCreateWithoutNewFlagsKeepsBody(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + if _, err := execCommand("incident", "create", "--title", "db down", "--severity", "Critical"); err != nil { + t.Fatalf("[incident-create-default] unexpected error: %v", err) + } + if stub.requests != 1 { + t.Fatalf("[incident-create-default] expected 1 request (no field lookup), got %d", stub.requests) + } + for _, key := range []string{"fields", "assigned_to"} { + if _, ok := stub.lastBody[key]; ok { + t.Fatalf("[incident-create-default] %s must be omitted, got %#v", key, stub.lastBody[key]) + } + } +} + +func TestCommandIncidentUpdateCustomFieldIsTyped(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stubCustomFieldDefs(stub) + + if _, err := execCommand("incident", "update", "inc-1", "--field", "urgent=false"); err != nil { + t.Fatalf("[incident-update-field-typed] unexpected error: %v", err) + } + if stub.lastPath != "/incident/field/reset" { + t.Fatalf("[incident-update-field-typed] expected /incident/field/reset, got %q", stub.lastPath) + } + if got, ok := stub.lastBody["field_value"]; !ok || got != false { + t.Fatalf("[incident-update-field-typed] field_value: want false, got %#v (present=%t)", got, ok) + } +} + +func TestCommandIncidentCustomFieldErrors(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {"unknown field", []string{"incident", "create", "--title", "db down", "--severity", "Info", "--field", "nope=1"}, "unknown custom field"}, + {"bad checkbox", []string{"incident", "update", "inc-1", "--field", "urgent=maybe"}, "true or false"}, + {"bad multi-select json", []string{"incident", "update", "inc-1", "--field", "tags=[oops"}, "invalid JSON array"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stubCustomFieldDefs(stub) + _, err := execCommand(tc.args...) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("[incident-custom-field-errors] want error containing %q, got %v", tc.want, err) + } + }) + } +} diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 6e3f8cb..c91ef0f 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -196,8 +196,12 @@ Update a comment type ### create Create a new incident - `--assign` intSlice +- `--assign-emails` stringSlice - `--channel` int64 - `--description` string +- `--escalate-layer` int64 +- `--escalate-rule-id` string +- `--field` stringArray - `--severity` string - `--title` string From 8c3f9c836ee7c83b52809734e2e56b47e615ac8e Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 21:11:43 -0700 Subject: [PATCH 9/9] fix(incident): reject --escalate-layer without --escalate-rule-id The layer index only applies to an escalation rule; on its own the flag was silently ignored and the incident was created unassigned. --- internal/cli/incident.go | 3 +++ internal/cli/incident_fields_test.go | 1 + 2 files changed, 4 insertions(+) diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 2653135..1804d0d 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -390,6 +390,9 @@ func newIncidentCreateCmd() *cobra.Command { if severity == "" { return fmt.Errorf("--severity is required (Critical, Warning, Info)") } + if cmd.Flags().Changed("escalate-layer") && escalateRuleID == "" { + return fmt.Errorf("--escalate-layer requires --escalate-rule-id") + } customFields, err := parseCustomFieldFlags(fieldFlags) if err != nil { return err diff --git a/internal/cli/incident_fields_test.go b/internal/cli/incident_fields_test.go index 900fea1..ddce7be 100644 --- a/internal/cli/incident_fields_test.go +++ b/internal/cli/incident_fields_test.go @@ -213,6 +213,7 @@ func TestCommandIncidentCustomFieldErrors(t *testing.T) { args []string want string }{ + {"layer without rule", []string{"incident", "create", "--title", "db down", "--severity", "Info", "--escalate-layer", "1"}, "--escalate-layer requires --escalate-rule-id"}, {"unknown field", []string{"incident", "create", "--title", "db down", "--severity", "Info", "--field", "nope=1"}, "unknown custom field"}, {"bad checkbox", []string{"incident", "update", "inc-1", "--field", "urgent=maybe"}, "true or false"}, {"bad multi-select json", []string{"incident", "update", "inc-1", "--field", "tags=[oops"}, "invalid JSON array"},