v0.24.0 166837a

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

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