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