cli: help and usage print the command as typed !485
43 files changed, +797 −115
Layout: unified · split
CHANGELOG.org +11
| @@ -69,6 +69,10 @@ must add =--scope full=. Existing tokens keep their scope. | ||
| 69 | 69 | separately (#275). |
| 70 | 70 | - Audit retention deletes by id, up to the newest row older than the |
| 71 | 71 | retention, so a clock step back cannot leave a gap in the chain (#275). |
| 72 | *Upgrade note.* Upgrade the instance before the CLI: an older server | |
| 73 | refuses the CLI's leading =--path== argument as an unknown command, | |
| 74 | for the eighteen commands whose CLI path differs from the registry's | |
| 75 | (the =gitbay auth ...= commands and =repo topics list=). | |
| 72 | 76 | - =dashboard= and =feed= print activity as sentences |
| 73 | 77 | (=cmc opened issue krz/gitbay#12=) instead of raw event payloads, |
| 74 | 78 | and a labelled event names its labels there and on the web feed. An |
| @@ -76,6 +80,13 @@ must add =--scope full=. Existing tokens keep their scope. | ||
| 76 | 80 | =open_issues= field of =dashboard --json= no longer includes issues |
| 77 | 81 | assigned to the caller. =notifications list= names =--all= when only |
| 78 | 82 | read items remain (#265). |
| 83 | - Help and usage print the command the way the caller typed it — | |
| 84 | =usage: gitbay auth keys remove <fingerprint>=, not the registry's | |
| 85 | own path. =auth --help= lists the email and API token commands | |
| 86 | alongside keys and PGP. A bad-flag error prints the same prefixed | |
| 87 | usage line a wrong-argument refusal does. Outside the CLI a usage | |
| 88 | refusal reads =usage: ssh git@<host> ...= on every surface, | |
| 89 | including the error text of the web UI and the JSON API (#267). | |
| 79 | 90 | |
| 80 | 91 | * v1.36.0 — 2026-09-23 |
| 81 | 92 | |
cmd/gitbay/main.go +56 −17
| @@ -253,12 +253,13 @@ func pass(use string, o passOpts) *cobra.Command { | ||
| 253 | 253 | // The registry is the only place flags are written down, so |
| 254 | 254 | // --help asks the server rather than reprinting the one-line |
| 255 | 255 | // summary cobra holds. |
| 256 | cliPath := cliPathOf(cmd) | |
| 256 | 257 | for _, a := range args { |
| 257 | 258 | if a == "--help" || a == "-h" { |
| 258 | os.Exit(runServerHelp(o)) | |
| 259 | os.Exit(runServerHelp(o, cliPath)) | |
| 259 | 260 | } |
| 260 | 261 | } |
| 261 | os.Exit(runPass(o, args)) | |
| 262 | os.Exit(runPass(o, cliPath, args)) | |
| 262 | 263 | return nil |
| 263 | 264 | }, |
| 264 | 265 | } |
| @@ -276,16 +277,17 @@ func withShort(cmd *cobra.Command, short string) *cobra.Command { | ||
| 276 | 277 | } |
| 277 | 278 | |
| 278 | 279 | // runServerHelp prints the registry's usage for one command. |
| 279 | func runServerHelp(o passOpts) int { | |
| 280 | func runServerHelp(o passOpts, cliPath string) int { | |
| 280 | 281 | t, err := resolveTarget() |
| 281 | 282 | if err != nil { |
| 282 | 283 | fmt.Fprintln(os.Stderr, "gitbay:", err) |
| 283 | 284 | return protocol.ExitFailure |
| 284 | 285 | } |
| 285 | return runSSH(t, append([]string{"help"}, o.server...), strings.NewReader("")) | |
| 286 | argv := withCLIPath(cliPath, strings.Join(o.server, " "), append([]string{"help"}, o.server...)) | |
| 287 | return runSSH(t, argv, strings.NewReader("")) | |
| 286 | 288 | } |
| 287 | 289 | |
| 288 | func runPass(o passOpts, args []string) int { | |
| 290 | func runPass(o passOpts, cliPath string, args []string) int { | |
| 289 | 291 | t, err := resolveTarget() |
| 290 | 292 | if err != nil { |
| 291 | 293 | fmt.Fprintln(os.Stderr, "gitbay:", err) |
| @@ -343,7 +345,8 @@ func runPass(o passOpts, args []string) int { | ||
| 343 | 345 | stdin = r |
| 344 | 346 | } |
| 345 | 347 | } |
| 346 | return runSSHPaged(t, append(o.server, args...), stdin, pages(o.server, args)) | |
| 348 | argv := withCLIPath(cliPath, strings.Join(o.server, " "), append(o.server, args...)) | |
| 349 | return runSSHPaged(t, argv, stdin, pages(o.server, args)) | |
| 347 | 350 | } |
| 348 | 351 | |
| 349 | 352 | func isEmptyReader(r io.Reader) bool { |
| @@ -380,27 +383,51 @@ func group(use, short string, subs ...*cobra.Command) *cobra.Command { | ||
| 380 | 383 | // know by that name, cobra's own tree still prints. |
| 381 | 384 | local := c.HelpFunc() |
| 382 | 385 | c.SetHelpFunc(func(cmd *cobra.Command, args []string) { |
| 383 | if !serverHelp(use) { | |
| 386 | if !serverHelp(use, cliPathOf(c)) { | |
| 384 | 387 | local(cmd, args) |
| 385 | 388 | } |
| 386 | 389 | }) |
| 387 | 390 | return c |
| 388 | 391 | } |
| 389 | 392 | |
| 393 | // aliasGroupNames are CLI-only noun names with no matching registry | |
| 394 | // prefix (internal/control's nounAliases keys — kept in sync by hand, | |
| 395 | // since the CLI has no registry to consult): auth's own cliPath is | |
| 396 | // "auth", equal to the prefix it asks help for, so withCLIPath's | |
| 397 | // equality shortcut reads that as "no override needed" even though no | |
| 398 | // registered command is named "auth" at all, and help would silently | |
| 399 | // fall back to the registered forms (#267 review finding). Force | |
| 400 | // --path= for these regardless of the equality check. | |
| 401 | var aliasGroupNames = map[string]bool{"auth": true} | |
| 402 | ||
| 403 | // helpArgv builds the server argv for a group's --help: --path=cliPath | |
| 404 | // when the CLI's path differs from the registered prefix it names, or | |
| 405 | // unconditionally when prefix is a CLI-only alias grouping the registry | |
| 406 | // never answers to under that name. | |
| 407 | func helpArgv(prefix, cliPath string) []string { | |
| 408 | argv := []string{"help", prefix} | |
| 409 | if aliasGroupNames[prefix] { | |
| 410 | return append([]string{"--path=" + cliPath}, argv...) | |
| 411 | } | |
| 412 | return withCLIPath(cliPath, prefix, argv) | |
| 413 | } | |
| 414 | ||
| 390 | 415 | // serverHelp prints the registry's usage for a prefix and reports whether |
| 391 | // it did. At a terminal it goes through the terminal-aware path, so it | |
| 392 | // gets the same --term=<cols>[,color] treatment (and layout) as any other | |
| 393 | // command; piped, it stays a quiet capture, so a network or lookup | |
| 394 | // failure falls back to cobra's local help without noise. | |
| 395 | func serverHelp(prefix string) bool { | |
| 416 | // it did. cliPath is the group's own path in the CLI. At a terminal it | |
| 417 | // goes through the terminal-aware path, so it gets the same | |
| 418 | // --term=<cols>[,color] treatment (and layout) as any other command; | |
| 419 | // piped, it stays a quiet capture, so a network or lookup failure falls | |
| 420 | // back to cobra's local help without noise. | |
| 421 | func serverHelp(prefix, cliPath string) bool { | |
| 396 | 422 | t, err := resolveTarget() |
| 397 | 423 | if err != nil { |
| 398 | 424 | return false |
| 399 | 425 | } |
| 426 | argv := helpArgv(prefix, cliPath) | |
| 400 | 427 | if term.IsTerminal(int(os.Stdout.Fd())) { |
| 401 | return runSSH(t, []string{"help", prefix}, strings.NewReader("")) == 0 | |
| 428 | return runSSH(t, argv, strings.NewReader("")) == 0 | |
| 402 | 429 | } |
| 403 | out, code := sshCapture(t, []string{"help", prefix}) | |
| 430 | out, code := sshCapture(t, argv) | |
| 404 | 431 | if code != 0 || out == "" { |
| 405 | 432 | return false |
| 406 | 433 | } |
| @@ -430,6 +457,12 @@ func authCmd() *cobra.Command { | ||
| 430 | 457 | keysAdd := pass("add", passOpts{server: []string{"keys", "add"}, alwaysStdin: true, stdinWhat: "an SSH public key"}) |
| 431 | 458 | // keys add always reads stdin on the server; wire it through directly. |
| 432 | 459 | keysAdd.RunE = func(cmd *cobra.Command, args []string) error { |
| 460 | cliPath := cliPathOf(cmd) | |
| 461 | for _, a := range args { | |
| 462 | if a == "--help" || a == "-h" { | |
| 463 | os.Exit(runServerHelp(passOpts{server: []string{"keys", "add"}}, cliPath)) | |
| 464 | } | |
| 465 | } | |
| 433 | 466 | t, err := resolveTarget() |
| 434 | 467 | if err != nil { |
| 435 | 468 | return err |
| @@ -438,7 +471,7 @@ func authCmd() *cobra.Command { | ||
| 438 | 471 | if err != nil { |
| 439 | 472 | return err |
| 440 | 473 | } |
| 441 | os.Exit(runSSH(t, append([]string{"keys", "add"}, args...), in)) | |
| 474 | os.Exit(runSSH(t, withCLIPath(cliPath, "keys add", append([]string{"keys", "add"}, args...)), in)) | |
| 442 | 475 | return nil |
| 443 | 476 | } |
| 444 | 477 | pgpAdd := &cobra.Command{ |
| @@ -450,6 +483,12 @@ func authCmd() *cobra.Command { | ||
| 450 | 483 | }, |
| 451 | 484 | DisableFlagParsing: true, |
| 452 | 485 | RunE: func(cmd *cobra.Command, args []string) error { |
| 486 | cliPath := cliPathOf(cmd) | |
| 487 | for _, a := range args { | |
| 488 | if a == "--help" || a == "-h" { | |
| 489 | os.Exit(runServerHelp(passOpts{server: []string{"pgp", "add"}}, cliPath)) | |
| 490 | } | |
| 491 | } | |
| 453 | 492 | t, err := resolveTarget() |
| 454 | 493 | if err != nil { |
| 455 | 494 | return err |
| @@ -458,7 +497,7 @@ func authCmd() *cobra.Command { | ||
| 458 | 497 | if err != nil { |
| 459 | 498 | return err |
| 460 | 499 | } |
| 461 | os.Exit(runSSH(t, append([]string{"pgp", "add"}, args...), in)) | |
| 500 | os.Exit(runSSH(t, withCLIPath(cliPath, "pgp add", append([]string{"pgp", "add"}, args...)), in)) | |
| 462 | 501 | return nil |
| 463 | 502 | }, |
| 464 | 503 | } |
| @@ -467,7 +506,7 @@ func authCmd() *cobra.Command { | ||
| 467 | 506 | pass("list", passOpts{server: []string{"token", "list"}}), |
| 468 | 507 | pass("revoke", passOpts{server: []string{"token", "revoke"}}), |
| 469 | 508 | ) |
| 470 | return group("auth", "identity: whoami, SSH and PGP keys", | |
| 509 | return group("auth", "whoami, SSH and PGP keys, email, API tokens", | |
| 471 | 510 | pass("export", passOpts{server: []string{"account", "export"}}), |
| 472 | 511 | tokens, |
| 473 | 512 | pass("whoami", passOpts{server: []string{"whoami"}}), |
cmd/gitbay/serverpath_test.go added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 4 | "slices" | |
| 5 | "testing" | |
| 6 | ||
| 7 | "github.com/spf13/cobra" | |
| 8 | ) | |
| 9 | ||
| 10 | // TestServerPathMismatches pins the commands whose CLI path differs from | |
| 11 | // the server path they dispatch, the ones that send --path= so usage and | |
| 12 | // help print the CLI's path (#267). A change to this list is deliberate. | |
| 13 | func TestServerPathMismatches(t *testing.T) { | |
| 14 | want := []string{ | |
| 15 | "auth email add", "auth email list", "auth email primary", | |
| 16 | "auth email remove", "auth email verify", | |
| 17 | "auth export", | |
| 18 | "auth keys add", "auth keys label", "auth keys list", "auth keys remove", | |
| 19 | "auth pgp add", "auth pgp list", "auth pgp remove", | |
| 20 | "auth token create", "auth token list", "auth token revoke", | |
| 21 | "auth whoami", | |
| 22 | "repo topics list", | |
| 23 | } | |
| 24 | ||
| 25 | var got []string | |
| 26 | var walk func(*cobra.Command) | |
| 27 | walk = func(c *cobra.Command) { | |
| 28 | if p := c.Annotations[serverPath]; p != "" { | |
| 29 | if cli := cliPathOf(c); cli != p { | |
| 30 | got = append(got, cli) | |
| 31 | } | |
| 32 | } | |
| 33 | for _, sub := range c.Commands() { | |
| 34 | walk(sub) | |
| 35 | } | |
| 36 | } | |
| 37 | root := newRoot() | |
| 38 | root.InitDefaultHelpCmd() | |
| 39 | walk(root) | |
| 40 | slices.Sort(got) | |
| 41 | ||
| 42 | if !slices.Equal(got, want) { | |
| 43 | t.Errorf("mismatched CLI paths = %v\nwant %v", got, want) | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | // TestHelpArgvSendsThePathForAnAliasGroup pins the review-round fix: a | |
| 48 | // bare `gitbay auth --help` (cliPath == prefix == "auth") must still send | |
| 49 | // --path=auth, since the registry has no "auth" command for withCLIPath's | |
| 50 | // equality shortcut to correctly skip (#267 follow-up). | |
| 51 | func TestHelpArgvSendsThePathForAnAliasGroup(t *testing.T) { | |
| 52 | got := helpArgv("auth", "auth") | |
| 53 | want := []string{"--path=auth", "help", "auth"} | |
| 54 | if !slices.Equal(got, want) { | |
| 55 | t.Errorf("helpArgv(auth, auth) = %v, want %v", got, want) | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | // TestHelpArgvSendsNoPathWhenCLIMatchesTheRegistry pins the unaffected | |
| 60 | // case: an ordinary noun's cliPath and registered prefix are the same | |
| 61 | // string, and it is not a CLI-only alias grouping, so no --path= is sent | |
| 62 | // and the server resolves it on its own. | |
| 63 | func TestHelpArgvSendsNoPathWhenCLIMatchesTheRegistry(t *testing.T) { | |
| 64 | got := helpArgv("issue", "issue") | |
| 65 | want := []string{"help", "issue"} | |
| 66 | if !slices.Equal(got, want) { | |
| 67 | t.Errorf("helpArgv(issue, issue) = %v, want %v", got, want) | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | // TestHelpArgvSendsThePathForAMismatchedGroup pins the ordinary | |
| 72 | // Task 2.1 case, unchanged by the alias-group fix: a nested group whose | |
| 73 | // cliPath differs from its registered prefix (auth keys, for keys) | |
| 74 | // already sends --path= through withCLIPath. | |
| 75 | func TestHelpArgvSendsThePathForAMismatchedGroup(t *testing.T) { | |
| 76 | got := helpArgv("keys", "auth keys") | |
| 77 | want := []string{"--path=auth keys", "help", "keys"} | |
| 78 | if !slices.Equal(got, want) { | |
| 79 | t.Errorf("helpArgv(keys, auth keys) = %v, want %v", got, want) | |
| 80 | } | |
| 81 | } | |
cmd/gitbay/ssh.go +18
| @@ -17,6 +17,7 @@ import ( | ||
| 17 | 17 | "gitbay.org/gitbay/internal/cliconfig" |
| 18 | 18 | "gitbay.org/gitbay/internal/protocol" |
| 19 | 19 | "gitbay.org/gitbay/internal/toolpath" |
| 20 | "github.com/spf13/cobra" | |
| 20 | 21 | ) |
| 21 | 22 | |
| 22 | 23 | // context is the resolved target for a command: which instance to talk to |
| @@ -166,6 +167,23 @@ func pages(server, args []string) bool { | ||
| 166 | 167 | return false |
| 167 | 168 | } |
| 168 | 169 | |
| 170 | // cliPathOf is the path a cobra command was reached by, without the | |
| 171 | // root's name: "auth keys remove". | |
| 172 | func cliPathOf(cmd *cobra.Command) string { | |
| 173 | return strings.TrimPrefix(cmd.CommandPath(), "gitbay ") | |
| 174 | } | |
| 175 | ||
| 176 | // withCLIPath prepends --path=<cliPath> to a server command line when the | |
| 177 | // CLI path is set and differs from the server path it dispatches, so | |
| 178 | // usage and help print a command the caller can run (#267). Dispatch | |
| 179 | // strips it before Lookup, as it does --term=. | |
| 180 | func withCLIPath(cliPath, server string, argv []string) []string { | |
| 181 | if cliPath == "" || cliPath == server { | |
| 182 | return argv | |
| 183 | } | |
| 184 | return append([]string{"--path=" + cliPath}, argv...) | |
| 185 | } | |
| 186 | ||
| 169 | 187 | // runSSH executes the server command over the system ssh binary, wiring |
| 170 | 188 | // stdio through, with no pager. It returns the remote exit code. |
| 171 | 189 | func runSSH(t target, serverArgv []string, stdin io.Reader) int { |
cmd/gitbay/summaries_gen.go +6 −6
| @@ -42,12 +42,12 @@ var summaries = map[string]string{ | ||
| 42 | 42 | "explore": "list public repositories", |
| 43 | 43 | "feed": "activity on repositories you can reach", |
| 44 | 44 | "help": "list available commands", |
| 45 | "issue assign": "assignees", | |
| 45 | "issue assign": "add or remove assignees", | |
| 46 | 46 | "issue close": "close an issue", |
| 47 | "issue comment": "comment", | |
| 47 | "issue comment": "add a comment", | |
| 48 | 48 | "issue create": "open an issue", |
| 49 | 49 | "issue edit": "edit title or body", |
| 50 | "issue label": "labels", | |
| 50 | "issue label": "add or remove labels", | |
| 51 | 51 | "issue list": "list issues", |
| 52 | 52 | "issue milestone": "set or clear an issue's milestone", |
| 53 | 53 | "issue reopen": "reopen an issue", |
| @@ -65,13 +65,13 @@ var summaries = map[string]string{ | ||
| 65 | 65 | "milestone list": "list milestones with progress", |
| 66 | 66 | "milestone reopen": "reopen a milestone", |
| 67 | 67 | "mr close": "close without merging", |
| 68 | "mr comment": "comment", | |
| 68 | "mr comment": "add a comment", | |
| 69 | 69 | "mr create": "open a merge request", |
| 70 | 70 | "mr diff": "show the diff", |
| 71 | 71 | "mr diff-comment": "comment on a diff line", |
| 72 | 72 | "mr draft": "mark a merge request as work in progress", |
| 73 | 73 | "mr edit": "edit title or body", |
| 74 | "mr label": "labels", | |
| 74 | "mr label": "add or remove labels", | |
| 75 | 75 | "mr list": "list merge requests", |
| 76 | 76 | "mr merge": "merge", |
| 77 | 77 | "mr milestone": "set or clear an MR's milestone", |
| @@ -80,7 +80,7 @@ var summaries = map[string]string{ | ||
| 80 | 80 | "mr resolve": "resolve a review thread", |
| 81 | 81 | "mr retarget": "retarget onto another branch", |
| 82 | 82 | "mr review request": "ask specific people for a review", |
| 83 | "mr review": "review", | |
| 83 | "mr review": "record a review verdict", | |
| 84 | 84 | "mr revisions": "the heads a merge request has had", |
| 85 | 85 | "mr show": "show a merge request", |
| 86 | 86 | "mr threads": "review threads on an MR", |
cmd/gitbay/summaries_test.go +18
| @@ -65,3 +65,21 @@ func TestGroupsSayWhatTheServerSays(t *testing.T) { | ||
| 65 | 65 | } |
| 66 | 66 | } |
| 67 | 67 | } |
| 68 | ||
| 69 | // aliasGroupNames is kept by hand because the CLI has no registry to | |
| 70 | // consult; this fails when it drifts from internal/control's nounAliases | |
| 71 | // keys instead of silently falling back to the registered forms (#267). | |
| 72 | func TestAliasGroupNamesMatchServer(t *testing.T) { | |
| 73 | want := map[string]bool{} | |
| 74 | for _, name := range control.NounAliasNames() { | |
| 75 | want[name] = true | |
| 76 | } | |
| 77 | if len(aliasGroupNames) != len(want) { | |
| 78 | t.Fatalf("aliasGroupNames has %d entries, server has %d", len(aliasGroupNames), len(want)) | |
| 79 | } | |
| 80 | for name := range want { | |
| 81 | if !aliasGroupNames[name] { | |
| 82 | t.Errorf("aliasGroupNames is missing %q", name) | |
| 83 | } | |
| 84 | } | |
| 85 | } | |
cmd/gitbay/term_test.go +31
| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "slices" | |
| 4 | 5 | "strings" |
| 5 | 6 | "testing" |
| 7 | ||
| 8 | "github.com/spf13/cobra" | |
| 6 | 9 | ) |
| 7 | 10 | |
| 8 | 11 | func TestTermValue(t *testing.T) { |
| @@ -77,3 +80,31 @@ func TestPages(t *testing.T) { | ||
| 77 | 80 | t.Error("list must not page") |
| 78 | 81 | } |
| 79 | 82 | } |
| 83 | ||
| 84 | func TestCLIPathOf(t *testing.T) { | |
| 85 | root := &cobra.Command{Use: "gitbay"} | |
| 86 | auth := &cobra.Command{Use: "auth"} | |
| 87 | keys := &cobra.Command{Use: "keys"} | |
| 88 | remove := &cobra.Command{Use: "remove"} | |
| 89 | keys.AddCommand(remove) | |
| 90 | auth.AddCommand(keys) | |
| 91 | root.AddCommand(auth) | |
| 92 | if got := cliPathOf(remove); got != "auth keys remove" { | |
| 93 | t.Errorf("cliPathOf = %q", got) | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | func TestWithCLIPath(t *testing.T) { | |
| 98 | argv := []string{"keys", "remove", "abc"} | |
| 99 | if got := withCLIPath("keys remove", "keys remove", argv); !slices.Equal(got, argv) { | |
| 100 | t.Errorf("matching path: %v", got) | |
| 101 | } | |
| 102 | if got := withCLIPath("", "keys remove", argv); !slices.Equal(got, argv) { | |
| 103 | t.Errorf("empty cliPath: %v", got) | |
| 104 | } | |
| 105 | got := withCLIPath("auth keys remove", "keys remove", argv) | |
| 106 | want := []string{"--path=auth keys remove", "keys", "remove", "abc"} | |
| 107 | if !slices.Equal(got, want) { | |
| 108 | t.Errorf("got %v, want %v", got, want) | |
| 109 | } | |
| 110 | } | |
e2e/cliusage_test.go added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | // The CLI sends its own path where it differs from the registered one, so | |
| 11 | // usage and help print a command that exists: gitbay auth keys remove, | |
| 12 | // never gitbay keys remove (#267). Stock ssh sends none and sees the | |
| 13 | // registered path, the only one it can type. | |
| 14 | func TestCLIUsagePrintsTheInvokingPath(t *testing.T) { | |
| 15 | t.Parallel() | |
| 16 | inst := startInstance(t) | |
| 17 | key := inst.newKey(t, "alice") | |
| 18 | inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub", | |
| 19 | "--email", "alice@example.test", "--verified") | |
| 20 | ||
| 21 | c := &cli{bin: buildGitbayCLI(t), configDir: t.TempDir(), inst: inst, key: key} | |
| 22 | c.must(t, "", "", "remote", "add", "test", "127.0.0.1", | |
| 23 | "--port", fmt.Sprint(inst.port), | |
| 24 | "--ssh-option", "-i", "--ssh-option", key, | |
| 25 | "--ssh-option", "-oIdentitiesOnly=yes", | |
| 26 | "--ssh-option", "-oStrictHostKeyChecking=no", | |
| 27 | "--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"), | |
| 28 | "--ssh-option", "-oBatchMode=yes", | |
| 29 | "--default") | |
| 30 | ||
| 31 | _, errOut, code := c.run(t, "", "", "auth", "keys", "remove") | |
| 32 | if code == 0 || !strings.Contains(errOut, "usage: gitbay auth keys remove <fingerprint>") { | |
| 33 | t.Errorf("CLI refusal: exit %d, stderr %q", code, errOut) | |
| 34 | } | |
| 35 | ||
| 36 | out, errOut, code := c.run(t, "", "", "auth", "keys", "remove", "--help") | |
| 37 | if code != 0 || !strings.Contains(out, "gitbay auth keys remove <fingerprint>") { | |
| 38 | t.Errorf("CLI help: exit %d, stdout %q, stderr %q", code, out, errOut) | |
| 39 | } | |
| 40 | ||
| 41 | // A command whose CLI path matches sends no --path=, and off a | |
| 42 | // terminal prints the ssh form as before. | |
| 43 | _, errOut, code = c.run(t, "", "", "issue", "show", "alice/app") | |
| 44 | if code == 0 || !strings.Contains(errOut, "usage: ssh git@") || !strings.Contains(errOut, " issue show <owner/name> <n>") { | |
| 45 | t.Errorf("matching command: exit %d, stderr %q", code, errOut) | |
| 46 | } | |
| 47 | ||
| 48 | _, errOut, code = inst.ssh(t, key, "", "keys", "remove") | |
| 49 | if code == 0 || !strings.Contains(errOut, "usage: ssh git@") || !strings.Contains(errOut, " keys remove <fingerprint>") { | |
| 50 | t.Errorf("stock ssh: exit %d, stderr %q", code, errOut) | |
| 51 | } | |
| 52 | if strings.Contains(errOut, "auth") { | |
| 53 | t.Errorf("stock ssh saw the CLI's auth grouping: %q", errOut) | |
| 54 | } | |
| 55 | ||
| 56 | // A bare `gitbay auth --help` (cliPath == "auth", the registered | |
| 57 | // prefix it asks for) must still send --path=auth: the registry has | |
| 58 | // no "auth" command, so withCLIPath's equality shortcut would | |
| 59 | // otherwise read that as "no override needed" and print the | |
| 60 | // registered rows the CLI cannot actually type (#267 review finding). | |
| 61 | out, errOut, code = c.run(t, "", "", "auth", "--help") | |
| 62 | if code != 0 { | |
| 63 | t.Errorf("gitbay auth --help: exit %d, stdout %q, stderr %q", code, out, errOut) | |
| 64 | } | |
| 65 | for _, want := range []string{"auth keys add", "auth export"} { | |
| 66 | if !strings.Contains(out, want) { | |
| 67 | t.Errorf("gitbay auth --help: missing %q in %q", want, out) | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | out, errOut, code = inst.ssh(t, key, "", "help", "auth") | |
| 72 | if code != 0 { | |
| 73 | t.Errorf("ssh help auth: exit %d, stdout %q, stderr %q", code, out, errOut) | |
| 74 | } | |
| 75 | for _, want := range []string{"keys add", "account export"} { | |
| 76 | if !strings.Contains(out, want) { | |
| 77 | t.Errorf("ssh help auth: missing %q in %q", want, out) | |
| 78 | } | |
| 79 | } | |
| 80 | if strings.Contains(out, "auth keys add") { | |
| 81 | t.Errorf("stock ssh saw the CLI's auth grouping: %q", out) | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | // keys add and pgp add wire stdin directly to the server rather than going | |
| 86 | // through pass(), so they lost the --help check every other passthrough | |
| 87 | // command has: --help was treated as key material instead of showing help | |
| 88 | // (#267). | |
| 89 | func TestKeysAddAndPGPAddCheckHelpBeforeStdin(t *testing.T) { | |
| 90 | t.Parallel() | |
| 91 | inst := startInstance(t) | |
| 92 | key := inst.newKey(t, "alice") | |
| 93 | inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub", | |
| 94 | "--email", "alice@example.test", "--verified") | |
| 95 | ||
| 96 | c := &cli{bin: buildGitbayCLI(t), configDir: t.TempDir(), inst: inst, key: key} | |
| 97 | c.must(t, "", "", "remote", "add", "test", "127.0.0.1", | |
| 98 | "--port", fmt.Sprint(inst.port), | |
| 99 | "--ssh-option", "-i", "--ssh-option", key, | |
| 100 | "--ssh-option", "-oIdentitiesOnly=yes", | |
| 101 | "--ssh-option", "-oStrictHostKeyChecking=no", | |
| 102 | "--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"), | |
| 103 | "--ssh-option", "-oBatchMode=yes", | |
| 104 | "--default") | |
| 105 | ||
| 106 | for _, args := range [][]string{ | |
| 107 | {"auth", "keys", "add", "--help"}, | |
| 108 | {"auth", "pgp", "add", "--help"}, | |
| 109 | } { | |
| 110 | out, errOut, code := c.run(t, "", "", args...) | |
| 111 | if code != 0 { | |
| 112 | t.Errorf("%v: exit %d, stdout %q, stderr %q", args, code, out, errOut) | |
| 113 | } | |
| 114 | want := "gitbay " + strings.Join(args[:len(args)-1], " ") | |
| 115 | if !strings.Contains(out, want) { | |
| 116 | t.Errorf("%v: stdout %q does not contain %q", args, out, want) | |
| 117 | } | |
| 118 | } | |
| 119 | } | |
internal/control/admin.go +2 −2
| @@ -137,7 +137,7 @@ func runAdminUserList(c *Ctx, args []string) int { | ||
| 137 | 137 | if code >= 0 { |
| 138 | 138 | return code |
| 139 | 139 | } |
| 140 | f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 0, | |
| 140 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--state"}, MaxPos: 0, | |
| 141 | 141 | Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]"}) |
| 142 | 142 | if err != nil { |
| 143 | 143 | return c.fail(protocol.ExitUsage, "%v", err) |
| @@ -410,7 +410,7 @@ func runAdminRepoList(c *Ctx, args []string) int { | ||
| 410 | 410 | if code >= 0 { |
| 411 | 411 | return code |
| 412 | 412 | } |
| 413 | f, err := parseFlags(args, flagSpec{Values: []string{"--owner", "--visibility"}, MaxPos: 0, | |
| 413 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--owner", "--visibility"}, MaxPos: 0, | |
| 414 | 414 | Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]"}) |
| 415 | 415 | if err != nil { |
| 416 | 416 | return c.fail(protocol.ExitUsage, "%v", err) |
internal/control/adminhost.go +1 −1
| @@ -77,7 +77,7 @@ func runAdminUserCreate(c *Ctx, args []string) int { | ||
| 77 | 77 | if code := requireInstanceAdmin(c); code >= 0 { |
| 78 | 78 | return code |
| 79 | 79 | } |
| 80 | f, err := parseFlags(args, flagSpec{Values: []string{"--email", "--key"}, Bools: []string{"--admin", "--verified"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 80 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--email", "--key"}, Bools: []string{"--admin", "--verified"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 81 | 81 | if err != nil { |
| 82 | 82 | return c.fail(protocol.ExitUsage, "%v", err) |
| 83 | 83 | } |
internal/control/audit.go +1 −1
| @@ -29,7 +29,7 @@ func runAudit(c *Ctx, args []string) int { | ||
| 29 | 29 | return c.fail(protocol.ExitDenied, "the audit log is for instance admins; ask one") |
| 30 | 30 | } |
| 31 | 31 | f := store.AuditFilter{Limit: 100} |
| 32 | fl, err := parseFlags(args, flagSpec{Values: []string{"--limit", "--actor", "--action", "--since"}, MaxPos: 0, Usage: c.Cmd.Usage}) | |
| 32 | fl, err := c.parseArgs(args, flagSpec{Values: []string{"--limit", "--actor", "--action", "--since"}, MaxPos: 0, Usage: c.Cmd.Usage}) | |
| 33 | 33 | if err != nil { |
| 34 | 34 | return c.fail(protocol.ExitUsage, "%v", err) |
| 35 | 35 | } |
internal/control/build.go +3 −3
| @@ -158,7 +158,7 @@ func runBuildList(c *Ctx, args []string) int { | ||
| 158 | 158 | if code >= 0 { |
| 159 | 159 | return code |
| 160 | 160 | } |
| 161 | f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--status", "--job"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 161 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--status", "--job"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 162 | 162 | if err != nil { |
| 163 | 163 | return c.fail(protocol.ExitUsage, "%v", err) |
| 164 | 164 | } |
| @@ -238,7 +238,7 @@ func runBuildShow(c *Ctx, args []string) int { | ||
| 238 | 238 | } |
| 239 | 239 | |
| 240 | 240 | func runBuildLog(c *Ctx, args []string) int { |
| 241 | f, err := parseFlags(args, flagSpec{Bools: []string{"--follow"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 241 | f, err := c.parseArgs(args, flagSpec{Bools: []string{"--follow"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 242 | 242 | if err != nil { |
| 243 | 243 | return c.fail(protocol.ExitUsage, "%v", err) |
| 244 | 244 | } |
| @@ -465,7 +465,7 @@ func runRunnerNext(c *Ctx, args []string) int { | ||
| 465 | 465 | if code >= 0 { |
| 466 | 466 | return code |
| 467 | 467 | } |
| 468 | f, err := parseFlags(args, flagSpec{Bools: []string{"--untrusted"}, MaxPos: -1, | |
| 468 | f, err := c.parseArgs(args, flagSpec{Bools: []string{"--untrusted"}, MaxPos: -1, | |
| 469 | 469 | Usage: "runner next [--untrusted] [<owner/name>...]"}) |
| 470 | 470 | if err != nil { |
| 471 | 471 | return c.fail(protocol.ExitUsage, "%v", err) |
internal/control/commitfile.go +1 −1
| @@ -43,7 +43,7 @@ const maxCommitFileBytes = 1 << 20 | ||
| 43 | 43 | // A repository that requires verified signatures therefore refuses the |
| 44 | 44 | // command rather than writing a commit its own policy would reject. |
| 45 | 45 | func runCommitFile(c *Ctx, args []string) int { |
| 46 | f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--message", "--file"}, MaxPos: -1, Usage: c.Cmd.Usage}) | |
| 46 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--message", "--file"}, MaxPos: -1, Usage: c.Cmd.Usage}) | |
| 47 | 47 | if err != nil { |
| 48 | 48 | return c.fail(protocol.ExitUsage, "%v", err) |
| 49 | 49 | } |
internal/control/control.go +27 −12
| @@ -30,6 +30,12 @@ type Ctx struct { | ||
| 30 | 30 | // Term is the client's terminal, from GITBAY_TERM. The zero value |
| 31 | 31 | // is plain output. |
| 32 | 32 | Term Term |
| 33 | // CLIPath is the path the gitbay CLI resolved this call to, from a | |
| 34 | // leading --path=, when it differs from the registered path being | |
| 35 | // dispatched (auth keys remove for keys remove). Usage and help | |
| 36 | // print it in place of the registered path (#267). Empty for stock | |
| 37 | // ssh, the web and the API. | |
| 38 | CLIPath string | |
| 33 | 39 | // ViaAPI marks requests arriving over HTTP, from the token API or |
| 34 | 40 | // the web. Every command runs there; nothing is held back for SSH |
| 35 | 41 | // any more (#234). The flag stays because the rate limiter and the |
| @@ -66,13 +72,13 @@ type Ctx struct { | ||
| 66 | 72 | // usage reports a bad invocation with the command's registered usage, |
| 67 | 73 | // the one source of it. |
| 68 | 74 | func (c *Ctx) usage() int { |
| 69 | return c.fail(protocol.ExitUsage, "usage: %s", c.Cmd.Usage) | |
| 75 | return c.fail(protocol.ExitUsage, "usage: %s", c.cmdUsage()) | |
| 70 | 76 | } |
| 71 | 77 | |
| 72 | 78 | // usageWith reports a specific problem with the arguments, then the |
| 73 | 79 | // registered usage, so a person always sees the shape that was expected. |
| 74 | 80 | func (c *Ctx) usageWith(msg string) int { |
| 75 | return c.fail(protocol.ExitUsage, "%s\nusage: %s", msg, c.Cmd.Usage) | |
| 81 | return c.fail(protocol.ExitUsage, "%s\nusage: %s", msg, c.cmdUsage()) | |
| 76 | 82 | } |
| 77 | 83 | |
| 78 | 84 | // Flag is one flag in a command's help. |
| @@ -132,18 +138,27 @@ func Dispatch(c *Ctx, argv []string) int { | ||
| 132 | 138 | return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help") |
| 133 | 139 | } |
| 134 | 140 | // A leading --term=<v> selects terminal output for this session, the |
| 135 | // same as GITBAY_TERM. It must come off before Lookup: Lookup matches | |
| 136 | // argv against a command's Path, and a --term= in front would never | |
| 137 | // match one. Over HTTP it is dropped unread: the web and the API | |
| 138 | // render no terminal. | |
| 139 | if v, ok := strings.CutPrefix(argv[0], "--term="); ok { | |
| 140 | if !c.ViaAPI { | |
| 141 | c.Term = ParseTerm(v) | |
| 141 | // same as GITBAY_TERM; a leading --path=<v> is the CLI's own path for | |
| 142 | // the command (Ctx.CLIPath). Both come off before Lookup, in either | |
| 143 | // order: Lookup matches argv against a command's Path, and either in | |
| 144 | // front would never match one. Over HTTP both are dropped unread: the | |
| 145 | // web and the API render no terminal and have no CLI path. | |
| 146 | for len(argv) > 0 { | |
| 147 | if v, ok := strings.CutPrefix(argv[0], "--term="); ok { | |
| 148 | if !c.ViaAPI { | |
| 149 | c.Term = ParseTerm(v) | |
| 150 | } | |
| 151 | } else if v, ok := strings.CutPrefix(argv[0], "--path="); ok { | |
| 152 | if !c.ViaAPI { | |
| 153 | c.CLIPath = v | |
| 154 | } | |
| 155 | } else { | |
| 156 | break | |
| 142 | 157 | } |
| 143 | 158 | argv = argv[1:] |
| 144 | if len(argv) == 0 { | |
| 145 | return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help") | |
| 146 | } | |
| 159 | } | |
| 160 | if len(argv) == 0 { | |
| 161 | return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help") | |
| 147 | 162 | } |
| 148 | 163 | cmd, rest, ok := Lookup(argv) |
| 149 | 164 | c.Cmd = cmd |
internal/control/control_test.go +58 −2
| @@ -260,6 +260,34 @@ func TestFailErrExitCodes(t *testing.T) { | ||
| 260 | 260 | } |
| 261 | 261 | } |
| 262 | 262 | |
| 263 | // TestPathArgument: --path= is read only as a leading argument, in | |
| 264 | // either order with --term=, and never over HTTP. | |
| 265 | func TestPathArgument(t *testing.T) { | |
| 266 | cases := []struct { | |
| 267 | name string | |
| 268 | viaAPI bool | |
| 269 | argv []string | |
| 270 | want string | |
| 271 | wantArgv []string | |
| 272 | }{ | |
| 273 | {"leading", false, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}}, | |
| 274 | {"after term", false, []string{"--term=80", "--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}}, | |
| 275 | {"before term", false, []string{"--path=auth keys remove", "--term=80", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}}, | |
| 276 | {"later", false, []string{"keys", "remove", "abc", "--path=x"}, "", []string{"abc", "--path=x"}}, | |
| 277 | {"over HTTP", true, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "", []string{"abc"}}, | |
| 278 | } | |
| 279 | for _, tc := range cases { | |
| 280 | c := &Ctx{Scope: "git", ViaAPI: tc.viaAPI, Stdout: io.Discard, Stderr: io.Discard} | |
| 281 | Dispatch(c, tc.argv) | |
| 282 | if c.CLIPath != tc.want { | |
| 283 | t.Errorf("%s: CLIPath %q, want %q", tc.name, c.CLIPath, tc.want) | |
| 284 | } | |
| 285 | if !slices.Equal(c.Argv, tc.wantArgv) { | |
| 286 | t.Errorf("%s: Argv %q, want %q", tc.name, c.Argv, tc.wantArgv) | |
| 287 | } | |
| 288 | } | |
| 289 | } | |
| 290 | ||
| 263 | 291 | // TestArgumentRefusalsNameTheUsage: a missing positional argument is a |
| 264 | 292 | // usage error that prints the registered usage, the shared reference |
| 265 | 293 | // helpers included (#215). |
| @@ -267,12 +295,40 @@ func TestArgumentRefusalsNameTheUsage(t *testing.T) { | ||
| 267 | 295 | for _, argv := range [][]string{{"build", "show"}, {"release", "show"}, {"mr", "resolve"}, {"issue", "show"}} { |
| 268 | 296 | var out, errOut bytes.Buffer |
| 269 | 297 | c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut} |
| 298 | c.Cfg.Server.SiteURL = "https://forge.test" | |
| 270 | 299 | if code := Dispatch(c, argv); code != protocol.ExitUsage { |
| 271 | 300 | t.Errorf("%v: exit %d, want %d (%s)", argv, code, protocol.ExitUsage, errOut.String()) |
| 272 | 301 | continue |
| 273 | 302 | } |
| 274 | if !strings.Contains(errOut.String(), "usage: "+strings.Join(argv, " ")) { | |
| 275 | t.Errorf("%v: no usage line: %q", argv, errOut.String()) | |
| 303 | want := "usage: ssh git@forge.test " + strings.Join(argv, " ") | |
| 304 | if !strings.Contains(errOut.String(), want) { | |
| 305 | t.Errorf("%v: got %q, want it to contain %q", argv, errOut.String(), want) | |
| 306 | } | |
| 307 | } | |
| 308 | } | |
| 309 | ||
| 310 | // TestFlagRefusalsNameTheUsage: a bad flag prints its usage the same way | |
| 311 | // a missing positional does, program and CLI path included (#267). | |
| 312 | func TestFlagRefusalsNameTheUsage(t *testing.T) { | |
| 313 | cases := []struct { | |
| 314 | name string | |
| 315 | argv []string | |
| 316 | want string | |
| 317 | }{ | |
| 318 | {"cli", []string{"--path=auth token create", "token", "create", "--bogus"}, "unknown flag \"--bogus\"\nusage: gitbay auth token create --name <n>"}, | |
| 319 | {"stock ssh", []string{"token", "create", "--bogus"}, "unknown flag \"--bogus\"\nusage: ssh git@forge.test token create --name <n>"}, | |
| 320 | {"cli, repo", []string{"--term=80", "issue", "list", "a/b", "--bogus"}, "usage: gitbay issue list [<owner/name>]"}, | |
| 321 | } | |
| 322 | for _, tc := range cases { | |
| 323 | var out, errOut bytes.Buffer | |
| 324 | c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut} | |
| 325 | c.Cfg.Server.SiteURL = "https://forge.test" | |
| 326 | if code := Dispatch(c, tc.argv); code != protocol.ExitUsage { | |
| 327 | t.Errorf("%s: exit %d, want %d (%s)", tc.name, code, protocol.ExitUsage, errOut.String()) | |
| 328 | continue | |
| 329 | } | |
| 330 | if !strings.Contains(errOut.String(), tc.want) { | |
| 331 | t.Errorf("%s: got %q, want it to contain %q", tc.name, errOut.String(), tc.want) | |
| 276 | 332 | } |
| 277 | 333 | } |
| 278 | 334 | } |
internal/control/deploykey.go +1 −1
| @@ -37,7 +37,7 @@ func init() { | ||
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | func runDeployKeyAdd(c *Ctx, args []string) int { |
| 40 | f, err := parseFlags(args, flagSpec{Values: []string{"--ttl"}, Bools: []string{"--rw"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 40 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--ttl"}, Bools: []string{"--rw"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 41 | 41 | if err != nil { |
| 42 | 42 | return c.fail(protocol.ExitUsage, "%v", err) |
| 43 | 43 | } |
internal/control/diffcomment.go +1 −1
| @@ -50,7 +50,7 @@ func init() { | ||
| 50 | 50 | } |
| 51 | 51 | |
| 52 | 52 | func runDiffComment(c *Ctx, args []string) int { |
| 53 | f, err := parseFlags(args, flagSpec{Values: []string{"--path", "--line", "--reply", "--message", "--file"}, | |
| 53 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--path", "--line", "--reply", "--message", "--file"}, | |
| 54 | 54 | Bools: []string{"--old", "--pending"}, MaxPos: -1, |
| 55 | 55 | Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--old] [--pending] [--reply <id>] [--message <m> | --file -]"}) |
| 56 | 56 | if err != nil { |
internal/control/explore.go +1 −1
| @@ -95,7 +95,7 @@ func runExplore(c *Ctx, args []string) int { | ||
| 95 | 95 | // release asset get writes an asset. The web's /archive route is the |
| 96 | 96 | // same bytes with a Content-Disposition on them. |
| 97 | 97 | func runRepoDownload(c *Ctx, args []string) int { |
| 98 | f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: -1, Usage: c.Cmd.Usage}) | |
| 98 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: -1, Usage: c.Cmd.Usage}) | |
| 99 | 99 | if err != nil { |
| 100 | 100 | return c.fail(protocol.ExitUsage, "%v", err) |
| 101 | 101 | } |
internal/control/flags.go +15
| @@ -92,6 +92,21 @@ func parseFlags(args []string, spec flagSpec) (flags, error) { | ||
| 92 | 92 | return f, nil |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | // parseArgs is parseFlags for the running command, with the usage line | |
| 96 | // printed the way a usage refusal prints it (cmdUsage): the program in | |
| 97 | // front and the CLI's own path where it differs (#267). spec.Usage stays | |
| 98 | // the text, since some commands spell their flags out more fully there | |
| 99 | // than in the registered Usage. | |
| 100 | func (c *Ctx) parseArgs(args []string, spec flagSpec) (flags, error) { | |
| 101 | usage := strings.TrimPrefix(spec.Usage, "usage: ") | |
| 102 | spec.Usage = "" | |
| 103 | f, err := parseFlags(args, spec) | |
| 104 | if err != nil && usage != "" { | |
| 105 | err = fmt.Errorf("%v\nusage: %s %s", err, c.program(), c.usageShape(c.Cmd.Path, usage)) | |
| 106 | } | |
| 107 | return f, err | |
| 108 | } | |
| 109 | ||
| 95 | 110 | // pos is the nth positional argument, or "" when absent. |
| 96 | 111 | func (f flags) pos(n int) string { |
| 97 | 112 | if n < len(f.Pos) { |
internal/control/ghimport.go +1 −1
| @@ -148,7 +148,7 @@ func attribution(src string, n int64, kind, login, date string) string { | ||
| 148 | 148 | } |
| 149 | 149 | |
| 150 | 150 | func runImportIssues(c *Ctx, args []string) int { |
| 151 | f, err := parseFlags(args, flagSpec{Values: []string{"--from", "--api-base"}, Bools: []string{"--token-stdin"}, MaxPos: 1, | |
| 151 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--from", "--api-base"}, Bools: []string{"--token-stdin"}, MaxPos: 1, | |
| 152 | 152 | Usage: "repo import-issues <owner/name> --from <owner/repo> [--api-base <url>] [--token-stdin]"}) |
| 153 | 153 | if err != nil { |
| 154 | 154 | return c.fail(protocol.ExitUsage, "%v", err) |
internal/control/help.go +165 −18
| @@ -28,6 +28,7 @@ var nounSummaries = map[string]string{ | ||
| 28 | 28 | "account": "export or import your account, for instance migration", |
| 29 | 29 | "admin": "instance administration (admins)", |
| 30 | 30 | "audit": "instance audit log (admins)", |
| 31 | "auth": "whoami, SSH and PGP keys, email, API tokens", | |
| 31 | 32 | "build": "CI builds", |
| 32 | 33 | "dashboard": "pinned repos, open MRs, assigned issues, recent builds", |
| 33 | 34 | "email": "manage email addresses", |
| @@ -61,6 +62,45 @@ var nounSummaries = map[string]string{ | ||
| 61 | 62 | // test (task 4.5). |
| 62 | 63 | func NounSummaries() map[string]string { return nounSummaries } |
| 63 | 64 | |
| 65 | // nounAlias is one bucket of registered commands, reachable under a | |
| 66 | // CLI-only noun that is not itself a registry path (auth, gathering | |
| 67 | // several unrelated registry prefixes): Registered is what runHelp | |
| 68 | // matches against the registry, CLI is the path a gitbay caller | |
| 69 | // actually types to reach it — not always Registered with the alias's | |
| 70 | // own name stitched on (account export -> auth export drops a word), | |
| 71 | // so the two are paired explicitly rather than derived. | |
| 72 | type nounAlias struct { | |
| 73 | Registered string | |
| 74 | CLI string | |
| 75 | } | |
| 76 | ||
| 77 | // nounAliases groups a CLI-only noun into the real prefixes it gathers, | |
| 78 | // so `help auth` renders with the same layout a real noun gets instead | |
| 79 | // of falling back to whatever a caller does when help fails. A stock | |
| 80 | // ssh caller — the only one who could ever ask for a bare "auth" and | |
| 81 | // get nothing back from the registry — sees the Registered forms | |
| 82 | // unchanged; the CLI, having sent its own path, sees CLI. | |
| 83 | var nounAliases = map[string][]nounAlias{ | |
| 84 | "auth": { | |
| 85 | {"account export", "auth export"}, | |
| 86 | {"whoami", "auth whoami"}, | |
| 87 | {"keys", "auth keys"}, | |
| 88 | {"email", "auth email"}, | |
| 89 | {"pgp", "auth pgp"}, | |
| 90 | {"token", "auth token"}, | |
| 91 | }, | |
| 92 | } | |
| 93 | ||
| 94 | // NounAliasNames returns the keys of nounAliases, for the CLI's own | |
| 95 | // aliasGroupNames agreement test. | |
| 96 | func NounAliasNames() []string { | |
| 97 | names := make([]string, 0, len(nounAliases)) | |
| 98 | for name := range nounAliases { | |
| 99 | names = append(names, name) | |
| 100 | } | |
| 101 | return names | |
| 102 | } | |
| 103 | ||
| 64 | 104 | // helpEntry is one row of the registry as help reports it. |
| 65 | 105 | type helpEntry struct { |
| 66 | 106 | Path string `json:"path"` |
| @@ -76,11 +116,33 @@ type helpEntry struct { | ||
| 76 | 116 | // noun with several commands under it renders a READ/WRITE summary. |
| 77 | 117 | func runHelp(c *Ctx, args []string) int { |
| 78 | 118 | prefix := joinPath(args) |
| 119 | prefixes := []string{prefix} | |
| 120 | override := map[string]string{} | |
| 121 | if aliased, ok := nounAliases[prefix]; ok { | |
| 122 | prefixes = nil | |
| 123 | for _, a := range aliased { | |
| 124 | prefixes = append(prefixes, a.Registered) | |
| 125 | } | |
| 126 | if c.CLIPath != "" { | |
| 127 | for _, cmd := range registry { | |
| 128 | p := joinPath(cmd.Path) | |
| 129 | for _, a := range aliased { | |
| 130 | if p == a.Registered || strings.HasPrefix(p, a.Registered+" ") { | |
| 131 | override[p] = a.CLI + strings.TrimPrefix(p, a.Registered) | |
| 132 | break | |
| 133 | } | |
| 134 | } | |
| 135 | } | |
| 136 | } | |
| 137 | } | |
| 79 | 138 | var matched []Command |
| 80 | 139 | for _, cmd := range registry { |
| 81 | 140 | p := joinPath(cmd.Path) |
| 82 | if prefix == "" || p == prefix || strings.HasPrefix(p, prefix+" ") { | |
| 83 | matched = append(matched, cmd) | |
| 141 | for _, pfx := range prefixes { | |
| 142 | if pfx == "" || p == pfx || strings.HasPrefix(p, pfx+" ") { | |
| 143 | matched = append(matched, cmd) | |
| 144 | break | |
| 145 | } | |
| 84 | 146 | } |
| 85 | 147 | } |
| 86 | 148 | if len(matched) == 0 { |
| @@ -106,21 +168,85 @@ func runHelp(c *Ctx, args []string) int { | ||
| 106 | 168 | case joinPath(matched[0].Path) == prefix: |
| 107 | 169 | c.helpVerb(w, matched[0], matched[1:]) |
| 108 | 170 | default: |
| 109 | c.helpNoun(w, prefix, matched) | |
| 171 | c.helpNoun(w, prefix, matched, override) | |
| 110 | 172 | } |
| 111 | 173 | }) |
| 112 | 174 | } |
| 113 | 175 | |
| 114 | // program is how help spells the command it documents: the CLI at a | |
| 115 | // terminal (only the CLI or a caller passing --term sets one), ssh | |
| 116 | // otherwise. | |
| 176 | // viaCLI reports whether the caller is the gitbay CLI, as far as the | |
| 177 | // session says: a terminal (only the CLI or a caller passing --term sets | |
| 178 | // one), or a CLI path, which only the CLI sends. | |
| 179 | func (c *Ctx) viaCLI() bool { | |
| 180 | return c.Term.Cols > 0 || c.CLIPath != "" | |
| 181 | } | |
| 182 | ||
| 183 | // program is how help spells the command it documents: gitbay for the | |
| 184 | // CLI, ssh otherwise. | |
| 117 | 185 | func (c *Ctx) program() string { |
| 118 | if c.Term.Cols > 0 { | |
| 186 | if c.viaCLI() { | |
| 119 | 187 | return "gitbay" |
| 120 | 188 | } |
| 121 | 189 | return "ssh git@" + hostOf(c.Cfg.Server.SiteURL) |
| 122 | 190 | } |
| 123 | 191 | |
| 192 | // cliUsage marks a leading <owner/name> optional in a usage line for the | |
| 193 | // CLI, which infers it inside a clone (cmd/gitbay/ssh.go's withRepo). | |
| 194 | // Stock ssh never does. | |
| 195 | func cliUsage(usage string) string { | |
| 196 | return strings.Replace(usage, "<owner/name>", "[<owner/name>]", 1) | |
| 197 | } | |
| 198 | ||
| 199 | // shownAs rewrites full, which starts with the registered path, to start | |
| 200 | // with the CLI's path instead when the CLI sent one that differs (#267). | |
| 201 | // The CLI path must name this command: either it regroups it (the same | |
| 202 | // last word, auth keys remove for keys remove) or extends it (repo | |
| 203 | // topics list for repo topics). Arguments after a CLI command can | |
| 204 | // dispatch to a longer registered path (gitbay repo topics list add | |
| 205 | // reaches repo topics add), and that command keeps its own name. | |
| 206 | func (c *Ctx) shownAs(registered, full string) string { | |
| 207 | rest, ok := strings.CutPrefix(full, registered) | |
| 208 | if !ok || c.CLIPath == "" || c.CLIPath == registered { | |
| 209 | return full | |
| 210 | } | |
| 211 | reg, cli := strings.Fields(registered), strings.Fields(c.CLIPath) | |
| 212 | if len(reg) == 0 || len(cli) == 0 || (cli[len(cli)-1] != reg[len(reg)-1] && !strings.HasPrefix(c.CLIPath, registered+" ")) { | |
| 213 | return full | |
| 214 | } | |
| 215 | return c.CLIPath + rest | |
| 216 | } | |
| 217 | ||
| 218 | // shownBelow is how another command listed beside registered prints to | |
| 219 | // this caller. When the CLI only regrouped the command (auth keys remove | |
| 220 | // for keys remove, the same last word) the other command takes the CLI's | |
| 221 | // parent in place of the registered one. When the CLI renamed the last | |
| 222 | // word (repo topics list for repo topics) nothing follows about the | |
| 223 | // other command's name, so it keeps its registered path. | |
| 224 | func (c *Ctx) shownBelow(registered, other string) string { | |
| 225 | reg, cli, o := strings.Fields(registered), strings.Fields(c.CLIPath), strings.Fields(other) | |
| 226 | if len(cli) == 0 || len(reg) == 0 || len(o) < len(reg) || cli[len(cli)-1] != reg[len(reg)-1] || | |
| 227 | !slices.Equal(o[:len(reg)-1], reg[:len(reg)-1]) { | |
| 228 | return other | |
| 229 | } | |
| 230 | return joinPath(append(slices.Clip(cli[:len(cli)-1]), o[len(reg)-1:]...)) | |
| 231 | } | |
| 232 | ||
| 233 | // usageShape is a usage line for the command registered at path as this | |
| 234 | // caller should see it: the CLI's path in place of the registered one | |
| 235 | // where they differ, and a leading <owner/name> optional for the CLI. | |
| 236 | func (c *Ctx) usageShape(path []string, usage string) string { | |
| 237 | shape := c.shownAs(joinPath(path), usage) | |
| 238 | if c.viaCLI() { | |
| 239 | shape = cliUsage(shape) | |
| 240 | } | |
| 241 | return shape | |
| 242 | } | |
| 243 | ||
| 244 | // cmdUsage is the running command's usage with the program in front, as | |
| 245 | // a usage refusal prints it. | |
| 246 | func (c *Ctx) cmdUsage() string { | |
| 247 | return c.program() + " " + c.usageShape(c.Cmd.Path, c.Cmd.Usage) | |
| 248 | } | |
| 249 | ||
| 124 | 250 | func (c *Ctx) heading(w io.Writer, s string) { |
| 125 | 251 | fmt.Fprintln(w, c.Term.paint(sgrBold, s)) |
| 126 | 252 | } |
| @@ -152,7 +278,8 @@ func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) { | ||
| 152 | 278 | // A required flag (repo delete --yes) or an alternative |
| 153 | 279 | // (notifications read <id>... | --all) has no " [--" to cut at, so |
| 154 | 280 | // the usage prints whole. |
| 155 | shape := cmd.Usage | |
| 281 | registered := joinPath(cmd.Path) | |
| 282 | shape := c.usageShape(cmd.Path, cmd.Usage) | |
| 156 | 283 | if i := strings.Index(shape, " [--"); i >= 0 { |
| 157 | 284 | shape = shape[:i] + " [flags]" |
| 158 | 285 | } |
| @@ -190,20 +317,39 @@ func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) { | ||
| 190 | 317 | fmt.Fprintln(w) |
| 191 | 318 | c.heading(w, "SEE ALSO") |
| 192 | 319 | for _, b := range below { |
| 193 | fmt.Fprintf(w, " %s %s\n", c.program(), joinPath(b.Path)) | |
| 320 | fmt.Fprintf(w, " %s %s\n", c.program(), c.shownBelow(registered, joinPath(b.Path))) | |
| 194 | 321 | } |
| 195 | 322 | } |
| 196 | 323 | } |
| 197 | 324 | |
| 198 | func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command) { | |
| 325 | // helpNoun renders a noun with several commands under it. override, from | |
| 326 | // an aliased noun (auth), gives the full CLI path for a row that is not | |
| 327 | // itself under prefix (keys list, gathered under auth, becomes "auth | |
| 328 | // keys list"); it is empty for an ordinary noun, so every row there | |
| 329 | // still trims to just its own verb. | |
| 330 | func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command, override map[string]string) { | |
| 199 | 331 | head := nounSummaries[strings.Fields(prefix)[0]] |
| 200 | 332 | fmt.Fprintln(w, head) |
| 201 | fmt.Fprintln(w) | |
| 202 | c.heading(w, "USAGE") | |
| 203 | fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), prefix) | |
| 333 | // An aliased noun is a CLI grouping: over stock ssh there is no | |
| 334 | // "auth <verb>" to type, and each row already names its full command. | |
| 335 | _, aliased := nounAliases[prefix] | |
| 336 | bare := aliased && c.CLIPath == "" | |
| 337 | display := c.shownAs(prefix, prefix) | |
| 338 | if !bare { | |
| 339 | fmt.Fprintln(w) | |
| 340 | c.heading(w, "USAGE") | |
| 341 | fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), display) | |
| 342 | } | |
| 343 | rowText := func(cmd Command) string { | |
| 344 | full := joinPath(cmd.Path) | |
| 345 | if ov, ok := override[full]; ok { | |
| 346 | return ov | |
| 347 | } | |
| 348 | return strings.TrimPrefix(full, prefix+" ") | |
| 349 | } | |
| 204 | 350 | wide := 0 |
| 205 | 351 | for _, cmd := range cmds { |
| 206 | wide = max(wide, cells(strings.TrimPrefix(joinPath(cmd.Path), prefix+" "))) | |
| 352 | wide = max(wide, cells(rowText(cmd))) | |
| 207 | 353 | } |
| 208 | 354 | for _, section := range []struct { |
| 209 | 355 | title string |
| @@ -219,10 +365,11 @@ func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command) { | ||
| 219 | 365 | c.heading(w, section.title) |
| 220 | 366 | first = false |
| 221 | 367 | } |
| 222 | verb := strings.TrimPrefix(joinPath(cmd.Path), prefix+" ") | |
| 223 | fmt.Fprintf(w, " %s %s\n", pad(verb, wide), cmd.Summary) | |
| 368 | fmt.Fprintf(w, " %s %s\n", pad(rowText(cmd), wide), cmd.Summary) | |
| 224 | 369 | } |
| 225 | 370 | } |
| 226 | fmt.Fprintln(w) | |
| 227 | fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), prefix) | |
| 371 | if !bare { | |
| 372 | fmt.Fprintln(w) | |
| 373 | fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), display) | |
| 374 | } | |
| 228 | 375 | } |
internal/control/help_test.go +134 −2
| @@ -25,7 +25,7 @@ func TestHelpVerb(t *testing.T) { | ||
| 25 | 25 | out := helpOut(t, Term{Cols: 100}, "issue", "list") |
| 26 | 26 | for _, want := range []string{ |
| 27 | 27 | "list issues\n", |
| 28 | "USAGE\n gitbay issue list <owner/name> [flags]\n", | |
| 28 | "USAGE\n gitbay issue list [<owner/name>] [flags]\n", | |
| 29 | 29 | "FLAGS\n", |
| 30 | 30 | " --state open|closed|all", |
| 31 | 31 | "which issues (default open)\n", |
| @@ -50,7 +50,7 @@ func TestHelpVerb(t *testing.T) { | ||
| 50 | 50 | // as though --yes were optional. |
| 51 | 51 | func TestHelpVerbUsageKeepsRequiredFlags(t *testing.T) { |
| 52 | 52 | out := helpOut(t, Term{Cols: 100}, "repo", "delete") |
| 53 | if !strings.Contains(out, "USAGE\n gitbay repo delete <owner/name> --yes\n") { | |
| 53 | if !strings.Contains(out, "USAGE\n gitbay repo delete [<owner/name>] --yes\n") { | |
| 54 | 54 | t.Errorf("missing required --yes in usage:\n%s", out) |
| 55 | 55 | } |
| 56 | 56 | if strings.Contains(out, "[flags]") { |
| @@ -142,3 +142,135 @@ func TestHelpIsComplete(t *testing.T) { | ||
| 142 | 142 | } |
| 143 | 143 | } |
| 144 | 144 | } |
| 145 | ||
| 146 | func TestCmdUsagePrefixesTheProgram(t *testing.T) { | |
| 147 | c := &Ctx{Cmd: Command{Path: []string{"keys", "remove"}, Usage: "keys remove <fingerprint>"}} | |
| 148 | c.Cfg.Server.SiteURL = "https://forge.test" | |
| 149 | if got := c.cmdUsage(); got != "ssh git@forge.test keys remove <fingerprint>" { | |
| 150 | t.Errorf("ssh form: %q", got) | |
| 151 | } | |
| 152 | c.Term = Term{Cols: 100} | |
| 153 | if got := c.cmdUsage(); got != "gitbay keys remove <fingerprint>" { | |
| 154 | t.Errorf("cli form, no CLIPath sent: %q", got) | |
| 155 | } | |
| 156 | c.CLIPath = "auth keys remove" | |
| 157 | if got := c.cmdUsage(); got != "gitbay auth keys remove <fingerprint>" { | |
| 158 | t.Errorf("cli form, mismatched registered path: %q", got) | |
| 159 | } | |
| 160 | c.Term = Term{} | |
| 161 | if got := c.cmdUsage(); got != "gitbay auth keys remove <fingerprint>" { | |
| 162 | t.Errorf("cli form off a terminal, mismatched registered path: %q", got) | |
| 163 | } | |
| 164 | ||
| 165 | c2 := &Ctx{Cmd: Command{Path: []string{"repo", "tree"}, Usage: "repo tree <owner/name> [<path>] [--ref <ref>]"}, Term: Term{Cols: 100}} | |
| 166 | if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" { | |
| 167 | t.Errorf("optional owner/name: %q", got) | |
| 168 | } | |
| 169 | c2.CLIPath = "repo tree" | |
| 170 | if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" { | |
| 171 | t.Errorf("matching CLIPath changes nothing: %q", got) | |
| 172 | } | |
| 173 | ||
| 174 | c3 := &Ctx{Cmd: Command{Path: []string{"repo", "topics", "add"}, Usage: "repo topics add <owner/name> <topic>..."}, Term: Term{Cols: 100}, CLIPath: "repo topics list"} | |
| 175 | if got := c3.cmdUsage(); got != "gitbay repo topics add [<owner/name>] <topic>..." { | |
| 176 | t.Errorf("CLIPath naming another command: %q", got) | |
| 177 | } | |
| 178 | c3.Cmd = Command{Path: []string{"repo", "topics"}, Usage: "repo topics <owner/name>"} | |
| 179 | if got := c3.cmdUsage(); got != "gitbay repo topics list [<owner/name>]" { | |
| 180 | t.Errorf("CLIPath extending the registered path: %q", got) | |
| 181 | } | |
| 182 | c3.CLIPath = " " | |
| 183 | if got := c3.cmdUsage(); got != "gitbay repo topics [<owner/name>]" { | |
| 184 | t.Errorf("blank CLIPath: %q", got) | |
| 185 | } | |
| 186 | } | |
| 187 | ||
| 188 | // TestShownBelow: another command listed beside the one the CLI named | |
| 189 | // takes the CLI's parent when the CLI only regrouped the command (auth | |
| 190 | // keys remove), and keeps its registered path when the CLI renamed the | |
| 191 | // leaf (repo topics list for repo topics), since that says nothing about | |
| 192 | // what the other command is called. | |
| 193 | func TestShownBelow(t *testing.T) { | |
| 194 | cases := []struct { | |
| 195 | cliPath, registered, other, want string | |
| 196 | }{ | |
| 197 | {"", "keys remove", "keys list", "keys list"}, | |
| 198 | {"keys remove", "keys remove", "keys list", "keys list"}, | |
| 199 | {"auth keys remove", "keys remove", "keys list", "auth keys list"}, | |
| 200 | {"auth export", "account export", "account export extra", "auth export extra"}, | |
| 201 | {"repo topics list", "repo topics", "repo topics add", "repo topics add"}, | |
| 202 | } | |
| 203 | for _, tc := range cases { | |
| 204 | c := &Ctx{CLIPath: tc.cliPath} | |
| 205 | if got := c.shownBelow(tc.registered, tc.other); got != tc.want { | |
| 206 | t.Errorf("CLIPath %q, %q beside %q: got %q, want %q", tc.cliPath, tc.other, tc.registered, got, tc.want) | |
| 207 | } | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | // TestHelpPrintsTheCLIPath: help reached through the CLI with a --path= | |
| 212 | // prints the CLI's path in USAGE, the noun header and SEE ALSO. | |
| 213 | func TestHelpPrintsTheCLIPath(t *testing.T) { | |
| 214 | via := func(t *testing.T, term Term, argv ...string) string { | |
| 215 | t.Helper() | |
| 216 | var out, errOut bytes.Buffer | |
| 217 | c := &Ctx{Stdout: &out, Stderr: &errOut, Term: term, Scope: "full"} | |
| 218 | c.Cfg.Server.SiteURL = "https://forge.test" | |
| 219 | if code := Dispatch(c, argv); code != protocol.ExitOK { | |
| 220 | t.Fatalf("%v: exit %d: %s", argv, code, errOut.String()) | |
| 221 | } | |
| 222 | return out.String() | |
| 223 | } | |
| 224 | verb := via(t, Term{Cols: 100}, "--path=auth keys remove", "help", "keys", "remove") | |
| 225 | if !strings.Contains(verb, "USAGE\n gitbay auth keys remove <fingerprint>") { | |
| 226 | t.Errorf("verb usage:\n%s", verb) | |
| 227 | } | |
| 228 | topics := via(t, Term{Cols: 100}, "--path=repo topics list", "help", "repo", "topics") | |
| 229 | for _, want := range []string{"USAGE\n gitbay repo topics list [<owner/name>]", "SEE ALSO\n gitbay repo topics add\n"} { | |
| 230 | if !strings.Contains(topics, want) { | |
| 231 | t.Errorf("missing %q in:\n%s", want, topics) | |
| 232 | } | |
| 233 | } | |
| 234 | if strings.Contains(topics, "topics list add") { | |
| 235 | t.Errorf("SEE ALSO renamed a child after the leaf:\n%s", topics) | |
| 236 | } | |
| 237 | noun := via(t, Term{Cols: 100}, "--path=auth keys", "help", "keys") | |
| 238 | for _, want := range []string{"USAGE\n gitbay auth keys <verb> ...\n", "gitbay auth keys <verb> --help for flags.\n"} { | |
| 239 | if !strings.Contains(noun, want) { | |
| 240 | t.Errorf("missing %q in:\n%s", want, noun) | |
| 241 | } | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | func TestHelpRendersAnAliasedNounWithTheRegistryLayout(t *testing.T) { | |
| 246 | var out, errOut bytes.Buffer | |
| 247 | c := &Ctx{Stdout: &out, Stderr: &errOut, Term: Term{Cols: 100}, Scope: "full", CLIPath: "auth"} | |
| 248 | c.Cfg.Server.SiteURL = "https://forge.test" | |
| 249 | if code := Dispatch(c, []string{"help", "auth"}); code != protocol.ExitOK { | |
| 250 | t.Fatalf("exit %d: %s", code, errOut.String()) | |
| 251 | } | |
| 252 | got := out.String() | |
| 253 | for _, want := range []string{"auth whoami", "auth keys list", "auth pgp add", "auth token create", "auth export"} { | |
| 254 | if !strings.Contains(got, want) { | |
| 255 | t.Errorf("missing %q in:\n%s", want, got) | |
| 256 | } | |
| 257 | } | |
| 258 | if strings.Contains(got, "no command matches") { | |
| 259 | t.Errorf("auth did not resolve: %s", got) | |
| 260 | } | |
| 261 | } | |
| 262 | ||
| 263 | func TestHelpRendersAnAliasedNounInRegisteredFormOverSSH(t *testing.T) { | |
| 264 | got := helpOut(t, Term{}, "auth") | |
| 265 | for _, want := range []string{"whoami", "keys list", "pgp add", "token create", "account export"} { | |
| 266 | if !strings.Contains(got, want) { | |
| 267 | t.Errorf("missing %q in:\n%s", want, got) | |
| 268 | } | |
| 269 | } | |
| 270 | if strings.Contains(got, "auth keys list") { | |
| 271 | t.Errorf("stock ssh should not see the CLI-only auth prefix: %s", got) | |
| 272 | } | |
| 273 | if strings.Contains(got, " auth <verb>") { | |
| 274 | t.Errorf("stock ssh should not be told to type auth <verb>, which the server has no command for: %s", got) | |
| 275 | } | |
| 276 | } | |
internal/control/identity.go +1 −1
| @@ -152,7 +152,7 @@ func expiresText(t *time.Time, now time.Time) string { | ||
| 152 | 152 | } |
| 153 | 153 | |
| 154 | 154 | func runKeysAdd(c *Ctx, args []string) int { |
| 155 | f, err := parseFlags(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage}) | |
| 155 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage}) | |
| 156 | 156 | if err != nil { |
| 157 | 157 | return c.fail(protocol.ExitUsage, "%v", err) |
| 158 | 158 | } |
internal/control/import.go +1 −1
| @@ -39,7 +39,7 @@ esac | ||
| 39 | 39 | ` |
| 40 | 40 | |
| 41 | 41 | func runRepoImport(c *Ctx, args []string) int { |
| 42 | f, err := parseFlags(args, flagSpec{Values: []string{"--from"}, Bools: []string{"--private", "--token-stdin"}, MaxPos: 1, | |
| 42 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--from"}, Bools: []string{"--private", "--token-stdin"}, MaxPos: 1, | |
| 43 | 43 | Usage: "repo import <owner/name> --from <url> [--private] [--token-stdin]"}) |
| 44 | 44 | if err != nil { |
| 45 | 45 | return c.fail(protocol.ExitUsage, "%v", err) |
internal/control/issue.go +6 −6
| @@ -67,7 +67,7 @@ func init() { | ||
| 67 | 67 | }, |
| 68 | 68 | ReadsStdin: true, Run: runIssueEdit}) |
| 69 | 69 | register(Command{Path: []string{"issue", "comment"}, |
| 70 | Summary: "comment", | |
| 70 | Summary: "add a comment", | |
| 71 | 71 | Usage: "issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]", |
| 72 | 72 | Flags: []Flag{ |
| 73 | 73 | {"--message", "<m>", "the comment's text", ""}, |
| @@ -90,7 +90,7 @@ func init() { | ||
| 90 | 90 | Examples: []string{"issue reopen krz/gitbay 42"}, |
| 91 | 91 | Run: runIssueReopen}) |
| 92 | 92 | register(Command{Path: []string{"issue", "label"}, |
| 93 | Summary: "labels", | |
| 93 | Summary: "add or remove labels", | |
| 94 | 94 | Usage: "issue label <owner/name> <n> [--add <l>]... [--remove <l>]...", |
| 95 | 95 | Flags: []Flag{ |
| 96 | 96 | {"--add", "<l>", "label to add, may repeat", ""}, |
| @@ -99,7 +99,7 @@ func init() { | ||
| 99 | 99 | Examples: []string{"issue label krz/gitbay 42 --add bug --remove needs-triage"}, |
| 100 | 100 | Run: runIssueLabel}) |
| 101 | 101 | register(Command{Path: []string{"issue", "assign"}, |
| 102 | Summary: "assignees", | |
| 102 | Summary: "add or remove assignees", | |
| 103 | 103 | Usage: "issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...", |
| 104 | 104 | Flags: []Flag{ |
| 105 | 105 | {"--add", "<user>", "user to assign, may repeat", ""}, |
| @@ -178,7 +178,7 @@ func issueToOut(i store.Issue, withBody bool) issueOut { | ||
| 178 | 178 | } |
| 179 | 179 | |
| 180 | 180 | func runIssueCreate(c *Ctx, args []string) int { |
| 181 | f, err := parseFlags(args, flagSpec{Values: []string{"--format", "--title", "--body", "--file"}, MaxPos: 1, | |
| 181 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--format", "--title", "--body", "--file"}, MaxPos: 1, | |
| 182 | 182 | Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]"}) |
| 183 | 183 | if err != nil { |
| 184 | 184 | return c.fail(protocol.ExitUsage, "%v", err) |
| @@ -231,7 +231,7 @@ func runIssueList(c *Ctx, args []string) int { | ||
| 231 | 231 | return code |
| 232 | 232 | } |
| 233 | 233 | f := store.IssueFilter{State: "open"} |
| 234 | fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 234 | fl, err := c.parseArgs(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 235 | 235 | if err != nil { |
| 236 | 236 | return c.fail(protocol.ExitUsage, "%v", err) |
| 237 | 237 | } |
| @@ -371,7 +371,7 @@ func setIssueState(c *Ctx, args []string, state string) int { | ||
| 371 | 371 | // they are accepted and reported in the returned flags, and count toward |
| 372 | 372 | // "at least one edit was given" alongside title/body/format. |
| 373 | 373 | func editText(c *Ctx, args []string, kind string, extra ...string) (rest []string, title, body, format *string, f flags, code int) { |
| 374 | f, err := parseFlags(args, flagSpec{Values: append([]string{"--title", "--body", "--file", "--format"}, extra...), MaxPos: -1, | |
| 374 | f, err := c.parseArgs(args, flagSpec{Values: append([]string{"--title", "--body", "--file", "--format"}, extra...), MaxPos: -1, | |
| 375 | 375 | Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"}) |
| 376 | 376 | if err != nil { |
| 377 | 377 | return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "%v", err) |
internal/control/label.go +1 −1
| @@ -68,7 +68,7 @@ func runLabelList(c *Ctx, args []string) int { | ||
| 68 | 68 | } |
| 69 | 69 | |
| 70 | 70 | func runLabelSet(c *Ctx, args []string) int { |
| 71 | f, err := parseFlags(args, flagSpec{Values: []string{"--color"}, MaxPos: -1, Usage: c.Cmd.Usage}) | |
| 71 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--color"}, MaxPos: -1, Usage: c.Cmd.Usage}) | |
| 72 | 72 | if err != nil { |
| 73 | 73 | return c.fail(protocol.ExitUsage, "%v", err) |
| 74 | 74 | } |
internal/control/migrate.go +1 −1
| @@ -155,7 +155,7 @@ func migAttribution(src, kind, author, date string, n int64) string { | ||
| 155 | 155 | // the git push that follows cannot be refused by them. Resumable: markers |
| 156 | 156 | // skip everything already imported. |
| 157 | 157 | func runAccountImportBundle(c *Ctx, args []string) int { |
| 158 | f, err := parseFlags(args, flagSpec{Values: []string{"--source"}, MaxPos: 0, Usage: "account import-bundle [--source <host>] < bundle.json"}) | |
| 158 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--source"}, MaxPos: 0, Usage: "account import-bundle [--source <host>] < bundle.json"}) | |
| 159 | 159 | if err != nil { |
| 160 | 160 | return c.fail(protocol.ExitUsage, "%v", err) |
| 161 | 161 | } |
internal/control/milestone.go +2 −2
| @@ -62,7 +62,7 @@ func init() { | ||
| 62 | 62 | var duePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) |
| 63 | 63 | |
| 64 | 64 | func runMilestoneCreate(c *Ctx, args []string) int { |
| 65 | f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2, | |
| 65 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2, | |
| 66 | 66 | Usage: "milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]"}) |
| 67 | 67 | if err != nil { |
| 68 | 68 | return c.fail(protocol.ExitUsage, "%v", err) |
| @@ -93,7 +93,7 @@ func runMilestoneCreate(c *Ctx, args []string) int { | ||
| 93 | 93 | } |
| 94 | 94 | |
| 95 | 95 | func runMilestoneList(c *Ctx, args []string) int { |
| 96 | f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "milestone list <owner/name> [--state open|closed|all]"}) | |
| 96 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "milestone list <owner/name> [--state open|closed|all]"}) | |
| 97 | 97 | if err != nil { |
| 98 | 98 | return c.fail(protocol.ExitUsage, "%v", err) |
| 99 | 99 | } |
internal/control/mirrorcmd.go +1 −1
| @@ -43,7 +43,7 @@ func init() { | ||
| 43 | 43 | } |
| 44 | 44 | |
| 45 | 45 | func runMirrorAdd(c *Ctx, args []string) int { |
| 46 | f, err := parseFlags(args, flagSpec{Values: []string{"--direction", "--username"}, Bools: []string{"--token-stdin"}, MaxPos: 2, | |
| 46 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--direction", "--username"}, Bools: []string{"--token-stdin"}, MaxPos: 2, | |
| 47 | 47 | Usage: "repo mirror add <owner/name> <url> --direction push|pull [--username <u>] [--token-stdin]"}) |
| 48 | 48 | if err != nil { |
| 49 | 49 | return c.fail(protocol.ExitUsage, "%v", err) |
internal/control/mr.go +9 −9
| @@ -143,7 +143,7 @@ func init() { | ||
| 143 | 143 | Examples: []string{"mr retarget krz/gitbay 431 main"}, |
| 144 | 144 | Run: runMRRetarget}) |
| 145 | 145 | register(Command{Path: []string{"mr", "comment"}, |
| 146 | Summary: "comment", | |
| 146 | Summary: "add a comment", | |
| 147 | 147 | Usage: "mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]", |
| 148 | 148 | Flags: []Flag{ |
| 149 | 149 | {"--message", "<m>", "the comment's text", ""}, |
| @@ -153,7 +153,7 @@ func init() { | ||
| 153 | 153 | Examples: []string{`mr comment krz/gitbay 431 --message "looks good"`}, |
| 154 | 154 | ReadsStdin: true, Run: runMRComment}) |
| 155 | 155 | register(Command{Path: []string{"mr", "review"}, |
| 156 | Summary: "review", | |
| 156 | Summary: "record a review verdict", | |
| 157 | 157 | Usage: "mr review <owner/name> <n> --approve|--request-changes|--comment|--discard", |
| 158 | 158 | Flags: []Flag{ |
| 159 | 159 | {"--approve", "", "approve the merge request", ""}, |
| @@ -173,7 +173,7 @@ func init() { | ||
| 173 | 173 | Examples: []string{"mr review request krz/gitbay 431 --add cmc"}, |
| 174 | 174 | Run: runMRReviewRequest}) |
| 175 | 175 | register(Command{Path: []string{"mr", "label"}, |
| 176 | Summary: "labels", | |
| 176 | Summary: "add or remove labels", | |
| 177 | 177 | Usage: "mr label <owner/name> <n> [--add <l>]... [--remove <l>]...", |
| 178 | 178 | Flags: []Flag{ |
| 179 | 179 | {"--add", "<l>", "label to add, may repeat", ""}, |
| @@ -207,7 +207,7 @@ type ForkOut struct { | ||
| 207 | 207 | } |
| 208 | 208 | |
| 209 | 209 | func runRepoFork(c *Ctx, args []string) int { |
| 210 | f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--owner"}, MaxPos: 1, Usage: "repo fork <owner/name> [--owner <o>] [--name <n>]"}) | |
| 210 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--name", "--owner"}, MaxPos: 1, Usage: "repo fork <owner/name> [--owner <o>] [--name <n>]"}) | |
| 211 | 211 | if err != nil { |
| 212 | 212 | return c.fail(protocol.ExitUsage, "%v", err) |
| 213 | 213 | } |
| @@ -393,7 +393,7 @@ func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool | ||
| 393 | 393 | func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) } |
| 394 | 394 | |
| 395 | 395 | func runMRCreate(c *Ctx, args []string) int { |
| 396 | f, err := parseFlags(args, flagSpec{Values: []string{"--source", "--target", "--title", "--body", "--file", "--format"}, | |
| 396 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--source", "--target", "--title", "--body", "--file", "--format"}, | |
| 397 | 397 | Bools: []string{"--draft"}, MaxPos: 1, |
| 398 | 398 | Usage: "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--draft]"}) |
| 399 | 399 | if err != nil { |
| @@ -566,7 +566,7 @@ func runMRList(c *Ctx, args []string) int { | ||
| 566 | 566 | return code |
| 567 | 567 | } |
| 568 | 568 | f := store.MRFilter{State: "open"} |
| 569 | fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--label", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 569 | fl, err := c.parseArgs(args, flagSpec{Values: []string{"--state", "--label", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 570 | 570 | if err != nil { |
| 571 | 571 | return c.fail(protocol.ExitUsage, "%v", err) |
| 572 | 572 | } |
| @@ -1205,7 +1205,7 @@ func runMRLabel(c *Ctx, args []string) int { | ||
| 1205 | 1205 | } |
| 1206 | 1206 | |
| 1207 | 1207 | func runMRMerge(c *Ctx, args []string) int { |
| 1208 | f, err := parseFlags(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"}) | |
| 1208 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"}) | |
| 1209 | 1209 | if err != nil { |
| 1210 | 1210 | return c.fail(protocol.ExitUsage, "%v", err) |
| 1211 | 1211 | } |
| @@ -1775,7 +1775,7 @@ func setMRDraft(c *Ctx, args []string, draft bool) int { | ||
| 1775 | 1775 | } |
| 1776 | 1776 | |
| 1777 | 1777 | func runMRClose(c *Ctx, args []string) int { |
| 1778 | f, err := parseFlags(args, flagSpec{Values: []string{"--by"}, MaxPos: 2, | |
| 1778 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--by"}, MaxPos: 2, | |
| 1779 | 1779 | Usage: "mr close <owner/name> <n> [--by <m>]"}) |
| 1780 | 1780 | if err != nil { |
| 1781 | 1781 | return c.fail(protocol.ExitUsage, "%v", err) |
| @@ -1907,7 +1907,7 @@ func runMRRevisions(c *Ctx, args []string) int { | ||
| 1907 | 1907 | } |
| 1908 | 1908 | |
| 1909 | 1909 | func runMRRangeDiff(c *Ctx, args []string) int { |
| 1910 | f, err := parseFlags(args, flagSpec{Values: []string{"--from", "--to"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 1910 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--from", "--to"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 1911 | 1911 | if err != nil { |
| 1912 | 1912 | return c.fail(protocol.ExitUsage, "%v", err) |
| 1913 | 1913 | } |
internal/control/notifications.go +3 −3
| @@ -285,7 +285,7 @@ func runNotificationsSettingsPush(c *Ctx, args []string) int { | ||
| 285 | 285 | const maxDeviceTokenBytes = 512 |
| 286 | 286 | |
| 287 | 287 | func runNotificationsDeviceAdd(c *Ctx, args []string) int { |
| 288 | f, err := parseFlags(args, flagSpec{Values: []string{"--label"}, Usage: c.Cmd.Usage}) | |
| 288 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--label"}, Usage: c.Cmd.Usage}) | |
| 289 | 289 | if err != nil { |
| 290 | 290 | return c.fail(protocol.ExitUsage, "%v", err) |
| 291 | 291 | } |
| @@ -389,7 +389,7 @@ func runNotificationsList(c *Ctx, args []string) int { | ||
| 389 | 389 | if code >= 0 { |
| 390 | 390 | return code |
| 391 | 391 | } |
| 392 | fl, err := parseFlags(rest, flagSpec{Bools: []string{"--all"}, Usage: c.Cmd.Usage}) | |
| 392 | fl, err := c.parseArgs(rest, flagSpec{Bools: []string{"--all"}, Usage: c.Cmd.Usage}) | |
| 393 | 393 | if err != nil { |
| 394 | 394 | return c.fail(protocol.ExitUsage, "%v", err) |
| 395 | 395 | } |
| @@ -443,7 +443,7 @@ func runNotificationsList(c *Ctx, args []string) int { | ||
| 443 | 443 | } |
| 444 | 444 | |
| 445 | 445 | func runNotificationsRead(c *Ctx, args []string) int { |
| 446 | fl, err := parseFlags(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: c.Cmd.Usage}) | |
| 446 | fl, err := c.parseArgs(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: c.Cmd.Usage}) | |
| 447 | 447 | if err != nil { |
| 448 | 448 | return c.fail(protocol.ExitUsage, "%v", err) |
| 449 | 449 | } |
internal/control/org.go +1 −1
| @@ -214,7 +214,7 @@ func runOrgDelete(c *Ctx, args []string) int { | ||
| 214 | 214 | } |
| 215 | 215 | |
| 216 | 216 | func runOrgMembersAdd(c *Ctx, args []string) int { |
| 217 | f, err := parseFlags(args, flagSpec{Values: []string{"--role"}, MaxPos: -1, Usage: "org members add <org> <user> [--role member|admin]"}) | |
| 217 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--role"}, MaxPos: -1, Usage: "org members add <org> <user> [--role member|admin]"}) | |
| 218 | 218 | if err != nil { |
| 219 | 219 | return c.fail(protocol.ExitUsage, "%v", err) |
| 220 | 220 | } |
internal/control/orglabel.go +3 −3
| @@ -86,7 +86,7 @@ func orgReader(c *Ctx, name string) (store.Org, []int64, int) { | ||
| 86 | 86 | } |
| 87 | 87 | |
| 88 | 88 | func runOrgLabelSet(c *Ctx, args []string) int { |
| 89 | f, err := parseFlags(args, flagSpec{Values: []string{"--color"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 89 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--color"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 90 | 90 | if err != nil { |
| 91 | 91 | return c.fail(protocol.ExitUsage, "%v", err) |
| 92 | 92 | } |
| @@ -180,7 +180,7 @@ func runOrgLabelRemove(c *Ctx, args []string) int { | ||
| 180 | 180 | } |
| 181 | 181 | |
| 182 | 182 | func runOrgMilestoneCreate(c *Ctx, args []string) int { |
| 183 | f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 183 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 184 | 184 | if err != nil { |
| 185 | 185 | return c.fail(protocol.ExitUsage, "%v", err) |
| 186 | 186 | } |
| @@ -212,7 +212,7 @@ func runOrgMilestoneCreate(c *Ctx, args []string) int { | ||
| 212 | 212 | } |
| 213 | 213 | |
| 214 | 214 | func runOrgMilestoneList(c *Ctx, args []string) int { |
| 215 | f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "org milestone list <org> [--state open|closed|all]"}) | |
| 215 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "org milestone list <org> [--state open|closed|all]"}) | |
| 216 | 216 | if err != nil { |
| 217 | 217 | return c.fail(protocol.ExitUsage, "%v", err) |
| 218 | 218 | } |
internal/control/read.go +2 −2
| @@ -110,7 +110,7 @@ func runRepoRefs(c *Ctx, args []string) int { | ||
| 110 | 110 | const BlameSpan = 1000 |
| 111 | 111 | |
| 112 | 112 | func runRepoBlame(c *Ctx, args []string) int { |
| 113 | f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--from", "--to"}, MaxPos: -1, Usage: c.Cmd.Usage}) | |
| 113 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--from", "--to"}, MaxPos: -1, Usage: c.Cmd.Usage}) | |
| 114 | 114 | if err != nil { |
| 115 | 115 | return c.fail(protocol.ExitUsage, "%v", err) |
| 116 | 116 | } |
| @@ -220,7 +220,7 @@ func runRepoBlame(c *Ctx, args []string) int { | ||
| 220 | 220 | // off argv. Positionals are returned in order so each command can name them |
| 221 | 221 | // in its own usage message. |
| 222 | 222 | func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) { |
| 223 | f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: maxPos, Usage: c.Cmd.Usage}) | |
| 223 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: maxPos, Usage: c.Cmd.Usage}) | |
| 224 | 224 | if err != nil { |
| 225 | 225 | return nil, "", c.fail(protocol.ExitUsage, "%v", err) |
| 226 | 226 | } |
internal/control/release.go +2 −2
| @@ -111,7 +111,7 @@ func releaseRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) | ||
| 111 | 111 | } |
| 112 | 112 | |
| 113 | 113 | func runReleaseCreate(c *Ctx, args []string) int { |
| 114 | f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 114 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 115 | 115 | if err != nil { |
| 116 | 116 | return c.fail(protocol.ExitUsage, "%v", err) |
| 117 | 117 | } |
| @@ -183,7 +183,7 @@ func releaseToOut(r store.Release, withNotes bool) releaseOut { | ||
| 183 | 183 | } |
| 184 | 184 | |
| 185 | 185 | func runReleaseEdit(c *Ctx, args []string) int { |
| 186 | f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 186 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 187 | 187 | if err != nil { |
| 188 | 188 | return c.fail(protocol.ExitUsage, "%v", err) |
| 189 | 189 | } |
internal/control/repo.go +3 −3
| @@ -249,7 +249,7 @@ func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) | ||
| 249 | 249 | } |
| 250 | 250 | |
| 251 | 251 | func runRepoCreate(c *Ctx, args []string) int { |
| 252 | f, err := parseFlags(args, flagSpec{Values: []string{"--description"}, Bools: []string{"--private"}, MaxPos: 1, Usage: "repo create <owner/name> [--private] [--description <text>]"}) | |
| 252 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--description"}, Bools: []string{"--private"}, MaxPos: 1, Usage: "repo create <owner/name> [--private] [--description <text>]"}) | |
| 253 | 253 | if err != nil { |
| 254 | 254 | return c.fail(protocol.ExitUsage, "%v", err) |
| 255 | 255 | } |
| @@ -1020,7 +1020,7 @@ func MatchesRepo(q, path, desc string, topics []string) bool { | ||
| 1020 | 1020 | } |
| 1021 | 1021 | |
| 1022 | 1022 | func runRepoGrep(c *Ctx, args []string) int { |
| 1023 | f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: "repo grep <owner/name> <query> [--ref <ref>]"}) | |
| 1023 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: "repo grep <owner/name> <query> [--ref <ref>]"}) | |
| 1024 | 1024 | if err != nil { |
| 1025 | 1025 | return c.fail(protocol.ExitUsage, "%v", err) |
| 1026 | 1026 | } |
| @@ -1246,7 +1246,7 @@ func setProtect(c *Ctx, args []string, protect bool) int { | ||
| 1246 | 1246 | // base, measured from their merge base the way a merge request diff is, |
| 1247 | 1247 | // so a base that moved on does not show up as removals (#118). |
| 1248 | 1248 | func runRepoDiff(c *Ctx, args []string) int { |
| 1249 | f, err := parseFlags(args, flagSpec{MaxPos: 3, Usage: "repo diff <owner/name> <base> <head>"}) | |
| 1249 | f, err := c.parseArgs(args, flagSpec{MaxPos: 3, Usage: "repo diff <owner/name> <base> <head>"}) | |
| 1250 | 1250 | if err != nil || len(f.Pos) != 3 { |
| 1251 | 1251 | return c.usage() |
| 1252 | 1252 | } |
internal/control/runnerrepo.go +1 −1
| @@ -37,7 +37,7 @@ func init() { | ||
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | func runRepoRunnerAdd(c *Ctx, args []string) int { |
| 40 | f, err := parseFlags(args, flagSpec{MaxPos: 1, Usage: "repo runner add <owner/name> < key.pub"}) | |
| 40 | f, err := c.parseArgs(args, flagSpec{MaxPos: 1, Usage: "repo runner add <owner/name> < key.pub"}) | |
| 41 | 41 | if err != nil || len(f.Pos) != 1 { |
| 42 | 42 | return c.usage() |
| 43 | 43 | } |
internal/control/search.go +1 −1
| @@ -84,7 +84,7 @@ func Search(st *store.Store, root string, userID int64, q string, kinds []string | ||
| 84 | 84 | } |
| 85 | 85 | |
| 86 | 86 | func runSearch(c *Ctx, args []string) int { |
| 87 | f, err := parseFlags(args, flagSpec{Multi: []string{"--kind"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 87 | f, err := c.parseArgs(args, flagSpec{Multi: []string{"--kind"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 88 | 88 | if err != nil { |
| 89 | 89 | return c.fail(protocol.ExitUsage, "%v", err) |
| 90 | 90 | } |
internal/control/sig.go +1 −1
| @@ -141,7 +141,7 @@ func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sh | ||
| 141 | 141 | } |
| 142 | 142 | |
| 143 | 143 | func runRepoLog(c *Ctx, args []string) int { |
| 144 | f, perr := parseFlags(args, flagSpec{Values: []string{"--ref", "--limit", "--path"}, MaxPos: 1, Usage: "repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]"}) | |
| 144 | f, perr := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--limit", "--path"}, MaxPos: 1, Usage: "repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]"}) | |
| 145 | 145 | if perr != nil { |
| 146 | 146 | return c.fail(protocol.ExitUsage, "%v", perr) |
| 147 | 147 | } |
internal/control/snippet.go +2 −2
| @@ -158,7 +158,7 @@ func newSnippetID() string { | ||
| 158 | 158 | } |
| 159 | 159 | |
| 160 | 160 | func runSnippetCreate(c *Ctx, args []string) int { |
| 161 | f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 161 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 162 | 162 | if err != nil { |
| 163 | 163 | return c.fail(protocol.ExitUsage, "%v", err) |
| 164 | 164 | } |
| @@ -287,7 +287,7 @@ func runSnippetList(c *Ctx, args []string) int { | ||
| 287 | 287 | } |
| 288 | 288 | |
| 289 | 289 | func runSnippetEdit(c *Ctx, args []string) int { |
| 290 | f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 290 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 291 | 291 | if err != nil { |
| 292 | 292 | return c.fail(protocol.ExitUsage, "%v", err) |
| 293 | 293 | } |
internal/control/thread.go +1 −1
| @@ -77,7 +77,7 @@ func runComment(c *Ctx, args []string, t thread, noun string, | ||
| 77 | 77 | add func(id, userID int64, body, format string) error, |
| 78 | 78 | participants func(id int64) ([]int64, error), |
| 79 | 79 | ) int { |
| 80 | f, err := parseFlags(args, flagSpec{Values: []string{"--message", "--file", "--format"}, MaxPos: -1, | |
| 80 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--message", "--file", "--format"}, MaxPos: -1, | |
| 81 | 81 | Usage: noun + " comment <owner/name> <n> [--message <m> | --file -] [--format md|org]"}) |
| 82 | 82 | if err != nil { |
| 83 | 83 | return c.fail(protocol.ExitUsage, "%v", err) |
internal/control/token.go +2 −2
| @@ -65,7 +65,7 @@ func (c *Ctx) ttlFlag(f flags) (*time.Time, int) { | ||
| 65 | 65 | } |
| 66 | 66 | |
| 67 | 67 | func runTokenCreate(c *Ctx, args []string) int { |
| 68 | f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage}) | |
| 68 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage}) | |
| 69 | 69 | if err != nil { |
| 70 | 70 | return c.fail(protocol.ExitUsage, "%v", err) |
| 71 | 71 | } |
| @@ -128,7 +128,7 @@ func runTokenList(c *Ctx, args []string) int { | ||
| 128 | 128 | } |
| 129 | 129 | |
| 130 | 130 | func runTokenRevoke(c *Ctx, args []string) int { |
| 131 | f, err := parseFlags(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 131 | f, err := c.parseArgs(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 132 | 132 | if err != nil { |
| 133 | 133 | return c.fail(protocol.ExitUsage, "%v", err) |
| 134 | 134 | } |
internal/control/webhook.go +2 −2
| @@ -45,7 +45,7 @@ func init() { | ||
| 45 | 45 | } |
| 46 | 46 | |
| 47 | 47 | func runWebhookAdd(c *Ctx, args []string) int { |
| 48 | f, err := parseFlags(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]"}) | |
| 48 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]"}) | |
| 49 | 49 | if err != nil { |
| 50 | 50 | return c.fail(protocol.ExitUsage, "%v", err) |
| 51 | 51 | } |
| @@ -136,7 +136,7 @@ func runWebhookRemove(c *Ctx, args []string) int { | ||
| 136 | 136 | } |
| 137 | 137 | |
| 138 | 138 | func runWebhookDeliveries(c *Ctx, args []string) int { |
| 139 | f, err := parseFlags(args, flagSpec{Values: []string{"--limit"}, MaxPos: 1, Usage: "webhook deliveries <owner/name> [--limit n]"}) | |
| 139 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--limit"}, MaxPos: 1, Usage: "webhook deliveries <owner/name> [--limit n]"}) | |
| 140 | 140 | if err != nil { |
| 141 | 141 | return c.fail(protocol.ExitUsage, "%v", err) |
| 142 | 142 | } |