feat(app): show the usage line on argument errors and allow -L up to 1000

Positional argument errors name what is wrong from the command's usage, then
print the usage line and where to find help:

  missing issue number
  Usage: tea issue view <number> [flags]
  Run 'tea issue view --help' for more information.

instead of "accepts 1 arg(s), received 0". Resource numbers accept #N.

--limit gains the gh shorthand -L and accepts up to 1000 results; the list
services already page through the server's smaller pages to fill a window.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 22:48:49 +02:00
1 parent 9b1d3a61e6
commit 2ef6090734
3 files changed
+167 -12

No files matched your search

+105 -5
View File
@@ -18,8 +18,9 @@ import (
"github.com/spf13/pflag" "github.com/spf13/pflag"
) )
// number parses a resource number written as N or #N.
func number(raw string) (int64, error) { func number(raw string) (int64, error) {
n, err := strconv.ParseInt(raw, 10, 64) n, err := strconv.ParseInt(strings.TrimPrefix(raw, "#"), 10, 64)
if err != nil || n < 1 { if err != nil || n < 1 {
return 0, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid resource number %q", raw)) return 0, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid resource number %q", raw))
} }
@@ -83,9 +84,13 @@ func oneOf(flag, value string, allowed ...string) error {
return clierr.Wrap(clierr.Usage, fmt.Errorf("--%s must be %s or %s", flag, choices, allowed[last])) return clierr.Wrap(clierr.Usage, fmt.Errorf("--%s must be %s or %s", flag, choices, allowed[last]))
} }
// pageFlags registers --limit and --page, which reject out-of-range values while parsing. // maxLimit bounds --limit; list services page through the server's smaller pages to reach it.
const maxLimit = 1000
// pageFlags registers -L/--limit and --page, which reject out-of-range values while parsing.
func pageFlags(cmd *cobra.Command, limit, page *int) { func pageFlags(cmd *cobra.Command, limit, page *int) {
positiveIntVar(cmd, limit, "limit", 30, 100, "Maximum results (1..100)") *limit = 30
cmd.Flags().VarP(positiveInt{limit, maxLimit}, "limit", "L", "Maximum number of results (1..1000)")
positiveIntVar(cmd, page, "page", 1, math.MaxInt, "Result page") positiveIntVar(cmd, page, "page", 1, math.MaxInt, "Result page")
} }
@@ -194,11 +199,106 @@ func argsExact(n int) cobra.PositionalArgs {
return usageArgs(cobra.ExactArgs(n)) return usageArgs(cobra.ExactArgs(n))
} }
// usageArgs reports positional argument errors with the usage exit code. // usageArgs reports positional argument errors with the usage exit code, the usage line and a
// pointer to the command's help.
func usageArgs(validate cobra.PositionalArgs) cobra.PositionalArgs { func usageArgs(validate cobra.PositionalArgs) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error {
return clierr.Wrap(clierr.Usage, validate(cmd, args)) err := validate(cmd, args)
if err == nil {
return nil
} }
return clierr.Wrap(clierr.Usage, &argumentError{problem: argumentProblem(cmd, args, err), cmd: cmd})
}
}
// argumentError reports a positional argument problem followed by the command's usage.
type argumentError struct {
problem string
cmd *cobra.Command
}
func (e *argumentError) Error() string {
more := "for more information"
if e.cmd.Example != "" {
more = "for examples"
}
return fmt.Sprintf("%s\nUsage: %s\nRun '%s --help' %s.", e.problem, e.cmd.UseLine(), e.cmd.CommandPath(), more)
}
// argumentProblem names what is wrong with args from the arguments in the command's Use, such
// as "missing issue number", falling back to the validator's message.
func argumentProblem(cmd *cobra.Command, args []string, err error) string {
arguments := usageArguments(cmd.Use)
required, variadic := 0, false
for _, argument := range arguments {
if !strings.HasPrefix(argument, "[") {
required++
}
variadic = variadic || strings.HasSuffix(strings.TrimRight(argument, "]}>"), "...")
}
switch {
case len(args) < required:
return "missing " + argumentName(cmd, arguments[len(args)])
case len(arguments) == 0 && len(args) > 0:
return fmt.Sprintf("unexpected argument %q", args[0])
case !variadic && len(arguments) > 0 && len(args) > len(arguments):
return "too many arguments"
}
return err.Error()
}
// usageArguments splits the arguments of a Use line, such as "view <number>", into its
// top-level groups: "<number>", "[<number> | <url>]" or "<file>...". [flags] is not one.
func usageArguments(use string) []string {
_, rest, _ := strings.Cut(use, " ")
var arguments []string
depth, start := 0, -1
for i, r := range rest {
switch {
case strings.ContainsRune("<[{", r):
if depth == 0 {
start = i
}
depth++
case strings.ContainsRune(">]}", r):
depth--
}
if depth == 0 && start >= 0 && (i == len(rest)-1 || rest[i+1] == ' ') {
if argument := rest[start : i+1]; argument != "[flags]" {
arguments = append(arguments, argument)
}
start = -1
}
}
return arguments
}
// argumentName words a Use argument for an error: "<asset-id>" is "asset ID", and a bare
// "<number>" or "<id>" names the command group's resource, as in "issue number".
func argumentName(cmd *cobra.Command, argument string) string {
first, _, _ := strings.Cut(argument, "|")
name := strings.Trim(first, "<>[]{}. ")
words := strings.Split(name, "-")
for i, word := range words {
if word == "id" {
words[i] = "ID"
}
}
text := strings.Join(words, " ")
if name != "number" && name != "id" && name != "id-or-name" {
return text
}
group := cmd
for group.HasParent() && group.Parent().HasParent() {
group = group.Parent()
}
if group == cmd {
return text
}
if noun := group.Name(); noun == "pr" {
return "pull request " + text
}
return group.Name() + " " + text
} }
// localFlagsChanged reports whether any command-specific flag was supplied. // localFlagsChanged reports whether any command-specific flag was supplied.
+55
View File
@@ -51,3 +51,58 @@ func TestParseTimeRangeRejectsOnlyInvertedBounds(t *testing.T) {
require.Equal(t, tc.wantErr, err != nil, tc.since+".."+tc.until) require.Equal(t, tc.wantErr, err != nil, tc.since+".."+tc.until)
} }
} }
func TestNumberAcceptsHashPrefix(t *testing.T) {
for raw, want := range map[string]int64{"12": 12, "#12": 12} {
n, err := number(raw)
require.NoError(t, err, raw)
require.Equal(t, want, n, raw)
}
for _, raw := range []string{"#", "##1", "0", "#-1", "1#", "abc"} {
_, err := number(raw)
require.ErrorContains(t, err, "invalid resource number", raw)
}
}
func TestUsageArgumentsSplitTopLevelGroups(t *testing.T) {
for use, want := range map[string][]string{
"list": nil,
"list [flags]": nil,
"view <number>": {"<number>"},
"view [number]": {"[number]"},
"close <number>...": {"<number>..."},
"clone <repository> [directory]": {"<repository>", "[directory]"},
"diff [<number> | <url> | <branch>]": {"[<number> | <url> | <branch>]"},
"download <tag> <asset-id> [flags]": {"<tag>", "<asset-id>"},
"upload <tag> <file>... [flags]": {"<tag>", "<file>..."},
} {
require.Equal(t, want, usageArguments(use), use)
}
}
func TestArgumentErrorsShowTheUsageLine(t *testing.T) {
for _, tc := range []struct {
args []string
want string
}{
{[]string{"issue", "view"}, "missing issue number\nUsage: tea issue view <number> [flags]\nRun 'tea issue view --help' for more information."},
{[]string{"issue", "view", "1", "2"}, "too many arguments\nUsage: tea issue view <number> [flags]"},
{[]string{"issue", "list", "extra"}, "unexpected argument \"extra\"\nUsage: tea issue list [flags]"},
{[]string{"pr", "checkout"}, "missing pull request number\nUsage: tea pr checkout <number> [flags]"},
{[]string{"run", "view"}, "missing run ID\nUsage: tea run view <id> [flags]"},
{[]string{"release", "asset", "download", "v1"}, "missing asset ID\nUsage: tea release asset download <tag> <asset-id> [flags]"},
{[]string{"repo", "clone"}, "missing repository\nUsage: tea repo clone <repository> [directory] [flags]"},
} {
requireUsageBeforeRequests(t, tc.want, tc.args...)
}
}
func TestLimitAcceptsShorthandUpToOneThousand(t *testing.T) {
var limit, page int
cmd := &cobra.Command{Use: "list"}
pageFlags(cmd, &limit, &page)
require.Equal(t, 30, limit)
require.NoError(t, cmd.Flags().Parse([]string{"-L", "1000"}))
require.Equal(t, 1000, limit)
require.ErrorContains(t, cmd.Flags().Parse([]string{"-L", "1001"}), "must be 1..1000")
}
+7 -7
View File
@@ -67,7 +67,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"issue", "edit", "1", "invalid", "--title", "Updated"}, "invalid resource number \"invalid\""}, {[]string{"issue", "edit", "1", "invalid", "--title", "Updated"}, "invalid resource number \"invalid\""},
{[]string{"issue", "edit", "1", "2", "--editor"}, "--editor requires one issue"}, {[]string{"issue", "edit", "1", "2", "--editor"}, "--editor requires one issue"},
{[]string{"issue", "edit", "1", "01", "--title", "Updated", "--no-input"}, "resource 1 is selected more than once"}, {[]string{"issue", "edit", "1", "01", "--title", "Updated", "--no-input"}, "resource 1 is selected more than once"},
{[]string{"issue", "list", "--limit", "0"}, `"--limit" flag: must be 1..100`}, {[]string{"issue", "list", "--limit", "0"}, `"-L, --limit" flag: must be 1..1000`},
{[]string{"issue", "list", "--page", "0"}, `"--page" flag: must be a positive integer`}, {[]string{"issue", "list", "--page", "0"}, `"--page" flag: must be a positive integer`},
{[]string{"issue", "list", "--label", "bug, "}, "label names cannot be empty"}, {[]string{"issue", "list", "--label", "bug, "}, "label names cannot be empty"},
{[]string{"label", "create", "--name", "bug", "--color", "wrong"}, "--color requires six hexadecimal digits"}, {[]string{"label", "create", "--name", "bug", "--color", "wrong"}, "--color requires six hexadecimal digits"},
@@ -83,11 +83,11 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"milestone", "edit", "9", "--title", " \t "}, "milestone title cannot be empty"}, {[]string{"milestone", "edit", "9", "--title", " \t "}, "milestone title cannot be empty"},
{[]string{"milestone", "issue", "add", "7", "1", "01", "--no-input"}, "resource 1 is selected more than once"}, {[]string{"milestone", "issue", "add", "7", "1", "01", "--no-input"}, "resource 1 is selected more than once"},
{[]string{"milestone", "issue", "remove", "7", "1", "1", "--no-input"}, "resource 1 is selected more than once"}, {[]string{"milestone", "issue", "remove", "7", "1", "1", "--no-input"}, "resource 1 is selected more than once"},
{[]string{"milestone", "list", "--limit", "101"}, `"--limit" flag: must be 1..100`}, {[]string{"milestone", "list", "--limit", "1001"}, `"-L, --limit" flag: must be 1..1000`},
{[]string{"milestone", "list", "--state", "merged"}, "--state must be open, closed, or all"}, {[]string{"milestone", "list", "--state", "merged"}, "--state must be open, closed, or all"},
{[]string{"notification", "list", "--status", "invalid"}, "--status must contain unread, read, or pinned"}, {[]string{"notification", "list", "--status", "invalid"}, "--status must contain unread, read, or pinned"},
{[]string{"notification", "list", "--type", "invalid"}, "--type must contain issue, pull, commit, or repository"}, {[]string{"notification", "list", "--type", "invalid"}, "--type must contain issue, pull, commit, or repository"},
{[]string{"notification", "list", "--limit", "many"}, `"--limit" flag: must be 1..100`}, {[]string{"notification", "list", "--limit", "many"}, `"-L, --limit" flag: must be 1..1000`},
{[]string{"notification", "list", "--mine", "-R", "alice/project"}, "--mine and --repo are mutually exclusive"}, {[]string{"notification", "list", "--mine", "-R", "alice/project"}, "--mine and --repo are mutually exclusive"},
{[]string{"notification", "list", "--since="}, "invalid --since: use a date, RFC3339 timestamp, or duration such as 24h"}, {[]string{"notification", "list", "--since="}, "invalid --since: use a date, RFC3339 timestamp, or duration such as 24h"},
{[]string{"notification", "list", "--since=1h", "--before=2h"}, "--since must not be later than --before"}, {[]string{"notification", "list", "--since=1h", "--before=2h"}, "--since must not be later than --before"},
@@ -102,7 +102,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"org", "create", "team", "--visibility", "unknown"}, "--visibility must be public, limited, or private"}, {[]string{"org", "create", "team", "--visibility", "unknown"}, "--visibility must be public, limited, or private"},
{[]string{"org", "edit", "team"}, "supply organization fields to edit"}, {[]string{"org", "edit", "team"}, "supply organization fields to edit"},
{[]string{"org", "list", "--all", "--username", "alice"}, "--all and --username are mutually exclusive"}, {[]string{"org", "list", "--all", "--username", "alice"}, "--all and --username are mutually exclusive"},
{[]string{"org", "member", "list", "team", "--limit", "0"}, `"--limit" flag: must be 1..100`}, {[]string{"org", "member", "list", "team", "--limit", "0"}, `"-L, --limit" flag: must be 1..1000`},
{[]string{"pr", "attachment", "download", "1", "1"}, "supply --output-file, or --output-file - for stdout"}, {[]string{"pr", "attachment", "download", "1", "1"}, "supply --output-file, or --output-file - for stdout"},
{[]string{"pr", "attachment", "download", "1", "1", "--json"}, "attachment downloads are binary"}, {[]string{"pr", "attachment", "download", "1", "1", "--json"}, "attachment downloads are binary"},
{[]string{"pr", "attachment", "download", "1", "1", "--output-file", "-", "--json=id"}, "attachment downloads are binary"}, {[]string{"pr", "attachment", "download", "1", "1", "--output-file", "-", "--json=id"}, "attachment downloads are binary"},
@@ -131,7 +131,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"pr", "list", "--sort", "unknown"}, "--sort must be oldest, recentupdate, recentclose, leastupdate, mostcomment, leastcomment, or priority"}, {[]string{"pr", "list", "--sort", "unknown"}, "--sort must be oldest, recentupdate, recentclose, leastupdate, mostcomment, leastcomment, or priority"},
{[]string{"pr", "list", "--milestone", "-1"}, "--milestone must be a nonnegative ID"}, {[]string{"pr", "list", "--milestone", "-1"}, "--milestone must be a nonnegative ID"},
{[]string{"pr", "list", "--state", "unknown"}, "--state must be open, closed, or all"}, {[]string{"pr", "list", "--state", "unknown"}, "--state must be open, closed, or all"},
{[]string{"pr", "list", "--limit", "0"}, `"--limit" flag: must be 1..100`}, {[]string{"pr", "list", "--limit", "0"}, `"-L, --limit" flag: must be 1..1000`},
{[]string{"pr", "review", "1", "--no-input"}, "missing required input: supply --body"}, {[]string{"pr", "review", "1", "--no-input"}, "missing required input: supply --body"},
{[]string{"pr", "review", "1", "--body="}, "review body cannot be empty without inline comments"}, {[]string{"pr", "review", "1", "--body="}, "review body cannot be empty without inline comments"},
{[]string{"pr", "review", "1", "--event=unknown"}, "--event must be APPROVED, REQUEST_CHANGES, COMMENT, or PENDING"}, {[]string{"pr", "review", "1", "--event=unknown"}, "--event must be APPROVED, REQUEST_CHANGES, COMMENT, or PENDING"},
@@ -167,7 +167,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"repo", "edit", "--default-branch="}, "default branch cannot be empty"}, {[]string{"repo", "edit", "--default-branch="}, "default branch cannot be empty"},
{[]string{"repo", "archive", "alice/project", "-R", "alice/other", "--yes"}, "supply the repository as an argument or --repo, not both"}, {[]string{"repo", "archive", "alice/project", "-R", "alice/other", "--yes"}, "supply the repository as an argument or --repo, not both"},
{[]string{"repo", "view", "alice/project", "--repo", "alice/other"}, "supply the repository as an argument or --repo, not both"}, {[]string{"repo", "view", "alice/project", "--repo", "alice/other"}, "supply the repository as an argument or --repo, not both"},
{[]string{"repo", "view", "alice/project", "alice/other"}, "accepts at most 1 arg(s)"}, {[]string{"repo", "view", "alice/project", "alice/other"}, "too many arguments\nUsage: tea repo view [repository] [flags]"},
{[]string{"repo", "list", "--owner", "account", "--starred"}, "--owner, --starred, and --watched select mutually exclusive collections"}, {[]string{"repo", "list", "--owner", "account", "--starred"}, "--owner, --starred, and --watched select mutually exclusive collections"},
{[]string{"repo", "list", "--starred", "--watched"}, "--owner, --starred, and --watched select mutually exclusive collections"}, {[]string{"repo", "list", "--starred", "--watched"}, "--owner, --starred, and --watched select mutually exclusive collections"},
{[]string{"repo", "list", "--type", "invalid"}, "--type must be fork, source, or mirror"}, {[]string{"repo", "list", "--type", "invalid"}, "--type must be fork, source, or mirror"},
@@ -189,7 +189,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"run", "list", "--since=-1h"}, "invalid --since: relative duration cannot be negative"}, {[]string{"run", "list", "--since=-1h"}, "invalid --since: relative duration cannot be negative"},
{[]string{"run", "list", "--until="}, "invalid --until: use a date, RFC3339 timestamp, or duration such as 24h"}, {[]string{"run", "list", "--until="}, "invalid --until: use a date, RFC3339 timestamp, or duration such as 24h"},
{[]string{"run", "list", "--since=1h", "--until=2h"}, "--since must not be later than --until"}, {[]string{"run", "list", "--since=1h", "--until=2h"}, "--since must not be later than --until"},
{[]string{"run", "list", "--limit", "0"}, `"--limit" flag: must be 1..100`}, {[]string{"run", "list", "--limit", "0"}, `"-L, --limit" flag: must be 1..1000`},
{[]string{"search", "issue", "query", "--state", "invalid"}, "--state must be open, closed, or all"}, {[]string{"search", "issue", "query", "--state", "invalid"}, "--state must be open, closed, or all"},
{[]string{"search", "issue", "query", "--author", "alice", "--authored"}, "--author and --authored are mutually exclusive"}, {[]string{"search", "issue", "query", "--author", "alice", "--authored"}, "--author and --authored are mutually exclusive"},
{[]string{"search", "issue", "--label", ","}, "label names cannot be empty"}, {[]string{"search", "issue", "--label", ","}, "label names cannot be empty"},