cli: front-ends for every registry command !103

merged merged by cmc on 2026-08-28 04:44 UTC · krz/gitbay:cli-coverage into main

2 files changed, +91 −5

Layout: unified · split

cmd/gitbay/coverage_test.go added +57
@@ -0,0 +1,57 @@
1package main
2
3import (
4 "strings"
5 "testing"
6
7 "github.com/spf13/cobra"
8
9 "gitbay.org/gitbay/internal/control"
10)
11
12// Commands with no CLI front-end on purpose. Everything else in the
13// registry must be reachable by typing it, or the CLI is not the
14// complete interface the design claims.
15var notInCLI = map[string]string{
16 "help": "cobra provides its own",
17 "runner next": "the CI runner's wire protocol, not for humans",
18 "runner done": "the CI runner's wire protocol, not for humans",
19 "runner log": "the CI runner's wire protocol, not for humans",
20 "account import-bundle": "the wire format behind `gitbay migrate`",
21}
22
23// TestEveryCommandIsReachable guards the invariant that adding a command
24// to the registry is enough. The tree is hand-written, so a command can
25// land server-side — reachable over raw SSH, the web and the API — while
26// `gitbay <cmd>` still says "unknown command". That happened to `explore`
27// and `wiki`.
28func TestEveryCommandIsReachable(t *testing.T) {
29 wired := map[string]bool{}
30 var walk func(*cobra.Command)
31 walk = func(c *cobra.Command) {
32 if p := c.Annotations[serverPath]; p != "" {
33 wired[p] = true
34 }
35 for _, sub := range c.Commands() {
36 walk(sub)
37 }
38 }
39 walk(newRoot())
40
41 for _, cmd := range control.Commands() {
42 path := strings.Join(cmd.Path, " ")
43 if wired[path] || notInCLI[path] != "" {
44 continue
45 }
46 t.Errorf("%q is in the registry but not in the CLI tree; "+
47 "add a pass() for it, or list it in notInCLI with a reason", path)
48 }
49
50 // A hand-built command (auth pgp add, repo settings visibility) is
51 // fine, but it must still name a real command.
52 for path := range wired {
53 if _, _, ok := control.Lookup(strings.Fields(path)); !ok {
54 t.Errorf("CLI dispatches %q, which the registry does not define", path)
55 }
56 }
57}
cmd/gitbay/main.go +34 −5
@@ -17,6 +17,15 @@ import (
17) 17)
18 18
19func main() { 19func main() {
20 if err := newRoot().Execute(); err != nil {
21 fmt.Fprintln(os.Stderr, "gitbay:", err)
22 os.Exit(protocol.ExitUsage)
23 }
24}
25
26// newRoot builds the command tree. Separate from main so the coverage
27// test can walk it.
28func newRoot() *cobra.Command {
20 root := &cobra.Command{ 29 root := &cobra.Command{
21 Use: "gitbay", 30 Use: "gitbay",
22 Short: "CLI-first git forge client", 31 Short: "CLI-first git forge client",
@@ -40,6 +49,12 @@ func main() {
40 passOpts{server: []string{"dashboard"}}), 49 passOpts{server: []string{"dashboard"}}),
41 pass("feed", "activity on repositories you can reach [--limit n] [--cursor c]", 50 pass("feed", "activity on repositories you can reach [--limit n] [--cursor c]",
42 passOpts{server: []string{"feed"}}), 51 passOpts{server: []string{"feed"}}),
52 pass("explore", "public repositories on this instance [--limit n] [--cursor c]",
53 passOpts{server: []string{"explore"}}),
54 group("wiki", "a repository's wiki pages",
55 pass("list", "list pages: [<owner/name>]", passOpts{server: []string{"wiki", "list"}, needsRepo: true}),
56 pass("show", "print a page: [<owner/name>] [<page>]", passOpts{server: []string{"wiki", "show"}, needsRepo: true}),
57 ),
43 repoCmd(), 58 repoCmd(),
44 issueCmd(), 59 issueCmd(),
45 milestoneCmd(), 60 milestoneCmd(),
@@ -57,15 +72,16 @@ func main() {
57 initCmd(), 72 initCmd(),
58 pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", 73 pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>",
59 passOpts{server: []string{"register"}}), 74 passOpts{server: []string{"register"}}),
75 pass("audit", "instance audit log (admins): [--limit <n>]", passOpts{server: []string{"audit"}}),
60 manCmd(root), 76 manCmd(root),
61 ) 77 )
62 78 return root
63 if err := root.Execute(); err != nil {
64 fmt.Fprintln(os.Stderr, "gitbay:", err)
65 os.Exit(protocol.ExitUsage)
66 }
67} 79}
68 80
81// serverPath is the annotation key holding a passthrough command's
82// server-side path, so the tree can be checked against the registry.
83const serverPath = "gitbay.server_path"
84
69// passOpts describes how one CLI command maps onto the server command. 85// passOpts describes how one CLI command maps onto the server command.
70type passOpts struct { 86type passOpts struct {
71 server []string // server-side command path 87 server []string // server-side command path
@@ -81,6 +97,7 @@ func pass(use, short string, o passOpts) *cobra.Command {
81 return &cobra.Command{ 97 return &cobra.Command{
82 Use: use, 98 Use: use,
83 Short: short, 99 Short: short,
100 Annotations: map[string]string{serverPath: strings.Join(o.server, " ")},
84 DisableFlagParsing: true, 101 DisableFlagParsing: true,
85 RunE: func(cmd *cobra.Command, args []string) error { 102 RunE: func(cmd *cobra.Command, args []string) error {
86 // cobra still owns `forge <cmd> --help`. 103 // cobra still owns `forge <cmd> --help`.
@@ -194,6 +211,7 @@ func authCmd() *cobra.Command {
194 } 211 }
195 pgpAdd := &cobra.Command{ 212 pgpAdd := &cobra.Command{
196 Use: "add", Short: "register an OpenPGP public key (armored, on stdin)", 213 Use: "add", Short: "register an OpenPGP public key (armored, on stdin)",
214 Annotations: map[string]string{serverPath: "pgp add"},
197 DisableFlagParsing: true, 215 DisableFlagParsing: true,
198 RunE: func(cmd *cobra.Command, args []string) error { 216 RunE: func(cmd *cobra.Command, args []string) error {
199 t, err := resolveTarget() 217 t, err := resolveTarget()
@@ -245,6 +263,15 @@ func repoCmd() *cobra.Command {
245 pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), 263 pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
246 pass("tree", "list a directory: [<path>] [--ref <ref>]", passOpts{server: []string{"repo", "tree"}, needsRepo: true}), 264 pass("tree", "list a directory: [<path>] [--ref <ref>]", passOpts{server: []string{"repo", "tree"}, needsRepo: true}),
247 pass("cat", "read a file: <path> [--ref <ref>]", passOpts{server: []string{"repo", "cat"}, needsRepo: true}), 265 pass("cat", "read a file: <path> [--ref <ref>]", passOpts{server: []string{"repo", "cat"}, needsRepo: true}),
266 pass("blame", "attribute lines to commits: <path> [--ref <ref>] [--from <n>] [--to <n>]",
267 passOpts{server: []string{"repo", "blame"}, needsRepo: true}),
268 pass("commit", "show one commit with its patch: <sha>",
269 passOpts{server: []string{"repo", "commit"}, needsRepo: true}),
270 pass("commit-file", "write a file and commit it: <path> [--ref <ref>] [--message <m>] --file -",
271 passOpts{server: []string{"repo", "commit-file"}, needsRepo: true, stdinOK: true}),
272 pass("refs", "list branches and tags", passOpts{server: []string{"repo", "refs"}, needsRepo: true}),
273 pass("download", "write a tar.gz of a ref to stdout: [--ref <r>] > repo.tar.gz",
274 passOpts{server: []string{"repo", "download"}, needsRepo: true}),
248 pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}), 275 pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}),
249 pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}), 276 pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}),
250 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), 277 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
@@ -293,6 +320,7 @@ func repoCmd() *cobra.Command {
293 pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), 320 pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}),
294 pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}), 321 pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}),
295 pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}), 322 pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}),
323 pass("visibility", "set repository visibility: public|private", passOpts{server: []string{"repo", "settings", "visibility"}, needsRepo: true}),
296 pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), 324 pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}),
297 pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}), 325 pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}),
298 pass("website", "set the repository website: <url> ('' clears)", passOpts{server: []string{"repo", "settings", "website"}, needsRepo: true}), 326 pass("website", "set the repository website: <url> ('' clears)", passOpts{server: []string{"repo", "settings", "website"}, needsRepo: true}),
@@ -373,6 +401,7 @@ func importCmd() *cobra.Command {
373 return &cobra.Command{ 401 return &cobra.Command{
374 Use: "import", 402 Use: "import",
375 Short: "server-side mirror of a foreign repo: gitbay repo import <owner/name> --from <url> [--private] [--token-stdin]", 403 Short: "server-side mirror of a foreign repo: gitbay repo import <owner/name> --from <url> [--private] [--token-stdin]",
404 Annotations: map[string]string{serverPath: "repo import"},
376 DisableFlagParsing: true, 405 DisableFlagParsing: true,
377 RunE: func(cmd *cobra.Command, args []string) error { 406 RunE: func(cmd *cobra.Command, args []string) error {
378 for _, a := range args { 407 for _, a := range args {