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}