cli: front-ends for every registry command !103
2 files changed, +91 −5
Layout: unified · split
cmd/gitbay/coverage_test.go added +57
| @@ -0,0 +1,57 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 15 | var 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`. | ||
| 28 | func 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 | ||
| 19 | func main() { | 19 | func 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. | ||
| 28 | func 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. | ||
| 83 | const 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. |
| 70 | type passOpts struct { | 86 | type 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 { |