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.
96| labels, assignees | yes | yes | yes | 96| labels, assignees | yes | yes | yes |
97| milestone | yes | yes | yes | 97| milestone | yes | yes | yes |
98| milestone list | yes | yes | yes | 98| milestone list | yes | yes | yes |
99| labels: list, colour | yes | no | no | 99| labels: list, colour | yes | yes | no |
100| choose body markup | yes | no | no | 100| choose body markup | yes | no | no |
101 101
102Labels are created on the fly by =issue label --add= and managed by 102Labels are created on the fly by =issue label --add= and managed by
103=label list=, =label set <label> --color rrggbb= and =label remove=, 103=label list=, =label set <label> --color rrggbb= and =label remove=,
104which takes the label off every issue. The web paints the stored colour 104which 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 105on every chip and derives one from the name when none is set. The set
106form for setting one yet. 106itself is at =/<owner>/<repo>/labels=, linked from the issue list:
107create, recolour and remove, dispatching the same commands.
107 108
108Issue, MR and release bodies, and their comments, carry the markup they 109Issue, MR and release bodies, and their comments, carry the markup they
109were written in — =--format md|org= on create, comment and edit, stored 110were 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) {
67 "/owner/secret/tree/main/", "/owner/secret/blob/main/notes.txt", 67 "/owner/secret/tree/main/", "/owner/secret/blob/main/notes.txt",
68 "/owner/secret/raw/main/notes.txt", "/owner/secret/log", 68 "/owner/secret/raw/main/notes.txt", "/owner/secret/log",
69 "/owner/secret/releases", "/owner/secret/builds", "/owner/secret/wiki", 69 "/owner/secret/releases", "/owner/secret/builds", "/owner/secret/wiki",
70 "/owner/secret/labels",
70 "/owner/secret/compare/main...feat", "/owner/secret/milestones", 71 "/owner/secret/compare/main...feat", "/owner/secret/milestones",
71 "/owner/secret/archive/main.tar.gz", "/owner/secret/badge/build.svg", 72 "/owner/secret/archive/main.tar.gz", "/owner/secret/badge/build.svg",
72 "/owner/secret/search?q=" + fileWord, 73 "/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 {
62 Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame}, 62 Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame},
63 Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search}, 63 Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search},
64 Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones}, 64 Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones},
65 Route{Method: "GET", Pattern: "/{owner}/{repo}/labels", Handler: s.labels},
65 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki", Handler: s.wiki}, 66 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki", Handler: s.wiki},
66 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw}, 67 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw},
67 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki}, 68 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki},
@@ -149,6 +150,8 @@ func (s *Server) Routes() []Route {
149 Handler: s.checkOrigin(s.requireUser(s.issueMilestoneSubmit))}, 150 Handler: s.checkOrigin(s.requireUser(s.issueMilestoneSubmit))},
150 Route{Method: "POST", Pattern: "/{owner}/{repo}/releases", Mutating: true, 151 Route{Method: "POST", Pattern: "/{owner}/{repo}/releases", Mutating: true,
151 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, 152 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))},
152 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, 155 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true,
153 Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))}, 156 Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))},
154 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds/{n}/cancel", Mutating: true, 157 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds/{n}/cancel", Mutating: true,
internal/web/templates/issues.html +1 −1
@@ -13,7 +13,7 @@
13 <input type="hidden" name="state" value="{{.State}}"> 13 <input type="hidden" name="state" value="{{.State}}">
14 </form> 14 </form>
15 <span class="spacer"></span> 15 <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>
17</div> 17</div>
18<ul class="issuelist"> 18<ul class="issuelist">
19{{range .Issues}}<li> 19{{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}}