cmd/gitbay/main.go
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}