v0.31.0 d5e0505

cli_repo.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bufio"
7	"context"
8	"errors"
9	"fmt"
10	"io"
11	"strconv"
12	"strings"
13
14	"connectrpc.com/connect"
15	"github.com/spf13/cobra"
16	"google.golang.org/protobuf/types/known/fieldmaskpb"
17
18	"bnema.dev/ermite-api/gen/ermite"
19	"bnema.dev/ermite/internal/adapters/client"
20)
21
22const (
23	visibilityPublic  = "public"
24	visibilityPrivate = "private"
25)
26
27// jsonRepo is the stable JSON shape of a repository.
28type jsonRepo struct {
29	Name          string   `json:"name"`
30	Description   string   `json:"description"`
31	Topics        []string `json:"topics"`
32	Visibility    string   `json:"visibility"`
33	DefaultBranch string   `json:"default_branch"`
34	CreatedAt     *string  `json:"created_at"`
35	PushedAt      *string  `json:"pushed_at"`
36	// MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").
37	MergeModes         []string `json:"merge_modes"`
38	FailingChecksBlock bool     `json:"failing_checks_block"`
39	AllowAutoMerge     bool     `json:"allow_auto_merge"`
40	// Issues is the issue mode ("off", "owner" or "public").
41	Issues string `json:"issues"`
42}
43
44func toJSONRepo(r *ermite.Repo) jsonRepo {
45	modes := []string{}
46	for _, m := range r.GetSettings().GetAllowedMergeModes() {
47		modes = append(modes, modeName(m))
48	}
49	return jsonRepo{
50		MergeModes:         modes,
51		FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
52		AllowAutoMerge:     r.GetSettings().GetAllowAutoMerge(),
53		Issues:             issuesModeName(r.GetSettings().GetIssues()),
54		Name:               r.GetName(),
55		Description:        r.GetDescription(),
56		Topics:             append([]string{}, r.GetTopics()...),
57		Visibility:         visibilityName(r.GetVisibility()),
58		DefaultBranch:      r.GetDefaultBranch(),
59		CreatedAt:          jsonTime(r.GetCreatedAt()),
60		PushedAt:           jsonTime(r.GetPushedAt()),
61	}
62}
63
64const (
65	issuesOff    = "off"
66	issuesOwner  = "owner"
67	issuesPublic = "public"
68)
69
70// issuesModeName names a mode; an unset one is off, the default.
71func issuesModeName(m ermite.IssuesMode) string {
72	switch m {
73	case ermite.IssuesMode_ISSUES_MODE_OWNER:
74		return issuesOwner
75	case ermite.IssuesMode_ISSUES_MODE_PUBLIC:
76		return issuesPublic
77	case ermite.IssuesMode_ISSUES_MODE_OFF, ermite.IssuesMode_ISSUES_MODE_UNSPECIFIED:
78	}
79	return issuesOff
80}
81
82func parseIssuesMode(s string) (ermite.IssuesMode, error) {
83	switch strings.ToLower(strings.TrimSpace(s)) {
84	case issuesOff:
85		return ermite.IssuesMode_ISSUES_MODE_OFF, nil
86	case issuesOwner:
87		return ermite.IssuesMode_ISSUES_MODE_OWNER, nil
88	case issuesPublic:
89		return ermite.IssuesMode_ISSUES_MODE_PUBLIC, nil
90	}
91	return 0, fmt.Errorf("invalid issues mode %q: use %s, %s or %s", s, issuesOff, issuesOwner, issuesPublic)
92}
93
94func visibilityName(v ermite.Visibility) string {
95	switch v {
96	case ermite.Visibility_VISIBILITY_PUBLIC:
97		return visibilityPublic
98	case ermite.Visibility_VISIBILITY_PRIVATE:
99		return visibilityPrivate
100	default:
101		return "unknown"
102	}
103}
104
105func parseVisibility(s string) (ermite.Visibility, error) {
106	switch strings.ToLower(strings.TrimSpace(s)) {
107	case visibilityPublic:
108		return ermite.Visibility_VISIBILITY_PUBLIC, nil
109	case visibilityPrivate:
110		return ermite.Visibility_VISIBILITY_PRIVATE, nil
111	default:
112		return 0, fmt.Errorf("invalid visibility %q: use %s or %s", s, visibilityPublic, visibilityPrivate)
113	}
114}
115
116const jsonRepoHelp = `
117JSON fields of a repository (--json): name, description, visibility ("public" or
118"private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
119null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or
120"merge"), failing_checks_block (whether a failing check prevents a merge), allow_auto_merge (whether
121auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").`
122
123func newRepoCmd(env *cliEnv) *cobra.Command {
124	repo := &cobra.Command{
125		Use:   "repo",
126		Short: "Manage repositories on a remote server",
127		Long:  "Manage repositories on the configured server (ERMITE_URL or the config file, see `ermite config`), authenticated with ERMITE_TOKEN or the token command of the config file.",
128	}
129	repo.AddCommand(
130		newRepoListCmd(env), newRepoShowCmd(env), newRepoCreateCmd(env),
131		newRepoEditCmd(env), newRepoDeleteCmd(env),
132	)
133	return repo
134}
135
136// listRepos reads every page of repositories.
137func listRepos(ctx context.Context, c *client.Client) ([]*ermite.Repo, error) {
138	return collect(ctx, func(token string) ([]*ermite.Repo, string, error) {
139		res, err := c.Repos().ListRepos(ctx, connect.NewRequest(&ermite.ListReposRequest{PageToken: token}))
140		if err != nil {
141			return nil, "", c.Describe(err)
142		}
143		return res.Msg.GetRepos(), res.Msg.GetNextPageToken(), nil
144	})
145}
146
147// completeRepoNames completes the first argument with repository names. Any
148// failure, including a missing configuration, silently completes nothing.
149func completeRepoNames(env *cliEnv) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
150	return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
151		if len(args) > 0 {
152			return nil, cobra.ShellCompDirectiveNoFileComp
153		}
154		return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp
155	}
156}
157
158// repoNames lists the repository names starting with prefix, with their
159// visibility and description as completion hints.
160func repoNames(env *cliEnv, cmd *cobra.Command, prefix string) []string {
161	return complete(env, cmd, prefix, listRepos, func(r *ermite.Repo) (string, string) {
162		desc := visibilityName(r.GetVisibility())
163		if d := clean(r.GetDescription()); d != "" {
164			desc += ", " + d
165		}
166		return r.GetName(), desc
167	})
168}
169
170// outputRepo prints a repository as JSON or as fields.
171func outputRepo(cmd *cobra.Command, r *ermite.Repo, asJSON bool) error {
172	if asJSON {
173		return writeJSON(cmd.OutOrStdout(), toJSONRepo(r))
174	}
175	return printRepo(cmd.OutOrStdout(), r)
176}
177
178func newRepoListCmd(env *cliEnv) *cobra.Command {
179	cmd := &cobra.Command{
180		Use:   "list",
181		Short: "List the repositories you can see",
182		Long:  "List the repositories you can see: all public ones, and the private ones your token may read." + jsonRepoHelp + "\n--json prints an array of repositories ordered by name.",
183		Args:  cobra.NoArgs,
184	}
185	asJSON := addJSONFlag(cmd)
186	cmd.RunE = func(cmd *cobra.Command, _ []string) error {
187		c, err := env.client(cmd)
188		if err != nil {
189			return err
190		}
191		repos, err := listRepos(env.ctx(cmd), c)
192		if err != nil {
193			return err
194		}
195		if *asJSON {
196			out := make([]jsonRepo, 0, len(repos))
197			for _, r := range repos {
198				out = append(out, toJSONRepo(r))
199			}
200			return writeJSON(cmd.OutOrStdout(), out)
201		}
202		if len(repos) == 0 {
203			noticef(cmd, "no repositories")
204			return nil
205		}
206		rows := make([][]string, 0, len(repos))
207		for _, r := range repos {
208			rows = append(rows, []string{
209				r.GetName(), visibilityName(r.GetVisibility()), r.GetDefaultBranch(),
210				shortTime(r.GetPushedAt(), "never"), r.GetDescription(),
211			})
212		}
213		return table(cmd.OutOrStdout(), []string{"NAME", "VISIBILITY", "BRANCH", "LAST PUSH", "DESCRIPTION"}, rows)
214	}
215	return cmd
216}
217
218func printRepo(w io.Writer, r *ermite.Repo) error {
219	desc := r.GetDescription()
220	if desc == "" {
221		desc = "-"
222	}
223	modes := toJSONRepo(r).MergeModes
224	return fields(w, [][2]string{
225		{"name", r.GetName()},
226		{"visibility", visibilityName(r.GetVisibility())},
227		{"default branch", r.GetDefaultBranch()},
228		{"description", desc},
229		{"topics", orDash(strings.Join(r.GetTopics(), ", "))},
230		{"created", shortTime(r.GetCreatedAt(), "-")},
231		{"last push", shortTime(r.GetPushedAt(), "never")},
232		{"merge modes", orDash(strings.Join(modes, ", "))},
233		{"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
234		{"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())},
235		{"issues", issuesModeName(r.GetSettings().GetIssues())},
236	})
237}
238
239func newRepoShowCmd(env *cliEnv) *cobra.Command {
240	cmd := &cobra.Command{
241		Use:               "show <name>",
242		Short:             "Show one repository",
243		Long:              "Show one repository." + jsonRepoHelp,
244		Args:              cobra.ExactArgs(1),
245		ValidArgsFunction: completeRepoNames(env),
246	}
247	asJSON := addJSONFlag(cmd)
248	cmd.RunE = func(cmd *cobra.Command, args []string) error {
249		c, err := env.client(cmd)
250		if err != nil {
251			return err
252		}
253		res, err := c.Repos().GetRepo(env.ctx(cmd), connect.NewRequest(&ermite.GetRepoRequest{Repo: args[0]}))
254		if err != nil {
255			return c.Describe(err)
256		}
257		return outputRepo(cmd, res.Msg.GetRepo(), *asJSON)
258	}
259	return cmd
260}
261
262func newRepoCreateCmd(env *cliEnv) *cobra.Command {
263	var (
264		description string
265		topics      []string
266		visibility  string
267	)
268	cmd := &cobra.Command{
269		Use:   "create <name>",
270		Short: "Create an empty repository",
271		Long: "Create an empty repository. It is private unless --visibility public is given. Names are\n" +
272			"lowercase, 1-100 characters from [a-z0-9._-]. Needs the repo:write scope." + jsonRepoHelp,
273		Args: cobra.ExactArgs(1),
274	}
275	cmd.Flags().StringVar(&description, "description", "", "one-line description")
276	cmd.Flags().StringSliceVar(&topics, "topics", nil, "topics, comma-separated (1-50 characters of a-z, 0-9 and -; at most 20)")
277	cmd.Flags().StringVar(&visibility, "visibility", visibilityPrivate, "visibility: public or private")
278	_ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
279	asJSON := addJSONFlag(cmd)
280	cmd.RunE = func(cmd *cobra.Command, args []string) error {
281		req, err := buildCreateRepo(args[0], description, topics, visibility)
282		if err != nil {
283			return err
284		}
285		c, err := env.client(cmd)
286		if err != nil {
287			return err
288		}
289		res, err := c.Repos().CreateRepo(env.ctx(cmd), connect.NewRequest(req))
290		if err != nil {
291			return c.Describe(err)
292		}
293		return outputRepo(cmd, res.Msg.GetRepo(), *asJSON)
294	}
295	return cmd
296}
297
298func visibilityCompletion(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
299	return []string{visibilityPublic, visibilityPrivate}, cobra.ShellCompDirectiveNoFileComp
300}
301
302// buildCreateRepo maps the flags of `repo create` to the request.
303func buildCreateRepo(name, description string, topics []string, visibility string) (*ermite.CreateRepoRequest, error) {
304	vis, err := parseVisibility(visibility)
305	if err != nil {
306		return nil, err
307	}
308	return &ermite.CreateRepoRequest{Name: name, Description: description, Topics: topics, Visibility: vis}, nil
309}
310
311func newRepoEditCmd(env *cliEnv) *cobra.Command {
312	var description, visibility, defaultBranch string
313	var topics, mergeModes []string
314	var failingChecksBlock, allowAutoMerge bool
315	var issues string
316	cmd := &cobra.Command{
317		Use:   "edit <name>",
318		Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues",
319		Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
320			"description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
321			"may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" +
322			"list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" +
323			"commits are signed with the server's key, see `ermite signing-key show`), and\n" +
324			"--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
325			"--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
326			"public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
327			"Needs the repo:admin scope." + jsonRepoHelp,
328		Args:              cobra.ExactArgs(1),
329		ValidArgsFunction: completeRepoNames(env),
330	}
331	cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)")
332	cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)")
333	cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
334	cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
335	cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
336	cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
337	cmd.Flags().BoolVar(&allowAutoMerge, "allow-auto-merge", false, "allow auto-merge on merge requests (--allow-auto-merge=false to forbid it)")
338	cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
339	_ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
340		return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp
341	})
342	_ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
343	_ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
344		return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
345	})
346	asJSON := addJSONFlag(cmd)
347	cmd.RunE = func(cmd *cobra.Command, args []string) error {
348		req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, allowAutoMerge, issues)
349		if err != nil {
350			return err
351		}
352		c, err := env.client(cmd)
353		if err != nil {
354			return err
355		}
356		if req.Issues != nil {
357			if err := requireIssues(env.ctx(cmd), c); err != nil {
358				return err
359			}
360		}
361		res, err := c.Repos().UpdateRepo(env.ctx(cmd), connect.NewRequest(req))
362		if err != nil {
363			return c.Describe(err)
364		}
365		return outputRepo(cmd, res.Msg.GetRepo(), *asJSON)
366	}
367	return cmd
368}
369
370// buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
371// the user passed go into the update mask.
372func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) {
373	req := &ermite.UpdateRepoRequest{Repo: name}
374	var paths []string
375	if cmd.Flags().Changed("description") {
376		req.Description = description
377		paths = append(paths, "description")
378	}
379	if cmd.Flags().Changed("topics") {
380		req.Topics = topics
381		paths = append(paths, "topics")
382	}
383	if cmd.Flags().Changed("visibility") {
384		vis, err := parseVisibility(visibility)
385		if err != nil {
386			return nil, err
387		}
388		req.Visibility = vis
389		paths = append(paths, "visibility")
390	}
391	if cmd.Flags().Changed("default-branch") {
392		branch := strings.TrimPrefix(strings.TrimSpace(defaultBranch), "refs/heads/")
393		if branch == "" {
394			return nil, errors.New("--default-branch must not be empty")
395		}
396		req.DefaultBranch = branch
397		paths = append(paths, "default_branch")
398	}
399	if cmd.Flags().Changed("merge-modes") {
400		var modes []ermite.MergeMode
401		for _, m := range mergeModes {
402			mode, err := parseMode(m)
403			if err != nil {
404				return nil, err
405			}
406			modes = append(modes, mode)
407		}
408		if len(modes) == 0 {
409			return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash, merge")
410		}
411		req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}
412		paths = append(paths, "settings.allowed_merge_modes")
413	}
414	if cmd.Flags().Changed("failing-checks-block") {
415		if req.Settings == nil {
416			req.Settings = &ermite.RepoSettings{}
417		}
418		req.Settings.FailingChecksBlock = failingChecksBlock
419		paths = append(paths, "settings.failing_checks_block")
420	}
421	if cmd.Flags().Changed("allow-auto-merge") {
422		if req.Settings == nil {
423			req.Settings = &ermite.RepoSettings{}
424		}
425		req.Settings.AllowAutoMerge = allowAutoMerge
426		paths = append(paths, "settings.allow_auto_merge")
427	}
428	if cmd.Flags().Changed("issues") {
429		mode, err := parseIssuesMode(issues)
430		if err != nil {
431			return nil, err
432		}
433		// The contract keeps issues out of the update mask.
434		req.Issues = &mode
435	}
436	if len(paths) == 0 && req.Issues == nil {
437		return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --allow-auto-merge, --issues")
438	}
439	req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
440	return req, nil
441}
442
443func newRepoDeleteCmd(env *cliEnv) *cobra.Command {
444	var yes bool
445	cmd := &cobra.Command{
446		Use:   "delete <name>",
447		Short: "Delete a repository",
448		Long: "Delete a repository. Without --yes you must type the repository name on standard input to\n" +
449			"confirm. The server moves the data to its trash directory instead of destroying it; there is no\n" +
450			"undelete command. Needs the repo:admin scope.",
451		Args:              cobra.ExactArgs(1),
452		ValidArgsFunction: completeRepoNames(env),
453	}
454	cmd.Flags().BoolVarP(&yes, "yes", "y", false, "do not ask for confirmation")
455	cmd.RunE = func(cmd *cobra.Command, args []string) error {
456		name := args[0]
457		// Check the configuration before asking anything.
458		c, err := env.client(cmd)
459		if err != nil {
460			return err
461		}
462		if !yes {
463			if err := confirmName(cmd.InOrStdin(), cmd.ErrOrStderr(), name); err != nil {
464				return err
465			}
466		}
467		if _, err := c.Repos().DeleteRepo(env.ctx(cmd), connect.NewRequest(&ermite.DeleteRepoRequest{Repo: name})); err != nil {
468			return c.Describe(err)
469		}
470		_, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted %s\n", name)
471		return err
472	}
473	return cmd
474}
475
476// confirmName asks the user to type name and fails when the answer differs.
477func confirmName(in io.Reader, prompt io.Writer, name string) error {
478	_, _ = fmt.Fprintf(prompt, "This deletes repository %q from the server.\nType its name to confirm: ", name)
479	line, err := bufio.NewReader(in).ReadString('\n')
480	if err != nil && line == "" {
481		_, _ = fmt.Fprintln(prompt)
482		return errors.New("aborted: no confirmation read (pass --yes to skip the question)")
483	}
484	if strings.TrimSpace(line) != name {
485		return errors.New("aborted: the name did not match")
486	}
487	return nil
488}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 43

↑↓ moveenter openesc close