Commit ca02f30bc0

ca02f30bc09194b796e456c746a08421359b58d4

parent: 2867b44a48

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

cmc <hello@cleberg.net> · 2026-09-06 14:59 UTC

web, wiki: manage the label set from the browser

/{owner}/{repo}/labels lists a repository's labels with colour and use,
and offers create, recolour and remove to a writer through the label
commands. Linked from the issue list.

Closes #163

Layout: unified · split

.gitbay/wiki/Parity.org +4 −3
@@ -96,14 +96,15 @@ reviews, since an approval was of the diff against the old branch.
9696| labels, assignees | yes | yes | yes |
9797| milestone | yes | yes | yes |
9898| milestone list | yes | yes | yes |
99| labels: list, colour | yes | no | no |
99| labels: list, colour | yes | yes | no |
100100| choose body markup | yes | no | no |
101101
102102Labels are created on the fly by =issue label --add= and managed by
103103=label list=, =label set <label> --color rrggbb= and =label remove=,
104104which takes the label off every issue. The web paints the stored colour
105on every chip and derives one from the name when none is set; it has no
106form for setting one yet.
105on every chip and derives one from the name when none is set. The set
106itself is at =/<owner>/<repo>/labels=, linked from the issue list:
107create, recolour and remove, dispatching the same commands.
107108
108109Issue, MR and release bodies, and their comments, carry the markup they
109110were written in — =--format md|org= on create, comment and edit, stored
e2e/isolation_test.go +1
@@ -67,6 +67,7 @@ func TestPrivateRepoIsInvisible(t *testing.T) {
6767 "/owner/secret/tree/main/", "/owner/secret/blob/main/notes.txt",
6868 "/owner/secret/raw/main/notes.txt", "/owner/secret/log",
6969 "/owner/secret/releases", "/owner/secret/builds", "/owner/secret/wiki",
70 "/owner/secret/labels",
7071 "/owner/secret/compare/main...feat", "/owner/secret/milestones",
7172 "/owner/secret/archive/main.tar.gz", "/owner/secret/badge/build.svg",
7273 "/owner/secret/search?q=" + fileWord,
e2e/labelweb_test.go added +76
@@ -0,0 +1,76 @@
1package e2e
2
3import (
4 "net/url"
5 "strings"
6 "testing"
7)
8
9// The label set itself is managed from the browser: create, recolour and
10// remove, each through the label command the CLI runs (#163).
11func TestLabelsWeb(t *testing.T) {
12 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
13 aliceKey := inst.newKey(t, "alice")
14 bobKey := inst.newKey(t, "bob")
15 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
16 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
17 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
18 t.Fatalf("repo create: %s", errOut)
19 }
20 inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "one")
21 inst.ssh(t, aliceKey, "", "issue", "label", "alice/app", "1", "--add", "bug")
22
23 alice := inst.login(t, aliceKey)
24 base := inst.base() + "/alice/app"
25
26 // The existing label is listed with its use count.
27 status, page := browserGet(t, alice, base+"/labels")
28 if status != 200 || !strings.Contains(page, "bug") {
29 t.Fatalf("labels page: %d\n%s", status, page)
30 }
31
32 // Creating one with a colour, and recolouring the existing one.
33 if status, _ := browserPost(t, alice, base+"/labels", url.Values{
34 "name": {"docs"}, "color": {"1f6feb"}}); status != 200 {
35 t.Fatal("label create failed")
36 }
37 if status, _ := browserPost(t, alice, base+"/labels", url.Values{
38 "name": {"bug"}, "color": {"cf222e"}}); status != 200 {
39 t.Fatal("label recolour failed")
40 }
41 out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app", "--json")
42 if !strings.Contains(out, `{"name":"bug","color":"#cf222e","issues":1}`) ||
43 !strings.Contains(out, `{"name":"docs","color":"#1f6feb","issues":0}`) {
44 t.Fatalf("labels not as posted:\n%s", out)
45 }
46
47 // A bad colour comes back as an error on the page, not a silent no-op.
48 _, body := browserPost(t, alice, base+"/labels", url.Values{
49 "name": {"docs"}, "color": {"red"}})
50 if !strings.Contains(body, `class="error"`) {
51 t.Errorf("bad colour accepted:\n%s", body)
52 }
53
54 // Removing takes the label off the issue too.
55 if status, _ := browserPost(t, alice, base+"/labels", url.Values{
56 "action": {"remove"}, "name": {"bug"}}); status != 200 {
57 t.Fatal("label remove failed")
58 }
59 if out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json"); strings.Contains(out, `"bug"`) {
60 t.Fatalf("issue still carries the removed label:\n%s", out)
61 }
62
63 // A reader sees the set and none of the controls.
64 bob := inst.login(t, bobKey)
65 _, p := browserGet(t, bob, base+"/labels")
66 if !strings.Contains(p, "docs") {
67 t.Fatalf("reader cannot see the labels:\n%s", p)
68 }
69 if strings.Contains(p, "New label") || strings.Contains(p, "Remove") {
70 t.Fatal("reader sees a label control")
71 }
72 browserPost(t, bob, base+"/labels", url.Values{"name": {"sneak"}})
73 if out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app", "--json"); strings.Contains(out, "sneak") {
74 t.Fatal("reader created a label")
75 }
76}
internal/httpd/labels.go added +50
@@ -0,0 +1,50 @@
1package httpd
2
3import (
4 "html/template"
5 "net/http"
6 "strings"
7
8 "gitbay.org/gitbay/internal/store"
9)
10
11// labels lists a repository's label set with its colour and how many
12// issues carry it. Applying a label is on the issue page; this is where
13// the set itself is kept (#163).
14func (s *Server) labels(w http.ResponseWriter, r *http.Request) {
15 p, ok := s.repoFor(w, r, "")
16 if !ok {
17 return
18 }
19 p.Tab = "issues"
20 labels, err := s.st.ListLabels(p.Repo.ID)
21 if err != nil {
22 http.Error(w, "internal error", http.StatusInternalServerError)
23 return
24 }
25 s.render(w, "labels.html", struct {
26 repoPage
27 Labels []store.Label
28 LabelColors map[string]template.CSS
29 CanWrite bool
30 Notice string
31 }{p, labels, s.labelColors(p.Repo.ID), s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
32}
33
34// labelSubmit creates a label, sets its colour, or removes it, through
35// the label commands the CLI runs.
36func (s *Server) labelSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
37 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
38 name := strings.TrimSpace(r.FormValue("name"))
39 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "labels", msg) }
40 if name == "" {
41 s.backTo(w, r, "labels", "name the label")
42 return
43 }
44 argv := []string{"label", "set", repo, name, "--color", strings.TrimSpace(r.FormValue("color"))}
45 if r.FormValue("action") == "remove" {
46 argv = []string{"label", "remove", repo, name}
47 }
48 _, msg, code := s.runControlCode(u, argv)
49 s.done(w, r, code, msg, back)
50}
internal/httpd/routes.go +3
@@ -62,6 +62,7 @@ func (s *Server) Routes() []Route {
6262 Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame},
6363 Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search},
6464 Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones},
65 Route{Method: "GET", Pattern: "/{owner}/{repo}/labels", Handler: s.labels},
6566 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki", Handler: s.wiki},
6667 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw},
6768 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki},
@@ -149,6 +150,8 @@ func (s *Server) Routes() []Route {
149150 Handler: s.checkOrigin(s.requireUser(s.issueMilestoneSubmit))},
150151 Route{Method: "POST", Pattern: "/{owner}/{repo}/releases", Mutating: true,
151152 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))},
153 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true,
154 Handler: s.checkOrigin(s.requireUser(s.labelSubmit))},
152155 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true,
153156 Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))},
154157 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds/{n}/cancel", Mutating: true,
internal/web/templates/issues.html +1 −1
@@ -13,7 +13,7 @@
1313 <input type="hidden" name="state" value="{{.State}}">
1414 </form>
1515 <span class="spacer"></span>
16 <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">milestones</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">new issue</a>{{end}}</p>
16 <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">milestones</a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/labels">labels</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">new issue</a>{{end}}</p>
1717</div>
1818<ul class="issuelist">
1919{{range .Issues}}<li>
internal/web/templates/labels.html added +36
@@ -0,0 +1,36 @@
1{{define "title"}}labels · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3<h1>Labels</h1>
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if .Labels}}<div class="tablewrap"><table class="keys">
6<tr class="cols"><th scope="col">label</th><th scope="col">colour</th><th scope="col">issues</th><th scope="col"></th></tr>
7{{range .Labels}}<tr>
8 <td><a class="chip label" style="{{index $.LabelColors .Name}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.Name}}">{{.Name}}</a></td>
9 <td>{{if $.CanWrite}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
10 <input type="hidden" name="name" value="{{.Name}}">
11 <input type="text" name="color" value="{{.Color}}" aria-label="Colour for {{.Name}}" placeholder="rrggbb" size="8">
12 <button type="submit" class="btn">Save</button>
13 </form>{{else}}<span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span>{{end}}</td>
14 <td>{{.Issues}}</td>
15 <td class="act">{{if $.CanWrite}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
16 <input type="hidden" name="action" value="remove">
17 <input type="hidden" name="name" value="{{.Name}}">
18 <button type="submit" class="linklike">Remove</button>
19 </form>{{end}}</td>
20</tr>
21{{end}}</table></div>
22{{else}}<p class="none">No labels yet.</p>{{end}}
23{{if .CanWrite}}
24<details class="editbox">
25 <summary>New label</summary>
26 <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/labels" class="setform stack">
27 <label for="labelname">Name</label>
28 <input type="text" id="labelname" name="name" maxlength="50" required>
29 <label for="labelcolor">Colour</label>
30 <input type="text" id="labelcolor" name="color" placeholder="rrggbb, or blank for one picked from the name">
31 <button type="submit">Create label</button>
32 </form>
33</details>
34<p class="meta">Removing a label takes it off every issue that carries it.</p>
35{{end}}
36{{end}}