Commit 1733abfe3d
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbay/main.go +5
| @@ -36,6 +36,11 @@ func newRoot() *cobra.Command { | ||
| 36 | 36 | |
| 37 | 37 | root.AddCommand( |
| 38 | 38 | authCmd(), |
| 39 | group("label", "issue labels", | |
| 40 | pass("list", "labels with colour and use", passOpts{server: []string{"label", "list"}, needsRepo: true}), | |
| 41 | pass("set", "create a label or set its colour: <label> [--color rrggbb|'']", passOpts{server: []string{"label", "set"}, needsRepo: true}), | |
| 42 | pass("remove", "remove a label everywhere: <label>", passOpts{server: []string{"label", "remove"}, needsRepo: true}), | |
| 43 | ), | |
| 39 | 44 | group("status", "commit statuses (CI)", |
| 40 | 45 | pass("set", "report a status: <sha> --context <c> --state <s> [--description d] [--url u]", passOpts{server: []string{"status", "set"}, needsRepo: true}), |
| 41 | 46 | pass("list", "statuses on a commit: <sha>", passOpts{server: []string{"status", "list"}, needsRepo: true}), |
e2e/label_test.go added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | // Labels get colours: set from the CLI, listed with their use, painted | |
| 9 | // on the web, and removed from every issue at once. | |
| 10 | func TestLabelColors(t *testing.T) { | |
| 11 | inst := startInstance(t) | |
| 12 | aliceKey := inst.newKey(t, "alice") | |
| 13 | bobKey := inst.newKey(t, "bob") | |
| 14 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 15 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 16 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 17 | t.Fatal("repo create failed") | |
| 18 | } | |
| 19 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "one"); code != 0 { | |
| 20 | t.Fatal("issue create failed") | |
| 21 | } | |
| 22 | // issue label still creates a colourless label on the fly. | |
| 23 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "label", "alice/app", "1", "--add", "bug"); code != 0 { | |
| 24 | t.Fatal("issue label failed") | |
| 25 | } | |
| 26 | if out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app"); !strings.Contains(out, "bug\t\t1") { | |
| 27 | t.Fatalf("list after issue label:\n%s", out) | |
| 28 | } | |
| 29 | if _, _, code := inst.ssh(t, bobKey, "", "label", "set", "alice/app", "bug", "--color", "cf222e"); code != 4 { | |
| 30 | t.Fatal("reader set a colour") | |
| 31 | } | |
| 32 | if _, errOut, code := inst.ssh(t, aliceKey, "", "label", "set", "alice/app", "bug", "--color", "red"); code != 2 || !strings.Contains(errOut, "rrggbb") { | |
| 33 | t.Fatalf("bad colour accepted: exit %d %s", code, errOut) | |
| 34 | } | |
| 35 | if out, _, code := inst.ssh(t, aliceKey, "", "label", "set", "alice/app", "bug", "--color", "CF222E"); code != 0 || !strings.Contains(out, "bug is #cf222e") { | |
| 36 | t.Fatalf("set colour: exit %d %s", code, out) | |
| 37 | } | |
| 38 | if _, _, code := inst.ssh(t, aliceKey, "", "label", "set", "alice/app", "docs"); code != 0 { | |
| 39 | t.Fatal("create without colour failed") | |
| 40 | } | |
| 41 | out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app", "--json") | |
| 42 | if !strings.Contains(out, `{"name":"bug","color":"#cf222e","issues":1}`) || !strings.Contains(out, `{"name":"docs","issues":0}`) { | |
| 43 | t.Fatalf("list json:\n%s", out) | |
| 44 | } | |
| 45 | // The web paints the chip with the stored colour. | |
| 46 | if _, body := inst.get(t, "/alice/app/issues"); !strings.Contains(body, "--chip:#cf222e") { | |
| 47 | t.Fatalf("issue list does not carry the colour:\n%s", body) | |
| 48 | } | |
| 49 | // Clearing the colour keeps the label; removing it unlinks the issue. | |
| 50 | if out, _, _ := inst.ssh(t, aliceKey, "", "label", "set", "alice/app", "bug", "--color", "''"); !strings.Contains(out, "no colour set") { | |
| 51 | t.Fatalf("clear colour:\n%s", out) | |
| 52 | } | |
| 53 | if _, _, code := inst.ssh(t, aliceKey, "", "label", "remove", "alice/app", "bug"); code != 0 { | |
| 54 | t.Fatal("remove failed") | |
| 55 | } | |
| 56 | if out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json"); strings.Contains(out, `"bug"`) { | |
| 57 | t.Fatalf("issue still carries the removed label:\n%s", out) | |
| 58 | } | |
| 59 | if _, _, code := inst.ssh(t, aliceKey, "", "label", "remove", "alice/app", "bug"); code != 3 { | |
| 60 | t.Fatal("second remove should be not found") | |
| 61 | } | |
| 62 | } | |
internal/control/label.go added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | "regexp" | |
| 8 | "strings" | |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/policy" | |
| 11 | "gitbay.org/gitbay/internal/protocol" | |
| 12 | "gitbay.org/gitbay/internal/store" | |
| 13 | ) | |
| 14 | ||
| 15 | func init() { | |
| 16 | register(Command{Path: []string{"label", "list"}, | |
| 17 | Summary: "list a repository's issue labels with colour and use", | |
| 18 | Usage: "label list <owner/name>", ReadOnly: true, Run: runLabelList}) | |
| 19 | register(Command{Path: []string{"label", "set"}, | |
| 20 | Summary: "create a label or set its colour", | |
| 21 | Usage: "label set <owner/name> <label> [--color rrggbb|'']", Run: runLabelSet}) | |
| 22 | register(Command{Path: []string{"label", "remove"}, | |
| 23 | Summary: "remove a label from the repository and from every issue", | |
| 24 | Usage: "label remove <owner/name> <label>", Run: runLabelRemove}) | |
| 25 | } | |
| 26 | ||
| 27 | // A colour is six hex digits, with or without the hash: over bare ssh a | |
| 28 | // bare # starts a comment for the remote tokenizer, so the form without | |
| 29 | // it is the one that types cleanly there. | |
| 30 | var labelColorPat = regexp.MustCompile(`^#?[0-9a-fA-F]{6}$`) | |
| 31 | ||
| 32 | func runLabelList(c *Ctx, args []string) int { | |
| 33 | if len(args) != 1 { | |
| 34 | return c.fail(protocol.ExitUsage, "usage: label list <owner/name>") | |
| 35 | } | |
| 36 | repo, code := resolveRepo(c, args[0], policy.CanRead) | |
| 37 | if code >= 0 { | |
| 38 | return code | |
| 39 | } | |
| 40 | labels, err := c.Store.ListLabels(repo.ID) | |
| 41 | if err != nil { | |
| 42 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 43 | } | |
| 44 | return c.emit(labels, func(w io.Writer) { | |
| 45 | for _, l := range labels { | |
| 46 | fmt.Fprintf(w, "%s\t%s\t%d\n", l.Name, l.Color, l.Issues) | |
| 47 | } | |
| 48 | }) | |
| 49 | } | |
| 50 | ||
| 51 | func runLabelSet(c *Ctx, args []string) int { | |
| 52 | const usage = "usage: label set <owner/name> <label> [--color #rrggbb|'']" | |
| 53 | var rest []string | |
| 54 | color, colorSet := "", false | |
| 55 | for i := 0; i < len(args); i++ { | |
| 56 | if args[i] == "--color" { | |
| 57 | if i+1 >= len(args) { | |
| 58 | return c.fail(protocol.ExitUsage, "--color requires rrggbb, or '' to clear") | |
| 59 | } | |
| 60 | color, colorSet = strings.ToLower(args[i+1]), true | |
| 61 | i++ | |
| 62 | continue | |
| 63 | } | |
| 64 | rest = append(rest, args[i]) | |
| 65 | } | |
| 66 | if len(rest) != 2 { | |
| 67 | return c.fail(protocol.ExitUsage, usage) | |
| 68 | } | |
| 69 | if colorSet && color != "" { | |
| 70 | if !labelColorPat.MatchString(color) { | |
| 71 | return c.fail(protocol.ExitUsage, "--color takes rrggbb (with or without #), or '' to clear") | |
| 72 | } | |
| 73 | color = "#" + strings.TrimPrefix(color, "#") | |
| 74 | } | |
| 75 | repo, code := resolveRepo(c, rest[0], policy.CanWrite) | |
| 76 | if code >= 0 { | |
| 77 | return code | |
| 78 | } | |
| 79 | if code := refuseArchived(c, repo); code >= 0 { | |
| 80 | return code | |
| 81 | } | |
| 82 | name := rest[1] | |
| 83 | if name == "" || len(name) > 50 { | |
| 84 | return c.fail(protocol.ExitUsage, "a label is 1 to 50 characters") | |
| 85 | } | |
| 86 | if !colorSet { | |
| 87 | // Keep the colour it has, if any; this is "make sure it exists". | |
| 88 | if labels, err := c.Store.ListLabels(repo.ID); err == nil { | |
| 89 | for _, l := range labels { | |
| 90 | if l.Name == name { | |
| 91 | color = l.Color | |
| 92 | } | |
| 93 | } | |
| 94 | } | |
| 95 | } | |
| 96 | if err := c.Store.SetLabel(repo.ID, name, color); err != nil { | |
| 97 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 98 | } | |
| 99 | return c.emit(store.Label{Name: name, Color: color}, func(w io.Writer) { | |
| 100 | if color == "" { | |
| 101 | fmt.Fprintf(w, "label %s, no colour set\n", name) | |
| 102 | return | |
| 103 | } | |
| 104 | fmt.Fprintf(w, "label %s is %s\n", name, color) | |
| 105 | }) | |
| 106 | } | |
| 107 | ||
| 108 | func runLabelRemove(c *Ctx, args []string) int { | |
| 109 | if len(args) != 2 { | |
| 110 | return c.fail(protocol.ExitUsage, "usage: label remove <owner/name> <label>") | |
| 111 | } | |
| 112 | repo, code := resolveRepo(c, args[0], policy.CanWrite) | |
| 113 | if code >= 0 { | |
| 114 | return code | |
| 115 | } | |
| 116 | if code := refuseArchived(c, repo); code >= 0 { | |
| 117 | return code | |
| 118 | } | |
| 119 | if err := c.Store.DeleteLabel(repo.ID, args[1]); err != nil { | |
| 120 | if errors.Is(err, store.ErrNotFound) { | |
| 121 | return c.fail(protocol.ExitNotFound, "no label %q in %s", args[1], repo.Path()) | |
| 122 | } | |
| 123 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 124 | } | |
| 125 | return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) { | |
| 126 | fmt.Fprintf(w, "removed label %s\n", args[1]) | |
| 127 | }) | |
| 128 | } | |
internal/store/labels.go added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | // Label is one of a repository's issue labels with its colour, "" when | |
| 4 | // none was set (the web then derives one from the name), and how many | |
| 5 | // issues carry it. | |
| 6 | type Label struct { | |
| 7 | Name string `json:"name"` | |
| 8 | Color string `json:"color,omitempty"` | |
| 9 | Issues int64 `json:"issues"` | |
| 10 | } | |
| 11 | ||
| 12 | // ListLabels lists a repository's labels by name. | |
| 13 | func (s *Store) ListLabels(repoID int64) ([]Label, error) { | |
| 14 | rows, err := s.DB.Query(`SELECT l.name, l.color, COUNT(il.issue_id) | |
| 15 | FROM labels l LEFT JOIN issue_labels il ON il.label_id = l.id | |
| 16 | WHERE l.repo_id = ? GROUP BY l.id ORDER BY l.name`, repoID) | |
| 17 | if err != nil { | |
| 18 | return nil, err | |
| 19 | } | |
| 20 | defer rows.Close() | |
| 21 | var out []Label | |
| 22 | for rows.Next() { | |
| 23 | var l Label | |
| 24 | if err := rows.Scan(&l.Name, &l.Color, &l.Issues); err != nil { | |
| 25 | return nil, err | |
| 26 | } | |
| 27 | out = append(out, l) | |
| 28 | } | |
| 29 | return out, rows.Err() | |
| 30 | } | |
| 31 | ||
| 32 | // SetLabel creates the label or sets its colour. | |
| 33 | func (s *Store) SetLabel(repoID int64, name, color string) error { | |
| 34 | _, err := s.DB.Exec(`INSERT INTO labels (repo_id, name, color) VALUES (?, ?, ?) | |
| 35 | ON CONFLICT (repo_id, name) DO UPDATE SET color = excluded.color`, repoID, name, color) | |
| 36 | return err | |
| 37 | } | |
| 38 | ||
| 39 | // DeleteLabel removes a label and takes it off every issue. | |
| 40 | func (s *Store) DeleteLabel(repoID int64, name string) error { | |
| 41 | res, err := s.DB.Exec("DELETE FROM labels WHERE repo_id = ? AND name = ?", repoID, name) | |
| 42 | if err != nil { | |
| 43 | return err | |
| 44 | } | |
| 45 | if n, _ := res.RowsAffected(); n == 0 { | |
| 46 | return ErrNotFound | |
| 47 | } | |
| 48 | return nil | |
| 49 | } | |