v0.15.0 8a6cfec

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

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