Skip to content

Commit b21322e

Browse files
committed
feat(cligen): list-envelope-aware response help + help-quality fixes
Make `fduty <cmd> --help` reliable enough that an SRE agent can call the API from help alone, without guessing (the eval goal). - responseSectionList: curated list commands flatten the {items} envelope into a top-level array, so their --help now says "this command's --json is a TOP-LEVEL array — pipe jq '.[]', NOT .items[]"; generated list commands keep the items[] envelope wording. Removes the all-null jq guess on list output. - listEnvelope detection: a list field is the rows array iff every other sibling is a scalar (handles {items,limit,p,total} member envelopes); type match uses strings.HasPrefix(f.Type,"array"). - Help-quality fixes from the audit: Response-fields blocks, typed flags, constraint/enum annotations across the generated surface. - zz_generated_response_help.go: spec-derived response-help map read by curated commands for the same help parity. Builds against go-flashduty v0.5.2. The toon snake_case output fix lives in go-flashduty (toon struct tags) and is wired in by a dependency bump once that change is published.
1 parent c369109 commit b21322e

44 files changed

Lines changed: 6046 additions & 581 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

internal/cli/alert.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func newAlertListCmd() *cobra.Command {
3434
cmd := &cobra.Command{
3535
Use: "list",
3636
Short: "List alerts",
37+
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\"))'. --limit max 100; --since/--until window must be < 31 days.", "Alerts", "ReadList"),
3738
RunE: func(cmd *cobra.Command, args []string) error {
3839
return runCommand(cmd, args, func(ctx *RunContext) error {
3940
if active && recovered {
@@ -114,6 +115,7 @@ func newAlertGetCmd() *cobra.Command {
114115
return &cobra.Command{
115116
Use: "get <alert_id>",
116117
Short: "Get alert detail",
118+
Long: curatedLong("Get the full detail of a single alert by ID.", "Alerts", "ReadInfo"),
117119
Args: requireArgs("alert_id"),
118120
RunE: func(cmd *cobra.Command, args []string) error {
119121
return runCommand(cmd, args, func(ctx *RunContext) error {
@@ -176,6 +178,7 @@ func newAlertEventsCmd() *cobra.Command {
176178
return &cobra.Command{
177179
Use: "events <alert_id>",
178180
Short: "List alert events",
181+
Long: curatedLong("List the individual events that compose a given alert.", "Alerts", "ReadEventList"),
179182
Args: requireArgs("alert_id"),
180183
RunE: func(cmd *cobra.Command, args []string) error {
181184
return runCommand(cmd, args, func(ctx *RunContext) error {
@@ -211,6 +214,7 @@ func newAlertTimelineCmd() *cobra.Command {
211214
cmd := &cobra.Command{
212215
Use: "timeline <alert_id>",
213216
Short: "View alert timeline",
217+
Long: curatedLong("View the chronological feed of timeline events (actions, state changes) for an alert.", "Alerts", "ReadFeed"),
214218
Args: requireArgs("alert_id"),
215219
RunE: func(cmd *cobra.Command, args []string) error {
216220
return runCommand(cmd, args, func(ctx *RunContext) error {

internal/cli/alert_event.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func newAlertEventListCmd() *cobra.Command {
2727
cmd := &cobra.Command{
2828
Use: "list",
2929
Short: "List alert events globally",
30+
Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type.", "Alerts", "EventReadList"),
3031
RunE: func(cmd *cobra.Command, args []string) error {
3132
return runCommand(cmd, args, func(ctx *RunContext) error {
3233
startTime, err := timeutil.Parse(since)

internal/cli/audit.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func newAuditSearchCmd() *cobra.Command {
2727
cmd := &cobra.Command{
2828
Use: "search",
2929
Short: "Search audit logs",
30+
Long: curatedLong("Search audit logs within a time window, optionally filtered by person and operation type.", "AuditLogs", "Search"),
3031
RunE: func(cmd *cobra.Command, args []string) error {
3132
return runCommand(cmd, args, func(ctx *RunContext) error {
3233
startTime, err := timeutil.Parse(since)
@@ -107,7 +108,7 @@ func newAuditSearchCmd() *cobra.Command {
107108
cmd.Flags().StringVar(&until, "until", "now", "End time")
108109
cmd.Flags().Int64Var(&person, "person", 0, "Filter by person ID")
109110
cmd.Flags().StringVar(&operation, "operation", "", "Filter by operation type")
110-
cmd.Flags().IntVar(&limit, "limit", 20, "Max results")
111+
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 99)")
111112
cmd.Flags().IntVar(&page, "page", 1, "Page number")
112113

113114
return cmd

internal/cli/change.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ func newChangeListCmd() *cobra.Command {
2323
var channel string
2424
var since, until string
2525
var limit, page int
26+
var query, integration string
2627

2728
cmd := &cobra.Command{
2829
Use: "list",
2930
Short: "List changes",
31+
Long: curatedLong("List changes recorded in the change feed. Time window must be < 31 days; --limit max is 100.", "Changes", "List"),
3032
RunE: func(cmd *cobra.Command, args []string) error {
3133
return runCommand(cmd, args, func(ctx *RunContext) error {
3234
startTime, err := timeutil.Parse(since)
@@ -65,6 +67,14 @@ func newChangeListCmd() *cobra.Command {
6567
}
6668
input.ChannelIDs = channelIDs
6769
}
70+
if integration != "" {
71+
integrationIDs, err := parseIntSlice(integration)
72+
if err != nil {
73+
return fmt.Errorf("invalid --integration: %w", err)
74+
}
75+
input.IntegrationIDs = integrationIDs
76+
}
77+
input.Query = query
6878

6979
result, _, err := ctx.Client.Changes.List(cmdContext(ctx.Cmd), input)
7080
if err != nil {
@@ -85,9 +95,11 @@ func newChangeListCmd() *cobra.Command {
8595
}
8696

8797
cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs")
88-
cmd.Flags().StringVar(&since, "since", "24h", "Start time")
89-
cmd.Flags().StringVar(&until, "until", "now", "End time")
90-
cmd.Flags().IntVar(&limit, "limit", 20, "Max results")
98+
cmd.Flags().StringVar(&query, "query", "", "Free-text/regex search over change fields")
99+
cmd.Flags().StringVar(&integration, "integration", "", "Comma-separated reporting integration IDs")
100+
cmd.Flags().StringVar(&since, "since", "24h", "Start time (accepts 7d/24h/now, RFC3339, or Unix epoch; window must be < 31 days)")
101+
cmd.Flags().StringVar(&until, "until", "now", "End time (accepts 7d/24h/now, RFC3339, or Unix epoch)")
102+
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)")
91103
cmd.Flags().IntVar(&page, "page", 1, "Page number")
92104

93105
return cmd

internal/cli/channel.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ func newChannelListCmd() *cobra.Command {
4343
cmd := &cobra.Command{
4444
Use: "list",
4545
Short: "List channels",
46+
Long: curatedLong("List channels in the account, optionally filtered by name.", "Channels", "ChannelList"),
4647
RunE: func(cmd *cobra.Command, args []string) error {
4748
return runCommand(cmd, args, func(ctx *RunContext) error {
4849
// Legacy parity: the hand-written SDK called /channel/list with an

internal/cli/field.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func newFieldListCmd() *cobra.Command {
2424
cmd := &cobra.Command{
2525
Use: "list",
2626
Short: "List custom fields",
27+
Long: curatedLong("List custom fields, optionally filtered by exact field name.", "AlertEnrichment", "FieldReadList"),
2728
RunE: func(cmd *cobra.Command, args []string) error {
2829
return runCommand(cmd, args, func(ctx *RunContext) error {
2930
result, _, err := ctx.Client.AlertEnrichment.FieldReadList(cmdContext(ctx.Cmd), &flashduty.FieldListRequest{})

internal/cli/gen_support.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package cli
33
import (
44
"encoding/json"
55
"fmt"
6+
"reflect"
7+
"strings"
68

79
"github.com/spf13/cobra"
810
)
@@ -27,8 +29,33 @@ func genAssembleBody(dataJSON string, setFlags func(body map[string]any)) (map[s
2729
return body, nil
2830
}
2931

32+
// responseHelp returns the rendered "Response fields" block for an SDK
33+
// Service.Method (from the generated responseHelpBySDKMethod table), or "" when
34+
// the response has no documented schema. Curated commands append it to their
35+
// Long so they show the same output shape as the generated commands.
36+
func responseHelp(service, method string) string {
37+
return responseHelpBySDKMethod[service+"."+method]
38+
}
39+
40+
// curatedLong composes a curated command's Long help from an intro paragraph
41+
// plus the shared spec-derived Response-fields block for the SDK method it
42+
// calls, so agents read output field names instead of guessing them with jq.
43+
func curatedLong(intro, service, method string) string {
44+
if rh := responseHelp(service, method); rh != "" {
45+
return intro + "\n\n" + rh
46+
}
47+
return intro
48+
}
49+
3050
// genBindBody marshals the assembled body map into the typed request struct so
3151
// the call benefits from the SDK's wire encoding (nullable pointers, etc.).
52+
//
53+
// POST request structs tag fields with `json`, so json.Unmarshal binds them.
54+
// GET query structs tag fields with `url` and carry NO json tag, so the
55+
// Unmarshal pass silently skips every field, leaving the request empty and the
56+
// command un-driveable. After the json pass, additively bind any url-tagged
57+
// field from the body by its url wire-name. For POST structs the url pass is a
58+
// no-op, so existing behavior is unchanged.
3259
func genBindBody(body map[string]any, req any) error {
3360
b, err := json.Marshal(body)
3461
if err != nil {
@@ -37,9 +64,69 @@ func genBindBody(body map[string]any, req any) error {
3764
if err := json.Unmarshal(b, req); err != nil {
3865
return fmt.Errorf("failed to bind request: %w", err)
3966
}
67+
bindURLTagged(body, reflect.ValueOf(req))
4068
return nil
4169
}
4270

71+
// bindURLTagged fills fields carrying a `url` struct tag (GET query params) from
72+
// the body map keyed by the url wire-name. json.Unmarshal cannot reach these
73+
// because they lack a json tag. The 6 GET request types use only int64/string
74+
// fields; values arrive as Go ints/strings (typed flags) or float64/string
75+
// (--data JSON), all coerced here.
76+
func bindURLTagged(body map[string]any, rv reflect.Value) {
77+
for rv.Kind() == reflect.Ptr {
78+
if rv.IsNil() {
79+
return
80+
}
81+
rv = rv.Elem()
82+
}
83+
if rv.Kind() != reflect.Struct {
84+
return
85+
}
86+
rt := rv.Type()
87+
for i := 0; i < rt.NumField(); i++ {
88+
f := rt.Field(i)
89+
if f.Anonymous {
90+
bindURLTagged(body, rv.Field(i))
91+
continue
92+
}
93+
if f.Tag.Get("json") != "" { // json-tagged: already bound by Unmarshal
94+
continue
95+
}
96+
wire := strings.Split(f.Tag.Get("url"), ",")[0]
97+
if wire == "" || wire == "-" {
98+
continue
99+
}
100+
raw, ok := body[wire]
101+
if !ok {
102+
continue
103+
}
104+
fv := rv.Field(i)
105+
if !fv.CanSet() {
106+
continue
107+
}
108+
switch fv.Kind() {
109+
case reflect.String:
110+
if s, ok := raw.(string); ok {
111+
fv.SetString(s)
112+
}
113+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
114+
switch n := raw.(type) {
115+
case float64:
116+
fv.SetInt(int64(n))
117+
case int64:
118+
fv.SetInt(n)
119+
case int:
120+
fv.SetInt(int64(n))
121+
case json.Number:
122+
if iv, err := n.Int64(); err == nil {
123+
fv.SetInt(iv)
124+
}
125+
}
126+
}
127+
}
128+
}
129+
43130
// printGenericResult renders a generated command's typed response. Generated
44131
// commands have no curated column set, so in machine-readable mode (TOON/JSON)
45132
// it marshals the whole value — which is what the agent reads — and in human

internal/cli/incident.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ func newIncidentListCmd() *cobra.Command {
8080
cmd := &cobra.Command{
8181
Use: "list",
8282
Short: "List incidents",
83+
Long: curatedLong("List incidents matching the given filters.", "Incidents", "List"),
8384
RunE: func(cmd *cobra.Command, args []string) error {
8485
return runCommand(cmd, args, func(ctx *RunContext) error {
8586
startTime, err := timeutil.Parse(since)
@@ -130,6 +131,7 @@ func newIncidentGetCmd() *cobra.Command {
130131
return &cobra.Command{
131132
Use: "get <id> [<id2> ...]",
132133
Short: "Get incident details",
134+
Long: curatedLong("Get details for one or more incidents by ID.", "Incidents", "List"),
133135
Args: requireArgs("incident_id"),
134136
RunE: func(cmd *cobra.Command, args []string) error {
135137
return runCommand(cmd, args, func(ctx *RunContext) error {
@@ -457,6 +459,7 @@ func newIncidentTimelineCmd() *cobra.Command {
457459
return &cobra.Command{
458460
Use: "timeline <id>",
459461
Short: "View incident timeline",
462+
Long: curatedLong("View the timeline (feed entries) for one or more incidents.", "Incidents", "Feed"),
460463
Args: requireArgs("incident_id"),
461464
RunE: func(cmd *cobra.Command, args []string) error {
462465
return runCommand(cmd, args, func(ctx *RunContext) error {
@@ -515,6 +518,7 @@ func newIncidentAlertsCmd() *cobra.Command {
515518
cmd := &cobra.Command{
516519
Use: "alerts <id>",
517520
Short: "View incident alerts",
521+
Long: curatedLong("View the alerts attached to an incident.", "Incidents", "AlertList"),
518522
Args: requireArgs("incident_id"),
519523
RunE: func(cmd *cobra.Command, args []string) error {
520524
return runCommand(cmd, args, func(ctx *RunContext) error {
@@ -553,6 +557,7 @@ func newIncidentSimilarCmd() *cobra.Command {
553557
cmd := &cobra.Command{
554558
Use: "similar <id>",
555559
Short: "Find similar incidents",
560+
Long: curatedLong("Find past incidents similar to the given incident.", "Incidents", "PastList"),
556561
Args: requireArgs("incident_id"),
557562
RunE: func(cmd *cobra.Command, args []string) error {
558563
return runCommand(cmd, args, func(ctx *RunContext) error {
@@ -1031,10 +1036,10 @@ func newIncidentWarRoomListCmd() *cobra.Command {
10311036
cmd := &cobra.Command{
10321037
Use: "list <incident_id>",
10331038
Short: "List incident war rooms",
1034-
Long: `List war rooms attached to an incident.
1039+
Long: curatedLong(`List war rooms attached to an incident.
10351040
10361041
Use this to discover chat IDs and integration IDs for follow-up commands such
1037-
as get, delete, and add-member.`,
1042+
as get, delete, and add-member.`, "Incidents", "WarRoomList"),
10381043
Example: ` flashduty incident war-room list inc_123
10391044
flashduty incident war-room list inc_123 --integration 42`,
10401045
Args: requireArgs("incident_id"),
@@ -1062,11 +1067,11 @@ func newIncidentWarRoomGetCmd() *cobra.Command {
10621067
cmd := &cobra.Command{
10631068
Use: "get <chat_id>",
10641069
Short: "Get incident war room details",
1065-
Long: `Get incident war room details by IM chat ID.
1070+
Long: curatedLong(`Get incident war room details by IM chat ID.
10661071
10671072
This command requires --integration because chat IDs are scoped to an IM
10681073
integration. Use 'flashduty incident war-room list' with an incident ID to find
1069-
the chat ID and integration ID for an incident.`,
1074+
the chat ID and integration ID for an incident.`, "Incidents", "WarRoomDetail"),
10701075
Example: ` flashduty incident war-room list inc_123
10711076
flashduty incident war-room get chat_123 --integration 42`,
10721077
Args: requireArgs("chat_id"),
@@ -1184,10 +1189,10 @@ func newIncidentWarRoomDefaultObserversCmd() *cobra.Command {
11841189
return &cobra.Command{
11851190
Use: "default-observers <incident_id>",
11861191
Short: "Preview historical responders for war-room observer invitation",
1187-
Long: `Preview historical responders eligible for war-room observer invitation.
1192+
Long: curatedLong(`Preview historical responders eligible for war-room observer invitation.
11881193
11891194
This is a read-only preview of the users FlashDuty would add when
1190-
--add-observers is used during war-room creation.`,
1195+
--add-observers is used during war-room creation.`, "Incidents", "ReadGetWarRoomDefaultObservers"),
11911196
Example: ` flashduty incident war-room default-observers inc_123
11921197
flashduty incident war-room create inc_123 --add-observers`,
11931198
Args: requireArgs("incident_id"),
@@ -1240,6 +1245,7 @@ func newIncidentFeedCmd() *cobra.Command {
12401245
cmd := &cobra.Command{
12411246
Use: "feed <id>",
12421247
Short: "View incident feed (paginated timeline)",
1248+
Long: curatedLong("View the paginated feed (timeline entries) for an incident.", "Incidents", "Feed"),
12431249
Args: requireArgs("incident_id"),
12441250
RunE: func(cmd *cobra.Command, args []string) error {
12451251
return runCommand(cmd, args, func(ctx *RunContext) error {
@@ -1330,6 +1336,7 @@ func newIncidentDetailCmd() *cobra.Command {
13301336
return &cobra.Command{
13311337
Use: "detail <id>",
13321338
Short: "View full incident detail with AI summary",
1339+
Long: curatedLong("View full incident detail, including the AI summary, root cause, and resolution.", "Incidents", "Info"),
13331340
Args: requireArgs("incident_id"),
13341341
RunE: func(cmd *cobra.Command, args []string) error {
13351342
return runCommand(cmd, args, func(ctx *RunContext) error {

internal/cli/insight.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func newInsightTeamCmd() *cobra.Command {
2929
cmd := &cobra.Command{
3030
Use: "team",
3131
Short: "Query insights by team",
32+
Long: curatedLong("Query incident response insight metrics aggregated by team over a time window.", "Analytics", "ByTeam"),
3233
RunE: func(cmd *cobra.Command, args []string) error {
3334
return runCommand(cmd, args, func(ctx *RunContext) error {
3435
startTime, err := timeutil.Parse(since)
@@ -92,6 +93,7 @@ func newInsightChannelCmd() *cobra.Command {
9293
cmd := &cobra.Command{
9394
Use: "channel",
9495
Short: "Query insights by channel",
96+
Long: curatedLong("Query incident response insight metrics aggregated by channel over a time window.", "Analytics", "ByChannel"),
9597
RunE: func(cmd *cobra.Command, args []string) error {
9698
return runCommand(cmd, args, func(ctx *RunContext) error {
9799
startTime, err := timeutil.Parse(since)
@@ -155,6 +157,7 @@ func newInsightResponderCmd() *cobra.Command {
155157
cmd := &cobra.Command{
156158
Use: "responder",
157159
Short: "Query insights by responder",
160+
Long: curatedLong("Query incident response insight metrics aggregated by responder over a time window.", "Analytics", "ByResponder"),
158161
RunE: func(cmd *cobra.Command, args []string) error {
159162
return runCommand(cmd, args, func(ctx *RunContext) error {
160163
startTime, err := timeutil.Parse(since)
@@ -213,6 +216,7 @@ func newInsightTopAlertsCmd() *cobra.Command {
213216
cmd := &cobra.Command{
214217
Use: "top-alerts",
215218
Short: "Query top alert sources by label",
219+
Long: curatedLong("Query the top-K noisiest alert sources grouped by a label dimension over a time window.", "Analytics", "TopkAlertsByLabel"),
216220
RunE: func(cmd *cobra.Command, args []string) error {
217221
return runCommand(cmd, args, func(ctx *RunContext) error {
218222
startTime, err := timeutil.Parse(since)
@@ -251,7 +255,7 @@ func newInsightTopAlertsCmd() *cobra.Command {
251255
},
252256
}
253257

254-
cmd.Flags().StringVar(&label, "label", "", "Label key to group by (e.g., \"integration_name\")")
258+
cmd.Flags().StringVar(&label, "label", "", "Group-by label dimension: one of [check, resource] (required)")
255259
cmd.Flags().StringVar(&since, "since", "7d", "Start time")
256260
cmd.Flags().StringVar(&until, "until", "now", "End time")
257261
cmd.Flags().IntVar(&limit, "limit", 10, "Top K results")
@@ -267,6 +271,7 @@ func newInsightIncidentsCmd() *cobra.Command {
267271
cmd := &cobra.Command{
268272
Use: "incidents",
269273
Short: "Query incidents with performance metrics",
274+
Long: curatedLong("List incidents with per-incident performance metrics (MTTA, MTTR, notifications) over a time window.", "Analytics", "IncidentList"),
270275
RunE: func(cmd *cobra.Command, args []string) error {
271276
return runCommand(cmd, args, func(ctx *RunContext) error {
272277
startTime, err := timeutil.Parse(since)

0 commit comments

Comments
 (0)