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}