cmd/gitbay/main.go
888 lines · 39938 bytes
37 symbols in this file
mainnewRootrootSectionrootSectionsrootHelpserverPathstdinModestdinWhatpassOptspassOpts.stdinModeNamepasswithShortrunServerHelprunPassisEmptyReaderusesStdinwithSubgroupaliasGroupNameshelpArgvserverHelplocalauthCmdrepoCmdissueCmdreleaseCmdmilestoneCmdmrCmdimportCmdusesTokenStdinwebCmdwebhookCmdorgCmdremoteCmdinitCmdmanCmdhelpCmd
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 "golang.org/x/term"
16
17 "gitbay.org/gitbay/internal/protocol"
18)
19
20func main() {
21 os.Args, noColor = stripNoColor(os.Args)
22 if err := newRoot().Execute(); err != nil {
23 fmt.Fprintln(os.Stderr, "gitbay:", err)
24 os.Exit(protocol.ExitUsage)
25 }
26}
27
28// newRoot builds the command tree. Separate from main so the coverage
29// test can walk it.
30func newRoot() *cobra.Command {
31 root := &cobra.Command{
32 Use: "gitbay",
33 Short: "CLI-first git forge client",
34 SilenceUsage: true,
35 SilenceErrors: true,
36 }
37 root.SetHelpCommand(helpCmd(root))
38
39 root.AddCommand(
40 authCmd(),
41 group("label", "issue labels",
42 pass("list", passOpts{server: []string{"label", "list"}, needsRepo: true}),
43 pass("set", passOpts{server: []string{"label", "set"}, needsRepo: true}),
44 pass("remove", passOpts{server: []string{"label", "remove"}, needsRepo: true}),
45 ),
46 group("status", "commit statuses (CI)",
47 pass("set", passOpts{server: []string{"status", "set"}, needsRepo: true}),
48 pass("list", passOpts{server: []string{"status", "list"}, needsRepo: true}),
49 ),
50 group("build", "CI builds",
51 pass("list", passOpts{server: []string{"build", "list"}, needsRepo: true}),
52 pass("show", passOpts{server: []string{"build", "show"}, needsRepo: true}),
53 pass("log", passOpts{server: []string{"build", "log"}, needsRepo: true}),
54 pass("jobs", passOpts{server: []string{"build", "jobs"}, needsRepo: true}),
55 pass("trigger", passOpts{server: []string{"build", "trigger"}, needsRepo: true}),
56 pass("cancel", passOpts{server: []string{"build", "cancel"}, needsRepo: true}),
57 ),
58 withShort(pass("dashboard", passOpts{server: []string{"dashboard"}}), "pinned repos, open MRs, assigned issues, recent builds"),
59 pass("feed", passOpts{server: []string{"feed"}}),
60 withShort(pass("explore", passOpts{server: []string{"explore"}}), "public repositories on this instance"),
61 withShort(pass("search", passOpts{server: []string{"search"}}), "find repositories, issues and merge requests"),
62 group("query", "saved issue and merge request queries across repositories",
63 pass("save", passOpts{server: []string{"query", "save"}}),
64 pass("list", passOpts{server: []string{"query", "list"}}),
65 pass("show", passOpts{server: []string{"query", "show"}}),
66 pass("run", passOpts{server: []string{"query", "run"}}),
67 pass("remove", passOpts{server: []string{"query", "remove"}}),
68 pass("pin", passOpts{server: []string{"query", "pin"}}),
69 pass("unpin", passOpts{server: []string{"query", "unpin"}}),
70 ),
71 group("notifications", "your notification inbox",
72 pass("list", passOpts{server: []string{"notifications", "list"}}),
73 pass("read", passOpts{server: []string{"notifications", "read"}}),
74 group("settings", "notification preferences",
75 pass("show", passOpts{server: []string{"notifications", "settings", "show"}}),
76 pass("mail", passOpts{server: []string{"notifications", "settings", "mail"}}),
77 pass("reply", passOpts{server: []string{"notifications", "settings", "reply"}}),
78 pass("watch", passOpts{server: []string{"notifications", "settings", "watch"}}),
79 pass("push", passOpts{server: []string{"notifications", "settings", "push"}}),
80 ),
81 group("device", "Apple devices registered for push",
82 pass("add", passOpts{server: []string{"notifications", "device", "add"}, alwaysStdin: true, stdinWhat: "the device token"}),
83 pass("list", passOpts{server: []string{"notifications", "device", "list"}}),
84 pass("remove", passOpts{server: []string{"notifications", "device", "remove"}}),
85 ),
86 ),
87 group("wiki", "a repository's wiki pages",
88 pass("list", passOpts{server: []string{"wiki", "list"}, needsRepo: true}),
89 pass("show", passOpts{server: []string{"wiki", "show"}, needsRepo: true}),
90 ),
91 group("snippet", "shared text files, outside any repository",
92 pass("create", passOpts{server: []string{"snippet", "create"}, alwaysStdin: true, stdinWhat: "the file's text"}),
93 pass("show", passOpts{server: []string{"snippet", "show"}}),
94 pass("list", passOpts{server: []string{"snippet", "list"}}),
95 pass("edit", passOpts{server: []string{"snippet", "edit"}}),
96 pass("delete", passOpts{server: []string{"snippet", "delete"}}),
97 group("file", "the files in a snippet",
98 pass("set", passOpts{server: []string{"snippet", "file", "set"}, alwaysStdin: true, stdinWhat: "the file's text"}),
99 pass("get", passOpts{server: []string{"snippet", "file", "get"}}),
100 pass("remove", passOpts{server: []string{"snippet", "file", "remove"}}),
101 ),
102 ),
103 repoCmd(),
104 issueCmd(),
105 milestoneCmd(),
106 mrCmd(),
107 releaseCmd(),
108 migrateCmd(),
109 webCmd(),
110 orgCmd(),
111 group("profile", "user and org profiles",
112 pass("show", passOpts{server: []string{"profile", "show"}}),
113 pass("set", passOpts{server: []string{"profile", "set"}}),
114 ),
115 webhookCmd(),
116 remoteCmd(),
117 initCmd(),
118 withShort(pass("register", passOpts{server: []string{"register"}}), "create an account on this instance"),
119 pass("audit", passOpts{server: []string{"audit"}}),
120 group("admin", "instance administration (admins)",
121 group("user", "accounts on this instance",
122 pass("list", passOpts{server: []string{"admin", "user", "list"}}),
123 pass("show", passOpts{server: []string{"admin", "user", "show"}}),
124 pass("promote", passOpts{server: []string{"admin", "user", "promote"}}),
125 pass("demote", passOpts{server: []string{"admin", "user", "demote"}}),
126 pass("create", passOpts{server: []string{"admin", "user", "create"}, stdinOK: true}),
127 pass("disable", passOpts{server: []string{"admin", "user", "disable"}}),
128 pass("enable", passOpts{server: []string{"admin", "user", "enable"}}),
129 pass("delete", passOpts{server: []string{"admin", "user", "delete"}}),
130 pass("limits", passOpts{server: []string{"admin", "user", "limits"}}),
131 ),
132 group("org", "any organization",
133 pass("limits", passOpts{server: []string{"admin", "org", "limits"}}),
134 ),
135 group("email", "addresses on any account",
136 pass("verify", passOpts{server: []string{"admin", "email", "verify"}}),
137 ),
138 pass("invite", passOpts{server: []string{"admin", "invite"}}),
139 pass("stats", passOpts{server: []string{"admin", "stats"}}),
140 withSub(pass("runners", passOpts{server: []string{"admin", "runners"}}),
141 pass("remove", passOpts{server: []string{"admin", "runners", "remove"}}),
142 pass("forget", passOpts{server: []string{"admin", "runners", "forget"}})),
143 group("repo", "any repository, for moderation (audited)",
144 pass("list", passOpts{server: []string{"admin", "repo", "list"}}),
145 pass("archive", passOpts{server: []string{"admin", "repo", "archive"}}),
146 pass("unarchive", passOpts{server: []string{"admin", "repo", "unarchive"}}),
147 pass("visibility", passOpts{server: []string{"admin", "repo", "visibility"}}),
148 pass("delete", passOpts{server: []string{"admin", "repo", "delete"}}),
149 ),
150 group("mr", "merge requests in any repository (audited)",
151 pass("prune", passOpts{server: []string{"admin", "mr", "prune"}}),
152 ),
153 group("symbols", "symbol indexes",
154 pass("reindex", passOpts{server: []string{"admin", "symbols", "reindex"}}),
155 ),
156 group("mail", "the instance's mail",
157 group("inbound", "the mailbox replies to notification mail arrive in",
158 pass("check", passOpts{server: []string{"admin", "mail", "inbound", "check"}}),
159 ),
160 ),
161 ),
162 manCmd(root),
163 )
164 defaultHelp := root.HelpFunc()
165 root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
166 if cmd == root {
167 rootHelp(root)
168 return
169 }
170 defaultHelp(cmd, args)
171 })
172 return root
173}
174
175// rootSection is one heading in gitbay --help: a title and the root
176// commands that belong under it.
177type rootSection struct {
178 title string
179 names []string
180}
181
182var rootSections = []rootSection{
183 {"WORK", []string{"issue", "mr", "build", "release", "milestone", "label", "search", "query"}},
184 {"REPOSITORIES", []string{"repo", "wiki", "status", "webhook", "init"}},
185 {"YOU", []string{"dashboard", "feed", "notifications", "auth", "profile", "snippet", "web"}},
186 {"INSTANCE", []string{"org", "explore", "register", "migrate", "remote", "admin", "audit", "man"}},
187}
188
189// rootHelp is gitbay --help: the nouns grouped by what they are for.
190func rootHelp(root *cobra.Command) {
191 byName := map[string]*cobra.Command{}
192 wide := 0
193 for _, c := range root.Commands() {
194 byName[c.Name()] = c
195 if !c.Hidden {
196 wide = max(wide, len(c.Name()))
197 }
198 }
199 fmt.Println("gitbay: command-line client for a gitbay forge")
200 fmt.Println()
201 fmt.Println("USAGE")
202 fmt.Println(" gitbay <command> [<owner/name>] [flags]")
203 for _, s := range rootSections {
204 var rows [][2]string
205 for _, n := range s.names {
206 if c := byName[n]; c != nil && !c.Hidden {
207 rows = append(rows, [2]string{n, c.Short})
208 }
209 }
210 if len(rows) == 0 {
211 continue
212 }
213 fmt.Println()
214 fmt.Println(s.title)
215 for _, r := range rows {
216 fmt.Printf(" %-*s %s\n", wide, r[0], r[1])
217 }
218 }
219 fmt.Println()
220 fmt.Println("gitbay <command> --help for its verbs; gitbay help <prefix> for the server reference.")
221}
222
223// serverPath is the annotation key holding a passthrough command's
224// server-side path, so the tree can be checked against the registry.
225const serverPath = "gitbay.server_path"
226const stdinMode = "gitbay.stdin_mode"
227
228// stdinWhat carries the payload's name onto the command tree so the
229// coverage test can assert every stdin-payload command has one; without
230// it the terminal prompt says the unhelpful word "input" (#150).
231const stdinWhat = "gitbay.stdin_what"
232
233// passOpts describes how one CLI command maps onto the server command.
234type passOpts struct {
235 server []string // server-side command path
236 needsRepo bool // prepend inferred owner/name unless given
237 crossRepo []string // flags that span repositories: with one, no repository is inferred
238 stdinOK bool // wire local stdin through when --file - asks for it
239 alwaysStdin bool // stdin is the payload, named by no flag: a bare redirect
240 // stdinWhat names the payload for the prompt shown when stdin is a
241 // terminal; stdinSecret hides the input and takes one line, for a
242 // value that should not reach the scrollback.
243 stdinWhat string
244 stdinSecret bool
245 editor string // open $EDITOR for a body when none given
246 inferSource bool // --source defaults to the checked-out branch inside a clone
247}
248
249// pass builds a passthrough command. Flags are parsed by the server, which
250// is the single source of truth for them; the CLI stays thin.
251// stdinModeName reports how this command takes stdin, so the coverage test can
252// check that a command reading a bare redirect is not left waiting for a
253// `--file -` that its callers never type.
254func (o passOpts) stdinModeName() string {
255 switch {
256 case o.alwaysStdin:
257 return "always"
258 case o.stdinOK:
259 return "flag"
260 }
261 return "none"
262}
263
264func pass(use string, o passOpts) *cobra.Command {
265 return &cobra.Command{
266 Use: use,
267 Short: summaries[strings.Join(o.server, " ")],
268 Annotations: map[string]string{
269 serverPath: strings.Join(o.server, " "),
270 stdinMode: o.stdinModeName(),
271 stdinWhat: o.stdinWhat,
272 },
273 DisableFlagParsing: true,
274 RunE: func(cmd *cobra.Command, args []string) error {
275 // The registry is the only place flags are written down, so
276 // --help asks the server rather than reprinting the one-line
277 // summary cobra holds.
278 cliPath := cliPathOf(cmd)
279 for _, a := range args {
280 if a == "--help" || a == "-h" {
281 os.Exit(runServerHelp(o, cliPath))
282 }
283 }
284 os.Exit(runPass(o, cliPath, args))
285 return nil
286 },
287 }
288}
289
290// withShort overrides a passthrough command's one-line summary. Used
291// where the registry's own Summary for a single-verb noun (dashboard,
292// explore, search, register) reads differently from nounSummaries, which
293// is what a grouped noun's header and the root help both show; the two
294// must agree (TestGroupsSayWhatTheServerSays), so these few take the
295// noun's wording instead of the command's.
296func withShort(cmd *cobra.Command, short string) *cobra.Command {
297 cmd.Short = short
298 return cmd
299}
300
301// runServerHelp prints the registry's usage for one command.
302func runServerHelp(o passOpts, cliPath string) int {
303 t, err := resolveTarget()
304 if err != nil {
305 fmt.Fprintln(os.Stderr, "gitbay:", err)
306 return protocol.ExitFailure
307 }
308 argv := withCLIPath(cliPath, strings.Join(o.server, " "), append([]string{"help"}, o.server...))
309 return runSSH(t, argv, strings.NewReader(""))
310}
311
312func runPass(o passOpts, cliPath string, args []string) int {
313 t, err := resolveTarget()
314 if err != nil {
315 fmt.Fprintln(os.Stderr, "gitbay:", err)
316 return protocol.ExitFailure
317 }
318 explicitRepo := len(args) > 0 && !strings.HasPrefix(args[0], "-") && strings.Contains(args[0], "/")
319 if o.needsRepo && !anyFlag(args, o.crossRepo) {
320 args, err = withRepo(t, args)
321 if err != nil {
322 fmt.Fprintln(os.Stderr, "gitbay:", err)
323 return protocol.ExitUsage
324 }
325 }
326 // Inside a clone, the branch you are on is the one you mean (#101).
327 // Only when the repository was inferred from the clone too: naming
328 // another repository and meaning this checkout's branch is unlikely.
329 if o.inferSource && !explicitRepo && !hasFlag(args, "--source") {
330 if branch := currentBranch(); branch != "" {
331 args = append(args, "--source", branch)
332 }
333 }
334
335 var stdin io.Reader = strings.NewReader("")
336 if o.editor != "" {
337 // Issue bodies prefill from the repo's .gitbay/issue-template*.md.
338 var prefill func() string
339 if o.editor == "issue" && len(args) > 0 && strings.Contains(args[0], "/") {
340 repoPath := args[0]
341 prefill = func() string { return fetchIssueTemplate(t, repoPath) }
342 }
343 extended, body, ok, err := maybeEditor(args, o.editor, prefill)
344 if err != nil {
345 fmt.Fprintln(os.Stderr, "gitbay:", err)
346 return protocol.ExitFailure
347 }
348 if !ok {
349 return protocol.ExitFailure
350 }
351 args = extended
352 if body != nil {
353 stdin = body
354 }
355 }
356 if stdin == nil || isEmptyReader(stdin) {
357 if o.alwaysStdin || (o.stdinOK && usesStdin(args)) {
358 what := o.stdinWhat
359 if what == "" {
360 what = "input"
361 }
362 r, err := stdinPayload(os.Stdin, what, o.stdinSecret)
363 if err != nil {
364 fmt.Fprintln(os.Stderr, "gitbay:", err)
365 return protocol.ExitFailure
366 }
367 stdin = r
368 }
369 }
370 argv := withCLIPath(cliPath, strings.Join(o.server, " "), append(o.server, args...))
371 return runSSHPaged(t, argv, stdin, pages(o.server, args))
372}
373
374func isEmptyReader(r io.Reader) bool {
375 sr, ok := r.(*strings.Reader)
376 return ok && sr.Len() == 0
377}
378
379// usesStdin reports whether the arguments request stdin content.
380func usesStdin(args []string) bool {
381 for i, a := range args {
382 if (a == "--file" || a == "--key" || a == "--secret") && i+1 < len(args) && args[i+1] == "-" {
383 return true
384 }
385 if a == "--token-stdin" {
386 return true
387 }
388 }
389 return false
390}
391
392// withSub hangs subcommands off a passthrough command, so `admin runners`
393// still runs while `admin runners forget` reaches its own command.
394func withSub(cmd *cobra.Command, subs ...*cobra.Command) *cobra.Command {
395 cmd.AddCommand(subs...)
396 return cmd
397}
398
399func group(use, short string, subs ...*cobra.Command) *cobra.Command {
400 c := &cobra.Command{Use: use, Short: short}
401 c.AddCommand(subs...)
402 // A noun's help is the server's, like a command's: the registry is
403 // the only place flags are written down, and cobra's subcommand list
404 // carried none (#130). Offline, or for a noun the server does not
405 // know by that name, cobra's own tree still prints.
406 local := c.HelpFunc()
407 c.SetHelpFunc(func(cmd *cobra.Command, args []string) {
408 if !serverHelp(use, cliPathOf(c)) {
409 local(cmd, args)
410 }
411 })
412 return c
413}
414
415// aliasGroupNames are CLI-only noun names with no matching registry
416// prefix (internal/control's nounAliases keys — kept in sync by hand,
417// since the CLI has no registry to consult): auth's own cliPath is
418// "auth", equal to the prefix it asks help for, so withCLIPath's
419// equality shortcut reads that as "no override needed" even though no
420// registered command is named "auth" at all, and help would silently
421// fall back to the registered forms (#267 review finding). Force
422// --path= for these regardless of the equality check.
423var aliasGroupNames = map[string]bool{"auth": true}
424
425// helpArgv builds the server argv for a group's --help: --path=cliPath
426// when the CLI's path differs from the registered prefix it names, or
427// unconditionally when prefix is a CLI-only alias grouping the registry
428// never answers to under that name.
429func helpArgv(prefix, cliPath string) []string {
430 argv := []string{"help", prefix}
431 if aliasGroupNames[prefix] {
432 return append([]string{"--path=" + cliPath}, argv...)
433 }
434 return withCLIPath(cliPath, prefix, argv)
435}
436
437// serverHelp prints the registry's usage for a prefix and reports whether
438// it did. cliPath is the group's own path in the CLI. At a terminal it
439// goes through the terminal-aware path, so it gets the same
440// --term=<cols>[,color] treatment (and layout) as any other command;
441// piped, it stays a quiet capture, so a network or lookup failure falls
442// back to cobra's local help without noise.
443func serverHelp(prefix, cliPath string) bool {
444 t, err := resolveTarget()
445 if err != nil {
446 return false
447 }
448 argv := helpArgv(prefix, cliPath)
449 if term.IsTerminal(int(os.Stdout.Fd())) {
450 return runSSH(t, argv, strings.NewReader("")) == 0
451 }
452 out, code := sshCapture(t, argv)
453 if code != 0 || out == "" {
454 return false
455 }
456 fmt.Print(out)
457 return true
458}
459
460// local wraps a locally-implemented command (git plumbing, config).
461func local(use, short string, fn func(args []string) int) *cobra.Command {
462 return &cobra.Command{
463 Use: use,
464 Short: short,
465 DisableFlagParsing: true,
466 RunE: func(cmd *cobra.Command, args []string) error {
467 for _, a := range args {
468 if a == "--help" || a == "-h" {
469 return cmd.Help()
470 }
471 }
472 os.Exit(fn(args))
473 return nil
474 },
475 }
476}
477
478func authCmd() *cobra.Command {
479 keysAdd := pass("add", passOpts{server: []string{"keys", "add"}, alwaysStdin: true, stdinWhat: "an SSH public key"})
480 // keys add always reads stdin on the server; wire it through directly.
481 keysAdd.RunE = func(cmd *cobra.Command, args []string) error {
482 cliPath := cliPathOf(cmd)
483 for _, a := range args {
484 if a == "--help" || a == "-h" {
485 os.Exit(runServerHelp(passOpts{server: []string{"keys", "add"}}, cliPath))
486 }
487 }
488 t, err := resolveTarget()
489 if err != nil {
490 return err
491 }
492 in, err := stdinPayload(os.Stdin, "an SSH public key", false)
493 if err != nil {
494 return err
495 }
496 os.Exit(runSSH(t, withCLIPath(cliPath, "keys add", append([]string{"keys", "add"}, args...)), in))
497 return nil
498 }
499 pgpAdd := &cobra.Command{
500 Use: "add", Short: "register an OpenPGP public key (armored, on stdin)",
501 Annotations: map[string]string{
502 serverPath: "pgp add",
503 stdinMode: "always",
504 stdinWhat: "an armored OpenPGP public key",
505 },
506 DisableFlagParsing: true,
507 RunE: func(cmd *cobra.Command, args []string) error {
508 cliPath := cliPathOf(cmd)
509 for _, a := range args {
510 if a == "--help" || a == "-h" {
511 os.Exit(runServerHelp(passOpts{server: []string{"pgp", "add"}}, cliPath))
512 }
513 }
514 t, err := resolveTarget()
515 if err != nil {
516 return err
517 }
518 in, err := stdinPayload(os.Stdin, "an armored OpenPGP public key", false)
519 if err != nil {
520 return err
521 }
522 os.Exit(runSSH(t, withCLIPath(cliPath, "pgp add", append([]string{"pgp", "add"}, args...)), in))
523 return nil
524 },
525 }
526 tokens := group("token", "API tokens (minted over SSH, used with the JSON API)",
527 pass("create", passOpts{server: []string{"token", "create"}}),
528 pass("list", passOpts{server: []string{"token", "list"}}),
529 pass("revoke", passOpts{server: []string{"token", "revoke"}}),
530 )
531 return group("auth", "whoami, SSH and PGP keys, email, API tokens",
532 pass("export", passOpts{server: []string{"account", "export"}}),
533 pass("delete", passOpts{server: []string{"account", "delete"}}),
534 tokens,
535 pass("whoami", passOpts{server: []string{"whoami"}}),
536 group("keys", "manage SSH keys",
537 pass("list", passOpts{server: []string{"keys", "list"}}),
538 keysAdd,
539 pass("label", passOpts{server: []string{"keys", "label"}}),
540 pass("remove", passOpts{server: []string{"keys", "remove"}}),
541 ),
542 group("email", "manage email addresses",
543 pass("add", passOpts{server: []string{"email", "add"}}),
544 pass("verify", passOpts{server: []string{"email", "verify"}}),
545 pass("list", passOpts{server: []string{"email", "list"}}),
546 pass("remove", passOpts{server: []string{"email", "remove"}}),
547 pass("primary", passOpts{server: []string{"email", "primary"}}),
548 ),
549 group("pgp", "manage OpenPGP keys",
550 pass("list", passOpts{server: []string{"pgp", "list"}}),
551 pgpAdd,
552 pass("remove", passOpts{server: []string{"pgp", "remove"}}),
553 ),
554 )
555}
556
557func repoCmd() *cobra.Command {
558 return group("repo", "create and manage repositories",
559 pass("create", passOpts{server: []string{"repo", "create"}}),
560 pass("list", passOpts{server: []string{"repo", "list"}}),
561 pass("show", passOpts{server: []string{"repo", "show"}, needsRepo: true}),
562 pass("log", passOpts{server: []string{"repo", "log"}, needsRepo: true}),
563 pass("transfer", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}),
564 pass("rename", passOpts{server: []string{"repo", "rename"}, needsRepo: true}),
565 pass("delete", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
566 pass("fork", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
567 pass("search", passOpts{server: []string{"repo", "search"}}),
568 pass("grep", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
569 pass("symbols", passOpts{server: []string{"repo", "symbols"}, needsRepo: true}),
570 pass("diff", passOpts{server: []string{"repo", "diff"}, needsRepo: true}),
571 pass("tree", passOpts{server: []string{"repo", "tree"}, needsRepo: true}),
572 pass("cat", passOpts{server: []string{"repo", "cat"}, needsRepo: true}),
573 pass("readme", passOpts{server: []string{"repo", "readme"}, needsRepo: true}),
574 pass("blame", passOpts{server: []string{"repo", "blame"}, needsRepo: true}),
575 pass("commit", passOpts{server: []string{"repo", "commit"}, needsRepo: true}),
576 pass("commit-file", passOpts{server: []string{"repo", "commit-file"}, needsRepo: true, stdinOK: true}),
577 pass("refs", passOpts{server: []string{"repo", "refs"}, needsRepo: true}),
578 pass("download", passOpts{server: []string{"repo", "download"}, needsRepo: true}),
579 pass("pin", passOpts{server: []string{"repo", "pin"}, needsRepo: true}),
580 pass("unpin", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}),
581 pass("bookmark", passOpts{server: []string{"repo", "bookmark"}, needsRepo: true}),
582 pass("unbookmark", passOpts{server: []string{"repo", "unbookmark"}, needsRepo: true}),
583 pass("bookmarks", passOpts{server: []string{"repo", "bookmarks"}}),
584 pass("watch", passOpts{server: []string{"repo", "watch"}, needsRepo: true}),
585 pass("unwatch", passOpts{server: []string{"repo", "unwatch"}, needsRepo: true}),
586 pass("mute", passOpts{server: []string{"repo", "mute"}, needsRepo: true}),
587 pass("archive", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
588 pass("unarchive", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}),
589 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone),
590 importCmd(),
591 pass("import-issues", passOpts{server: []string{"repo", "import-issues"}, needsRepo: true, stdinOK: true}),
592 group("deploy-key", "repository-bound CI keys",
593 pass("add", passOpts{server: []string{"repo", "deploy-key", "add"}, needsRepo: true, alwaysStdin: true, stdinWhat: "an SSH public key"}),
594 pass("list", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}),
595 pass("remove", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}),
596 ),
597 group("runner", "runners attached to a repository",
598 pass("add", passOpts{server: []string{"repo", "runner", "add"}, needsRepo: true, alwaysStdin: true, stdinWhat: "an SSH public key"}),
599 pass("list", passOpts{server: []string{"repo", "runner", "list"}, needsRepo: true}),
600 pass("remove", passOpts{server: []string{"repo", "runner", "remove"}, needsRepo: true}),
601 ),
602 group("mirror", "sync with a foreign remote",
603 pass("add", passOpts{server: []string{"repo", "mirror", "add"}, needsRepo: true, stdinOK: true}),
604 pass("list", passOpts{server: []string{"repo", "mirror", "list"}, needsRepo: true}),
605 pass("remove", passOpts{server: []string{"repo", "mirror", "remove"}, needsRepo: true}),
606 pass("sync", passOpts{server: []string{"repo", "mirror", "sync"}, needsRepo: true}),
607 ),
608 group("deps", "check dependencies against upstream registries",
609 pass("enable", passOpts{server: []string{"repo", "deps", "enable"}, needsRepo: true}),
610 pass("disable", passOpts{server: []string{"repo", "deps", "disable"}, needsRepo: true}),
611 pass("status", passOpts{server: []string{"repo", "deps", "status"}, needsRepo: true}),
612 ),
613 group("secret", "build secrets (values on stdin, injected into build env)",
614 pass("set", passOpts{server: []string{"repo", "secret", "set"}, needsRepo: true, alwaysStdin: true, stdinWhat: "the secret value", stdinSecret: true}),
615 pass("list", passOpts{server: []string{"repo", "secret", "list"}, needsRepo: true}),
616 pass("remove", passOpts{server: []string{"repo", "secret", "remove"}, needsRepo: true}),
617 ),
618 group("domain", "custom domains for the pages branch",
619 pass("add", passOpts{server: []string{"repo", "domain", "add"}, needsRepo: true}),
620 pass("verify", passOpts{server: []string{"repo", "domain", "verify"}, needsRepo: true}),
621 pass("list", passOpts{server: []string{"repo", "domain", "list"}, needsRepo: true}),
622 pass("remove", passOpts{server: []string{"repo", "domain", "remove"}, needsRepo: true}),
623 ),
624 group("topics", "free-form repository tags",
625 pass("list", passOpts{server: []string{"repo", "topics"}, needsRepo: true}),
626 pass("add", passOpts{server: []string{"repo", "topics", "add"}, needsRepo: true}),
627 pass("remove", passOpts{server: []string{"repo", "topics", "remove"}, needsRepo: true}),
628 ),
629 group("access", "manage access grants",
630 pass("grant", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}),
631 pass("revoke", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}),
632 pass("list", passOpts{server: []string{"repo", "access", "list"}, needsRepo: true}),
633 ),
634 group("settings", "repository settings",
635 pass("show", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}),
636 pass("protect", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}),
637 pass("unprotect", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}),
638 pass("protect-tag", passOpts{server: []string{"repo", "settings", "protect-tag"}, needsRepo: true}),
639 pass("unprotect-tag", passOpts{server: []string{"repo", "settings", "unprotect-tag"}, needsRepo: true}),
640 pass("default-branch", passOpts{server: []string{"repo", "settings", "default-branch"}, needsRepo: true}),
641 pass("require-approvals", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}),
642 pass("require-resolved", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}),
643 pass("require-codeowners", passOpts{server: []string{"repo", "settings", "require-codeowners"}, needsRepo: true}),
644 pass("require-checks", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}),
645 pass("require-contexts", passOpts{server: []string{"repo", "settings", "require-contexts"}, needsRepo: true}),
646 pass("visibility", passOpts{server: []string{"repo", "settings", "visibility"}, needsRepo: true}),
647 pass("require-signed", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}),
648 pass("require-mr", passOpts{server: []string{"repo", "settings", "require-mr"}, needsRepo: true}),
649 pass("description", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}),
650 pass("website", passOpts{server: []string{"repo", "settings", "website"}, needsRepo: true}),
651 pass("git-daemon", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}),
652 ),
653 )
654}
655
656func issueCmd() *cobra.Command {
657 return group("issue", "issues",
658 pass("create", passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}),
659 pass("list", passOpts{server: []string{"issue", "list"}, needsRepo: true, crossRepo: []string{"--query", "--q"}}),
660 pass("show", passOpts{server: []string{"issue", "show"}, needsRepo: true}),
661 pass("comment", passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
662 pass("close", passOpts{server: []string{"issue", "close"}, needsRepo: true}),
663 pass("reopen", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}),
664 pass("label", passOpts{server: []string{"issue", "label"}, needsRepo: true}),
665 pass("assign", passOpts{server: []string{"issue", "assign"}, needsRepo: true}),
666 pass("react", passOpts{server: []string{"issue", "react"}, needsRepo: true}),
667 pass("edit", passOpts{server: []string{"issue", "edit"}, needsRepo: true, stdinOK: true}),
668 pass("milestone", passOpts{server: []string{"issue", "milestone"}, needsRepo: true}),
669 pass("templates", passOpts{server: []string{"issue", "templates"}, needsRepo: true}),
670 )
671}
672
673func releaseCmd() *cobra.Command {
674 return group("release", "tag-anchored releases with notes and assets",
675 pass("create", passOpts{server: []string{"release", "create"}, needsRepo: true, stdinOK: true, editor: "release"}),
676 pass("edit", passOpts{server: []string{"release", "edit"}, needsRepo: true, stdinOK: true}),
677 pass("list", passOpts{server: []string{"release", "list"}, needsRepo: true}),
678 pass("show", passOpts{server: []string{"release", "show"}, needsRepo: true}),
679 pass("delete", passOpts{server: []string{"release", "delete"}, needsRepo: true}),
680 group("asset", "binary assets on a release",
681 pass("add", passOpts{server: []string{"release", "asset", "add"}, needsRepo: true, alwaysStdin: true, stdinWhat: "the asset's bytes"}),
682 pass("get", passOpts{server: []string{"release", "asset", "get"}, needsRepo: true}),
683 pass("remove", passOpts{server: []string{"release", "asset", "remove"}, needsRepo: true}),
684 ),
685 )
686}
687
688func milestoneCmd() *cobra.Command {
689 return group("milestone", "group issues and MRs toward a release",
690 pass("create", passOpts{server: []string{"milestone", "create"}, needsRepo: true}),
691 pass("list", passOpts{server: []string{"milestone", "list"}, needsRepo: true}),
692 pass("close", passOpts{server: []string{"milestone", "close"}, needsRepo: true}),
693 pass("reopen", passOpts{server: []string{"milestone", "reopen"}, needsRepo: true}),
694 )
695}
696
697func mrCmd() *cobra.Command {
698 review := pass("review", passOpts{server: []string{"mr", "review"}, needsRepo: true})
699 review.AddCommand(pass("request", passOpts{server: []string{"mr", "review", "request"}, needsRepo: true}))
700 return group("mr", "merge requests",
701 pass("create", passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request", inferSource: true}),
702 pass("list", passOpts{server: []string{"mr", "list"}, needsRepo: true, crossRepo: []string{"--query", "--q"}}),
703 pass("show", passOpts{server: []string{"mr", "show"}, needsRepo: true}),
704 pass("diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}),
705 local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout),
706 local("rebase", "replay the MR's branch onto its target and re-push: gitbay mr rebase <n>", cmdMRRebase),
707 pass("comment", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
708 pass("react", passOpts{server: []string{"mr", "react"}, needsRepo: true}),
709 pass("diff-comment", passOpts{server: []string{"mr", "diff-comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
710 pass("threads", passOpts{server: []string{"mr", "threads"}, needsRepo: true}),
711 pass("resolve", passOpts{server: []string{"mr", "resolve"}, needsRepo: true}),
712 mrApplySuggestionCmd(),
713 pass("unresolve", passOpts{server: []string{"mr", "unresolve"}, needsRepo: true}),
714 review,
715 pass("merge", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
716 pass("close", passOpts{server: []string{"mr", "close"}, needsRepo: true}),
717 pass("revisions", passOpts{server: []string{"mr", "revisions"}, needsRepo: true}),
718 pass("range-diff", passOpts{server: []string{"mr", "range-diff"}, needsRepo: true}),
719 pass("draft", passOpts{server: []string{"mr", "draft"}, needsRepo: true}),
720 pass("ready", passOpts{server: []string{"mr", "ready"}, needsRepo: true}),
721 pass("edit", passOpts{server: []string{"mr", "edit"}, needsRepo: true, stdinOK: true}),
722 pass("label", passOpts{server: []string{"mr", "label"}, needsRepo: true}),
723 pass("milestone", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}),
724 pass("retarget", passOpts{server: []string{"mr", "retarget"}, needsRepo: true}),
725 )
726}
727
728// importCmd passes repo import through with stdin wired for --token-stdin.
729func importCmd() *cobra.Command {
730 return &cobra.Command{
731 Use: "import",
732 Short: "server-side mirror of a foreign repo: gitbay repo import <owner/name> --from <url> [--private] [--token-stdin]",
733 Annotations: map[string]string{serverPath: "repo import"},
734 DisableFlagParsing: true,
735 RunE: func(cmd *cobra.Command, args []string) error {
736 for _, a := range args {
737 if a == "--help" || a == "-h" {
738 return cmd.Help()
739 }
740 }
741 t, err := resolveTarget()
742 if err != nil {
743 return err
744 }
745 var stdin io.Reader = strings.NewReader("")
746 if usesTokenStdin(args) {
747 stdin = os.Stdin
748 }
749 os.Exit(runSSH(t, append([]string{"repo", "import"}, args...), stdin))
750 return nil
751 },
752 }
753}
754
755func usesTokenStdin(args []string) bool {
756 for _, a := range args {
757 if a == "--token-stdin" {
758 return true
759 }
760 }
761 return false
762}
763
764func webCmd() *cobra.Command {
765 return group("web", "browser session",
766 pass("login", passOpts{server: []string{"web", "login"}}),
767 group("sessions", "your browser sessions",
768 pass("list", passOpts{server: []string{"web", "sessions", "list"}}),
769 pass("revoke", passOpts{server: []string{"web", "sessions", "revoke"}}),
770 ),
771 group("theme", "the colour scheme the web UI uses for you",
772 pass("show", passOpts{server: []string{"web", "theme", "show"}}),
773 pass("set", passOpts{server: []string{"web", "theme", "set"}}),
774 ),
775 group("diff", "the diff layout the web UI uses for you",
776 pass("show", passOpts{server: []string{"web", "diff", "show"}}),
777 pass("set", passOpts{server: []string{"web", "diff", "set"}}),
778 ),
779 )
780}
781
782func webhookCmd() *cobra.Command {
783 return group("webhook", "outbound event delivery",
784 pass("add", passOpts{server: []string{"webhook", "add"}, needsRepo: true, stdinOK: true, stdinWhat: "the webhook secret", stdinSecret: true}),
785 pass("list", passOpts{server: []string{"webhook", "list"}, needsRepo: true}),
786 pass("remove", passOpts{server: []string{"webhook", "remove"}, needsRepo: true}),
787 pass("deliveries", passOpts{server: []string{"webhook", "deliveries"}, needsRepo: true}),
788 pass("redeliver", passOpts{server: []string{"webhook", "redeliver"}, needsRepo: true}),
789 )
790}
791
792func orgCmd() *cobra.Command {
793 return group("org", "organizations",
794 pass("create", passOpts{server: []string{"org", "create"}}),
795 pass("list", passOpts{server: []string{"org", "list"}}),
796 pass("show", passOpts{server: []string{"org", "show"}}),
797 pass("rename", passOpts{server: []string{"org", "rename"}}),
798 pass("delete", passOpts{server: []string{"org", "delete"}}),
799 pass("profile", passOpts{server: []string{"org", "profile"}}),
800 group("members", "manage members",
801 pass("add", passOpts{server: []string{"org", "members", "add"}}),
802 pass("remove", passOpts{server: []string{"org", "members", "remove"}}),
803 pass("list", passOpts{server: []string{"org", "members", "list"}}),
804 ),
805 group("label", "labels every org repository sees",
806 pass("set", passOpts{server: []string{"org", "label", "set"}}),
807 pass("list", passOpts{server: []string{"org", "label", "list"}}),
808 pass("remove", passOpts{server: []string{"org", "label", "remove"}}),
809 ),
810 group("milestone", "milestones spanning an org's repositories",
811 pass("create", passOpts{server: []string{"org", "milestone", "create"}}),
812 pass("list", passOpts{server: []string{"org", "milestone", "list"}}),
813 pass("close", passOpts{server: []string{"org", "milestone", "close"}}),
814 pass("reopen", passOpts{server: []string{"org", "milestone", "reopen"}}),
815 ),
816 group("team", "scope repository access with teams",
817 pass("create", passOpts{server: []string{"org", "team", "create"}}),
818 pass("delete", passOpts{server: []string{"org", "team", "delete"}}),
819 pass("list", passOpts{server: []string{"org", "team", "list"}}),
820 pass("show", passOpts{server: []string{"org", "team", "show"}}),
821 pass("add", passOpts{server: []string{"org", "team", "add"}}),
822 pass("remove", passOpts{server: []string{"org", "team", "remove"}}),
823 pass("grant", passOpts{server: []string{"org", "team", "grant"}}),
824 pass("revoke", passOpts{server: []string{"org", "team", "revoke"}}),
825 ),
826 group("settings", "organization settings",
827 pass("members-role", passOpts{server: []string{"org", "settings", "members-role"}}),
828 ),
829 )
830}
831
832func remoteCmd() *cobra.Command {
833 return group("remote", "local instance profiles (no server contact)",
834 local("add", "add a named gitbay instance: gitbay remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]",
835 cmdRemoteAdd),
836 local("list", "list configured instances", func([]string) int { return cmdRemoteList() }),
837 )
838}
839
840func initCmd() *cobra.Command {
841 return local("init [name] [--private]", "git init + repo create + set origin, in one step", cmdInit)
842}
843
844// manCmd generates man pages; a CLI-first tool without man pages is not
845// CLI-first.
846func manCmd(root *cobra.Command) *cobra.Command {
847 var dir string
848 cmd := &cobra.Command{
849 Use: "man",
850 Short: "generate man pages into a directory",
851 Hidden: true,
852 RunE: func(cmd *cobra.Command, args []string) error {
853 if err := os.MkdirAll(dir, 0o755); err != nil {
854 return err
855 }
856 return doc.GenManTree(root, &doc.GenManHeader{Title: "FORGE", Section: "1"}, dir)
857 },
858 }
859 cmd.Flags().StringVar(&dir, "dir", "man", "output directory")
860 return cmd
861}
862
863// helpCmd is `gitbay help`. Bare, it is cobra's tree of local commands.
864// With anything after it — a prefix such as `mr`, or --json — it is the
865// server's help: the registry is the only place flags are written down,
866// and its JSON is the contract. Cobra's built-in help used to swallow
867// both forms, so `gitbay help --json` failed on an unknown flag.
868func helpCmd(root *cobra.Command) *cobra.Command {
869 return &cobra.Command{
870 Use: "help [<prefix>...] [--json]",
871 Short: "this list, or the server's command reference for a prefix",
872 Annotations: map[string]string{serverPath: "help", stdinMode: "none"},
873 DisableFlagParsing: true,
874 RunE: func(cmd *cobra.Command, args []string) error {
875 if len(args) == 0 {
876 rootHelp(root)
877 return nil
878 }
879 t, err := resolveTarget()
880 if err != nil {
881 fmt.Fprintln(os.Stderr, "gitbay:", err)
882 os.Exit(protocol.ExitFailure)
883 }
884 os.Exit(runSSH(t, append([]string{"help"}, args...), strings.NewReader("")))
885 return nil
886 },
887 }
888}