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 | 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 @@ | |||
| 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 | } | ||