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