v0.32.0 53e25a3

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

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 2

Tags 44

↑↓ moveenter openesc close