v0.28.0 fca9155

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	// 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		FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
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"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue
119mode: "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		{"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
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 failingChecksBlock 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			"--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
322			"--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
323			"public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
324			"Needs the repo:admin scope." + jsonRepoHelp,
325		Args:              cobra.ExactArgs(1),
326		ValidArgsFunction: completeRepoNames(env),
327	}
328	cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)")
329	cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)")
330	cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
331	cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
332	cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
333	cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
334	cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
335	_ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
336		return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp
337	})
338	_ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
339	_ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
340		return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
341	})
342	asJSON := addJSONFlag(cmd)
343	cmd.RunE = func(cmd *cobra.Command, args []string) error {
344		req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, issues)
345		if err != nil {
346			return err
347		}
348		c, err := env.client(cmd)
349		if err != nil {
350			return err
351		}
352		if req.Issues != nil {
353			if err := requireIssues(env.ctx(cmd), c); err != nil {
354				return err
355			}
356		}
357		res, err := c.Repos().UpdateRepo(env.ctx(cmd), connect.NewRequest(req))
358		if err != nil {
359			return c.Describe(err)
360		}
361		return outputRepo(cmd, res.Msg.GetRepo(), *asJSON)
362	}
363	return cmd
364}
365
366// buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
367// the user passed go into the update mask.
368func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool, issues string) (*ermite.UpdateRepoRequest, error) {
369	req := &ermite.UpdateRepoRequest{Repo: name}
370	var paths []string
371	if cmd.Flags().Changed("description") {
372		req.Description = description
373		paths = append(paths, "description")
374	}
375	if cmd.Flags().Changed("topics") {
376		req.Topics = topics
377		paths = append(paths, "topics")
378	}
379	if cmd.Flags().Changed("visibility") {
380		vis, err := parseVisibility(visibility)
381		if err != nil {
382			return nil, err
383		}
384		req.Visibility = vis
385		paths = append(paths, "visibility")
386	}
387	if cmd.Flags().Changed("default-branch") {
388		branch := strings.TrimPrefix(strings.TrimSpace(defaultBranch), "refs/heads/")
389		if branch == "" {
390			return nil, errors.New("--default-branch must not be empty")
391		}
392		req.DefaultBranch = branch
393		paths = append(paths, "default_branch")
394	}
395	if cmd.Flags().Changed("merge-modes") {
396		var modes []ermite.MergeMode
397		for _, m := range mergeModes {
398			mode, err := parseMode(m)
399			if err != nil {
400				return nil, err
401			}
402			modes = append(modes, mode)
403		}
404		if len(modes) == 0 {
405			return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash, merge")
406		}
407		req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}
408		paths = append(paths, "settings.allowed_merge_modes")
409	}
410	if cmd.Flags().Changed("failing-checks-block") {
411		if req.Settings == nil {
412			req.Settings = &ermite.RepoSettings{}
413		}
414		req.Settings.FailingChecksBlock = failingChecksBlock
415		paths = append(paths, "settings.failing_checks_block")
416	}
417	if cmd.Flags().Changed("issues") {
418		mode, err := parseIssuesMode(issues)
419		if err != nil {
420			return nil, err
421		}
422		// The contract keeps issues out of the update mask.
423		req.Issues = &mode
424	}
425	if len(paths) == 0 && req.Issues == nil {
426		return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --issues")
427	}
428	req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
429	return req, nil
430}
431
432func newRepoDeleteCmd(env *cliEnv) *cobra.Command {
433	var yes bool
434	cmd := &cobra.Command{
435		Use:   "delete <name>",
436		Short: "Delete a repository",
437		Long: "Delete a repository. Without --yes you must type the repository name on standard input to\n" +
438			"confirm. The server moves the data to its trash directory instead of destroying it; there is no\n" +
439			"undelete command. Needs the repo:admin scope.",
440		Args:              cobra.ExactArgs(1),
441		ValidArgsFunction: completeRepoNames(env),
442	}
443	cmd.Flags().BoolVarP(&yes, "yes", "y", false, "do not ask for confirmation")
444	cmd.RunE = func(cmd *cobra.Command, args []string) error {
445		name := args[0]
446		// Check the configuration before asking anything.
447		c, err := env.client(cmd)
448		if err != nil {
449			return err
450		}
451		if !yes {
452			if err := confirmName(cmd.InOrStdin(), cmd.ErrOrStderr(), name); err != nil {
453				return err
454			}
455		}
456		if _, err := c.Repos().DeleteRepo(env.ctx(cmd), connect.NewRequest(&ermite.DeleteRepoRequest{Repo: name})); err != nil {
457			return c.Describe(err)
458		}
459		_, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted %s\n", name)
460		return err
461	}
462	return cmd
463}
464
465// confirmName asks the user to type name and fails when the answer differs.
466func confirmName(in io.Reader, prompt io.Writer, name string) error {
467	_, _ = fmt.Fprintf(prompt, "This deletes repository %q from the server.\nType its name to confirm: ", name)
468	line, err := bufio.NewReader(in).ReadString('\n')
469	if err != nil && line == "" {
470		_, _ = fmt.Fprintln(prompt)
471		return errors.New("aborted: no confirmation read (pass --yes to skip the question)")
472	}
473	if strings.TrimSpace(line) != name {
474		return errors.New("aborted: the name did not match")
475	}
476	return nil
477}

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 45

↑↓ moveenter openesc close