cmd/gitbay/main.go

888 lines · 39938 bytes

37 symbols in this file
  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}