cmd/gitbay/main.go

d775997ead04022093817ab513287b5886065247
gitbay/cmd/gitbay/main.go history · blame · raw

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}