cmd/gitbay/main.go

v1.24.0
gitbay/cmd/gitbay/main.go history · blame · raw

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