Commit e4fa403437

e4fa4034379ae93e3cf6f1084ed577a7e02b530c

parent: 4dbe61f2db

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-26 02:59 UTC

web: triage issues from the browser

Close, reopen, labels, assignees, and milestone dispatch the issue
commands the CLI runs, so label rules, access checks, and the system
comments they leave behind keep one implementation. The issue page
gains the merge request page's aside to hold them; readers see none of
it, and a forged post is refused by the command.

The tree listing lost its mode column in the redesign, but the rule
hiding that column on phones stayed, so nothing was hidden and long
commit subjects widened the whole page. The subject column now hides
below 40rem and listings scroll inside their own box.

Ref #35

Layout: unified · split

e2e/issueweb_test.go added +91
@@ -0,0 +1,91 @@
1package e2e
2
3import (
4 "net/url"
5 "strings"
6 "testing"
7)
8
9// TestIssueWebTriage closes, labels, assigns, and sets a milestone from
10// the browser. Each action runs the issue command the CLI runs, so the
11// CLI is the check that they took effect.
12func TestIssueWebTriage(t *testing.T) {
13 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
14 aliceKey := inst.newKey(t, "alice")
15 bobKey := inst.newKey(t, "bob")
16 inst.admin(t, "admin", "user", "create", "alice",
17 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
18 inst.admin(t, "admin", "user", "create", "bob",
19 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
20
21 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
22 t.Fatalf("repo create: %s", errOut)
23 }
24 if _, errOut, code := inst.ssh(t, aliceKey, "", "milestone", "create", "alice/app", "v1"); code != 0 {
25 t.Fatalf("milestone create: %s", errOut)
26 }
27 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'needs triage'"); code != 0 {
28 t.Fatalf("issue create: %s", errOut)
29 }
30
31 alice := inst.login(t, aliceKey)
32 issueURL := inst.base() + "/alice/app/issues/1"
33
34 // The triage controls are on the page for someone with write access.
35 _, body := browserGet(t, alice, issueURL)
36 for _, want := range []string{`/issues/1/state`, `/issues/1/label`, `/issues/1/assign`, `value="v1"`} {
37 if !strings.Contains(body, want) {
38 t.Fatalf("issue page missing %q:\n%s", want, body)
39 }
40 }
41
42 // Label, assign, and milestone, each verified through the CLI.
43 if status, _ := browserPost(t, alice, issueURL+"/label", url.Values{"add": {"bug ui"}}); status != 200 {
44 t.Fatalf("label post: %d", status)
45 }
46 if status, _ := browserPost(t, alice, issueURL+"/assign", url.Values{"add": {"bob"}}); status != 200 {
47 t.Fatalf("assign post: %d", status)
48 }
49 if status, _ := browserPost(t, alice, issueURL+"/milestone", url.Values{"milestone": {"v1"}}); status != 200 {
50 t.Fatalf("milestone post: %d", status)
51 }
52 out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json")
53 for _, want := range []string{`"bug"`, `"ui"`, `"bob"`, `"v1"`} {
54 if !strings.Contains(out, want) {
55 t.Fatalf("triage did not land (%s):\n%s", want, out)
56 }
57 }
58
59 // Removing works the same way.
60 if status, _ := browserPost(t, alice, issueURL+"/label", url.Values{"remove": {"ui"}}); status != 200 {
61 t.Fatalf("label remove: %d", status)
62 }
63 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json")
64 if strings.Contains(out, `"ui"`) {
65 t.Fatalf("label not removed:\n%s", out)
66 }
67
68 // Close, then reopen.
69 if status, _ := browserPost(t, alice, issueURL+"/state", url.Values{"action": {"close"}}); status != 200 {
70 t.Fatalf("close post: %d", status)
71 }
72 if out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json"); !strings.Contains(out, `"state":"closed"`) {
73 t.Fatalf("issue not closed:\n%s", out)
74 }
75 _, reopened := browserPost(t, alice, issueURL+"/state", url.Values{"action": {"reopen"}})
76 if !strings.Contains(reopened, "chip-open") {
77 t.Fatalf("issue not reopened:\n%s", reopened)
78 }
79
80 // A reader sees no controls and is refused if they forge the post.
81 carolKey := inst.newKey(t, "carol")
82 inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub")
83 _, readerView := browserGet(t, inst.login(t, carolKey), issueURL)
84 if strings.Contains(readerView, `/issues/1/label`) {
85 t.Fatal("reader sees triage controls")
86 }
87 _, denied := browserPost(t, inst.login(t, carolKey), issueURL+"/state", url.Values{"action": {"close"}})
88 if !strings.Contains(denied, `class="error"`) {
89 t.Fatalf("reader closed someone else's issue:\n%s", denied)
90 }
91}
internal/httpd/issueactions.go added +92
@@ -0,0 +1,92 @@
1package httpd
2
3import (
4 "fmt"
5 "net/http"
6 "net/url"
7 "strings"
8
9 "gitbay.org/gitbay/internal/store"
10)
11
12// Issue actions. Like the merge request controls, each one runs the
13// command the CLI runs, so label rules, access checks, and the system
14// comments they leave behind have a single implementation.
15
16func (s *Server) issueRedirect(w http.ResponseWriter, r *http.Request, msg string) {
17 dest := fmt.Sprintf("/%s/%s/issues/%s",
18 r.PathValue("owner"), r.PathValue("repo"), r.PathValue("n"))
19 if msg != "" {
20 if len(msg) > 300 {
21 msg = msg[:300]
22 }
23 dest += "?e=" + url.QueryEscape(msg)
24 }
25 http.Redirect(w, r, dest, http.StatusSeeOther)
26}
27
28func issueArgs(r *http.Request, verb string, extra ...string) []string {
29 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
30 return append([]string{"issue", verb, repo, r.PathValue("n")}, extra...)
31}
32
33// issueStateSubmit closes or reopens, following the button pressed.
34func (s *Server) issueStateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
35 verb := "close"
36 if r.FormValue("action") == "reopen" {
37 verb = "reopen"
38 }
39 _, msg, ok := s.runControl(u, issueArgs(r, verb))
40 if ok {
41 msg = ""
42 }
43 s.issueRedirect(w, r, msg)
44}
45
46// fieldArgs turns a space-separated form value into repeated flags, the
47// shape the label and assign commands take.
48func fieldArgs(flag, values string) []string {
49 var out []string
50 for _, v := range strings.Fields(values) {
51 out = append(out, flag, v)
52 }
53 return out
54}
55
56func (s *Server) issueLabelSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
57 args := append(fieldArgs("--add", r.FormValue("add")), fieldArgs("--remove", r.FormValue("remove"))...)
58 if len(args) == 0 {
59 s.issueRedirect(w, r, "name at least one label")
60 return
61 }
62 _, msg, ok := s.runControl(u, issueArgs(r, "label", args...))
63 if ok {
64 msg = ""
65 }
66 s.issueRedirect(w, r, msg)
67}
68
69func (s *Server) issueAssignSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
70 args := append(fieldArgs("--add", r.FormValue("add")), fieldArgs("--remove", r.FormValue("remove"))...)
71 if len(args) == 0 {
72 s.issueRedirect(w, r, "name at least one person")
73 return
74 }
75 _, msg, ok := s.runControl(u, issueArgs(r, "assign", args...))
76 if ok {
77 msg = ""
78 }
79 s.issueRedirect(w, r, msg)
80}
81
82func (s *Server) issueMilestoneSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
83 title := strings.TrimSpace(r.FormValue("milestone"))
84 if title == "" {
85 title = "none"
86 }
87 _, msg, ok := s.runControl(u, issueArgs(r, "milestone", title))
88 if ok {
89 msg = ""
90 }
91 s.issueRedirect(w, r, msg)
92}
internal/httpd/routes.go +9
@@ -110,6 +110,15 @@ func (s *Server) Routes() []Route {
110 Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))}, 110 Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))},
111 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true, 111 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true,
112 Handler: s.checkOrigin(s.requireUser(s.issueEditSubmit))}, 112 Handler: s.checkOrigin(s.requireUser(s.issueEditSubmit))},
113 // Triage: each runs the matching issue command.
114 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/state", Mutating: true,
115 Handler: s.checkOrigin(s.requireUser(s.issueStateSubmit))},
116 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/label", Mutating: true,
117 Handler: s.checkOrigin(s.requireUser(s.issueLabelSubmit))},
118 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/assign", Mutating: true,
119 Handler: s.checkOrigin(s.requireUser(s.issueAssignSubmit))},
120 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/milestone", Mutating: true,
121 Handler: s.checkOrigin(s.requireUser(s.issueMilestoneSubmit))},
113 Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/new", 122 Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/new",
114 Handler: s.requireUser(s.mrCreateForm)}, 123 Handler: s.requireUser(s.mrCreateForm)},
115 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/new", Mutating: true, 124 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/new", Mutating: true,
internal/httpd/web.go +6 −1
@@ -1349,15 +1349,20 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1349 return 1349 return
1350 } 1350 }
1351 md := s.ugcFor(r, p.Repo) 1351 md := s.ugcFor(r, p.Repo)
1352 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1352 s.render(w, "issue.html", struct { 1353 s.render(w, "issue.html", struct {
1353 repoPage 1354 repoPage
1354 Issue store.Issue 1355 Issue store.Issue
1355 BodyHTML template.HTML 1356 BodyHTML template.HTML
1356 Comments []renderedComment 1357 Comments []renderedComment
1357 CanEdit bool 1358 CanEdit bool
1359 CanWrite bool
1360 Milestones []store.Milestone
1361 Notice string
1358 LabelColors map[string]template.CSS 1362 LabelColors map[string]template.CSS
1359 }{p, iss, md(iss.Body), renderComments(comments, md), 1363 }{p, iss, md(iss.Body), renderComments(comments, md),
1360 s.canEditItem(r, p.Repo, iss.Author), s.labelColors(p.Repo.ID)}) 1364 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1365 milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1361} 1366}
1362 1367
1363// canEditItem: the author or anyone with write access may edit. 1368// canEditItem: the author or anyone with write access may edit.
internal/web/static/style.css +8
@@ -446,6 +446,9 @@ details.refmenu .refdrop a:hover { background: var(--hover); text-decoration: no
446details.refmenu .refdrop a.allrefs { color: var(--accent); font-family: var(--sans); border-top: 1px solid var(--faint); margin-top: var(--sp-1); } 446details.refmenu .refdrop a.allrefs { color: var(--accent); font-family: var(--sans); border-top: 1px solid var(--faint); margin-top: var(--sp-1); }
447 447
448/* file and refs tables: bordered cards, rows inside */ 448/* file and refs tables: bordered cards, rows inside */
449/* wide listings scroll inside their own box; the page never does */
450.tablewrap { overflow-x: auto; max-width: 100%; }
451
449table.tree, table.refs { 452table.tree, table.refs {
450 background: var(--surface); 453 background: var(--surface);
451 border: 1px solid var(--line); 454 border: 1px solid var(--line);
@@ -1190,7 +1193,12 @@ pre.matchline mark {
1190 main.container { padding-top: var(--sp-2); } 1193 main.container { padding-top: var(--sp-2); }
1191 .repogrid { grid-template-columns: 1fr; } 1194 .repogrid { grid-template-columns: 1fr; }
1192 .thread { margin-left: var(--sp-3); } 1195 .thread { margin-left: var(--sp-3); }
1196 /* the commit subject is the widest thing in the listing and the one
1197 column a phone can do without: the tipbar above already names the
1198 latest commit. */
1199 table.tree td.lastcommit, table.tree th.lastcommit { display: none; }
1193 table.tree td.mode, table.tree th.mode { display: none; } 1200 table.tree td.mode, table.tree th.mode { display: none; }
1201 .clone, .pathbar { overflow-wrap: anywhere; }
1194 .readme, .code { padding: var(--sp-3); } 1202 .readme, .code { padding: var(--sp-3); }
1195 .blamehunk { flex-direction: column; gap: 0; } 1203 .blamehunk { flex-direction: column; gap: 0; }
1196 .blameinfo { width: auto; } 1204 .blameinfo { width: auto; }
internal/web/templates/issue.html +58
@@ -6,6 +6,10 @@
6{{if .Issue.Labels}} · {{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}{{end}} 6{{if .Issue.Labels}} · {{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}{{end}}
7{{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}} 7{{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}}
8{{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p> 8{{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p>
9{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
10
11<div class="withaside">
12<div class="mainside">
9{{if .CanEdit}}<details class="editbox"><summary>edit</summary> 13{{if .CanEdit}}<details class="editbox"><summary>edit</summary>
10<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform"> 14<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform">
11<p><input type="text" name="title" aria-label="Title" value="{{.Issue.Title}}" required></p> 15<p><input type="text" name="title" aria-label="Title" value="{{.Issue.Title}}" required></p>
@@ -30,4 +34,58 @@
30<p><button type="submit">comment</button></p> 34<p><button type="submit">comment</button></p>
31</form> 35</form>
32{{end}} 36{{end}}
37</div>
38
39{{$base := printf "/%s/%s/issues/%d" .Repo.OwnerName .Repo.Name .Issue.Number}}
40<aside class="aside">
41 {{if .CanWrite}}
42 <div class="grp">
43 <h2>State</h2>
44 <form method="post" action="{{$base}}/state" class="actions">
45 {{if eq .Issue.State "open"}}<button type="submit" name="action" value="close">Close issue</button>
46 {{else}}<button type="submit" name="action" value="reopen">Reopen issue</button>{{end}}
47 </form>
48 </div>
49 {{end}}
50 <div class="grp">
51 <h2>Labels</h2>
52 {{if .Issue.Labels}}<p class="row">{{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}</p>
53 {{else}}<p class="none">None yet</p>{{end}}
54 {{if .CanWrite}}
55 <form method="post" action="{{$base}}/label" class="actions">
56 <input type="text" name="add" aria-label="Add labels" placeholder="add, space-separated">
57 <input type="text" name="remove" aria-label="Remove labels" placeholder="remove">
58 <button type="submit">Apply</button>
59 </form>
60 {{end}}
61 </div>
62 <div class="grp">
63 <h2>Assignees</h2>
64 {{if .Issue.Assignees}}<p class="row">{{range .Issue.Assignees}}<a href="/{{.}}">{{.}}</a> {{end}}</p>
65 {{else}}<p class="none">Nobody yet</p>{{end}}
66 {{if .CanWrite}}
67 <form method="post" action="{{$base}}/assign" class="actions">
68 <input type="text" name="add" aria-label="Add assignees" placeholder="add, space-separated">
69 <input type="text" name="remove" aria-label="Remove assignees" placeholder="remove">
70 <button type="submit">Apply</button>
71 </form>
72 {{end}}
73 </div>
74 <div class="grp">
75 <h2>Milestone</h2>
76 {{if .Issue.Milestone}}<p class="row"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a></p>
77 {{else}}<p class="none">None</p>{{end}}
78 {{if .CanWrite}}
79 <form method="post" action="{{$base}}/milestone" class="actions">
80 <label class="none" for="milestone">Set milestone</label>
81 <select id="milestone" name="milestone">
82 <option value="none">No milestone</option>
83 {{range .Milestones}}<option value="{{.Title}}"{{if eq .Title $.Issue.Milestone}} selected{{end}}>{{.Title}}</option>{{end}}
84 </select>
85 <button type="submit">Apply</button>
86 </form>
87 {{end}}
88 </div>
89</aside>
90</div>
33{{end}} 91{{end}}
internal/web/templates/tree.html +2
@@ -17,6 +17,7 @@
17 <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a> 17 <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a>
18 <span class="age">{{ago .When}}</span> 18 <span class="age">{{ago .When}}</span>
19</div>{{end}}{{end}} 19</div>{{end}}{{end}}
20<div class="tablewrap">
20<table class="tree"> 21<table class="tree">
21<tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr> 22<tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr>
22{{range .Entries}}{{$c := index $.LastCommits .Name}}<tr> 23{{range .Entries}}{{$c := index $.LastCommits .Name}}<tr>
@@ -27,6 +28,7 @@
27</tr> 28</tr>
28{{else}}<tr><td class="name empty" colspan="3">this repository is empty — push something:<br><code>git remote add origin {{.CloneURL}}</code></td></tr>{{end}} 29{{else}}<tr><td class="name empty" colspan="3">this repository is empty — push something:<br><code>git remote add origin {{.CloneURL}}</code></td></tr>{{end}}
29</table> 30</table>
31</div>
30{{if .ReadmeHTML}}<section class="readme"> 32{{if .ReadmeHTML}}<section class="readme">
31<div class="cardhead"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.Prefix}}{{.ReadmeName}}">{{.ReadmeName}}</a></div> 33<div class="cardhead"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.Prefix}}{{.ReadmeName}}">{{.ReadmeName}}</a></div>
32<div class="rendered">{{.ReadmeHTML}}</div> 34<div class="rendered">{{.ReadmeHTML}}</div>