web: triage issues from the browser !68

merged merged by cmc on 2026-08-26 03:00 UTC · krz/gitbay:issue-web-actions into main

7 files changed, +266 −1

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>