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 | 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 | 29 | root := &cobra.Command{ |
| 21 | 30 | Use: "gitbay", |
| 22 | 31 | Short: "CLI-first git forge client", |
| @@ -40,6 +49,12 @@ func main() { | ||
| 40 | 49 | passOpts{server: []string{"dashboard"}}), |
| 41 | 50 | pass("feed", "activity on repositories you can reach [--limit n] [--cursor c]", |
| 42 | 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 | 58 | repoCmd(), |
| 44 | 59 | issueCmd(), |
| 45 | 60 | milestoneCmd(), |
| @@ -57,15 +72,16 @@ func main() { | ||
| 57 | 72 | initCmd(), |
| 58 | 73 | pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", |
| 59 | 74 | passOpts{server: []string{"register"}}), |
| 75 | pass("audit", "instance audit log (admins): [--limit <n>]", passOpts{server: []string{"audit"}}), | |
| 60 | 76 | manCmd(root), |
| 61 | 77 | ) |
| 62 | ||
| 63 | if err := root.Execute(); err != nil { | |
| 64 | fmt.Fprintln(os.Stderr, "gitbay:", err) | |
| 65 | os.Exit(protocol.ExitUsage) | |
| 66 | } | |
| 78 | return root | |
| 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 | 85 | // passOpts describes how one CLI command maps onto the server command. |
| 70 | 86 | type passOpts struct { |
| 71 | 87 | server []string // server-side command path |
| @@ -81,6 +97,7 @@ func pass(use, short string, o passOpts) *cobra.Command { | ||
| 81 | 97 | return &cobra.Command{ |
| 82 | 98 | Use: use, |
| 83 | 99 | Short: short, |
| 100 | Annotations: map[string]string{serverPath: strings.Join(o.server, " ")}, | |
| 84 | 101 | DisableFlagParsing: true, |
| 85 | 102 | RunE: func(cmd *cobra.Command, args []string) error { |
| 86 | 103 | // cobra still owns `forge <cmd> --help`. |
| @@ -194,6 +211,7 @@ func authCmd() *cobra.Command { | ||
| 194 | 211 | } |
| 195 | 212 | pgpAdd := &cobra.Command{ |
| 196 | 213 | Use: "add", Short: "register an OpenPGP public key (armored, on stdin)", |
| 214 | Annotations: map[string]string{serverPath: "pgp add"}, | |
| 197 | 215 | DisableFlagParsing: true, |
| 198 | 216 | RunE: func(cmd *cobra.Command, args []string) error { |
| 199 | 217 | t, err := resolveTarget() |
| @@ -245,6 +263,15 @@ func repoCmd() *cobra.Command { | ||
| 245 | 263 | pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), |
| 246 | 264 | pass("tree", "list a directory: [<path>] [--ref <ref>]", passOpts{server: []string{"repo", "tree"}, needsRepo: true}), |
| 247 | 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 | 275 | pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}), |
| 249 | 276 | pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}), |
| 250 | 277 | pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), |
| @@ -293,6 +320,7 @@ func repoCmd() *cobra.Command { | ||
| 293 | 320 | pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), |
| 294 | 321 | pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}), |
| 295 | 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 | 324 | pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), |
| 297 | 325 | pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}), |
| 298 | 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 | 401 | return &cobra.Command{ |
| 374 | 402 | Use: "import", |
| 375 | 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 | 405 | DisableFlagParsing: true, |
| 377 | 406 | RunE: func(cmd *cobra.Command, args []string) error { |
| 378 | 407 | for _, a := range args { |