Commit e4fa403437
Verified · cmc ci/build: success
e2e/issueweb_test.go added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 12 | func 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 16 | func (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 | ||
| 28 | func 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. | |
| 34 | func (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. | |
| 48 | func 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 | ||
| 56 | func (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 | ||
| 69 | func (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 | ||
| 82 | func (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 | 110 | Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))}, |
| 111 | 111 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true, |
| 112 | 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 | 122 | Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/new", |
| 114 | 123 | Handler: s.requireUser(s.mrCreateForm)}, |
| 115 | 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 | 1349 | return |
| 1350 | 1350 | } |
| 1351 | 1351 | md := s.ugcFor(r, p.Repo) |
| 1352 | milestones, _ := s.st.ListMilestones(p.Repo.ID, "open") | |
| 1352 | 1353 | s.render(w, "issue.html", struct { |
| 1353 | 1354 | repoPage |
| 1354 | 1355 | Issue store.Issue |
| 1355 | 1356 | BodyHTML template.HTML |
| 1356 | 1357 | Comments []renderedComment |
| 1357 | 1358 | CanEdit bool |
| 1359 | CanWrite bool | |
| 1360 | Milestones []store.Milestone | |
| 1361 | Notice string | |
| 1358 | 1362 | LabelColors map[string]template.CSS |
| 1359 | 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 | 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 | ||
| 446 | 446 | details.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 | 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 | ||
| 449 | 452 | table.tree, table.refs { |
| 450 | 453 | background: var(--surface); |
| 451 | 454 | border: 1px solid var(--line); |
| @@ -1190,7 +1193,12 @@ pre.matchline mark { | ||
| 1190 | 1193 | main.container { padding-top: var(--sp-2); } |
| 1191 | 1194 | .repogrid { grid-template-columns: 1fr; } |
| 1192 | 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 | 1200 | table.tree td.mode, table.tree th.mode { display: none; } |
| 1201 | .clone, .pathbar { overflow-wrap: anywhere; } | |
| 1194 | 1202 | .readme, .code { padding: var(--sp-3); } |
| 1195 | 1203 | .blamehunk { flex-direction: column; gap: 0; } |
| 1196 | 1204 | .blameinfo { width: auto; } |
internal/web/templates/issue.html +58
| @@ -6,6 +6,10 @@ | ||
| 6 | 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 | 7 | {{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}} |
| 8 | 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 | 13 | {{if .CanEdit}}<details class="editbox"><summary>edit</summary> |
| 10 | 14 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform"> |
| 11 | 15 | <p><input type="text" name="title" aria-label="Title" value="{{.Issue.Title}}" required></p> |
| @@ -30,4 +34,58 @@ | ||
| 30 | 34 | <p><button type="submit">comment</button></p> |
| 31 | 35 | </form> |
| 32 | 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 | 91 | {{end}} |
internal/web/templates/tree.html +2
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a> |
| 18 | 18 | <span class="age">{{ago .When}}</span> |
| 19 | 19 | </div>{{end}}{{end}} |
| 20 | <div class="tablewrap"> | |
| 20 | 21 | <table class="tree"> |
| 21 | 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 | 23 | {{range .Entries}}{{$c := index $.LastCommits .Name}}<tr> |
| @@ -27,6 +28,7 @@ | ||
| 27 | 28 | </tr> |
| 28 | 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 | 30 | </table> |
| 31 | </div> | |
| 30 | 32 | {{if .ReadmeHTML}}<section class="readme"> |
| 31 | 33 | <div class="cardhead"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.Prefix}}{{.ReadmeName}}">{{.ReadmeName}}</a></div> |
| 32 | 34 | <div class="rendered">{{.ReadmeHTML}}</div> |