cmd/gitbay/main.go
737 lines · 41291 bytes
1// forge is the client CLI. It is ergonomics over a control plane that is
2// fully usable from bare OpenSSH: most commands pass through to the server
3// over the system ssh binary, adding instance resolution, repo inference
4// from the origin remote, and $EDITOR for long text.
5package main
6
7import (
8 "fmt"
9 "io"
10 "os"
11 "strings"
12
13 "github.com/spf13/cobra"
14 "github.com/spf13/cobra/doc"
15
16 "gitbay.org/gitbay/internal/protocol"
17)
18
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 {
29 root := &cobra.Command{
30 Use: "gitbay",
31 Short: "CLI-first git forge client",
32 SilenceUsage: true,
33 SilenceErrors: true,
34 }
35 root.SetHelpCommand(helpCmd(root))
36
37 root.AddCommand(
38 authCmd(),
39 group("label", "issue labels",
40 pass("list", "labels with colour and use", passOpts{server: []string{"label", "list"}, needsRepo: true}),
41 pass("set", "create a label or set its colour: <label> [--color rrggbb|'']", passOpts{server: []string{"label", "set"}, needsRepo: true}),
42 pass("remove", "remove a label everywhere: <label>", passOpts{server: []string{"label", "remove"}, needsRepo: true}),
43 ),
44 group("status", "commit statuses (CI)",
45 pass("set", "report a status: <sha> --context <c> --state <s> [--description d] [--url u]", passOpts{server: []string{"status", "set"}, needsRepo: true}),
46 pass("list", "statuses on a commit: <sha>", passOpts{server: []string{"status", "list"}, needsRepo: true}),
47 ),
48 group("build", "CI builds",
49 pass("list", "recent builds: <owner/name>", passOpts{server: []string{"build", "list"}, needsRepo: true}),
50 pass("show", "one build: <owner/name> <n>", passOpts{server: []string{"build", "show"}, needsRepo: true}),
51 pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}),
52 pass("jobs", "list the jobs a trigger can name", passOpts{server: []string{"build", "jobs"}, needsRepo: true}),
53 pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}),
54 pass("cancel", "withdraw a queued build: <n>", passOpts{server: []string{"build", "cancel"}, needsRepo: true}),
55 ),
56 pass("dashboard", "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds",
57 passOpts{server: []string{"dashboard"}}),
58 pass("feed", "activity on repositories you can reach [--limit n] [--cursor c]",
59 passOpts{server: []string{"feed"}}),
60 pass("explore", "public repositories on this instance [--limit n] [--cursor c]",
61 passOpts{server: []string{"explore"}}),
62 pass("search", "find repositories, issues and merge requests: <query> [--kind repo|issue|mr]",
63 passOpts{server: []string{"search"}}),
64 group("notifications", "your notification inbox",
65 pass("list", "unread notifications, or [--all] [--limit n] [--cursor c]",
66 passOpts{server: []string{"notifications", "list"}}),
67 pass("read", "mark notifications read: <id>... | --all",
68 passOpts{server: []string{"notifications", "read"}}),
69 group("settings", "notification preferences",
70 pass("show", "your notification preferences", passOpts{server: []string{"notifications", "settings", "show"}}),
71 pass("mail", "activity by mail as well as the inbox: on|off", passOpts{server: []string{"notifications", "settings", "mail"}}),
72 ),
73 ),
74 group("wiki", "a repository's wiki pages",
75 pass("list", "list pages: [<owner/name>]", passOpts{server: []string{"wiki", "list"}, needsRepo: true}),
76 pass("show", "print a page: [<owner/name>] [<page>]", passOpts{server: []string{"wiki", "show"}, needsRepo: true}),
77 ),
78 repoCmd(),
79 issueCmd(),
80 milestoneCmd(),
81 mrCmd(),
82 releaseCmd(),
83 migrateCmd(),
84 webCmd(),
85 orgCmd(),
86 group("profile", "user and org profiles",
87 pass("show", "show a profile: [name]", passOpts{server: []string{"profile", "show"}}),
88 pass("set", "set your profile: [--description d] [--website url] [--about t|--file -] [--about-format md|org] [--link label|url]...",
89 passOpts{server: []string{"profile", "set"}, stdinOK: true}),
90 ),
91 webhookCmd(),
92 remoteCmd(),
93 initCmd(),
94 pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>",
95 passOpts{server: []string{"register"}}),
96 pass("audit", "instance audit log (admins): [--actor <user>|-] [--action <prefix>] [--since <duration|date>] [--limit <n>]", passOpts{server: []string{"audit"}}),
97 group("admin", "instance administration (admins)",
98 group("user", "accounts on this instance",
99 pass("list", "list accounts: [--state active|pending|disabled|admin] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "user", "list"}}),
100 pass("show", "show an account: <username>", passOpts{server: []string{"admin", "user", "show"}}),
101 pass("promote", "make an account an instance admin: <username>", passOpts{server: []string{"admin", "user", "promote"}}),
102 pass("demote", "remove instance admin (never the last one): <username>", passOpts{server: []string{"admin", "user", "demote"}}),
103 pass("create", "create an account: <username> [--admin] [--email a [--verified]] [--key -] < key.pub", passOpts{server: []string{"admin", "user", "create"}, stdinOK: true}),
104 pass("disable", "suspend an account: <username>", passOpts{server: []string{"admin", "user", "disable"}}),
105 pass("enable", "restore a suspended account: <username>", passOpts{server: []string{"admin", "user", "enable"}}),
106 pass("delete", "delete an account that anchors nothing: <username> --yes", passOpts{server: []string{"admin", "user", "delete"}}),
107 pass("limits", "show or set repository and storage caps: <username> [--repos n|default] [--bytes n|default]", passOpts{server: []string{"admin", "user", "limits"}}),
108 ),
109 group("email", "addresses on any account",
110 pass("verify", "mark an address verified by admin assertion: <username> <address>", passOpts{server: []string{"admin", "email", "verify"}}),
111 ),
112 pass("invite", "issue a registration invite and mail its code: --email <address>", passOpts{server: []string{"admin", "invite"}}),
113 pass("stats", "instance statistics: counts and per-repository disk usage", passOpts{server: []string{"admin", "stats"}}),
114 withSub(pass("runners", "the build queue and runner keys: last poll, scope, the build each holds", passOpts{server: []string{"admin", "runners"}}),
115 pass("forget", "drop a key's heartbeat row: <fingerprint>", passOpts{server: []string{"admin", "runners", "forget"}})),
116 group("repo", "any repository, for moderation (audited)",
117 pass("list", "every repository with size and last push: [--owner o] [--visibility v] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "repo", "list"}}),
118 pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}),
119 pass("unarchive", "unarchive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "unarchive"}}),
120 pass("visibility", "set visibility: <owner/name> public|private", passOpts{server: []string{"admin", "repo", "visibility"}}),
121 pass("delete", "delete a repository: <owner/name> --yes", passOpts{server: []string{"admin", "repo", "delete"}}),
122 ),
123 ),
124 manCmd(root),
125 )
126 return root
127}
128
129// serverPath is the annotation key holding a passthrough command's
130// server-side path, so the tree can be checked against the registry.
131const serverPath = "gitbay.server_path"
132const stdinMode = "gitbay.stdin_mode"
133
134// stdinWhat carries the payload's name onto the command tree so the
135// coverage test can assert every stdin-payload command has one; without
136// it the terminal prompt says the unhelpful word "input" (#150).
137const stdinWhat = "gitbay.stdin_what"
138
139// passOpts describes how one CLI command maps onto the server command.
140type passOpts struct {
141 server []string // server-side command path
142 needsRepo bool // prepend inferred owner/name unless given
143 stdinOK bool // wire local stdin through when --file - asks for it
144 alwaysStdin bool // stdin is the payload, named by no flag: a bare redirect
145 // stdinWhat names the payload for the prompt shown when stdin is a
146 // terminal; stdinSecret hides the input and takes one line, for a
147 // value that should not reach the scrollback.
148 stdinWhat string
149 stdinSecret bool
150 editor string // open $EDITOR for a body when none given
151 inferSource bool // --source defaults to the checked-out branch inside a clone
152}
153
154// pass builds a passthrough command. Flags are parsed by the server, which
155// is the single source of truth for them; the CLI stays thin.
156// stdinModeName reports how this command takes stdin, so the coverage test can
157// check that a command reading a bare redirect is not left waiting for a
158// `--file -` that its callers never type.
159func (o passOpts) stdinModeName() string {
160 switch {
161 case o.alwaysStdin:
162 return "always"
163 case o.stdinOK:
164 return "flag"
165 }
166 return "none"
167}
168
169func pass(use, short string, o passOpts) *cobra.Command {
170 return &cobra.Command{
171 Use: use,
172 Short: short,
173 Annotations: map[string]string{
174 serverPath: strings.Join(o.server, " "),
175 stdinMode: o.stdinModeName(),
176 stdinWhat: o.stdinWhat,
177 },
178 DisableFlagParsing: true,
179 RunE: func(cmd *cobra.Command, args []string) error {
180 // The registry is the only place flags are written down, so
181 // --help asks the server rather than reprinting the one-line
182 // summary cobra holds.
183 for _, a := range args {
184 if a == "--help" || a == "-h" {
185 os.Exit(runServerHelp(o))
186 }
187 }
188 os.Exit(runPass(o, args))
189 return nil
190 },
191 }
192}
193
194// runServerHelp prints the registry's usage for one command.
195func runServerHelp(o passOpts) int {
196 t, err := resolveTarget()
197 if err != nil {
198 fmt.Fprintln(os.Stderr, "gitbay:", err)
199 return protocol.ExitFailure
200 }
201 return runSSH(t, append([]string{"help"}, o.server...), strings.NewReader(""))
202}
203
204func runPass(o passOpts, args []string) int {
205 t, err := resolveTarget()
206 if err != nil {
207 fmt.Fprintln(os.Stderr, "gitbay:", err)
208 return protocol.ExitFailure
209 }
210 explicitRepo := len(args) > 0 && !strings.HasPrefix(args[0], "-") && strings.Contains(args[0], "/")
211 if o.needsRepo {
212 args, err = withRepo(t, args)
213 if err != nil {
214 fmt.Fprintln(os.Stderr, "gitbay:", err)
215 return protocol.ExitUsage
216 }
217 }
218 // Inside a clone, the branch you are on is the one you mean (#101).
219 // Only when the repository was inferred from the clone too: naming
220 // another repository and meaning this checkout's branch is unlikely.
221 if o.inferSource && !explicitRepo && !hasFlag(args, "--source") {
222 if branch := currentBranch(); branch != "" {
223 args = append(args, "--source", branch)
224 }
225 }
226
227 var stdin io.Reader = strings.NewReader("")
228 if o.editor != "" {
229 // Issue bodies prefill from the repo's .gitbay/issue-template*.md.
230 var prefill func() string
231 if o.editor == "issue" && len(args) > 0 && strings.Contains(args[0], "/") {
232 repoPath := args[0]
233 prefill = func() string { return fetchIssueTemplate(t, repoPath) }
234 }
235 extended, body, ok, err := maybeEditor(args, o.editor, prefill)
236 if err != nil {
237 fmt.Fprintln(os.Stderr, "gitbay:", err)
238 return protocol.ExitFailure
239 }
240 if !ok {
241 return protocol.ExitFailure
242 }
243 args = extended
244 if body != nil {
245 stdin = body
246 }
247 }
248 if stdin == nil || isEmptyReader(stdin) {
249 if o.alwaysStdin || (o.stdinOK && usesStdin(args)) {
250 what := o.stdinWhat
251 if what == "" {
252 what = "input"
253 }
254 r, err := stdinPayload(os.Stdin, what, o.stdinSecret)
255 if err != nil {
256 fmt.Fprintln(os.Stderr, "gitbay:", err)
257 return protocol.ExitFailure
258 }
259 stdin = r
260 }
261 }
262 return runSSH(t, append(o.server, args...), stdin)
263}
264
265func isEmptyReader(r io.Reader) bool {
266 sr, ok := r.(*strings.Reader)
267 return ok && sr.Len() == 0
268}
269
270// usesStdin reports whether the arguments request stdin content.
271func usesStdin(args []string) bool {
272 for i, a := range args {
273 if (a == "--file" || a == "--key") && i+1 < len(args) && args[i+1] == "-" {
274 return true
275 }
276 if a == "--token-stdin" {
277 return true
278 }
279 }
280 return false
281}
282
283// withSub hangs subcommands off a passthrough command, so `admin runners`
284// still runs while `admin runners forget` reaches its own command.
285func withSub(cmd *cobra.Command, subs ...*cobra.Command) *cobra.Command {
286 cmd.AddCommand(subs...)
287 return cmd
288}
289
290func group(use, short string, subs ...*cobra.Command) *cobra.Command {
291 c := &cobra.Command{Use: use, Short: short}
292 c.AddCommand(subs...)
293 // A noun's help is the server's, like a command's: the registry is
294 // the only place flags are written down, and cobra's subcommand list
295 // carried none (#130). Offline, or for a noun the server does not
296 // know by that name, cobra's own tree still prints.
297 local := c.HelpFunc()
298 c.SetHelpFunc(func(cmd *cobra.Command, args []string) {
299 if !serverHelp(use) {
300 local(cmd, args)
301 }
302 })
303 return c
304}
305
306// serverHelp prints the registry's usage for a prefix and reports whether
307// it did.
308func serverHelp(prefix string) bool {
309 t, err := resolveTarget()
310 if err != nil {
311 return false
312 }
313 out, code := sshCapture(t, []string{"help", prefix})
314 if code != 0 || out == "" {
315 return false
316 }
317 fmt.Print(out)
318 return true
319}
320
321// local wraps a locally-implemented command (git plumbing, config).
322func local(use, short string, fn func(args []string) int) *cobra.Command {
323 return &cobra.Command{
324 Use: use,
325 Short: short,
326 DisableFlagParsing: true,
327 RunE: func(cmd *cobra.Command, args []string) error {
328 for _, a := range args {
329 if a == "--help" || a == "-h" {
330 return cmd.Help()
331 }
332 }
333 os.Exit(fn(args))
334 return nil
335 },
336 }
337}
338
339func authCmd() *cobra.Command {
340 keysAdd := pass("add", "register an SSH public key (reads the key from stdin or --file -)",
341 passOpts{server: []string{"keys", "add"}, alwaysStdin: true, stdinWhat: "an SSH public key"})
342 // keys add always reads stdin on the server; wire it through directly.
343 keysAdd.RunE = func(cmd *cobra.Command, args []string) error {
344 t, err := resolveTarget()
345 if err != nil {
346 return err
347 }
348 in, err := stdinPayload(os.Stdin, "an SSH public key", false)
349 if err != nil {
350 return err
351 }
352 os.Exit(runSSH(t, append([]string{"keys", "add"}, args...), in))
353 return nil
354 }
355 pgpAdd := &cobra.Command{
356 Use: "add", Short: "register an OpenPGP public key (armored, on stdin)",
357 Annotations: map[string]string{
358 serverPath: "pgp add",
359 stdinMode: "always",
360 stdinWhat: "an armored OpenPGP public key",
361 },
362 DisableFlagParsing: true,
363 RunE: func(cmd *cobra.Command, args []string) error {
364 t, err := resolveTarget()
365 if err != nil {
366 return err
367 }
368 in, err := stdinPayload(os.Stdin, "an armored OpenPGP public key", false)
369 if err != nil {
370 return err
371 }
372 os.Exit(runSSH(t, append([]string{"pgp", "add"}, args...), in))
373 return nil
374 },
375 }
376 tokens := group("token", "API tokens (minted over SSH, used with the JSON API)",
377 pass("create", "mint a token: --name <n> [--scope full|read] [--ttl 30d]", passOpts{server: []string{"token", "create"}}),
378 pass("list", "list API tokens", passOpts{server: []string{"token", "list"}}),
379 pass("revoke", "revoke a token by name", passOpts{server: []string{"token", "revoke"}}),
380 )
381 return group("auth", "identity: whoami, SSH and PGP keys",
382 pass("export", "write your account bundle (a user-level backup) to stdout",
383 passOpts{server: []string{"account", "export"}}),
384 tokens,
385 pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}),
386 group("keys", "manage SSH keys",
387 pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}),
388 keysAdd,
389 pass("label", "name a key: <fingerprint> [<text>]; no text clears it", passOpts{server: []string{"keys", "label"}}),
390 pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}),
391 ),
392 group("email", "manage email addresses",
393 pass("add", "add an address and get a verification code by mail", passOpts{server: []string{"email", "add"}}),
394 pass("verify", "confirm a verification code", passOpts{server: []string{"email", "verify"}}),
395 pass("list", "list the addresses on your account", passOpts{server: []string{"email", "list"}}),
396 pass("remove", "remove an address; not the primary, nor the last verified one", passOpts{server: []string{"email", "remove"}}),
397 pass("primary", "make a verified address the primary", passOpts{server: []string{"email", "primary"}}),
398 ),
399 group("pgp", "manage OpenPGP keys",
400 pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}),
401 pgpAdd,
402 pass("remove", "remove a PGP key by fingerprint", passOpts{server: []string{"pgp", "remove"}}),
403 ),
404 )
405}
406
407func repoCmd() *cobra.Command {
408 return group("repo", "create and manage repositories",
409 pass("create", "create a repository: gitbay repo create <owner/name> [--private]",
410 passOpts{server: []string{"repo", "create"}}),
411 pass("list", "list repositories you own or can access [--limit n] [--cursor c]", passOpts{server: []string{"repo", "list"}}),
412 pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}),
413 pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}),
414 pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}),
415 pass("rename", "rename a repository: <new-name> (clone URLs change)", passOpts{server: []string{"repo", "rename"}, needsRepo: true}),
416 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
417 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
418 pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}),
419 pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
420 pass("diff", "the patch between two refs: <base> <head>", passOpts{server: []string{"repo", "diff"}, needsRepo: true}),
421 pass("tree", "list a directory: [<path>] [--ref <ref>]", passOpts{server: []string{"repo", "tree"}, needsRepo: true}),
422 pass("cat", "read a file: <path> [--ref <ref>]", passOpts{server: []string{"repo", "cat"}, needsRepo: true}),
423 pass("blame", "attribute lines to commits: <path> [--ref <ref>] [--from <n>] [--to <n>]",
424 passOpts{server: []string{"repo", "blame"}, needsRepo: true}),
425 pass("commit", "show one commit with its patch: <sha>",
426 passOpts{server: []string{"repo", "commit"}, needsRepo: true}),
427 pass("commit-file", "write a file and commit it: <path> [--ref <ref>] [--message <m>] --file -",
428 passOpts{server: []string{"repo", "commit-file"}, needsRepo: true, stdinOK: true}),
429 pass("refs", "list branches and tags", passOpts{server: []string{"repo", "refs"}, needsRepo: true}),
430 pass("download", "write a tar.gz of a ref to stdout: [--ref <r>] > repo.tar.gz",
431 passOpts{server: []string{"repo", "download"}, needsRepo: true}),
432 pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}),
433 pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}),
434 pass("bookmark", "bookmark a repository to come back to", passOpts{server: []string{"repo", "bookmark"}, needsRepo: true}),
435 pass("unbookmark", "remove a bookmark", passOpts{server: []string{"repo", "unbookmark"}, needsRepo: true}),
436 pass("bookmarks", "list the repositories you have bookmarked", passOpts{server: []string{"repo", "bookmarks"}}),
437 pass("watch", "hear about all activity on a repository", passOpts{server: []string{"repo", "watch"}, needsRepo: true}),
438 pass("unwatch", "stop watching a repository", passOpts{server: []string{"repo", "unwatch"}, needsRepo: true}),
439 pass("mute", "mute a repository, including work you are part of", passOpts{server: []string{"repo", "mute"}, needsRepo: true}),
440 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
441 pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}),
442 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone),
443 importCmd(),
444 pass("import-issues", "import GitHub issue/PR history: --from <ghowner/ghrepo> [--token-stdin]",
445 passOpts{server: []string{"repo", "import-issues"}, needsRepo: true, stdinOK: true}),
446 group("deploy-key", "repository-bound CI keys",
447 pass("add", "bind a key: [--rw] < key.pub", passOpts{server: []string{"repo", "deploy-key", "add"}, needsRepo: true, alwaysStdin: true, stdinWhat: "an SSH public key"}),
448 pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}),
449 pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}),
450 ),
451 group("runner", "runners attached to a repository",
452 pass("add", "attach a runner's public key: < key.pub", passOpts{server: []string{"repo", "runner", "add"}, needsRepo: true, alwaysStdin: true, stdinWhat: "an SSH public key"}),
453 pass("list", "list attached runners", passOpts{server: []string{"repo", "runner", "list"}, needsRepo: true}),
454 pass("remove", "detach a runner: <fingerprint>", passOpts{server: []string{"repo", "runner", "remove"}, needsRepo: true}),
455 ),
456 group("mirror", "sync with a foreign remote",
457 pass("add", "add a mirror: <https-url> --direction push|pull [--username <u>] [--token-stdin]",
458 passOpts{server: []string{"repo", "mirror", "add"}, needsRepo: true, stdinOK: true}),
459 pass("list", "list mirrors with sync status", passOpts{server: []string{"repo", "mirror", "list"}, needsRepo: true}),
460 pass("remove", "remove a mirror: <id>", passOpts{server: []string{"repo", "mirror", "remove"}, needsRepo: true}),
461 pass("sync", "schedule an immediate sync", passOpts{server: []string{"repo", "mirror", "sync"}, needsRepo: true}),
462 ),
463 group("deps", "check dependencies against upstream registries",
464 pass("enable", "check this repo's dependencies for updates", passOpts{server: []string{"repo", "deps", "enable"}, needsRepo: true}),
465 pass("disable", "stop checking dependencies", passOpts{server: []string{"repo", "deps", "disable"}, needsRepo: true}),
466 pass("status", "show check state and what is behind", passOpts{server: []string{"repo", "deps", "status"}, needsRepo: true}),
467 ),
468 group("secret", "build secrets (values on stdin, injected into build env)",
469 pass("set", "set a secret: <NAME> (value on stdin)", passOpts{server: []string{"repo", "secret", "set"}, needsRepo: true, alwaysStdin: true, stdinWhat: "the secret value", stdinSecret: true}),
470 pass("list", "list secret names", passOpts{server: []string{"repo", "secret", "list"}, needsRepo: true}),
471 pass("remove", "remove a secret: <NAME>", passOpts{server: []string{"repo", "secret", "remove"}, needsRepo: true}),
472 ),
473 group("domain", "custom domains for the pages branch",
474 pass("add", "claim a domain (verify with a DNS TXT record): <domain>", passOpts{server: []string{"repo", "domain", "add"}, needsRepo: true}),
475 pass("verify", "check the DNS challenge and activate a claim: <domain>", passOpts{server: []string{"repo", "domain", "verify"}, needsRepo: true}),
476 pass("list", "list custom pages domains", passOpts{server: []string{"repo", "domain", "list"}, needsRepo: true}),
477 pass("remove", "remove a custom pages domain: <domain>", passOpts{server: []string{"repo", "domain", "remove"}, needsRepo: true}),
478 ),
479 group("topics", "free-form repository tags",
480 pass("list", "list topics", passOpts{server: []string{"repo", "topics"}, needsRepo: true}),
481 pass("add", "add topics: <topic>...", passOpts{server: []string{"repo", "topics", "add"}, needsRepo: true}),
482 pass("remove", "remove topics: <topic>...", passOpts{server: []string{"repo", "topics", "remove"}, needsRepo: true}),
483 ),
484 group("access", "manage access grants",
485 pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}),
486 pass("revoke", "revoke access: ... <user>", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}),
487 pass("list", "list access grants", passOpts{server: []string{"repo", "access", "list"}, needsRepo: true}),
488 ),
489 group("settings", "repository settings",
490 pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}),
491 pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}),
492 pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}),
493 pass("protect-tag", "protect tags matching a glob: <glob>", passOpts{server: []string{"repo", "settings", "protect-tag"}, needsRepo: true}),
494 pass("unprotect-tag", "drop a protected-tag glob: <glob>", passOpts{server: []string{"repo", "settings", "unprotect-tag"}, needsRepo: true}),
495 pass("default-branch", "set the default branch: <branch>", passOpts{server: []string{"repo", "settings", "default-branch"}, needsRepo: true}),
496 pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}),
497 pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}),
498 pass("require-codeowners", "require an owner's approval per covered file: on|off", passOpts{server: []string{"repo", "settings", "require-codeowners"}, needsRepo: true}),
499 pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}),
500 pass("visibility", "set repository visibility: public|private", passOpts{server: []string{"repo", "settings", "visibility"}, needsRepo: true}),
501 pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}),
502 pass("require-mr", "protected branches take changes through merge requests only: on|off", passOpts{server: []string{"repo", "settings", "require-mr"}, needsRepo: true}),
503 pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}),
504 pass("website", "set the repository website: <url> ('' clears)", passOpts{server: []string{"repo", "settings", "website"}, needsRepo: true}),
505 pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}),
506 ),
507 )
508}
509
510func issueCmd() *cobra.Command {
511 return group("issue", "issues",
512 pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]",
513 passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}),
514 pass("list", "list issues [--state open|closed|all] [--label l] [--assignee u] [--author u] [--milestone m|none] [--limit n] [--cursor c]", passOpts{server: []string{"issue", "list"}, needsRepo: true}),
515 pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}),
516 pass("comment", "comment on an issue [--message|--file -|$EDITOR]",
517 passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
518 pass("close", "close an issue", passOpts{server: []string{"issue", "close"}, needsRepo: true}),
519 pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}),
520 pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}),
521 pass("assign", "assign users: [--add <u>]... [--remove <u>]...", passOpts{server: []string{"issue", "assign"}, needsRepo: true}),
522 pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"issue", "edit"}, needsRepo: true, stdinOK: true}),
523 pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"issue", "milestone"}, needsRepo: true}),
524 pass("templates", "list issue templates (.gitbay/issue-template*.md)", passOpts{server: []string{"issue", "templates"}, needsRepo: true}),
525 )
526}
527
528func releaseCmd() *cobra.Command {
529 return group("release", "tag-anchored releases with notes and assets",
530 pass("create", "create a release on a pushed tag: <tag> [--title <t>] [--notes|--file -|$EDITOR]",
531 passOpts{server: []string{"release", "create"}, needsRepo: true, stdinOK: true, editor: "release"}),
532 pass("edit", "update title and notes: <tag> [--title <t>] [--notes|--file -]",
533 passOpts{server: []string{"release", "edit"}, needsRepo: true, stdinOK: true}),
534 pass("list", "list releases", passOpts{server: []string{"release", "list"}, needsRepo: true}),
535 pass("show", "show a release with assets: <tag>", passOpts{server: []string{"release", "show"}, needsRepo: true}),
536 pass("delete", "delete a release and its assets: <tag> --yes", passOpts{server: []string{"release", "delete"}, needsRepo: true}),
537 group("asset", "binary assets on a release",
538 pass("add", "upload from stdin: <tag> <filename> < file", passOpts{server: []string{"release", "asset", "add"}, needsRepo: true, alwaysStdin: true, stdinWhat: "the asset's bytes"}),
539 pass("get", "download to stdout: <tag> <filename> > file", passOpts{server: []string{"release", "asset", "get"}, needsRepo: true}),
540 pass("remove", "remove an asset: <tag> <filename>", passOpts{server: []string{"release", "asset", "remove"}, needsRepo: true}),
541 ),
542 )
543}
544
545func milestoneCmd() *cobra.Command {
546 return group("milestone", "group issues and MRs toward a release",
547 pass("create", "create a milestone: <title> [--description <d>] [--due YYYY-MM-DD]",
548 passOpts{server: []string{"milestone", "create"}, needsRepo: true}),
549 pass("list", "list milestones with progress [--state open|closed|all]",
550 passOpts{server: []string{"milestone", "list"}, needsRepo: true}),
551 pass("close", "close a milestone: <title>", passOpts{server: []string{"milestone", "close"}, needsRepo: true}),
552 pass("reopen", "reopen a milestone: <title>", passOpts{server: []string{"milestone", "reopen"}, needsRepo: true}),
553 )
554}
555
556func mrCmd() *cobra.Command {
557 review := pass("review", "submit a review: --approve|--request-changes|--comment, or --discard a pending batch", passOpts{server: []string{"mr", "review"}, needsRepo: true})
558 review.AddCommand(pass("request", "ask specific people for review: [--add <u>]... [--remove <u>]...", passOpts{server: []string{"mr", "review", "request"}, needsRepo: true}))
559 return group("mr", "merge requests",
560 pass("create", "open a merge request: --source <branch> --target <branch> --title <t>",
561 passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request", inferSource: true}),
562 pass("list", "list merge requests [--state ...] [--author u] [--milestone m|none] [--limit n] [--cursor c]", passOpts{server: []string{"mr", "list"}, needsRepo: true}),
563 pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}),
564 pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}),
565 local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout),
566 local("rebase", "replay the MR's branch onto its target and re-push: gitbay mr rebase <n>", cmdMRRebase),
567 pass("comment", "comment on a merge request", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
568 pass("diff-comment", "comment on a diff line: --path <f> --line <l> [--old] [--pending] [--reply <id>]", passOpts{server: []string{"mr", "diff-comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
569 pass("threads", "review threads on an MR", passOpts{server: []string{"mr", "threads"}, needsRepo: true}),
570 pass("resolve", "resolve a review thread: <n> <thread-id>", passOpts{server: []string{"mr", "resolve"}, needsRepo: true}),
571 pass("unresolve", "reopen a review thread: <n> <thread-id>", passOpts{server: []string{"mr", "unresolve"}, needsRepo: true}),
572 review,
573 pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
574 pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}),
575 pass("revisions", "the heads this merge request has had", passOpts{server: []string{"mr", "revisions"}, needsRepo: true}),
576 pass("range-diff", "what changed between two revisions: [--from <sha>] [--to <sha>]", passOpts{server: []string{"mr", "range-diff"}, needsRepo: true}),
577 pass("draft", "mark as work in progress", passOpts{server: []string{"mr", "draft"}, needsRepo: true}),
578 pass("ready", "take the draft mark off, so it can merge", passOpts{server: []string{"mr", "ready"}, needsRepo: true}),
579 pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"mr", "edit"}, needsRepo: true, stdinOK: true}),
580 pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}),
581 pass("retarget", "retarget onto another branch: <n> <branch>", passOpts{server: []string{"mr", "retarget"}, needsRepo: true}),
582 )
583}
584
585// importCmd passes repo import through with stdin wired for --token-stdin.
586func importCmd() *cobra.Command {
587 return &cobra.Command{
588 Use: "import",
589 Short: "server-side mirror of a foreign repo: gitbay repo import <owner/name> --from <url> [--private] [--token-stdin]",
590 Annotations: map[string]string{serverPath: "repo import"},
591 DisableFlagParsing: true,
592 RunE: func(cmd *cobra.Command, args []string) error {
593 for _, a := range args {
594 if a == "--help" || a == "-h" {
595 return cmd.Help()
596 }
597 }
598 t, err := resolveTarget()
599 if err != nil {
600 return err
601 }
602 var stdin io.Reader = strings.NewReader("")
603 if usesTokenStdin(args) {
604 stdin = os.Stdin
605 }
606 os.Exit(runSSH(t, append([]string{"repo", "import"}, args...), stdin))
607 return nil
608 },
609 }
610}
611
612func usesTokenStdin(args []string) bool {
613 for _, a := range args {
614 if a == "--token-stdin" {
615 return true
616 }
617 }
618 return false
619}
620
621func webCmd() *cobra.Command {
622 return group("web", "browser session",
623 pass("login", "mint a one-time browser login URL over ssh", passOpts{server: []string{"web", "login"}}),
624 group("sessions", "your browser sessions",
625 pass("list", "list your browser sessions", passOpts{server: []string{"web", "sessions", "list"}}),
626 pass("revoke", "end a browser session: <id>|--all", passOpts{server: []string{"web", "sessions", "revoke"}}),
627 ),
628 )
629}
630
631func webhookCmd() *cobra.Command {
632 return group("webhook", "outbound event delivery",
633 pass("add", "add a webhook: <url> [--secret s] [--events k1,k2|*]", passOpts{server: []string{"webhook", "add"}, needsRepo: true}),
634 pass("list", "list webhooks", passOpts{server: []string{"webhook", "list"}, needsRepo: true}),
635 pass("remove", "remove a webhook: <id>", passOpts{server: []string{"webhook", "remove"}, needsRepo: true}),
636 pass("deliveries", "recent deliveries [--limit n]", passOpts{server: []string{"webhook", "deliveries"}, needsRepo: true}),
637 pass("redeliver", "requeue a delivery: <delivery-id>", passOpts{server: []string{"webhook", "redeliver"}, needsRepo: true}),
638 )
639}
640
641func orgCmd() *cobra.Command {
642 return group("org", "organizations",
643 pass("create", "create an organization", passOpts{server: []string{"org", "create"}}),
644 pass("list", "list organizations you belong to", passOpts{server: []string{"org", "list"}}),
645 pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}),
646 pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}),
647 pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}),
648 pass("profile", "show or set an org profile: <org> [--description d] [--website url] [--about t|--file -] [--about-format md|org] [--link label|url]...",
649 passOpts{server: []string{"org", "profile"}, stdinOK: true}),
650 group("members", "manage members",
651 pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}),
652 pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}),
653 pass("list", "list members: <org>", passOpts{server: []string{"org", "members", "list"}}),
654 ),
655 group("label", "labels every org repository sees",
656 pass("set", "create an org label or set its colour: <org> <label> [--color rrggbb|'']", passOpts{server: []string{"org", "label", "set"}}),
657 pass("list", "list org labels with use across readable repositories: <org>", passOpts{server: []string{"org", "label", "list"}}),
658 pass("remove", "remove an org label everywhere: <org> <label>", passOpts{server: []string{"org", "label", "remove"}}),
659 ),
660 group("milestone", "milestones spanning an org's repositories",
661 pass("create", "create an org milestone: <org> <title> [--description d] [--due YYYY-MM-DD]", passOpts{server: []string{"org", "milestone", "create"}}),
662 pass("list", "list org milestones with progress: <org> [--state open|closed|all]", passOpts{server: []string{"org", "milestone", "list"}}),
663 pass("close", "close an org milestone: <org> <title>", passOpts{server: []string{"org", "milestone", "close"}}),
664 pass("reopen", "reopen an org milestone: <org> <title>", passOpts{server: []string{"org", "milestone", "reopen"}}),
665 ),
666 group("team", "scope repository access with teams",
667 pass("create", "create a team: <org> <team>", passOpts{server: []string{"org", "team", "create"}}),
668 pass("delete", "delete a team: <org> <team>", passOpts{server: []string{"org", "team", "delete"}}),
669 pass("list", "list teams: <org>", passOpts{server: []string{"org", "team", "list"}}),
670 pass("show", "show members and grants: <org> <team>", passOpts{server: []string{"org", "team", "show"}}),
671 pass("add", "add org members: <org> <team> <user>...", passOpts{server: []string{"org", "team", "add"}}),
672 pass("remove", "remove members: <org> <team> <user>...", passOpts{server: []string{"org", "team", "remove"}}),
673 pass("grant", "grant a repo role: <org> <team> <owner/name> read|write|admin", passOpts{server: []string{"org", "team", "grant"}}),
674 pass("revoke", "revoke a repo grant: <org> <team> <owner/name>", passOpts{server: []string{"org", "team", "revoke"}}),
675 ),
676 group("settings", "organization settings",
677 pass("members-role", "role plain membership implies: <org> write|read|none", passOpts{server: []string{"org", "settings", "members-role"}}),
678 ),
679 )
680}
681
682func remoteCmd() *cobra.Command {
683 return group("remote", "local instance profiles (no server contact)",
684 local("add", "add a named gitbay instance: gitbay remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]",
685 cmdRemoteAdd),
686 local("list", "list configured instances", func([]string) int { return cmdRemoteList() }),
687 )
688}
689
690func initCmd() *cobra.Command {
691 return local("init", "git init + repo create + set origin, in one step: gitbay init [name] [--private]", cmdInit)
692}
693
694// manCmd generates man pages; a CLI-first tool without man pages is not
695// CLI-first.
696func manCmd(root *cobra.Command) *cobra.Command {
697 var dir string
698 cmd := &cobra.Command{
699 Use: "man",
700 Short: "generate man pages into a directory",
701 Hidden: true,
702 RunE: func(cmd *cobra.Command, args []string) error {
703 if err := os.MkdirAll(dir, 0o755); err != nil {
704 return err
705 }
706 return doc.GenManTree(root, &doc.GenManHeader{Title: "FORGE", Section: "1"}, dir)
707 },
708 }
709 cmd.Flags().StringVar(&dir, "dir", "man", "output directory")
710 return cmd
711}
712
713// helpCmd is `gitbay help`. Bare, it is cobra's tree of local commands.
714// With anything after it — a prefix such as `mr`, or --json — it is the
715// server's help: the registry is the only place flags are written down,
716// and its JSON is the contract. Cobra's built-in help used to swallow
717// both forms, so `gitbay help --json` failed on an unknown flag.
718func helpCmd(root *cobra.Command) *cobra.Command {
719 return &cobra.Command{
720 Use: "help [<prefix>...] [--json]",
721 Short: "this list, or the server's command reference for a prefix",
722 Annotations: map[string]string{serverPath: "help", stdinMode: "none"},
723 DisableFlagParsing: true,
724 RunE: func(cmd *cobra.Command, args []string) error {
725 if len(args) == 0 {
726 return root.Help()
727 }
728 t, err := resolveTarget()
729 if err != nil {
730 fmt.Fprintln(os.Stderr, "gitbay:", err)
731 os.Exit(protocol.ExitFailure)
732 }
733 os.Exit(runSSH(t, append([]string{"help"}, args...), strings.NewReader("")))
734 return nil
735 },
736 }
737}