A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit e4fa403437

e4fa4034379ae93e3cf6f1084ed577a7e02b530c

parent: 4dbe61f2db

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-26T02:59:50Z

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
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 {
110110 Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))},
111111 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true,
112112 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))},
113122 Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/new",
114123 Handler: s.requireUser(s.mrCreateForm)},
115124 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) {
13491349 return
13501350 }
13511351 md := s.ugcFor(r, p.Repo)
1352 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
13521353 s.render(w, "issue.html", struct {
13531354 repoPage
13541355 Issue store.Issue
13551356 BodyHTML template.HTML
13561357 Comments []renderedComment
13571358 CanEdit bool
1359 CanWrite bool
1360 Milestones []store.Milestone
1361 Notice string
13581362 LabelColors map[string]template.CSS
13591363 }{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)})
13611366 }
13621367
13631368 // 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
446446 details.refmenu .refdrop a.allrefs { color: var(--accent); font-family: var(--sans); border-top: 1px solid var(--faint); margin-top: var(--sp-1); }
447447
448448 /* 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
449452 table.tree, table.refs {
450453 background: var(--surface);
451454 border: 1px solid var(--line);
@@ -1190,7 +1193,12 @@ pre.matchline mark {
11901193 main.container { padding-top: var(--sp-2); }
11911194 .repogrid { grid-template-columns: 1fr; }
11921195 .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; }
11931200 table.tree td.mode, table.tree th.mode { display: none; }
1201 .clone, .pathbar { overflow-wrap: anywhere; }
11941202 .readme, .code { padding: var(--sp-3); }
11951203 .blamehunk { flex-direction: column; gap: 0; }
11961204 .blameinfo { width: auto; }
internal/web/templates/issue.html +58
@@ -6,6 +6,10 @@
66 {{if .Issue.Labels}} · {{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}{{end}}
77 {{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}}
88 {{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">
913 {{if .CanEdit}}<details class="editbox"><summary>edit</summary>
1014 <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform">
1115 <p><input type="text" name="title" aria-label="Title" value="{{.Issue.Title}}" required></p>
@@ -30,4 +34,58 @@
3034 <p><button type="submit">comment</button></p>
3135 </form>
3236 {{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>
3391 {{end}}
internal/web/templates/tree.html +2
@@ -17,6 +17,7 @@
1717 <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a>
1818 <span class="age">{{ago .When}}</span>
1919 </div>{{end}}{{end}}
20<div class="tablewrap">
2021 <table class="tree">
2122 <tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr>
2223 {{range .Entries}}{{$c := index $.LastCommits .Name}}<tr>
@@ -27,6 +28,7 @@
2728 </tr>
2829 {{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}}
2930 </table>
31</div>
3032 {{if .ReadmeHTML}}<section class="readme">
3133 <div class="cardhead"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.Prefix}}{{.ReadmeName}}">{{.ReadmeName}}</a></div>
3234 <div class="rendered">{{.ReadmeHTML}}</div>