Commit ca02f30bc0
Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success
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 | ||
| 102 | Labels are created on the fly by =issue label --add= and managed by | 102 | Labels 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=, |
| 104 | which takes the label off every issue. The web paints the stored colour | 104 | which takes the label off every issue. The web paints the stored colour |
| 105 | on every chip and derives one from the name when none is set; it has no | 105 | on every chip and derives one from the name when none is set. The set |
| 106 | form for setting one yet. | 106 | itself is at =/<owner>/<repo>/labels=, linked from the issue list: |
| 107 | create, recolour and remove, dispatching the same commands. | ||
| 107 | 108 | ||
| 108 | Issue, MR and release bodies, and their comments, carry the markup they | 109 | Issue, MR and release bodies, and their comments, carry the markup they |
| 109 | were written in — =--format md|org= on create, comment and edit, stored | 110 | were 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 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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). | ||
| 11 | func 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 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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). | ||
| 14 | func (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. | ||
| 36 | func (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}} | ||