cli: help and usage print the command as typed !485

merged merged by cmc on 2026-09-28 22:05 UTC · krz/gitbay:cli-ux-help into main

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 separately (#275). 69 separately (#275).
70- Audit retention deletes by id, up to the newest row older than the 70- Audit retention deletes by id, up to the newest row older than the
71 retention, so a clock step back cannot leave a gap in the chain (#275). 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
73refuses the CLI's leading =--path== argument as an unknown command,
74for the eighteen commands whose CLI path differs from the registry's
75(the =gitbay auth ...= commands and =repo topics list=).
72- =dashboard= and =feed= print activity as sentences 76- =dashboard= and =feed= print activity as sentences
73 (=cmc opened issue krz/gitbay#12=) instead of raw event payloads, 77 (=cmc opened issue krz/gitbay#12=) instead of raw event payloads,
74 and a labelled event names its labels there and on the web feed. An 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 =open_issues= field of =dashboard --json= no longer includes issues 80 =open_issues= field of =dashboard --json= no longer includes issues
77 assigned to the caller. =notifications list= names =--all= when only 81 assigned to the caller. =notifications list= names =--all= when only
78 read items remain (#265). 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* v1.36.0 — 2026-09-23 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 // The registry is the only place flags are written down, so 253 // The registry is the only place flags are written down, so
254 // --help asks the server rather than reprinting the one-line 254 // --help asks the server rather than reprinting the one-line
255 // summary cobra holds. 255 // summary cobra holds.
256 cliPath := cliPathOf(cmd)
256 for _, a := range args { 257 for _, a := range args {
257 if a == "--help" || a == "-h" { 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 return nil 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// runServerHelp prints the registry's usage for one command. 279// runServerHelp prints the registry's usage for one command.
279func runServerHelp(o passOpts) int { 280func runServerHelp(o passOpts, cliPath string) int {
280 t, err := resolveTarget() 281 t, err := resolveTarget()
281 if err != nil { 282 if err != nil {
282 fmt.Fprintln(os.Stderr, "gitbay:", err) 283 fmt.Fprintln(os.Stderr, "gitbay:", err)
283 return protocol.ExitFailure 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
288func runPass(o passOpts, args []string) int { 290func runPass(o passOpts, cliPath string, args []string) int {
289 t, err := resolveTarget() 291 t, err := resolveTarget()
290 if err != nil { 292 if err != nil {
291 fmt.Fprintln(os.Stderr, "gitbay:", err) 293 fmt.Fprintln(os.Stderr, "gitbay:", err)
@@ -343,7 +345,8 @@ func runPass(o passOpts, args []string) int {
343 stdin = r 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
349func isEmptyReader(r io.Reader) bool { 352func isEmptyReader(r io.Reader) bool {
@@ -380,27 +383,51 @@ func group(use, short string, subs ...*cobra.Command) *cobra.Command {
380 // know by that name, cobra's own tree still prints. 383 // know by that name, cobra's own tree still prints.
381 local := c.HelpFunc() 384 local := c.HelpFunc()
382 c.SetHelpFunc(func(cmd *cobra.Command, args []string) { 385 c.SetHelpFunc(func(cmd *cobra.Command, args []string) {
383 if !serverHelp(use) { 386 if !serverHelp(use, cliPathOf(c)) {
384 local(cmd, args) 387 local(cmd, args)
385 } 388 }
386 }) 389 })
387 return c 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.
401var 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.
407func 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// serverHelp prints the registry's usage for a prefix and reports whether 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 416// it did. cliPath is the group's own path in the CLI. At a terminal it
392// gets the same --term=<cols>[,color] treatment (and layout) as any other 417// goes through the terminal-aware path, so it gets the same
393// command; piped, it stays a quiet capture, so a network or lookup 418// --term=<cols>[,color] treatment (and layout) as any other command;
394// failure falls back to cobra's local help without noise. 419// piped, it stays a quiet capture, so a network or lookup failure falls
395func serverHelp(prefix string) bool { 420// back to cobra's local help without noise.
421func serverHelp(prefix, cliPath string) bool {
396 t, err := resolveTarget() 422 t, err := resolveTarget()
397 if err != nil { 423 if err != nil {
398 return false 424 return false
399 } 425 }
426 argv := helpArgv(prefix, cliPath)
400 if term.IsTerminal(int(os.Stdout.Fd())) { 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 if code != 0 || out == "" { 431 if code != 0 || out == "" {
405 return false 432 return false
406 } 433 }
@@ -430,6 +457,12 @@ func authCmd() *cobra.Command {
430 keysAdd := pass("add", passOpts{server: []string{"keys", "add"}, alwaysStdin: true, stdinWhat: "an SSH public key"}) 457 keysAdd := pass("add", passOpts{server: []string{"keys", "add"}, alwaysStdin: true, stdinWhat: "an SSH public key"})
431 // keys add always reads stdin on the server; wire it through directly. 458 // keys add always reads stdin on the server; wire it through directly.
432 keysAdd.RunE = func(cmd *cobra.Command, args []string) error { 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 t, err := resolveTarget() 466 t, err := resolveTarget()
434 if err != nil { 467 if err != nil {
435 return err 468 return err
@@ -438,7 +471,7 @@ func authCmd() *cobra.Command {
438 if err != nil { 471 if err != nil {
439 return err 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 return nil 475 return nil
443 } 476 }
444 pgpAdd := &cobra.Command{ 477 pgpAdd := &cobra.Command{
@@ -450,6 +483,12 @@ func authCmd() *cobra.Command {
450 }, 483 },
451 DisableFlagParsing: true, 484 DisableFlagParsing: true,
452 RunE: func(cmd *cobra.Command, args []string) error { 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 t, err := resolveTarget() 492 t, err := resolveTarget()
454 if err != nil { 493 if err != nil {
455 return err 494 return err
@@ -458,7 +497,7 @@ func authCmd() *cobra.Command {
458 if err != nil { 497 if err != nil {
459 return err 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 return nil 501 return nil
463 }, 502 },
464 } 503 }
@@ -467,7 +506,7 @@ func authCmd() *cobra.Command {
467 pass("list", passOpts{server: []string{"token", "list"}}), 506 pass("list", passOpts{server: []string{"token", "list"}}),
468 pass("revoke", passOpts{server: []string{"token", "revoke"}}), 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 pass("export", passOpts{server: []string{"account", "export"}}), 510 pass("export", passOpts{server: []string{"account", "export"}}),
472 tokens, 511 tokens,
473 pass("whoami", passOpts{server: []string{"whoami"}}), 512 pass("whoami", passOpts{server: []string{"whoami"}}),
cmd/gitbay/serverpath_test.go added +81
@@ -0,0 +1,81 @@
1package main
2
3import (
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.
13func 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).
51func 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.
63func 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.
75func 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 "gitbay.org/gitbay/internal/cliconfig" 17 "gitbay.org/gitbay/internal/cliconfig"
18 "gitbay.org/gitbay/internal/protocol" 18 "gitbay.org/gitbay/internal/protocol"
19 "gitbay.org/gitbay/internal/toolpath" 19 "gitbay.org/gitbay/internal/toolpath"
20 "github.com/spf13/cobra"
20) 21)
21 22
22// context is the resolved target for a command: which instance to talk to 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 return false 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".
172func 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=.
180func 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// runSSH executes the server command over the system ssh binary, wiring 187// runSSH executes the server command over the system ssh binary, wiring
170// stdio through, with no pager. It returns the remote exit code. 188// stdio through, with no pager. It returns the remote exit code.
171func runSSH(t target, serverArgv []string, stdin io.Reader) int { 189func 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 "explore": "list public repositories", 42 "explore": "list public repositories",
43 "feed": "activity on repositories you can reach", 43 "feed": "activity on repositories you can reach",
44 "help": "list available commands", 44 "help": "list available commands",
45 "issue assign": "assignees", 45 "issue assign": "add or remove assignees",
46 "issue close": "close an issue", 46 "issue close": "close an issue",
47 "issue comment": "comment", 47 "issue comment": "add a comment",
48 "issue create": "open an issue", 48 "issue create": "open an issue",
49 "issue edit": "edit title or body", 49 "issue edit": "edit title or body",
50 "issue label": "labels", 50 "issue label": "add or remove labels",
51 "issue list": "list issues", 51 "issue list": "list issues",
52 "issue milestone": "set or clear an issue's milestone", 52 "issue milestone": "set or clear an issue's milestone",
53 "issue reopen": "reopen an issue", 53 "issue reopen": "reopen an issue",
@@ -65,13 +65,13 @@ var summaries = map[string]string{
65 "milestone list": "list milestones with progress", 65 "milestone list": "list milestones with progress",
66 "milestone reopen": "reopen a milestone", 66 "milestone reopen": "reopen a milestone",
67 "mr close": "close without merging", 67 "mr close": "close without merging",
68 "mr comment": "comment", 68 "mr comment": "add a comment",
69 "mr create": "open a merge request", 69 "mr create": "open a merge request",
70 "mr diff": "show the diff", 70 "mr diff": "show the diff",
71 "mr diff-comment": "comment on a diff line", 71 "mr diff-comment": "comment on a diff line",
72 "mr draft": "mark a merge request as work in progress", 72 "mr draft": "mark a merge request as work in progress",
73 "mr edit": "edit title or body", 73 "mr edit": "edit title or body",
74 "mr label": "labels", 74 "mr label": "add or remove labels",
75 "mr list": "list merge requests", 75 "mr list": "list merge requests",
76 "mr merge": "merge", 76 "mr merge": "merge",
77 "mr milestone": "set or clear an MR's milestone", 77 "mr milestone": "set or clear an MR's milestone",
@@ -80,7 +80,7 @@ var summaries = map[string]string{
80 "mr resolve": "resolve a review thread", 80 "mr resolve": "resolve a review thread",
81 "mr retarget": "retarget onto another branch", 81 "mr retarget": "retarget onto another branch",
82 "mr review request": "ask specific people for a review", 82 "mr review request": "ask specific people for a review",
83 "mr review": "review", 83 "mr review": "record a review verdict",
84 "mr revisions": "the heads a merge request has had", 84 "mr revisions": "the heads a merge request has had",
85 "mr show": "show a merge request", 85 "mr show": "show a merge request",
86 "mr threads": "review threads on an MR", 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).
72func 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 @@
1package main 1package main
2 2
3import ( 3import (
4 "slices"
4 "strings" 5 "strings"
5 "testing" 6 "testing"
7
8 "github.com/spf13/cobra"
6) 9)
7 10
8func TestTermValue(t *testing.T) { 11func TestTermValue(t *testing.T) {
@@ -77,3 +80,31 @@ func TestPages(t *testing.T) {
77 t.Error("list must not page") 80 t.Error("list must not page")
78 } 81 }
79} 82}
83
84func 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
97func 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 @@
1package e2e
2
3import (
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.
14func 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).
89func 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 if code >= 0 { 137 if code >= 0 {
138 return code 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 Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]"}) 141 Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]"})
142 if err != nil { 142 if err != nil {
143 return c.fail(protocol.ExitUsage, "%v", err) 143 return c.fail(protocol.ExitUsage, "%v", err)
@@ -410,7 +410,7 @@ func runAdminRepoList(c *Ctx, args []string) int {
410 if code >= 0 { 410 if code >= 0 {
411 return code 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 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]"}) 414 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]"})
415 if err != nil { 415 if err != nil {
416 return c.fail(protocol.ExitUsage, "%v", err) 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 if code := requireInstanceAdmin(c); code >= 0 { 77 if code := requireInstanceAdmin(c); code >= 0 {
78 return code 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 if err != nil { 81 if err != nil {
82 return c.fail(protocol.ExitUsage, "%v", err) 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 return c.fail(protocol.ExitDenied, "the audit log is for instance admins; ask one") 29 return c.fail(protocol.ExitDenied, "the audit log is for instance admins; ask one")
30 } 30 }
31 f := store.AuditFilter{Limit: 100} 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 if err != nil { 33 if err != nil {
34 return c.fail(protocol.ExitUsage, "%v", err) 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 if code >= 0 { 158 if code >= 0 {
159 return code 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 if err != nil { 162 if err != nil {
163 return c.fail(protocol.ExitUsage, "%v", err) 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
240func runBuildLog(c *Ctx, args []string) int { 240func 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 if err != nil { 242 if err != nil {
243 return c.fail(protocol.ExitUsage, "%v", err) 243 return c.fail(protocol.ExitUsage, "%v", err)
244 } 244 }
@@ -465,7 +465,7 @@ func runRunnerNext(c *Ctx, args []string) int {
465 if code >= 0 { 465 if code >= 0 {
466 return code 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 Usage: "runner next [--untrusted] [<owner/name>...]"}) 469 Usage: "runner next [--untrusted] [<owner/name>...]"})
470 if err != nil { 470 if err != nil {
471 return c.fail(protocol.ExitUsage, "%v", err) 471 return c.fail(protocol.ExitUsage, "%v", err)
internal/control/commitfile.go +1 −1
@@ -43,7 +43,7 @@ const maxCommitFileBytes = 1 << 20
43// A repository that requires verified signatures therefore refuses the 43// A repository that requires verified signatures therefore refuses the
44// command rather than writing a commit its own policy would reject. 44// command rather than writing a commit its own policy would reject.
45func runCommitFile(c *Ctx, args []string) int { 45func 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 if err != nil { 47 if err != nil {
48 return c.fail(protocol.ExitUsage, "%v", err) 48 return c.fail(protocol.ExitUsage, "%v", err)
49 } 49 }
internal/control/control.go +27 −12
@@ -30,6 +30,12 @@ type Ctx struct {
30 // Term is the client's terminal, from GITBAY_TERM. The zero value 30 // Term is the client's terminal, from GITBAY_TERM. The zero value
31 // is plain output. 31 // is plain output.
32 Term Term 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 // ViaAPI marks requests arriving over HTTP, from the token API or 39 // ViaAPI marks requests arriving over HTTP, from the token API or
34 // the web. Every command runs there; nothing is held back for SSH 40 // the web. Every command runs there; nothing is held back for SSH
35 // any more (#234). The flag stays because the rate limiter and the 41 // any more (#234). The flag stays because the rate limiter and the
@@ -66,13 +72,13 @@ type Ctx struct {
66// usage reports a bad invocation with the command's registered usage, 72// usage reports a bad invocation with the command's registered usage,
67// the one source of it. 73// the one source of it.
68func (c *Ctx) usage() int { 74func (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// usageWith reports a specific problem with the arguments, then the 78// usageWith reports a specific problem with the arguments, then the
73// registered usage, so a person always sees the shape that was expected. 79// registered usage, so a person always sees the shape that was expected.
74func (c *Ctx) usageWith(msg string) int { 80func (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// Flag is one flag in a command's help. 84// Flag is one flag in a command's help.
@@ -132,18 +138,27 @@ func Dispatch(c *Ctx, argv []string) int {
132 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help") 138 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
133 } 139 }
134 // A leading --term=<v> selects terminal output for this session, the 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 141 // same as GITBAY_TERM; a leading --path=<v> is the CLI's own path for
136 // argv against a command's Path, and a --term= in front would never 142 // the command (Ctx.CLIPath). Both come off before Lookup, in either
137 // match one. Over HTTP it is dropped unread: the web and the API 143 // order: Lookup matches argv against a command's Path, and either in
138 // render no terminal. 144 // front would never match one. Over HTTP both are dropped unread: the
139 if v, ok := strings.CutPrefix(argv[0], "--term="); ok { 145 // web and the API render no terminal and have no CLI path.
140 if !c.ViaAPI { 146 for len(argv) > 0 {
141 c.Term = ParseTerm(v) 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 argv = argv[1:] 158 argv = argv[1:]
144 if len(argv) == 0 { 159 }
145 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help") 160 if len(argv) == 0 {
146 } 161 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
147 } 162 }
148 cmd, rest, ok := Lookup(argv) 163 cmd, rest, ok := Lookup(argv)
149 c.Cmd = cmd 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.
265func 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// TestArgumentRefusalsNameTheUsage: a missing positional argument is a 291// TestArgumentRefusalsNameTheUsage: a missing positional argument is a
264// usage error that prints the registered usage, the shared reference 292// usage error that prints the registered usage, the shared reference
265// helpers included (#215). 293// helpers included (#215).
@@ -267,12 +295,40 @@ func TestArgumentRefusalsNameTheUsage(t *testing.T) {
267 for _, argv := range [][]string{{"build", "show"}, {"release", "show"}, {"mr", "resolve"}, {"issue", "show"}} { 295 for _, argv := range [][]string{{"build", "show"}, {"release", "show"}, {"mr", "resolve"}, {"issue", "show"}} {
268 var out, errOut bytes.Buffer 296 var out, errOut bytes.Buffer
269 c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut} 297 c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut}
298 c.Cfg.Server.SiteURL = "https://forge.test"
270 if code := Dispatch(c, argv); code != protocol.ExitUsage { 299 if code := Dispatch(c, argv); code != protocol.ExitUsage {
271 t.Errorf("%v: exit %d, want %d (%s)", argv, code, protocol.ExitUsage, errOut.String()) 300 t.Errorf("%v: exit %d, want %d (%s)", argv, code, protocol.ExitUsage, errOut.String())
272 continue 301 continue
273 } 302 }
274 if !strings.Contains(errOut.String(), "usage: "+strings.Join(argv, " ")) { 303 want := "usage: ssh git@forge.test " + strings.Join(argv, " ")
275 t.Errorf("%v: no usage line: %q", argv, errOut.String()) 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).
312func 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
39func runDeployKeyAdd(c *Ctx, args []string) int { 39func 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 if err != nil { 41 if err != nil {
42 return c.fail(protocol.ExitUsage, "%v", err) 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
52func runDiffComment(c *Ctx, args []string) int { 52func 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 Bools: []string{"--old", "--pending"}, MaxPos: -1, 54 Bools: []string{"--old", "--pending"}, MaxPos: -1,
55 Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--old] [--pending] [--reply <id>] [--message <m> | --file -]"}) 55 Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--old] [--pending] [--reply <id>] [--message <m> | --file -]"})
56 if err != nil { 56 if err != nil {
internal/control/explore.go +1 −1
@@ -95,7 +95,7 @@ func runExplore(c *Ctx, args []string) int {
95// release asset get writes an asset. The web's /archive route is the 95// release asset get writes an asset. The web's /archive route is the
96// same bytes with a Content-Disposition on them. 96// same bytes with a Content-Disposition on them.
97func runRepoDownload(c *Ctx, args []string) int { 97func 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 if err != nil { 99 if err != nil {
100 return c.fail(protocol.ExitUsage, "%v", err) 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 return f, nil 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.
100func (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// pos is the nth positional argument, or "" when absent. 110// pos is the nth positional argument, or "" when absent.
96func (f flags) pos(n int) string { 111func (f flags) pos(n int) string {
97 if n < len(f.Pos) { 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
150func runImportIssues(c *Ctx, args []string) int { 150func 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 Usage: "repo import-issues <owner/name> --from <owner/repo> [--api-base <url>] [--token-stdin]"}) 152 Usage: "repo import-issues <owner/name> --from <owner/repo> [--api-base <url>] [--token-stdin]"})
153 if err != nil { 153 if err != nil {
154 return c.fail(protocol.ExitUsage, "%v", err) 154 return c.fail(protocol.ExitUsage, "%v", err)
internal/control/help.go +165 −18
@@ -28,6 +28,7 @@ var nounSummaries = map[string]string{
28 "account": "export or import your account, for instance migration", 28 "account": "export or import your account, for instance migration",
29 "admin": "instance administration (admins)", 29 "admin": "instance administration (admins)",
30 "audit": "instance audit log (admins)", 30 "audit": "instance audit log (admins)",
31 "auth": "whoami, SSH and PGP keys, email, API tokens",
31 "build": "CI builds", 32 "build": "CI builds",
32 "dashboard": "pinned repos, open MRs, assigned issues, recent builds", 33 "dashboard": "pinned repos, open MRs, assigned issues, recent builds",
33 "email": "manage email addresses", 34 "email": "manage email addresses",
@@ -61,6 +62,45 @@ var nounSummaries = map[string]string{
61// test (task 4.5). 62// test (task 4.5).
62func NounSummaries() map[string]string { return nounSummaries } 63func 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.
72type 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.
83var 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.
96func 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// helpEntry is one row of the registry as help reports it. 104// helpEntry is one row of the registry as help reports it.
65type helpEntry struct { 105type helpEntry struct {
66 Path string `json:"path"` 106 Path string `json:"path"`
@@ -76,11 +116,33 @@ type helpEntry struct {
76// noun with several commands under it renders a READ/WRITE summary. 116// noun with several commands under it renders a READ/WRITE summary.
77func runHelp(c *Ctx, args []string) int { 117func runHelp(c *Ctx, args []string) int {
78 prefix := joinPath(args) 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 var matched []Command 138 var matched []Command
80 for _, cmd := range registry { 139 for _, cmd := range registry {
81 p := joinPath(cmd.Path) 140 p := joinPath(cmd.Path)
82 if prefix == "" || p == prefix || strings.HasPrefix(p, prefix+" ") { 141 for _, pfx := range prefixes {
83 matched = append(matched, cmd) 142 if pfx == "" || p == pfx || strings.HasPrefix(p, pfx+" ") {
143 matched = append(matched, cmd)
144 break
145 }
84 } 146 }
85 } 147 }
86 if len(matched) == 0 { 148 if len(matched) == 0 {
@@ -106,21 +168,85 @@ func runHelp(c *Ctx, args []string) int {
106 case joinPath(matched[0].Path) == prefix: 168 case joinPath(matched[0].Path) == prefix:
107 c.helpVerb(w, matched[0], matched[1:]) 169 c.helpVerb(w, matched[0], matched[1:])
108 default: 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 176// viaCLI reports whether the caller is the gitbay CLI, as far as the
115// terminal (only the CLI or a caller passing --term sets one), ssh 177// session says: a terminal (only the CLI or a caller passing --term sets
116// otherwise. 178// one), or a CLI path, which only the CLI sends.
179func (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.
117func (c *Ctx) program() string { 185func (c *Ctx) program() string {
118 if c.Term.Cols > 0 { 186 if c.viaCLI() {
119 return "gitbay" 187 return "gitbay"
120 } 188 }
121 return "ssh git@" + hostOf(c.Cfg.Server.SiteURL) 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.
195func 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.
206func (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.
224func (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.
236func (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.
246func (c *Ctx) cmdUsage() string {
247 return c.program() + " " + c.usageShape(c.Cmd.Path, c.Cmd.Usage)
248}
249
124func (c *Ctx) heading(w io.Writer, s string) { 250func (c *Ctx) heading(w io.Writer, s string) {
125 fmt.Fprintln(w, c.Term.paint(sgrBold, s)) 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 // A required flag (repo delete --yes) or an alternative 278 // A required flag (repo delete --yes) or an alternative
153 // (notifications read <id>... | --all) has no " [--" to cut at, so 279 // (notifications read <id>... | --all) has no " [--" to cut at, so
154 // the usage prints whole. 280 // the usage prints whole.
155 shape := cmd.Usage 281 registered := joinPath(cmd.Path)
282 shape := c.usageShape(cmd.Path, cmd.Usage)
156 if i := strings.Index(shape, " [--"); i >= 0 { 283 if i := strings.Index(shape, " [--"); i >= 0 {
157 shape = shape[:i] + " [flags]" 284 shape = shape[:i] + " [flags]"
158 } 285 }
@@ -190,20 +317,39 @@ func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
190 fmt.Fprintln(w) 317 fmt.Fprintln(w)
191 c.heading(w, "SEE ALSO") 318 c.heading(w, "SEE ALSO")
192 for _, b := range below { 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
198func (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.
330func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command, override map[string]string) {
199 head := nounSummaries[strings.Fields(prefix)[0]] 331 head := nounSummaries[strings.Fields(prefix)[0]]
200 fmt.Fprintln(w, head) 332 fmt.Fprintln(w, head)
201 fmt.Fprintln(w) 333 // An aliased noun is a CLI grouping: over stock ssh there is no
202 c.heading(w, "USAGE") 334 // "auth <verb>" to type, and each row already names its full command.
203 fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), prefix) 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 wide := 0 350 wide := 0
205 for _, cmd := range cmds { 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 for _, section := range []struct { 354 for _, section := range []struct {
209 title string 355 title string
@@ -219,10 +365,11 @@ func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command) {
219 c.heading(w, section.title) 365 c.heading(w, section.title)
220 first = false 366 first = false
221 } 367 }
222 verb := strings.TrimPrefix(joinPath(cmd.Path), prefix+" ") 368 fmt.Fprintf(w, " %s %s\n", pad(rowText(cmd), wide), cmd.Summary)
223 fmt.Fprintf(w, " %s %s\n", pad(verb, wide), cmd.Summary)
224 } 369 }
225 } 370 }
226 fmt.Fprintln(w) 371 if !bare {
227 fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), prefix) 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 out := helpOut(t, Term{Cols: 100}, "issue", "list") 25 out := helpOut(t, Term{Cols: 100}, "issue", "list")
26 for _, want := range []string{ 26 for _, want := range []string{
27 "list issues\n", 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 "FLAGS\n", 29 "FLAGS\n",
30 " --state open|closed|all", 30 " --state open|closed|all",
31 "which issues (default open)\n", 31 "which issues (default open)\n",
@@ -50,7 +50,7 @@ func TestHelpVerb(t *testing.T) {
50// as though --yes were optional. 50// as though --yes were optional.
51func TestHelpVerbUsageKeepsRequiredFlags(t *testing.T) { 51func TestHelpVerbUsageKeepsRequiredFlags(t *testing.T) {
52 out := helpOut(t, Term{Cols: 100}, "repo", "delete") 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 t.Errorf("missing required --yes in usage:\n%s", out) 54 t.Errorf("missing required --yes in usage:\n%s", out)
55 } 55 }
56 if strings.Contains(out, "[flags]") { 56 if strings.Contains(out, "[flags]") {
@@ -142,3 +142,135 @@ func TestHelpIsComplete(t *testing.T) {
142 } 142 }
143 } 143 }
144} 144}
145
146func 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.
193func 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.
213func 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
245func 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
263func 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
154func runKeysAdd(c *Ctx, args []string) int { 154func 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 if err != nil { 156 if err != nil {
157 return c.fail(protocol.ExitUsage, "%v", err) 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
41func runRepoImport(c *Ctx, args []string) int { 41func 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 Usage: "repo import <owner/name> --from <url> [--private] [--token-stdin]"}) 43 Usage: "repo import <owner/name> --from <url> [--private] [--token-stdin]"})
44 if err != nil { 44 if err != nil {
45 return c.fail(protocol.ExitUsage, "%v", err) 45 return c.fail(protocol.ExitUsage, "%v", err)
internal/control/issue.go +6 −6
@@ -67,7 +67,7 @@ func init() {
67 }, 67 },
68 ReadsStdin: true, Run: runIssueEdit}) 68 ReadsStdin: true, Run: runIssueEdit})
69 register(Command{Path: []string{"issue", "comment"}, 69 register(Command{Path: []string{"issue", "comment"},
70 Summary: "comment", 70 Summary: "add a comment",
71 Usage: "issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]", 71 Usage: "issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
72 Flags: []Flag{ 72 Flags: []Flag{
73 {"--message", "<m>", "the comment's text", ""}, 73 {"--message", "<m>", "the comment's text", ""},
@@ -90,7 +90,7 @@ func init() {
90 Examples: []string{"issue reopen krz/gitbay 42"}, 90 Examples: []string{"issue reopen krz/gitbay 42"},
91 Run: runIssueReopen}) 91 Run: runIssueReopen})
92 register(Command{Path: []string{"issue", "label"}, 92 register(Command{Path: []string{"issue", "label"},
93 Summary: "labels", 93 Summary: "add or remove labels",
94 Usage: "issue label <owner/name> <n> [--add <l>]... [--remove <l>]...", 94 Usage: "issue label <owner/name> <n> [--add <l>]... [--remove <l>]...",
95 Flags: []Flag{ 95 Flags: []Flag{
96 {"--add", "<l>", "label to add, may repeat", ""}, 96 {"--add", "<l>", "label to add, may repeat", ""},
@@ -99,7 +99,7 @@ func init() {
99 Examples: []string{"issue label krz/gitbay 42 --add bug --remove needs-triage"}, 99 Examples: []string{"issue label krz/gitbay 42 --add bug --remove needs-triage"},
100 Run: runIssueLabel}) 100 Run: runIssueLabel})
101 register(Command{Path: []string{"issue", "assign"}, 101 register(Command{Path: []string{"issue", "assign"},
102 Summary: "assignees", 102 Summary: "add or remove assignees",
103 Usage: "issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...", 103 Usage: "issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...",
104 Flags: []Flag{ 104 Flags: []Flag{
105 {"--add", "<user>", "user to assign, may repeat", ""}, 105 {"--add", "<user>", "user to assign, may repeat", ""},
@@ -178,7 +178,7 @@ func issueToOut(i store.Issue, withBody bool) issueOut {
178} 178}
179 179
180func runIssueCreate(c *Ctx, args []string) int { 180func 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 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]"}) 182 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]"})
183 if err != nil { 183 if err != nil {
184 return c.fail(protocol.ExitUsage, "%v", err) 184 return c.fail(protocol.ExitUsage, "%v", err)
@@ -231,7 +231,7 @@ func runIssueList(c *Ctx, args []string) int {
231 return code 231 return code
232 } 232 }
233 f := store.IssueFilter{State: "open"} 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 if err != nil { 235 if err != nil {
236 return c.fail(protocol.ExitUsage, "%v", err) 236 return c.fail(protocol.ExitUsage, "%v", err)
237 } 237 }
@@ -371,7 +371,7 @@ func setIssueState(c *Ctx, args []string, state string) int {
371// they are accepted and reported in the returned flags, and count toward 371// they are accepted and reported in the returned flags, and count toward
372// "at least one edit was given" alongside title/body/format. 372// "at least one edit was given" alongside title/body/format.
373func editText(c *Ctx, args []string, kind string, extra ...string) (rest []string, title, body, format *string, f flags, code int) { 373func 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 Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"}) 375 Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"})
376 if err != nil { 376 if err != nil {
377 return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "%v", err) 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
70func runLabelSet(c *Ctx, args []string) int { 70func 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 if err != nil { 72 if err != nil {
73 return c.fail(protocol.ExitUsage, "%v", err) 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// the git push that follows cannot be refused by them. Resumable: markers 155// the git push that follows cannot be refused by them. Resumable: markers
156// skip everything already imported. 156// skip everything already imported.
157func runAccountImportBundle(c *Ctx, args []string) int { 157func 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 if err != nil { 159 if err != nil {
160 return c.fail(protocol.ExitUsage, "%v", err) 160 return c.fail(protocol.ExitUsage, "%v", err)
161 } 161 }
internal/control/milestone.go +2 −2
@@ -62,7 +62,7 @@ func init() {
62var duePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) 62var duePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
63 63
64func runMilestoneCreate(c *Ctx, args []string) int { 64func 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 Usage: "milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]"}) 66 Usage: "milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]"})
67 if err != nil { 67 if err != nil {
68 return c.fail(protocol.ExitUsage, "%v", err) 68 return c.fail(protocol.ExitUsage, "%v", err)
@@ -93,7 +93,7 @@ func runMilestoneCreate(c *Ctx, args []string) int {
93} 93}
94 94
95func runMilestoneList(c *Ctx, args []string) int { 95func 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 if err != nil { 97 if err != nil {
98 return c.fail(protocol.ExitUsage, "%v", err) 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
45func runMirrorAdd(c *Ctx, args []string) int { 45func 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 Usage: "repo mirror add <owner/name> <url> --direction push|pull [--username <u>] [--token-stdin]"}) 47 Usage: "repo mirror add <owner/name> <url> --direction push|pull [--username <u>] [--token-stdin]"})
48 if err != nil { 48 if err != nil {
49 return c.fail(protocol.ExitUsage, "%v", err) 49 return c.fail(protocol.ExitUsage, "%v", err)
internal/control/mr.go +9 −9
@@ -143,7 +143,7 @@ func init() {
143 Examples: []string{"mr retarget krz/gitbay 431 main"}, 143 Examples: []string{"mr retarget krz/gitbay 431 main"},
144 Run: runMRRetarget}) 144 Run: runMRRetarget})
145 register(Command{Path: []string{"mr", "comment"}, 145 register(Command{Path: []string{"mr", "comment"},
146 Summary: "comment", 146 Summary: "add a comment",
147 Usage: "mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]", 147 Usage: "mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
148 Flags: []Flag{ 148 Flags: []Flag{
149 {"--message", "<m>", "the comment's text", ""}, 149 {"--message", "<m>", "the comment's text", ""},
@@ -153,7 +153,7 @@ func init() {
153 Examples: []string{`mr comment krz/gitbay 431 --message "looks good"`}, 153 Examples: []string{`mr comment krz/gitbay 431 --message "looks good"`},
154 ReadsStdin: true, Run: runMRComment}) 154 ReadsStdin: true, Run: runMRComment})
155 register(Command{Path: []string{"mr", "review"}, 155 register(Command{Path: []string{"mr", "review"},
156 Summary: "review", 156 Summary: "record a review verdict",
157 Usage: "mr review <owner/name> <n> --approve|--request-changes|--comment|--discard", 157 Usage: "mr review <owner/name> <n> --approve|--request-changes|--comment|--discard",
158 Flags: []Flag{ 158 Flags: []Flag{
159 {"--approve", "", "approve the merge request", ""}, 159 {"--approve", "", "approve the merge request", ""},
@@ -173,7 +173,7 @@ func init() {
173 Examples: []string{"mr review request krz/gitbay 431 --add cmc"}, 173 Examples: []string{"mr review request krz/gitbay 431 --add cmc"},
174 Run: runMRReviewRequest}) 174 Run: runMRReviewRequest})
175 register(Command{Path: []string{"mr", "label"}, 175 register(Command{Path: []string{"mr", "label"},
176 Summary: "labels", 176 Summary: "add or remove labels",
177 Usage: "mr label <owner/name> <n> [--add <l>]... [--remove <l>]...", 177 Usage: "mr label <owner/name> <n> [--add <l>]... [--remove <l>]...",
178 Flags: []Flag{ 178 Flags: []Flag{
179 {"--add", "<l>", "label to add, may repeat", ""}, 179 {"--add", "<l>", "label to add, may repeat", ""},
@@ -207,7 +207,7 @@ type ForkOut struct {
207} 207}
208 208
209func runRepoFork(c *Ctx, args []string) int { 209func 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 if err != nil { 211 if err != nil {
212 return c.fail(protocol.ExitUsage, "%v", err) 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
393func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) } 393func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
394 394
395func runMRCreate(c *Ctx, args []string) int { 395func 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 Bools: []string{"--draft"}, MaxPos: 1, 397 Bools: []string{"--draft"}, MaxPos: 1,
398 Usage: "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--draft]"}) 398 Usage: "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--draft]"})
399 if err != nil { 399 if err != nil {
@@ -566,7 +566,7 @@ func runMRList(c *Ctx, args []string) int {
566 return code 566 return code
567 } 567 }
568 f := store.MRFilter{State: "open"} 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 if err != nil { 570 if err != nil {
571 return c.fail(protocol.ExitUsage, "%v", err) 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
1207func runMRMerge(c *Ctx, args []string) int { 1207func 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 if err != nil { 1209 if err != nil {
1210 return c.fail(protocol.ExitUsage, "%v", err) 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
1777func runMRClose(c *Ctx, args []string) int { 1777func 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 Usage: "mr close <owner/name> <n> [--by <m>]"}) 1779 Usage: "mr close <owner/name> <n> [--by <m>]"})
1780 if err != nil { 1780 if err != nil {
1781 return c.fail(protocol.ExitUsage, "%v", err) 1781 return c.fail(protocol.ExitUsage, "%v", err)
@@ -1907,7 +1907,7 @@ func runMRRevisions(c *Ctx, args []string) int {
1907} 1907}
1908 1908
1909func runMRRangeDiff(c *Ctx, args []string) int { 1909func 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 if err != nil { 1911 if err != nil {
1912 return c.fail(protocol.ExitUsage, "%v", err) 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 {
285const maxDeviceTokenBytes = 512 285const maxDeviceTokenBytes = 512
286 286
287func runNotificationsDeviceAdd(c *Ctx, args []string) int { 287func 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 if err != nil { 289 if err != nil {
290 return c.fail(protocol.ExitUsage, "%v", err) 290 return c.fail(protocol.ExitUsage, "%v", err)
291 } 291 }
@@ -389,7 +389,7 @@ func runNotificationsList(c *Ctx, args []string) int {
389 if code >= 0 { 389 if code >= 0 {
390 return code 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 if err != nil { 393 if err != nil {
394 return c.fail(protocol.ExitUsage, "%v", err) 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
445func runNotificationsRead(c *Ctx, args []string) int { 445func 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 if err != nil { 447 if err != nil {
448 return c.fail(protocol.ExitUsage, "%v", err) 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
216func runOrgMembersAdd(c *Ctx, args []string) int { 216func 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 if err != nil { 218 if err != nil {
219 return c.fail(protocol.ExitUsage, "%v", err) 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
88func runOrgLabelSet(c *Ctx, args []string) int { 88func 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 if err != nil { 90 if err != nil {
91 return c.fail(protocol.ExitUsage, "%v", err) 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
182func runOrgMilestoneCreate(c *Ctx, args []string) int { 182func 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 if err != nil { 184 if err != nil {
185 return c.fail(protocol.ExitUsage, "%v", err) 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
214func runOrgMilestoneList(c *Ctx, args []string) int { 214func 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 if err != nil { 216 if err != nil {
217 return c.fail(protocol.ExitUsage, "%v", err) 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 {
110const BlameSpan = 1000 110const BlameSpan = 1000
111 111
112func runRepoBlame(c *Ctx, args []string) int { 112func 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 if err != nil { 114 if err != nil {
115 return c.fail(protocol.ExitUsage, "%v", err) 115 return c.fail(protocol.ExitUsage, "%v", err)
116 } 116 }
@@ -220,7 +220,7 @@ func runRepoBlame(c *Ctx, args []string) int {
220// off argv. Positionals are returned in order so each command can name them 220// off argv. Positionals are returned in order so each command can name them
221// in its own usage message. 221// in its own usage message.
222func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) { 222func 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 if err != nil { 224 if err != nil {
225 return nil, "", c.fail(protocol.ExitUsage, "%v", err) 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
113func runReleaseCreate(c *Ctx, args []string) int { 113func 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 if err != nil { 115 if err != nil {
116 return c.fail(protocol.ExitUsage, "%v", err) 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
185func runReleaseEdit(c *Ctx, args []string) int { 185func 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 if err != nil { 187 if err != nil {
188 return c.fail(protocol.ExitUsage, "%v", err) 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
251func runRepoCreate(c *Ctx, args []string) int { 251func 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 if err != nil { 253 if err != nil {
254 return c.fail(protocol.ExitUsage, "%v", err) 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
1022func runRepoGrep(c *Ctx, args []string) int { 1022func 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 if err != nil { 1024 if err != nil {
1025 return c.fail(protocol.ExitUsage, "%v", err) 1025 return c.fail(protocol.ExitUsage, "%v", err)
1026 } 1026 }
@@ -1246,7 +1246,7 @@ func setProtect(c *Ctx, args []string, protect bool) int {
1246// base, measured from their merge base the way a merge request diff is, 1246// base, measured from their merge base the way a merge request diff is,
1247// so a base that moved on does not show up as removals (#118). 1247// so a base that moved on does not show up as removals (#118).
1248func runRepoDiff(c *Ctx, args []string) int { 1248func 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 if err != nil || len(f.Pos) != 3 { 1250 if err != nil || len(f.Pos) != 3 {
1251 return c.usage() 1251 return c.usage()
1252 } 1252 }
internal/control/runnerrepo.go +1 −1
@@ -37,7 +37,7 @@ func init() {
37} 37}
38 38
39func runRepoRunnerAdd(c *Ctx, args []string) int { 39func 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 if err != nil || len(f.Pos) != 1 { 41 if err != nil || len(f.Pos) != 1 {
42 return c.usage() 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
86func runSearch(c *Ctx, args []string) int { 86func 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 if err != nil { 88 if err != nil {
89 return c.fail(protocol.ExitUsage, "%v", err) 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
143func runRepoLog(c *Ctx, args []string) int { 143func 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 if perr != nil { 145 if perr != nil {
146 return c.fail(protocol.ExitUsage, "%v", perr) 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
160func runSnippetCreate(c *Ctx, args []string) int { 160func 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 if err != nil { 162 if err != nil {
163 return c.fail(protocol.ExitUsage, "%v", err) 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
289func runSnippetEdit(c *Ctx, args []string) int { 289func 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 if err != nil { 291 if err != nil {
292 return c.fail(protocol.ExitUsage, "%v", err) 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 add func(id, userID int64, body, format string) error, 77 add func(id, userID int64, body, format string) error,
78 participants func(id int64) ([]int64, error), 78 participants func(id int64) ([]int64, error),
79) int { 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 Usage: noun + " comment <owner/name> <n> [--message <m> | --file -] [--format md|org]"}) 81 Usage: noun + " comment <owner/name> <n> [--message <m> | --file -] [--format md|org]"})
82 if err != nil { 82 if err != nil {
83 return c.fail(protocol.ExitUsage, "%v", err) 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
67func runTokenCreate(c *Ctx, args []string) int { 67func 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 if err != nil { 69 if err != nil {
70 return c.fail(protocol.ExitUsage, "%v", err) 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
130func runTokenRevoke(c *Ctx, args []string) int { 130func 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 if err != nil { 132 if err != nil {
133 return c.fail(protocol.ExitUsage, "%v", err) 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
47func runWebhookAdd(c *Ctx, args []string) int { 47func 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 if err != nil { 49 if err != nil {
50 return c.fail(protocol.ExitUsage, "%v", err) 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
138func runWebhookDeliveries(c *Ctx, args []string) int { 138func 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 if err != nil { 140 if err != nil {
141 return c.fail(protocol.ExitUsage, "%v", err) 141 return c.fail(protocol.ExitUsage, "%v", err)
142 } 142 }