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