diff --git a/internal/cli/alert.go b/internal/cli/alert.go index d460193..fd6962a 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -123,7 +123,10 @@ func newAlertGetCmd() *cobra.Command { Use: "get ", Short: "Get alert detail", Long: curatedLong("Get the full detail of a single alert by ID.", "Alerts", "ReadInfo"), - Args: requireArgs("alert_id"), + // incident exposes the same lookup as both "get" and "detail"; accept the + // "detail" spelling here too so the two resources behave identically. + Aliases: []string{"detail"}, + Args: requireArgs("alert_id"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { result, _, err := ctx.Client.Alerts.ReadInfo(cmdContext(ctx.Cmd), &flashduty.AlertInfoRequest{ diff --git a/internal/cli/verb_aliases_test.go b/internal/cli/verb_aliases_test.go new file mode 100644 index 0000000..e06101d --- /dev/null +++ b/internal/cli/verb_aliases_test.go @@ -0,0 +1,109 @@ +package cli + +import ( + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// get/detail verb aliases +// +// The single-record read verb is spelled inconsistently across resources: +// incident answers both "get" and "detail", alert answered only "get", and +// channel answered only the path-derived "info". Aliases make the other +// spellings resolve to the same command instead of failing with +// "unknown command". +// --------------------------------------------------------------------------- + +// TestCommandAlertDetailAliasResolvesToGet pins that `alert detail ` runs +// the same request as `alert get ` (POST /alert/info with the alert_id). +func TestCommandAlertDetailAliasResolvesToGet(t *testing.T) { + for _, verb := range []string{"get", "detail"} { + t.Run(verb, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + if _, err := execCommand("alert", verb, "alert-1"); err != nil { + t.Fatalf("execCommand: %v", err) + } + if stub.lastPath != "/alert/info" { + t.Fatalf("expected /alert/info, got %q", stub.lastPath) + } + if stub.lastBody["alert_id"] != "alert-1" { + t.Fatalf("expected alert_id %q, got %#v", "alert-1", stub.lastBody["alert_id"]) + } + }) + } +} + +// TestCommandChannelGetDetailAliasesResolveToInfo pins that `channel get ` +// and `channel detail ` run the same request as the canonical +// `channel info ` (POST /channel/info with the channel_id). +func TestCommandChannelGetDetailAliasesResolveToInfo(t *testing.T) { + for _, verb := range []string{"info", "get", "detail"} { + t.Run(verb, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + if _, err := execCommand("channel", verb, "1001"); err != nil { + t.Fatalf("execCommand: %v", err) + } + if stub.lastPath != "/channel/info" { + t.Fatalf("expected /channel/info, got %q", stub.lastPath) + } + if stub.lastBody["channel_id"] != float64(1001) { + t.Fatalf("expected channel_id 1001, got %#v", stub.lastBody["channel_id"]) + } + }) + } +} + +// TestCommandAliasHelpShowsCanonicalCommand verifies that invoking an alias +// with --help renders the aliased command's help (its canonical Use line and +// flags), not an error. +func TestCommandAliasHelpShowsCanonicalCommand(t *testing.T) { + saveAndResetGlobals(t) + + out, err := execCommand("channel", "get", "--help") + if err != nil { + t.Fatalf("unexpected error running alias with --help: %v", err) + } + if !strings.Contains(out, "Get channel detail") { + t.Fatalf("expected the channel info command's help, got %q", out) + } + if !strings.Contains(out, "--channel-id") { + t.Fatalf("expected the channel info flags in help output, got %q", out) + } +} + +// TestCommandUnknownVerbStillFailsLoudly guards the aliases against swallowing +// genuinely unknown verbs: they must keep the hard failure (and the +// did-you-mean suggestion for near misses) from newGroupCmd. +func TestCommandUnknownVerbStillFailsLoudly(t *testing.T) { + saveAndResetGlobals(t) + + if _, err := execCommand("alert", "show", "alert-1"); err == nil { + t.Fatal("expected an error for unknown subcommand \"show\", got nil") + } else if !strings.Contains(err.Error(), `unknown command "show" for "flashduty alert"`) { + t.Fatalf("expected error to identify the unknown command, got %q", err.Error()) + } + + if _, err := execCommand("channel", "show", "1001"); err == nil { + t.Fatal("expected an error for unknown subcommand \"show\", got nil") + } else if !strings.Contains(err.Error(), `unknown command "show" for "flashduty channel"`) { + t.Fatalf("expected error to identify the unknown command, got %q", err.Error()) + } + + // A near-miss typo of a real verb still gets a suggestion. (Suggestions + // match command names; "gt" is a 1-edit typo of "get".) + _, err := execCommand("alert", "gt", "alert-1") + if err == nil { + t.Fatal("expected an error for unknown subcommand \"gt\", got nil") + } + if !strings.Contains(err.Error(), "Did you mean this?") { + t.Fatalf("expected a suggestion block, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "get") { + t.Fatalf("expected \"get\" to be suggested, got %q", err.Error()) + } +} diff --git a/internal/cli/zz_generated_channels.go b/internal/cli/zz_generated_channels.go index 7ba46b2..07c6163 100644 --- a/internal/cli/zz_generated_channels.go +++ b/internal/cli/zz_generated_channels.go @@ -913,6 +913,7 @@ Response fields ('data' envelope is unwrapped — these fields are at the top le - team_name (string) — Owning team name (resolved from the team directory; empty when unavailable). - updated_at (integer) — Last update timestamp (unix seconds). `, + Aliases: []string{"get", "detail"}, Args: requireBodyFieldOrExactArg("channel_id", "channel-id"), Example: ` flashduty channel info --data '{"channel_id":1001}'`, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/cligen/main.go b/internal/cmd/cligen/main.go index 4a0f70c..76a42f0 100644 --- a/internal/cmd/cligen/main.go +++ b/internal/cmd/cligen/main.go @@ -842,6 +842,16 @@ var optionalPositional = map[string]bool{ "incidentInfo": true, } +// opAliases maps an operationId to extra cobra aliases its generated command +// should accept, keyed like positionalOverride. Generated verbs are path-derived +// (channelInfo → "info"), which diverges from the get/detail spelling curated +// commands use for the same single-record read (incident get/detail, alert +// get). Aliases let the generated command answer both spellings so a slip +// (`channel get `) succeeds instead of erroring. +var opAliases = map[string][]string{ + "channelInfo": {"get", "detail"}, +} + // positional describes the positional argument a generated command exposes. type positional struct { Wire string // request-body wire key the positional folds into @@ -1042,6 +1052,13 @@ func emitCmd(fn string, s service, o specOp, mi methodInfo) string { fmt.Fprintf(&b, "\t\tUse: %q,\n", use) fmt.Fprintf(&b, "\t\tShort: %q,\n", oneLine(o.Summary)) fmt.Fprintf(&b, "\t\tLong: %s,\n", quoteMultiline(longHelp(o, scalars, complexFields, specByWire))) + if aliases := opAliases[o.OpID]; len(aliases) > 0 { + quoted := make([]string, len(aliases)) + for i, a := range aliases { + quoted[i] = fmt.Sprintf("%q", a) + } + fmt.Fprintf(&b, "\t\tAliases: []string{%s},\n", strings.Join(quoted, ", ")) + } if hasPos { // Required positionals use body-aware validators: the body field can come // from a positional, its typed flag, or --data. Scalar positionals still