Commit 1733abfe3d

1733abfe3d719e88fe74fe7a566a8fd9afe6f833

parent: 1c22065e25

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 00:12 UTC

labels: list, set a colour, remove

The labels table had a colour column that nothing wrote: issue label
created name-only rows and no command or form set a colour, so every
chip took the derived one.

label list shows a repository's labels with colour and how many issues
carry each. label set <label> [--color rrggbb|''] creates the label or
sets its colour; the hash is optional because over bare ssh a bare #
starts a comment for the tokenizer. label remove takes the label off
every issue. The web already painted stored colours, so it needed no
change. issue label --add still creates a colourless label on the fly.

Closes #85

Layout: unified · split

cmd/gitbay/main.go +5
@@ -36,6 +36,11 @@ func newRoot() *cobra.Command {
36 36
37 root.AddCommand( 37 root.AddCommand(
38 authCmd(), 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 group("status", "commit statuses (CI)", 44 group("status", "commit statuses (CI)",
40 pass("set", "report a status: <sha> --context <c> --state <s> [--description d] [--url u]", passOpts{server: []string{"status", "set"}, needsRepo: true}), 45 pass("set", "report a status: <sha> --context <c> --state <s> [--description d] [--url u]", passOpts{server: []string{"status", "set"}, needsRepo: true}),
41 pass("list", "statuses on a commit: <sha>", passOpts{server: []string{"status", "list"}, needsRepo: true}), 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 @@
1package e2e
2
3import (
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.
10func 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 @@
1package control
2
3import (
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
15func 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.
30var labelColorPat = regexp.MustCompile(`^#?[0-9a-fA-F]{6}$`)
31
32func 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
51func 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
108func 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 @@
1package 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.
6type 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.
13func (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.
33func (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.
40func (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}