Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 17m ago 4b8c9e1d
history blame raw 68.8 KiB
1package server_test
2
3// The browser is a complete surface for the two discussion objects: an issue can
4// be filed, answered, closed and reopened, and a change can be opened, discussed,
5// closed and reopened. Each write goes through the same metadata writer as the
6// CLI, so the branch is the only source of truth.
7
8import (
9 "io"
10 "net/http"
11 "net/url"
12 "os"
13 "path/filepath"
14 "strconv"
15 "strings"
16 "testing"
17 "time"
18
19 "rodent.software/vole/forge/index"
20 "rodent.software/vole/metadata"
21)
22
23func TestIssueCanBeClosedAndReopenedInTheBrowser(t *testing.T) {
24 t.Parallel()
25 f := newFixture(t)
26 f.publish(f.repo)
27 sess, cookie := f.browserSession(t, f.ownerID)
28
29 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
30 "csrf": {sess.CSRF}, "title": {"Close me from the web"}, "body": {"body\n"},
31 })
32 if resp.StatusCode != http.StatusSeeOther {
33 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
34 }
35 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
36 if id == "" {
37 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
38 }
39
40 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{
41 "csrf": {sess.CSRF}, "state": {"closed"},
42 })
43 if resp.StatusCode != http.StatusSeeOther {
44 t.Fatalf("close issue: %d\n%s", resp.StatusCode, body)
45 }
46 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
47 if !strings.Contains(page, "Reopen</button>") || !strings.Contains(page, `class="state closed"`) {
48 t.Fatalf("closed issue does not offer a reopen:\n%s", page)
49 }
50 // The default list is open issues; the closed one is behind the All filter.
51 if _, list := f.browserGet(t, "/"+f.repo+"/issues", cookie); strings.Contains(list, "Close me from the web") {
52 t.Fatalf("closed issue is still in the open list:\n%s", list)
53 }
54 if _, list := f.browserGet(t, "/"+f.repo+"/issues?state=all", cookie); !strings.Contains(list, "Close me from the web") {
55 t.Fatalf("closed issue is missing from the All list:\n%s", list)
56 }
57
58 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{
59 "csrf": {sess.CSRF}, "state": {"open"},
60 })
61 if resp.StatusCode != http.StatusSeeOther {
62 t.Fatalf("reopen issue: %d\n%s", resp.StatusCode, body)
63 }
64 if _, page := f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie); !strings.Contains(page, "Close</button>") ||
65 !strings.Contains(page, `class="danger"`) {
66 t.Fatalf("reopened issue does not offer a close:\n%s", page)
67 }
68}
69
70// The browser could file, triage, discuss and close an issue or a change, but it
71// could not correct what one said. The edit controls are the missing half: the
72// author and a maintainer may rewrite the title and description in place, and
73// nobody else may, on either surface. The standalone edit page stays as the
74// no-JavaScript fallback.
75func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
76 t.Parallel()
77 f := newFixture(t)
78 f.publish(f.repo)
79
80 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
81 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
82 t.Fatalf("main push: %s", errs)
83 }
84 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
85 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
86 t.Fatalf("feature push: %s", errs)
87 }
88
89 sess, cookie := f.browserSession(t, f.ownerID)
90
91 // An issue: the page offers Edit, the form carries what the issue says, and
92 // saving returns to the issue with the new prose.
93 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
94 "csrf": {sess.CSRF}, "title": {"First draft"}, "body": {"original body\n"},
95 })
96 if resp.StatusCode != http.StatusSeeOther {
97 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
98 }
99 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
100 if issueID == "" {
101 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
102 }
103 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
104 if !strings.Contains(page, "data-title-edit") || !strings.Contains(page, "data-title-edit-form") ||
105 !strings.Contains(page, `data-comment-menu`) || !strings.Contains(page, "Copy text") ||
106 !strings.Contains(page, "data-comment-edit") || !strings.Contains(page, `name="field" value="title"`) ||
107 !strings.Contains(page, `name="field" value="body"`) || !strings.Contains(page, "data-autosize") {
108 t.Fatalf("the issue page offers no in-place title and description controls:\n%s", page)
109 }
110 resp, form := f.browserGet(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie)
111 if resp.StatusCode != http.StatusOK || !strings.Contains(form, `action="/`+f.repo+`/issues/`+issueID+`/edit"`) ||
112 !strings.Contains(form, `value="First draft"`) || !strings.Contains(form, "original body") {
113 t.Fatalf("issue edit page = %d:\n%s", resp.StatusCode, form)
114 }
115 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
116 "csrf": {sess.CSRF}, "title": {"Corrected issue"}, "body": {"corrected issue body\n"},
117 })
118 if resp.StatusCode != http.StatusSeeOther {
119 t.Fatalf("edit issue: %d\n%s", resp.StatusCode, body)
120 }
121 iss, err := index.LoadIssue(mustRepo(t, f), issueID)
122 if err != nil {
123 t.Fatal(err)
124 }
125 if iss.Title != "Corrected issue" || strings.TrimSpace(iss.Description) != "corrected issue body" {
126 t.Fatalf("edited issue = %q / %q", iss.Title, iss.Description)
127 }
128 if _, page := f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie); !strings.Contains(page, "Corrected issue") ||
129 !strings.Contains(page, "corrected issue body") {
130 t.Fatalf("the issue page does not show the edit:\n%s", page)
131 }
132
133 // The title pencil posts only the title. Its response is the heading, not the
134 // description, so a description draft is left alone.
135 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
136 "csrf": {sess.CSRF}, "field": {"title"}, "title": {"In-place issue"},
137 })
138 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
139 !strings.Contains(body, `id="issue-head"`) || !strings.Contains(body, "In-place issue") ||
140 strings.Contains(body, `id="issue-description"`) {
141 t.Fatalf("issue inline title edit = %d:\n%s", resp.StatusCode, body)
142 }
143 if iss, err = index.LoadIssue(mustRepo(t, f), issueID); err != nil ||
144 iss.Title != "In-place issue" || strings.TrimSpace(iss.Description) != "corrected issue body" {
145 t.Fatalf("inline issue title edit = %+v, %v", iss, err)
146 }
147
148 // The description menu posts only the description, and comes back as the
149 // description card with the title untouched.
150 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
151 "csrf": {sess.CSRF}, "field": {"body"}, "body": {"in-place issue body\n"},
152 })
153 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
154 !strings.Contains(body, `id="issue-description"`) || !strings.Contains(body, "in-place issue body") ||
155 strings.Contains(body, `id="issue-head"`) {
156 t.Fatalf("issue inline description edit = %d:\n%s", resp.StatusCode, body)
157 }
158 if iss, err = index.LoadIssue(mustRepo(t, f), issueID); err != nil ||
159 iss.Title != "In-place issue" || strings.TrimSpace(iss.Description) != "in-place issue body" {
160 t.Fatalf("inline issue description edit = %+v, %v", iss, err)
161 }
162
163 // A refused title keeps the title form open with what was typed; the
164 // description card is not part of the response.
165 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
166 "csrf": {sess.CSRF}, "field": {"title"}, "title": {""},
167 })
168 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "an issue needs a title") ||
169 !strings.Contains(body, `data-title-edit-form`) || !strings.Contains(body, `autofocus`) ||
170 strings.Contains(body, `id="issue-description"`) {
171 t.Fatalf("refused inline issue title edit = %d:\n%s", resp.StatusCode, body)
172 }
173
174 // A change: same controls, and the same writer.
175 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
176 "csrf": {sess.CSRF}, "title": {"First change draft"}, "body": {"original change body\n"},
177 "base": {"main"}, "head": {"feature"},
178 })
179 if resp.StatusCode != http.StatusSeeOther {
180 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
181 }
182 changeID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
183 if changeID == "" {
184 t.Fatalf("no change id in %q", resp.Header.Get("Location"))
185 }
186 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+changeID, cookie)
187 if !strings.Contains(page, "data-title-edit") || !strings.Contains(page, "data-title-edit-form") ||
188 !strings.Contains(page, `data-comment-menu`) || !strings.Contains(page, "Copy text") ||
189 !strings.Contains(page, "data-comment-edit") || !strings.Contains(page, `name="field" value="title"`) ||
190 !strings.Contains(page, `name="field" value="body"`) || !strings.Contains(page, "data-autosize") {
191 t.Fatalf("the change page offers no in-place title and description controls:\n%s", page)
192 }
193 // The Files tab reuses the change head but has no description form there, so
194 // its title pencil must keep the ordinary link to the standalone page.
195 if _, files := f.browserGet(t, "/"+f.repo+"/changes/"+changeID+"/files", cookie); !strings.Contains(files, "/changes/"+changeID+"/edit") ||
196 strings.Contains(files, "data-title-edit") {
197 t.Fatalf("the Files tab does not link to the edit page:\n%s", files)
198 }
199 resp, form = f.browserGet(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie)
200 if resp.StatusCode != http.StatusOK || !strings.Contains(form, `action="/`+f.repo+`/changes/`+changeID+`/edit"`) ||
201 !strings.Contains(form, `value="First change draft"`) || !strings.Contains(form, "original change body") {
202 t.Fatalf("change edit page = %d:\n%s", resp.StatusCode, form)
203 }
204 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
205 "csrf": {sess.CSRF}, "title": {"Corrected change"}, "body": {"corrected change body\n"},
206 })
207 if resp.StatusCode != http.StatusSeeOther {
208 t.Fatalf("edit change: %d\n%s", resp.StatusCode, body)
209 }
210 req, err := metadata.GetRequest(mustRepo(t, f).Git(), changeID)
211 if err != nil {
212 t.Fatal(err)
213 }
214 if req.Title != "Corrected change" || strings.TrimSpace(req.Description) != "corrected change body" {
215 t.Fatalf("edited change = %q / %q", req.Title, req.Description)
216 }
217 if _, page := f.browserGet(t, "/"+f.repo+"/changes/"+changeID, cookie); !strings.Contains(page, "Corrected change") ||
218 !strings.Contains(page, "corrected change body") {
219 t.Fatalf("the change page does not show the edit:\n%s", page)
220 }
221
222 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
223 "csrf": {sess.CSRF}, "field": {"title"}, "title": {"In-place change"},
224 })
225 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
226 !strings.Contains(body, `id="request-head"`) || !strings.Contains(body, "In-place change") ||
227 strings.Contains(body, `id="request-description"`) || strings.Contains(body, `id="request-alert"`) {
228 t.Fatalf("change inline title edit = %d:\n%s", resp.StatusCode, body)
229 }
230 if req, err = metadata.GetRequest(mustRepo(t, f).Git(), changeID); err != nil ||
231 req.Title != "In-place change" || strings.TrimSpace(req.Description) != "corrected change body" {
232 t.Fatalf("inline change title edit = %+v, %v", req, err)
233 }
234 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
235 "csrf": {sess.CSRF}, "field": {"body"}, "body": {"in-place change body\n"},
236 })
237 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
238 !strings.Contains(body, `id="request-description"`) || !strings.Contains(body, "in-place change body") ||
239 strings.Contains(body, `id="request-head"`) {
240 t.Fatalf("change inline description edit = %d:\n%s", resp.StatusCode, body)
241 }
242 if req, err = metadata.GetRequest(mustRepo(t, f).Git(), changeID); err != nil ||
243 req.Title != "In-place change" || strings.TrimSpace(req.Description) != "in-place change body" {
244 t.Fatalf("inline change description edit = %+v, %v", req, err)
245 }
246 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
247 "csrf": {sess.CSRF}, "field": {"title"}, "title": {""},
248 })
249 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
250 !strings.Contains(body, "a change needs a title") || !strings.Contains(body, `data-title-edit-form`) ||
251 !strings.Contains(body, `autofocus`) || strings.Contains(body, `id="request-description"`) {
252 t.Fatalf("refused inline change title edit = %d:\n%s", resp.StatusCode, body)
253 }
254
255 // Somebody who is neither the author nor a maintainer may open neither form
256 // and may not post either edit.
257 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_stranger"))
258 stranger, err := f.srv.Identity.Create("stranger", pub, "test")
259 if err != nil {
260 t.Fatal(err)
261 }
262 strangerSess, strangerCookie := f.browserSession(t, stranger.Name)
263 for _, path := range []string{
264 "/" + f.repo + "/issues/" + issueID + "/edit",
265 "/" + f.repo + "/changes/" + changeID + "/edit",
266 } {
267 if resp, _ := f.browserGet(t, path, strangerCookie); resp.StatusCode != http.StatusForbidden {
268 t.Fatalf("stranger got %d from %s, want 403", resp.StatusCode, path)
269 }
270 }
271 for _, path := range []string{
272 "/" + f.repo + "/issues/" + issueID + "/edit",
273 "/" + f.repo + "/changes/" + changeID + "/edit",
274 } {
275 resp, _ := f.browserPost(t, path, strangerCookie, url.Values{
276 "csrf": {strangerSess.CSRF}, "title": {"Not mine"}, "body": {"not mine\n"},
277 })
278 if resp.StatusCode != http.StatusForbidden {
279 t.Fatalf("stranger's edit of %s = %d, want 403", path, resp.StatusCode)
280 }
281 }
282}
283
284func TestChangeCanBeDiscussedAndClosedInTheBrowser(t *testing.T) {
285 t.Parallel()
286 f := newFixture(t)
287 f.publish(f.repo)
288
289 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
290 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
291 t.Fatalf("main push: %s", errs)
292 }
293 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
294 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
295 t.Fatalf("feature push: %s", errs)
296 }
297
298 sess, cookie := f.browserSession(t, f.ownerID)
299 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
300 "csrf": {sess.CSRF}, "title": {"Browser change"}, "body": {"please review\n"},
301 "base": {"main"}, "head": {"feature"},
302 })
303 if resp.StatusCode != http.StatusSeeOther {
304 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
305 }
306 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
307 if id == "" {
308 t.Fatalf("no change id in %q", resp.Header.Get("Location"))
309 }
310
311 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
312 "csrf": {sess.CSRF}, "body": {"Looks good from the browser.\n"},
313 })
314 if resp.StatusCode != http.StatusSeeOther {
315 t.Fatalf("comment: %d\n%s", resp.StatusCode, body)
316 }
317 _, page := f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie)
318 if !strings.Contains(page, "Looks good from the browser.") {
319 t.Fatalf("the browser comment is not on the page:\n%s", page)
320 }
321
322 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
323 "csrf": {sess.CSRF}, "state": {"closed"},
324 })
325 if resp.StatusCode != http.StatusSeeOther {
326 t.Fatalf("close change: %d\n%s", resp.StatusCode, body)
327 }
328 if _, list := f.browserGet(t, "/"+f.repo+"/changes", cookie); strings.Contains(list, "Browser change") {
329 t.Fatalf("closed change is still in the open list:\n%s", list)
330 }
331 if _, list := f.browserGet(t, "/"+f.repo+"/changes?state=all", cookie); !strings.Contains(list, "Browser change") {
332 t.Fatalf("closed change is missing from the All list:\n%s", list)
333 }
334 if _, page := f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie); !strings.Contains(page, "Reopen</button>") {
335 t.Fatalf("closed change does not offer a reopen:\n%s", page)
336 }
337
338 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
339 "csrf": {sess.CSRF}, "state": {"open"},
340 })
341 if resp.StatusCode != http.StatusSeeOther {
342 t.Fatalf("reopen change: %d\n%s", resp.StatusCode, body)
343 }
344 if _, page := f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie); !strings.Contains(page, "Close</button>") ||
345 !strings.Contains(page, `class="danger"`) {
346 t.Fatalf("reopened change does not offer a close:\n%s", page)
347 }
348}
349
350func TestNewIssueAndChangePages(t *testing.T) {
351 t.Parallel()
352 f := newFixture(t)
353 f.publish(f.repo)
354 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
355 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
356 t.Fatalf("main push: %s", errs)
357 }
358 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
359 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
360 t.Fatalf("feature push: %s", errs)
361 }
362 sess, cookie := f.browserSession(t, f.ownerID)
363
364 resp, body := f.browserGet(t, "/"+f.repo+"/issues/new", cookie)
365 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/`+f.repo+`/issues"`) || !strings.Contains(body, "Open issue") {
366 t.Fatalf("new issue page = %d:\n%s", resp.StatusCode, body)
367 }
368 // The CSRF token is on the form, not in a popover that has to be opened first.
369 if !strings.Contains(body, sess.CSRF) {
370 t.Fatal("new issue page has no CSRF token")
371 }
372
373 resp, body = f.browserGet(t, "/"+f.repo+"/changes/new", cookie)
374 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/`+f.repo+`/changes"`) ||
375 !strings.Contains(body, "feature") || !strings.Contains(body, "Open change") {
376 t.Fatalf("new change page = %d:\n%s", resp.StatusCode, body)
377 }
378
379 // The list page links to the pages instead of embedding the forms.
380 if _, page := f.browserGet(t, "/"+f.repo+"/issues", cookie); !strings.Contains(page, "/issues/new") {
381 t.Fatal("issues list does not link to the new issue page")
382 }
383 if _, page := f.browserGet(t, "/"+f.repo+"/changes", cookie); !strings.Contains(page, "/changes/new") {
384 t.Fatal("changes list does not link to the new change page")
385 }
386}
387
388func TestIssueLabelsCanBeSetAndEditedInTheBrowser(t *testing.T) {
389 t.Parallel()
390 f := newFixture(t)
391 f.publish(f.repo)
392 sess, cookie := f.browserSession(t, f.ownerID)
393
394 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
395 "csrf": {sess.CSRF}, "title": {"Labels from the browser"}, "body": {"body\n"},
396 "label": {"bug", "web", "perf", "docs"},
397 })
398 if resp.StatusCode != http.StatusSeeOther {
399 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
400 }
401 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
402 if id == "" {
403 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
404 }
405 iss, err := index.LoadIssue(mustRepo(t, f), id)
406 if err != nil {
407 t.Fatal(err)
408 }
409 if got := strings.Join(iss.Labels, ","); got != "bug,docs,perf,web" {
410 t.Fatalf("created labels = %q", got)
411 }
412
413 // The detail page seeds the token input with the current labels and offers a
414 // save form. The browser turns that value into chips.
415 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
416 for _, want := range []string{`hx-trigger="token-change"`, `value="bug, docs, perf, web"`, `data-label-input`} {
417 if !strings.Contains(page, want) {
418 t.Fatalf("issue page does not show %q:\n%s", want, page)
419 }
420 }
421 // A new issue page offers the labels already used in the repository.
422 if _, page := f.browserGet(t, "/"+f.repo+"/issues/new", cookie); !strings.Contains(page, `data-label-option="bug"`) {
423 t.Fatalf("new issue page has no existing label picker:\n%s", page)
424 }
425
426 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+id+"/labels", cookie, url.Values{
427 "csrf": {sess.CSRF}, "label": {"bug", "ux"},
428 })
429 if resp.StatusCode != http.StatusSeeOther {
430 t.Fatalf("edit labels: %d\n%s", resp.StatusCode, body)
431 }
432 iss, err = index.LoadIssue(mustRepo(t, f), id)
433 if err != nil {
434 t.Fatal(err)
435 }
436 if got := strings.Join(iss.Labels, ","); got != "bug,ux" {
437 t.Fatalf("edited labels = %q", got)
438 }
439 _, page = f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
440 if !strings.Contains(page, `value="bug, ux"`) || strings.Contains(page, `value="bug, docs, perf, web"`) {
441 t.Fatalf("edited labels are not reflected in the picker:\n%s", page)
442 }
443}
444
445func TestIssueAssigneesCanBeSetAndEditedInTheBrowser(t *testing.T) {
446 t.Parallel()
447 f := newFixture(t)
448 f.publish(f.repo)
449 f.addWriter(t) // wilbur: maintain access, so assignable
450 sess, cookie := f.browserSession(t, f.ownerID)
451
452 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
453 "csrf": {sess.CSRF}, "title": {"Assignees from the browser"}, "body": {"body\n"},
454 "assignee": {"wilbur", "keenan"},
455 })
456 if resp.StatusCode != http.StatusSeeOther {
457 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
458 }
459 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
460 if id == "" {
461 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
462 }
463 iss, err := index.LoadIssue(mustRepo(t, f), id)
464 if err != nil {
465 t.Fatal(err)
466 }
467 if got := strings.Join(iss.Assignees, ","); got != "keenan,wilbur" {
468 t.Fatalf("created assignees = %q", got)
469 }
470
471 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
472 for _, want := range []string{`hx-trigger="token-change"`, `value="keenan, wilbur"`, `data-label-item="assignee"`} {
473 if !strings.Contains(page, want) {
474 t.Fatalf("issue page does not show %q:\n%s", want, page)
475 }
476 }
477 if _, page := f.browserGet(t, "/"+f.repo+"/issues/new", cookie); !strings.Contains(page, `data-label-option="wilbur"`) {
478 t.Fatalf("new issue page does not offer wilbur as an assignee:\n%s", page)
479 }
480
481 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+id+"/assignees", cookie, url.Values{
482 "csrf": {sess.CSRF}, "assignee": {"wilbur"},
483 })
484 if resp.StatusCode != http.StatusSeeOther {
485 t.Fatalf("edit assignees: %d\n%s", resp.StatusCode, body)
486 }
487 iss, err = index.LoadIssue(mustRepo(t, f), id)
488 if err != nil {
489 t.Fatal(err)
490 }
491 if got := strings.Join(iss.Assignees, ","); got != "wilbur" {
492 t.Fatalf("edited assignees = %q", got)
493 }
494 _, page = f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
495 if !strings.Contains(page, `value="wilbur"`) || strings.Contains(page, `value="keenan, wilbur"`) {
496 t.Fatalf("edited assignees are not reflected in the picker:\n%s", page)
497 }
498
499 // An assignee nobody can resolve is a typo, not a person.
500 resp, body = f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
501 "csrf": {sess.CSRF}, "title": {"Bad assignee"}, "body": {"body\n"},
502 "assignee": {"nobody"},
503 })
504 if resp.StatusCode != http.StatusBadRequest {
505 t.Fatalf("unknown assignee accepted: %d\n%s", resp.StatusCode, body)
506 }
507}
508
509func TestChangeLinksIssuesFromTheIssuesSection(t *testing.T) {
510 t.Parallel()
511 f := newFixture(t)
512 f.publish(f.repo)
513
514 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
515 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
516 t.Fatalf("main push: %s", errs)
517 }
518 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
519 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
520 t.Fatalf("feature push: %s", errs)
521 }
522
523 sess, cookie := f.browserSession(t, f.ownerID)
524 createIssue := func(title string) string {
525 t.Helper()
526 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
527 "csrf": {sess.CSRF}, "title": {title}, "body": {"body\n"},
528 })
529 if resp.StatusCode != http.StatusSeeOther {
530 t.Fatalf("create issue %q: %d\n%s", title, resp.StatusCode, body)
531 }
532 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
533 if id == "" {
534 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
535 }
536 return id
537 }
538 first := createIssue("First linked issue")
539 second := createIssue("Second linked issue")
540
541 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
542 "csrf": {sess.CSRF}, "title": {"Linked issues"}, "body": {"body\n"},
543 "base": {"main"}, "head": {"feature"}, "closes": {first},
544 })
545 if resp.StatusCode != http.StatusSeeOther {
546 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
547 }
548 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
549 if id == "" {
550 t.Fatalf("no change id in %q", resp.Header.Get("Location"))
551 }
552
553 // The Issues section uses the overview's issue rows and offers the link
554 // controls instead of the old one-line "Closes ... when merged".
555 _, page := f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie)
556 for _, want := range []string{
557 `id="request-issues"`, "First linked issue", `value="` + first + `"`,
558 `value="remove"`, `request-unlink`, `class="request-issue-search"`,
559 `hx-get="/` + f.repo + `/changes/` + id + `/live"`,
560 `hx-trigger="every 20s [document.visibilityState === 'visible']"`, `hx-swap="none"`,
561 } {
562 if !strings.Contains(page, want) {
563 t.Fatalf("the request page is missing %q:\n%s", want, page)
564 }
565 }
566 if strings.Contains(page, "when merged.") {
567 t.Fatalf("the old closes line is still on the page:\n%s", page)
568 }
569 if strings.Contains(page, "document.activeElement") {
570 t.Fatalf("the poll still guards on focus instead of updating only data regions:\n%s", page)
571 }
572
573 // The search box filters by title, `#number` and ULID prefix. A match is a
574 // button that posts the link, and the Issues section comes back in place.
575 m := mustRepo(t, f)
576 nums, err := metadata.LoadNumbers(m.Git(), metadata.Ref)
577 if err != nil {
578 t.Fatal(err)
579 }
580 number, ok := nums.Of(second)
581 if !ok {
582 t.Fatalf("the second issue has no number")
583 }
584 for _, query := range []string{"Second", "#" + strconv.Itoa(number), second[:8]} {
585 resp, results := f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/issues/search?q="+url.QueryEscape(query), cookie)
586 if resp.StatusCode != http.StatusOK || !strings.Contains(results, "Second linked issue") ||
587 !strings.Contains(results, `value="`+second+`"`) || !strings.Contains(results, `value="add"`) ||
588 !strings.Contains(results, `hx-target="#request-issues"`) ||
589 !strings.Contains(results, `request-issue-popover`) {
590 t.Fatalf("search %q is not the expected result (%d):\n%s", query, resp.StatusCode, results)
591 }
592 }
593 // An issue already linked is not offered, so a shared title cannot hide the
594 // rows that are still available.
595 resp, results := f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/issues/search?q=First", cookie)
596 if resp.StatusCode != http.StatusOK || strings.Contains(results, "First linked issue") ||
597 !strings.Contains(results, "No issues match") {
598 t.Fatalf("a linked issue is still in the search results (%d):\n%s", resp.StatusCode, results)
599 }
600 // Clearing the field removes the popover rather than leaving a blank panel.
601 resp, results = f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/issues/search?q=", cookie)
602 if resp.StatusCode != http.StatusOK || strings.TrimSpace(results) != "" {
603 t.Fatalf("an empty search left a panel (%d):\n%s", resp.StatusCode, results)
604 }
605
606 // Clicking a result posts its id, and the section comes back with both links.
607 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
608 "csrf": {sess.CSRF}, "action": {"add"}, "issue": {second},
609 })
610 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-issues"`) ||
611 !strings.Contains(body, "Second linked issue") || !strings.Contains(body, "First linked issue") {
612 t.Fatalf("add issue response is not the expected section (%d):\n%s", resp.StatusCode, body)
613 }
614 if got, err := metadata.GetRequest(m.Git(), id); err != nil || len(got.Closes) != 2 {
615 t.Fatalf("linked issues = %v, %v; want both", got, err)
616 }
617
618 // Removing a link is the same write, and the row disappears.
619 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
620 "csrf": {sess.CSRF}, "action": {"remove"}, "issue": {first},
621 })
622 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-issues"`) ||
623 strings.Contains(body, "First linked issue") || !strings.Contains(body, "Second linked issue") {
624 t.Fatalf("remove issue response is not the expected section (%d):\n%s", resp.StatusCode, body)
625 }
626 got, err := metadata.GetRequest(m.Git(), id)
627 if err != nil || len(got.Closes) != 1 || got.Closes[0] != second {
628 t.Fatalf("linked issues = %v, %v; want only %s", got, err, second)
629 }
630
631 // A duplicate says so beside the control instead of rewriting the list.
632 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
633 "csrf": {sess.CSRF}, "action": {"add"}, "issue": {second},
634 })
635 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "already linked") {
636 t.Fatalf("duplicate link response (%d):\n%s", resp.StatusCode, body)
637 }
638
639 // A closed request keeps its record but offers no edits, and the writer
640 // refuses one posted anyway.
641 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
642 "csrf": {sess.CSRF}, "state": {"closed"},
643 })
644 if resp.StatusCode != http.StatusSeeOther {
645 t.Fatalf("close change: %d\n%s", resp.StatusCode, body)
646 }
647 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie)
648 if !strings.Contains(page, "Second linked issue") || strings.Contains(page, `request-unlink`) ||
649 strings.Contains(page, `class="request-issue-search"`) {
650 t.Fatalf("a closed request does not show the right Issues section:\n%s", page)
651 }
652 resp, _ = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
653 "csrf": {sess.CSRF}, "action": {"remove"}, "issue": {second},
654 })
655 if resp.StatusCode != http.StatusForbidden {
656 t.Fatalf("a closed request changed its issues: status=%d", resp.StatusCode)
657 }
658}
659
660func TestRepositoryCanRequireALinkedIssueForNewChanges(t *testing.T) {
661 t.Parallel()
662 f := newFixture(t)
663 f.publish(f.repo)
664
665 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
666 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
667 t.Fatalf("main push: %s", errs)
668 }
669 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
670 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
671 t.Fatalf("feature push: %s", errs)
672 }
673
674 m := mustRepo(t, f)
675 a, err := m.ACL()
676 if err != nil {
677 t.Fatal(err)
678 }
679 a.RequireLinkedIssue = true
680 if err := m.SetACL(a, "test"); err != nil {
681 t.Fatal(err)
682 }
683
684 sess, cookie := f.browserSession(t, f.ownerID)
685 resp, body := f.browserGet(t, "/"+f.repo+"/changes/new", cookie)
686 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "requires a linked issue") ||
687 !strings.Contains(body, "required") {
688 t.Fatalf("new change page does not explain the rule (%d):\n%s", resp.StatusCode, body)
689 }
690
691 form := url.Values{
692 "csrf": {sess.CSRF}, "title": {"Unlinked change"}, "body": {"body\n"},
693 "base": {"main"}, "head": {"feature"},
694 }
695 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, form)
696 if resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "requires at least one linked issue") {
697 t.Fatalf("an unlinked change was accepted (%d):\n%s", resp.StatusCode, body)
698 }
699
700 resp, body = f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
701 "csrf": {sess.CSRF}, "title": {"The linked issue"}, "body": {"body\n"},
702 })
703 if resp.StatusCode != http.StatusSeeOther {
704 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
705 }
706 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
707 if issueID == "" {
708 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
709 }
710
711 form.Set("title", "Linked change")
712 form.Set("closes", issueID)
713 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, form)
714 if resp.StatusCode != http.StatusSeeOther {
715 t.Fatalf("a linked change was rejected (%d):\n%s", resp.StatusCode, body)
716 }
717 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
718 got, err := metadata.GetRequest(m.Git(), id)
719 if err != nil {
720 t.Fatal(err)
721 }
722 if len(got.Closes) != 1 || got.Closes[0] != issueID {
723 t.Fatalf("linked issues = %v, want %s", got.Closes, issueID)
724 }
725}
726
727func (f *fixture) htmxPost(t *testing.T, path, cookie string, form url.Values) (*http.Response, string) {
728 t.Helper()
729 req, err := http.NewRequest("POST", f.http.URL+path, strings.NewReader(form.Encode()))
730 if err != nil {
731 t.Fatal(err)
732 }
733 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
734 req.Header.Set("Cookie", cookie)
735 req.Header.Set("HX-Request", "true")
736 resp, err := noRedirectAuth.Do(req)
737 if err != nil {
738 t.Fatal(err)
739 }
740 defer resp.Body.Close()
741 body, _ := io.ReadAll(resp.Body)
742 return resp, string(body)
743}
744
745func TestIssueSidebarFragmentsUpdateWithHtmx(t *testing.T) {
746 t.Parallel()
747 f := newFixture(t)
748 f.publish(f.repo)
749 f.addWriter(t)
750 sess, cookie := f.browserSession(t, f.ownerID)
751
752 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
753 "csrf": {sess.CSRF}, "title": {"HTMX issue"}, "body": {"body\n"},
754 })
755 if resp.StatusCode != http.StatusSeeOther {
756 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
757 }
758 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
759
760 // Labels: the response is the fragment, not a page and not a redirect.
761 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+id+"/labels", cookie, url.Values{
762 "csrf": {sess.CSRF}, "label": {"bug", "ux"},
763 })
764 if resp.StatusCode != http.StatusOK {
765 t.Fatalf("htmx labels: %d\n%s", resp.StatusCode, body)
766 }
767 if strings.Contains(body, "<!doctype html>") || !strings.Contains(body, `id="issue-labels"`) ||
768 !strings.Contains(body, `value="bug, ux"`) {
769 t.Fatalf("labels response is not the expected fragment:\n%s", body)
770 }
771
772 // Assignees: the replacement fragment seeds the token input and keeps the
773 // token-change trigger.
774 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+id+"/assignees", cookie, url.Values{
775 "csrf": {sess.CSRF}, "assignee": {"wilbur"},
776 })
777 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-assignees"`) ||
778 !strings.Contains(body, `value="wilbur"`) || !strings.Contains(body, `hx-trigger="token-change"`) {
779 t.Fatalf("assignees response is not the expected fragment (%d):\n%s", resp.StatusCode, body)
780 }
781
782 // Comments: the thread fragment includes the new comment and the composer.
783 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+id+"/comments", cookie, url.Values{
784 "csrf": {sess.CSRF}, "body": {"Posted over HTMX.\n"},
785 })
786 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-thread"`) ||
787 !strings.Contains(body, "Posted over HTMX.") {
788 t.Fatalf("comment response is not the expected fragment (%d):\n%s", resp.StatusCode, body)
789 }
790}
791
792func TestRequestSidebarAndDiscussionUpdateWithHtmx(t *testing.T) {
793 t.Parallel()
794 f := newFixture(t)
795 f.addWriter(t) // wilbur: maintain, and not the request author
796 f.publish(f.repo)
797
798 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
799 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
800 t.Fatalf("main push: %s", errs)
801 }
802 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
803 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
804 t.Fatalf("feature push: %s", errs)
805 }
806
807 ownerSess, ownerCookie := f.browserSession(t, f.ownerID)
808 resp, body := f.browserPost(t, "/"+f.repo+"/changes", ownerCookie, url.Values{
809 "csrf": {ownerSess.CSRF}, "title": {"HTMX change"}, "body": {"body\n"},
810 "base": {"main"}, "head": {"feature"},
811 })
812 if resp.StatusCode != http.StatusSeeOther {
813 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
814 }
815 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
816
817 // A maintainer approves; the data-only regions and the action buttons come
818 // back as out-of-band updates, so the Issues section and any open search are
819 // left alone.
820 writerSess, writerCookie := f.browserSession(t, f.writerID)
821 request, err := metadata.GetRequest(mustRepo(t, f).Git(), id)
822 if err != nil {
823 t.Fatal(err)
824 }
825 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/approve", writerCookie, url.Values{
826 "csrf": {writerSess.CSRF}, "base": {request.Base}, "head": {request.Head},
827 })
828 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `hx-swap-oob="outerHTML"`) ||
829 !strings.Contains(body, `id="request-approvals"`) || !strings.Contains(body, "1 of 1 required approval(s).") ||
830 !strings.Contains(body, `id="request-actions"`) || !strings.Contains(body, `id="request-review-status"`) {
831 t.Fatalf("approval response is not the expected live update (%d):\n%s", resp.StatusCode, body)
832 }
833 for _, unwanted := range []string{`id="request-issues"`, `id="request-target"`, `request-issue-search`} {
834 if strings.Contains(body, unwanted) {
835 t.Fatalf("approval response reset unrelated state %q:\n%s", unwanted, body)
836 }
837 }
838
839 // The page's poll returns only out-of-band data regions. It has no sidebar
840 // wrapper and no form, so it cannot close a search or replace a picker.
841 resp, body = f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/live", writerCookie)
842 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
843 strings.Contains(body, "<form") || strings.Contains(body, `id="request-aside"`) ||
844 strings.Contains(body, `id="request-sidebar"`) || strings.Contains(body, `request-issues`) {
845 t.Fatalf("live poll is not the expected data-only fragment (%d):\n%s", resp.StatusCode, body)
846 }
847 for _, want := range []string{
848 `hx-swap-oob="outerHTML"`, `id="request-review-status"`, `id="request-changes"`,
849 `id="request-approvals"`, `id="request-checks"`, `id="request-combined"`,
850 `id="request-merge-note"`, "Merges into main cleanly", "1 of 1 required approval(s).",
851 `class="help"`,
852 } {
853 if !strings.Contains(body, want) {
854 t.Fatalf("live poll is missing %q (%d):\n%s", want, resp.StatusCode, body)
855 }
856 }
857
858 // Posting a comment replaces only the discussion fragment.
859 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/comments", ownerCookie, url.Values{
860 "csrf": {ownerSess.CSRF}, "body": {"Review note over HTMX.\n"},
861 })
862 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-discussion"`) ||
863 !strings.Contains(body, "Review note over HTMX.") {
864 t.Fatalf("discussion response is not the expected fragment (%d):\n%s", resp.StatusCode, body)
865 }
866}
867
868func (f *fixture) htmxGet(t *testing.T, path, cookie string) (*http.Response, string) {
869 t.Helper()
870 req, err := http.NewRequest("GET", f.http.URL+path, nil)
871 if err != nil {
872 t.Fatal(err)
873 }
874 req.Header.Set("Cookie", cookie)
875 req.Header.Set("HX-Request", "true")
876 resp, err := noRedirectAuth.Do(req)
877 if err != nil {
878 t.Fatal(err)
879 }
880 defer resp.Body.Close()
881 body, _ := io.ReadAll(resp.Body)
882 return resp, string(body)
883}
884
885func TestIssueAndChangeListsReturnFragmentsForHtmx(t *testing.T) {
886 t.Parallel()
887 f := newFixture(t)
888 f.publish(f.repo)
889 files := map[string]string{
890 "issues/01M5JR2JK29KKMAA1T9MDNTPD1/issue.md": issueFile("Open thing", "keenan", "open", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
891 "issues/01M5JR2JK29KKMAA1T9MDNTPD2/issue.md": issueFile("Closed thing", "keenan", "closed", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
892 }
893 work := f.issuesRepo(t, files)
894 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
895 t.Fatalf("push: %s", errs)
896 }
897
898 resp, body := f.htmxGet(t, "/"+f.repo+"/issues?state=closed", "")
899 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-list"`) ||
900 strings.Contains(body, "<!doctype html>") {
901 t.Fatalf("issues fragment = %d:\n%s", resp.StatusCode, body)
902 }
903 if !strings.Contains(body, "Closed thing") || strings.Contains(body, "Open thing") {
904 t.Fatalf("issues fragment did not apply the state filter:\n%s", body)
905 }
906 // The list uses the same status shape as the change list, not the state pill
907 // the page headers still use.
908 if !strings.Contains(body, `class="row-status closed"`) || strings.Contains(body, `class="state closed"`) {
909 t.Fatalf("the closed issue does not use the list status style:\n%s", body)
910 }
911 if !strings.Contains(body, `hx-target="#issue-list"`) {
912 t.Fatal("issues fragment lost its HTMX target")
913 }
914
915 resp, body = f.htmxGet(t, "/"+f.repo+"/changes?state=all", "")
916 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="change-list"`) ||
917 strings.Contains(body, "<!doctype html>") {
918 t.Fatalf("changes fragment = %d:\n%s", resp.StatusCode, body)
919 }
920 if !strings.Contains(body, `hx-target="#change-list"`) {
921 t.Fatal("changes fragment lost its HTMX target")
922 }
923}
924
925// TestRequestFilesListsEveryChangedFile: the changed-file tree and the count come
926// from the change's inventory, so a file larger than a page can render does not
927// hide the files named after it. The patch the tab shows is still one file's.
928func TestRequestFilesListsEveryChangedFile(t *testing.T) {
929 t.Parallel()
930 f := newFixture(t)
931 f.publish(f.repo)
932 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
933 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
934 t.Fatalf("main push: %s", errs)
935 }
936 // a-big.txt sorts first and is larger than the 512 KiB that used to bound the
937 // patch the tree was parsed from, so the files after it were never listed.
938 files := map[string]string{"a-big.txt": strings.Repeat("x", 600<<10) + "\n"}
939 for _, name := range []string{"b.txt", "docs/c.txt", "z.txt"} {
940 files[name] = name + "\n"
941 }
942 feature, _ := f.cloneWithChange(t, files, "a big change")
943 // A deletion rides along: it has one path and no new one, which is the shape
944 // that used to make the page fail when it was opened.
945 if _, errs, err := runGit(t, feature, "rm", "-q", "readme.md"); err != nil {
946 t.Fatalf("rm: %s", errs)
947 }
948 if _, errs, err := runGit(t, feature, "commit", "-qm", "drop the readme"); err != nil {
949 t.Fatalf("commit: %s", errs)
950 }
951 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
952 t.Fatalf("feature push: %s", errs)
953 }
954 sess, cookie := f.browserSession(t, f.ownerID)
955 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
956 "csrf": {sess.CSRF}, "title": {"Every file"}, "body": {"body\n"},
957 "base": {"main"}, "head": {"feature"},
958 })
959 if resp.StatusCode != http.StatusSeeOther {
960 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
961 }
962 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
963
964 filesURL := "/" + f.repo + "/changes/" + id + "/files"
965 _, page := f.browserGet(t, filesURL, cookie)
966 if !strings.Contains(page, "5 changed") {
967 t.Errorf("the tree does not count every file:\n%.2000s", page)
968 }
969 for _, name := range []string{"a-big.txt", "b.txt", "docs/c.txt", "readme.md", "z.txt"} {
970 if !strings.Contains(page, filesURL+"/"+name) {
971 t.Errorf("the tree is missing %s:\n%.2000s", name, page)
972 }
973 }
974 // The open file is one file's patch, and it is whole.
975 _, page = f.browserGet(t, filesURL+"/z.txt", cookie)
976 if !strings.Contains(page, `<span class="path">z.txt</span>`) ||
977 !strings.Contains(page, `data-highlight-side="new">z.txt<`) {
978 t.Fatalf("the selected file has no patch:\n%.2000s", page)
979 }
980 // The deletion opens too: one path, no new one, and its removed lines.
981 resp, page = f.browserGet(t, filesURL+"/readme.md", cookie)
982 if resp.StatusCode != http.StatusOK {
983 t.Fatalf("deleted file = %d:\n%.2000s", resp.StatusCode, page)
984 }
985 if !strings.Contains(page, `data-highlight-side="old">v1<`) {
986 t.Fatalf("the deleted file has no patch:\n%.2000s", page)
987 }
988}
989
990func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) {
991 t.Parallel()
992 f := newFixture(t)
993 f.publish(f.repo)
994 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
995 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
996 t.Fatalf("main push: %s", errs)
997 }
998 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
999 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1000 t.Fatalf("feature push: %s", errs)
1001 }
1002 sess, cookie := f.browserSession(t, f.ownerID)
1003 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
1004 "csrf": {sess.CSRF}, "title": {"Line comments"}, "body": {"body\n"},
1005 "base": {"main"}, "head": {"feature"},
1006 })
1007 if resp.StatusCode != http.StatusSeeOther {
1008 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
1009 }
1010 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
1011
1012 // The Files tab brings the changed file and its per-line comment button.
1013 filesURL := "/" + f.repo + "/changes/" + id + "/files"
1014 _, page := f.browserGet(t, filesURL, cookie)
1015 if !strings.Contains(page, `id="review-tree"`) || !strings.Contains(page, `data-status="M"`) ||
1016 !strings.Contains(page, filesURL+"/readme.md") {
1017 t.Fatalf("files tab has no changed-file tree:\n%s", page)
1018 }
1019 if !strings.Contains(page, "path=readme.md") || !strings.Contains(page, "line=2") ||
1020 !strings.Contains(page, "/comment-form?path=readme.md") {
1021 t.Fatalf("diff has no line comment control:\n%s", page)
1022 }
1023
1024 // A tree click returns only the review pane.
1025 resp, piece := f.htmxGet(t, filesURL+"/readme.md", cookie)
1026 if resp.StatusCode != http.StatusOK || strings.Contains(piece, "<!doctype html>") ||
1027 !strings.Contains(piece, `id="review-main"`) || !strings.Contains(piece, "readme.md") {
1028 t.Fatalf("review fragment = %d:\n%s", resp.StatusCode, piece)
1029 }
1030
1031 // HTMX inserts the composer under the line, with the anchor already filled in.
1032 resp, form := f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/comment-form?path=readme.md&line=2&side=new", cookie)
1033 if resp.StatusCode != http.StatusOK || strings.Contains(form, "<!doctype html>") ||
1034 !strings.Contains(form, `class="inline-comment"`) ||
1035 !strings.Contains(form, `name="path" value="readme.md"`) ||
1036 !strings.Contains(form, `name="line" value="2"`) ||
1037 !strings.Contains(form, "data-inline-comment-cancel") {
1038 t.Fatalf("inline composer = %d:\n%s", resp.StatusCode, form)
1039 }
1040
1041 // The old full-page fallback still opens the composer with the anchor filled in.
1042 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+id+"?path=readme.md&line=2&side=new", cookie)
1043 if !strings.Contains(page, "Commenting on") || !strings.Contains(page, `name="path" value="readme.md"`) ||
1044 !strings.Contains(page, `name="line" value="2"`) {
1045 t.Fatalf("anchored composer is not pre-filled:\n%s", page)
1046 }
1047
1048 // A rejected anchored comment re-renders the discussion with the anchor still
1049 // chosen, so the reader keeps the line they picked instead of being sent back to
1050 // the Files tab to choose it again.
1051 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
1052 "csrf": {sess.CSRF}, "body": {""},
1053 "path": {"readme.md"}, "line": {"2"}, "side": {"new"},
1054 })
1055 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Commenting on") ||
1056 !strings.Contains(body, `name="line" value="2"`) ||
1057 !strings.Contains(body, "a comment needs a body") {
1058 t.Fatalf("rejected anchored comment = %d:\n%s", resp.StatusCode, body)
1059 }
1060
1061 // Posting it through HTMX stores the anchor and returns the discussion.
1062 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
1063 "csrf": {sess.CSRF}, "body": {"This line needs a second look."},
1064 "path": {"readme.md"}, "line": {"2"}, "side": {"new"},
1065 })
1066 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-discussion"`) ||
1067 !strings.Contains(body, "This line needs a second look.") {
1068 t.Fatalf("anchored comment response = %d:\n%s", resp.StatusCode, body)
1069 }
1070 req, err := metadata.GetRequest(mustRepo(t, f).Git(), id)
1071 if err != nil {
1072 t.Fatal(err)
1073 }
1074 if len(req.Comments) != 1 || req.Comments[0].Anchor == nil {
1075 t.Fatalf("anchored comment was not stored: %+v", req.Comments)
1076 }
1077 a := req.Comments[0].Anchor
1078 if a.Path != "readme.md" || a.Line != 2 || a.Side != "new" || a.Blob == "" {
1079 t.Fatalf("anchor = %+v", a)
1080 }
1081
1082 // The Files full page puts the comment beneath the line it belongs to.
1083 _, page = f.browserGet(t, filesURL+"/readme.md", cookie)
1084 if !strings.Contains(page, `id="inline-c-`+req.Comments[0].ID+`"`) ||
1085 !strings.Contains(page, "This line needs a second look.") {
1086 t.Fatalf("anchored comment is not rendered inline:\n%s", page)
1087 }
1088
1089 // A normal form post from the Files tab returns to that file's line note.
1090 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
1091 "csrf": {sess.CSRF}, "body": {"Another line note."},
1092 "path": {"readme.md"}, "line": {"2"}, "side": {"new"},
1093 })
1094 if resp.StatusCode != http.StatusSeeOther {
1095 t.Fatalf("inline comment post = %d:\n%s", resp.StatusCode, body)
1096 }
1097 if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, filesURL+"/readme.md#inline-c-") {
1098 t.Fatalf("inline comment redirect = %q", loc)
1099 }
1100}
1101
1102func TestIssueAndChangeCommentsThreadOneLevel(t *testing.T) {
1103 t.Parallel()
1104 f := newFixture(t)
1105 f.publish(f.repo)
1106 sess, cookie := f.browserSession(t, f.ownerID)
1107 commentID := func(resp *http.Response) string {
1108 loc := resp.Header.Get("Location")
1109 if i := strings.LastIndex(loc, "#c-"); i >= 0 {
1110 return loc[i+3:]
1111 }
1112 return ""
1113 }
1114
1115 // Issue: replies are rendered under their root, even when replying to a reply.
1116 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
1117 "csrf": {sess.CSRF}, "title": {"Threaded issue"}, "body": {"body\n"},
1118 })
1119 if resp.StatusCode != http.StatusSeeOther {
1120 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
1121 }
1122 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
1123 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1124 "csrf": {sess.CSRF}, "body": {"Root comment."},
1125 })
1126 rootID := commentID(resp)
1127 if resp.StatusCode != http.StatusSeeOther || rootID == "" {
1128 t.Fatalf("issue root comment: %d %q\n%s", resp.StatusCode, rootID, body)
1129 }
1130 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1131 "csrf": {sess.CSRF}, "body": {"Issue reply."}, "reply_to": {rootID},
1132 })
1133 replyID := commentID(resp)
1134 if resp.StatusCode != http.StatusSeeOther || replyID == "" {
1135 t.Fatalf("issue reply: %d %q\n%s", resp.StatusCode, replyID, body)
1136 }
1137 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
1138 if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+replyID+`"`) ||
1139 !strings.Contains(page, "Issue reply.") || !strings.Contains(page, "data-comment-menu") ||
1140 !strings.Contains(page, `data-reply-to="`+replyID+`"`) ||
1141 !strings.Contains(page, `class="reply-context"`) || !strings.Contains(page, `href="#c-`+rootID+`"`) {
1142 t.Fatalf("issue reply is not nested:\n%s", page)
1143 }
1144 if !strings.Contains(page, `<div id="issue-thread">`) ||
1145 !strings.Contains(page, `class="thread-replies"`) ||
1146 strings.Contains(page, `class="discussion-panel"`) || strings.Contains(page, `class="comment-scroll"`) ||
1147 strings.Count(page, `discussion-composer`) != 1 ||
1148 !strings.Contains(page, "data-reply-input") || !strings.Contains(page, "Cancel reply") {
1149 t.Fatalf("issue discussion is not a full thread with one composer:\n%s", page)
1150 }
1151 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1152 "csrf": {sess.CSRF}, "body": {"Reply to a reply."}, "reply_to": {replyID},
1153 })
1154 deeperID := commentID(resp)
1155 if resp.StatusCode != http.StatusSeeOther || deeperID == "" {
1156 t.Fatalf("reply to a reply: %d %q\n%s", resp.StatusCode, deeperID, body)
1157 }
1158 _, page = f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
1159 if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+deeperID+`"`) ||
1160 !strings.Contains(page, "Reply to a reply.") || !strings.Contains(page, `data-reply-to="`+deeperID+`"`) ||
1161 !strings.Contains(page, `href="#c-`+replyID+`"`) {
1162 t.Fatalf("a reply to a reply was not flattened under its root:\n%s", page)
1163 }
1164
1165 // Change: the same flattening rule applies to the review discussion.
1166 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
1167 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1168 t.Fatalf("main push: %s", errs)
1169 }
1170 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
1171 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1172 t.Fatalf("feature push: %s", errs)
1173 }
1174 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
1175 "csrf": {sess.CSRF}, "title": {"Threaded change"}, "body": {"body\n"},
1176 "base": {"main"}, "head": {"feature"},
1177 })
1178 if resp.StatusCode != http.StatusSeeOther {
1179 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
1180 }
1181 requestID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
1182 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
1183 "csrf": {sess.CSRF}, "body": {"Change root."},
1184 })
1185 rootID = commentID(resp)
1186 if resp.StatusCode != http.StatusSeeOther || rootID == "" {
1187 t.Fatalf("change root comment: %d %q\n%s", resp.StatusCode, rootID, body)
1188 }
1189 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
1190 "csrf": {sess.CSRF}, "body": {"Change reply."}, "reply_to": {rootID},
1191 })
1192 replyID = commentID(resp)
1193 if resp.StatusCode != http.StatusSeeOther || replyID == "" {
1194 t.Fatalf("change reply: %d %q\n%s", resp.StatusCode, replyID, body)
1195 }
1196 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+requestID, cookie)
1197 if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+replyID+`"`) ||
1198 !strings.Contains(page, "Change reply.") || !strings.Contains(page, `data-reply-to="`+replyID+`"`) ||
1199 !strings.Contains(page, `class="reply-context"`) || !strings.Contains(page, `href="#c-`+rootID+`"`) ||
1200 !strings.Contains(page, `<div id="request-discussion">`) ||
1201 !strings.Contains(page, `class="thread-replies"`) ||
1202 strings.Count(page, `discussion-composer`) != 1 {
1203 t.Fatalf("change reply is not nested:\n%s", page)
1204 }
1205}
1206
1207func TestCommentEditAndDeleteFromTheWeb(t *testing.T) {
1208 t.Parallel()
1209 f := newFixture(t)
1210 f.publish(f.repo)
1211 f.addWriter(t)
1212 sess, cookie := f.browserSession(t, f.ownerID)
1213 commentID := func(resp *http.Response) string {
1214 loc := resp.Header.Get("Location")
1215 if i := strings.LastIndex(loc, "#c-"); i >= 0 {
1216 return loc[i+3:]
1217 }
1218 return ""
1219 }
1220
1221 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
1222 "csrf": {sess.CSRF}, "title": {"Editable issue"}, "body": {"body\n"},
1223 })
1224 if resp.StatusCode != http.StatusSeeOther {
1225 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
1226 }
1227 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
1228 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1229 "csrf": {sess.CSRF}, "body": {"Original root."},
1230 })
1231 rootID := commentID(resp)
1232 if resp.StatusCode != http.StatusSeeOther || rootID == "" {
1233 t.Fatalf("root comment: %d %q\n%s", resp.StatusCode, rootID, body)
1234 }
1235 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1236 "csrf": {sess.CSRF}, "body": {"A surviving reply."}, "reply_to": {rootID},
1237 })
1238 if resp.StatusCode != http.StatusSeeOther {
1239 t.Fatalf("reply: %d\n%s", resp.StatusCode, body)
1240 }
1241
1242 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
1243 for _, want := range []string{"data-comment-menu-toggle", "Copy text", "Copy link", "data-comment-edit", "Delete"} {
1244 if !strings.Contains(page, want) {
1245 t.Fatalf("comment menu is missing %q:\n%s", want, page)
1246 }
1247 }
1248
1249 // A maintainer who is not the author cannot rewrite or remove the comment.
1250 writerSess, writerCookie := f.browserSession(t, f.writerID)
1251 resp, _ = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/edit", writerCookie, url.Values{
1252 "csrf": {writerSess.CSRF}, "body": {"Not mine."},
1253 })
1254 if resp.StatusCode != http.StatusForbidden {
1255 t.Fatalf("foreign edit = %d, want 403", resp.StatusCode)
1256 }
1257
1258 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/edit", cookie, url.Values{
1259 "csrf": {sess.CSRF}, "body": {"Edited root."},
1260 })
1261 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-thread"`) ||
1262 !strings.Contains(body, "Edited root.") || strings.Contains(body, "Original root.") {
1263 t.Fatalf("edit response = %d:\n%s", resp.StatusCode, body)
1264 }
1265
1266 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/delete", cookie, url.Values{
1267 "csrf": {sess.CSRF},
1268 })
1269 if resp.StatusCode != http.StatusSeeOther {
1270 t.Fatalf("delete = %d:\n%s", resp.StatusCode, body)
1271 }
1272 _, page = f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
1273 if !strings.Contains(page, "Comment deleted") || !strings.Contains(page, "A surviving reply.") {
1274 t.Fatalf("deleted root did not become a tombstone:\n%s", page)
1275 }
1276
1277 // The same routes work for a change discussion.
1278 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
1279 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1280 t.Fatalf("main push: %s", errs)
1281 }
1282 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
1283 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1284 t.Fatalf("feature push: %s", errs)
1285 }
1286 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
1287 "csrf": {sess.CSRF}, "title": {"Editable change"}, "body": {"body\n"},
1288 "base": {"main"}, "head": {"feature"},
1289 })
1290 if resp.StatusCode != http.StatusSeeOther {
1291 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
1292 }
1293 requestID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
1294 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
1295 "csrf": {sess.CSRF}, "body": {"Original change root."},
1296 })
1297 rootID = commentID(resp)
1298 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
1299 "csrf": {sess.CSRF}, "body": {"Change reply survives."}, "reply_to": {rootID},
1300 })
1301 if resp.StatusCode != http.StatusSeeOther {
1302 t.Fatalf("change reply: %d\n%s", resp.StatusCode, body)
1303 }
1304 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments/"+rootID+"/edit", cookie, url.Values{
1305 "csrf": {sess.CSRF}, "body": {"Edited change root."},
1306 })
1307 if resp.StatusCode != http.StatusSeeOther {
1308 t.Fatalf("change edit: %d\n%s", resp.StatusCode, body)
1309 }
1310 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+requestID, cookie)
1311 if !strings.Contains(page, "Edited change root.") || strings.Contains(page, "Original change root.") {
1312 t.Fatalf("change edit did not replace the comment:\n%s", page)
1313 }
1314 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments/"+rootID+"/delete", cookie, url.Values{
1315 "csrf": {sess.CSRF},
1316 })
1317 if resp.StatusCode != http.StatusSeeOther {
1318 t.Fatalf("change delete: %d\n%s", resp.StatusCode, body)
1319 }
1320 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+requestID, cookie)
1321 if !strings.Contains(page, "Comment deleted") || !strings.Contains(page, "Change reply survives.") {
1322 t.Fatalf("deleted change root did not become a tombstone:\n%s", page)
1323 }
1324}
1325
1326// Landing redirects to the request page, which now reads as merged. The redirect
1327// carries no flag: the page shows the merged state from the request itself.
1328func TestLandingRedirectsToTheRequestPage(t *testing.T) {
1329 t.Parallel()
1330 f := newFixture(t)
1331 f.publish(f.repo)
1332
1333 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
1334 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1335 t.Fatalf("initial main: %s", errs)
1336 }
1337 feature, head := f.cloneWithChange(t, map[string]string{"readme.md": "v2\n"}, "feature")
1338 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1339 t.Fatalf("feature push: %s", errs)
1340 }
1341 meta := f.newMetaRepo(t)
1342 if err := meta.apply(map[string]string{
1343 "changes/" + reqID + "/change.md": requestMeta("main", head, "wilbur"),
1344 }, "request"); err != nil {
1345 t.Fatalf("request: %v", err)
1346 }
1347
1348 sess, cookie := f.browserSession(t, f.ownerID)
1349 resp, body := f.browserPost(t, "/"+f.repo+"/changes/"+reqID+"/merge", cookie,
1350 url.Values{"csrf": {sess.CSRF}})
1351 if resp.StatusCode != http.StatusSeeOther {
1352 t.Fatalf("merge = %d, want 303:\n%s", resp.StatusCode, body)
1353 }
1354 want := "/" + f.repo + "/changes/" + reqID
1355 if got := resp.Header.Get("Location"); got != want {
1356 t.Fatalf("merge redirect = %q, want %q", got, want)
1357 }
1358
1359 // The page it points at is the ordinary merged view, not a special case.
1360 resp, page := f.browserGet(t, want, cookie)
1361 if resp.StatusCode != http.StatusOK {
1362 t.Fatalf("landing page = %d", resp.StatusCode)
1363 }
1364 if !strings.Contains(page, "merged into") {
1365 t.Fatalf("landing page does not show the merged state:\n%s", page)
1366 }
1367}
1368
1369// The header polls the two tab counts so a reader watching a repository sees new
1370// issues and changes arrive without a reload. The poll must answer with the two
1371// badges alone: an out-of-band update leaves the page, its current tab and
1372// anything the reader typed where they were.
1373func TestRepoTabCountsPollOutOfBand(t *testing.T) {
1374 t.Parallel()
1375 f := newFixture(t)
1376 f.publish(f.repo)
1377 files := map[string]string{
1378 "issues/01M5JR2JK29KKMAA1T9MDNTPD1/issue.md": issueFile("Open thing", "keenan", "open", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
1379 "issues/01M5JR2JK29KKMAA1T9MDNTPD2/issue.md": issueFile("Closed thing", "keenan", "closed", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
1380 }
1381 work := f.issuesRepo(t, files)
1382 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
1383 t.Fatalf("push: %s", errs)
1384 }
1385
1386 // The page is what the poll has to keep in step: the badges it names, and the
1387 // request that refreshes them while the page is visible.
1388 code, page := f.get("/" + f.repo + "/issues")
1389 if code != http.StatusOK {
1390 t.Fatalf("issue page: %d", code)
1391 }
1392 for _, want := range []string{
1393 `id="repo-issue-count"`,
1394 `id="repo-request-count"`,
1395 `hx-get="/` + f.repo + `/counts"`,
1396 "every 30s [document.visibilityState === 'visible']",
1397 } {
1398 if !strings.Contains(page, want) {
1399 t.Fatalf("the repository header does not show %q:\n%s", want, page)
1400 }
1401 }
1402
1403 // The answer is only the badges, each replacing itself out of band. Both are
1404 // present on every poll, so a count that did not move is not left stale.
1405 resp, body := f.htmxGet(t, "/"+f.repo+"/counts", "")
1406 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") {
1407 t.Fatalf("counts poll = %d:\n%s", resp.StatusCode, body)
1408 }
1409 for _, want := range []string{
1410 `id="repo-issue-count" hx-swap-oob="outerHTML">1<`,
1411 `id="repo-request-count" hx-swap-oob="outerHTML">0<`,
1412 } {
1413 if !strings.Contains(body, want) {
1414 t.Fatalf("counts poll does not replace %q:\n%s", want, body)
1415 }
1416 }
1417 if got := strings.Count(body, "hx-swap-oob"); got != 2 {
1418 t.Fatalf("counts poll replaced %d elements, want 2:\n%s", got, body)
1419 }
1420
1421 // A repository the reader cannot see answers like a missing one, so the poll
1422 // cannot be used to discover private repositories.
1423 if resp, _ := f.htmxGet(t, "/no-such-repo/counts", ""); resp.StatusCode != http.StatusNotFound {
1424 t.Fatalf("counts for an unknown repository = %d, want 404", resp.StatusCode)
1425 }
1426}
1427
1428// TestIssueListShowsActivityColumnsAndSortsByUpdated: the list is read for what
1429// moved, so the row carries the opening under the title, labels in their own
1430// column, people as people, and the time of the last edit or comment.
1431func TestIssueListShowsActivityColumnsAndSortsByUpdated(t *testing.T) {
1432 t.Parallel()
1433 f := newFixture(t)
1434 f.publish(f.repo)
1435 f.addWriter(t) // wilbur, so an assignee is a real profile link
1436 const olderID = "01M5JR2JK29KKMAA1T9MDNTPC1"
1437 const newerID = "01M5JR2JK29KKMAA1T9MDNTPC2"
1438 files := map[string]string{
1439 "issues/" + olderID + "/issue.md": issueFile("Older issue", "keenan", "open", "2026-10-20T10:00:00Z", []string{"bug"}, "body\n", "wilbur"),
1440 "issues/" + olderID + "/comments/01M5JR2JK29KKMAA1T9MDNTPK1.md": commentFile("keenan", "2026-10-21T10:00:00Z", "a note\n"),
1441 "issues/" + newerID + "/issue.md": issueFile("Newer issue", "keenan", "open", "2026-10-22T10:00:00Z", nil, "body\n"),
1442 }
1443 work := f.issuesRepo(t, files)
1444 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
1445 t.Fatalf("push issues: %v\n%s", err, errs)
1446 }
1447
1448 // Edit the older issue in a commit of its own, dated after the first push.
1449 // Creation order and the ULID both put the newer issue first, so only sorting
1450 // by activity can move the edited one to the top.
1451 edited := issueFile("Older issue, edited", "keenan", "open", "2026-10-20T10:00:00Z", []string{"bug"}, "body\n", "wilbur")
1452 if err := os.WriteFile(filepath.Join(work, "issues", olderID, "issue.md"), []byte(edited), 0o644); err != nil {
1453 t.Fatal(err)
1454 }
1455 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
1456 t.Fatalf("add the edit: %v: %s", err, errs)
1457 }
1458 later := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
1459 if _, errs, err := f.git(work, "commit", "-qm", "edit the older issue", "--date", later); err != nil {
1460 t.Fatalf("commit the edit: %v\n%s", err, errs)
1461 }
1462 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
1463 t.Fatalf("push the edit: %v\n%s", err, errs)
1464 }
1465
1466 code, body := f.get("/" + f.repo + "/issues")
1467 if code != http.StatusOK {
1468 t.Fatalf("issue list: %d\n%s", code, body)
1469 }
1470 for _, want := range []string{
1471 "<th>Status</th><th>Issue</th><th>Labels</th><th>Assignees</th><th>Updated</th>",
1472 `<span class="row-status open">`,
1473 `>Open</span>`,
1474 `<span>1 comment</span>`,
1475 `<span class="label">bug</span>`,
1476 `opened by <a class="user" href="/-/u/keenan">keenan</a>`,
1477 `<a class="user" href="/-/u/wilbur">wilbur</a>`,
1478 } {
1479 if !strings.Contains(body, want) {
1480 t.Fatalf("issue list is missing %q:\n%s", want, body)
1481 }
1482 }
1483 if strings.Contains(body, "<th>Age</th>") || strings.Contains(body, "<th>Author</th>") ||
1484 strings.Contains(body, "<th>Replies</th>") {
1485 t.Fatalf("issue list still has the replaced columns:\n%s", body)
1486 }
1487 editedAt, otherAt := strings.Index(body, "Older issue, edited"), strings.Index(body, "Newer issue")
1488 if editedAt < 0 || otherAt < 0 || editedAt > otherAt {
1489 t.Fatalf("issue list is not ordered by updated: edited at %d, other at %d\n%s", editedAt, otherAt, body)
1490 }
1491}
1492
1493// TestChangeListShowsOneStatus: the list answers "where is this change?" in one
1494// column, from the approvals and checks the repository already has, and carries
1495// the author and opening under the title like the issue list does.
1496func TestChangeListShowsOneStatus(t *testing.T) {
1497 t.Parallel()
1498 f := newFixture(t)
1499 f.addWriter(t) // wilbur, so the approved change is not approved by its author
1500 f.publish(f.repo)
1501 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
1502 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1503 t.Fatalf("main push: %v\n%s", err, errs)
1504 }
1505 m := mustRepo(t, f)
1506 a, _ := m.ACL()
1507 a.Protected = []string{"refs/heads/main"}
1508 if err := m.SetACL(a, "test"); err != nil {
1509 t.Fatal(err)
1510 }
1511
1512 first, firstHead := f.cloneWithChange(t, map[string]string{"a.txt": "1\n"}, "change one")
1513 if _, errs, err := f.git(first, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature-one"); err != nil {
1514 t.Fatalf("feature one push: %v\n%s", err, errs)
1515 }
1516 second, secondHead := f.cloneWithChange(t, map[string]string{"b.txt": "1\n"}, "change two")
1517 if _, errs, err := f.git(second, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature-two"); err != nil {
1518 t.Fatalf("feature two push: %v\n%s", err, errs)
1519 }
1520
1521 const reviewID = "01M5JR2JK29KKMAA1T9MDNTR02"
1522 const approvedID = "01M5JR2JK29KKMAA1T9MDNTR03"
1523 const mergedID = "01M5JR2JK29KKMAA1T9MDNTR04"
1524 const closedID = "01M5JR2JK29KKMAA1T9MDNTR05"
1525 const followingID = "01M5JR2JK29KKMAA1T9MDNTR06"
1526 meta := f.newMetaRepo(t)
1527 if err := meta.apply(map[string]string{
1528 "changes/" + reviewID + "/change.md": requestFileTitled("Needs a review", "main", firstHead, "keenan", "body\n"),
1529 "changes/" + approvedID + "/change.md": requestFileTitled("Approved change", "main", secondHead, "wilbur", "body\n"),
1530 "changes/" + approvedID + "/approvals/" + f.accountName("keenan") + ".md": approvalFile("main", secondHead),
1531 "changes/" + mergedID + "/change.md": requestFileMerged("Landed change", "main", secondHead, "wilbur", "keenan", nil),
1532 "changes/" + closedID + "/change.md": requestFileState("Dropped change", "main", firstHead, "wilbur", "closed", "body\n"),
1533 "changes/" + followingID + "/change.md": requestFileFollowing("Follows a branch", "main", firstHead, "feature-one", "keenan", "body\n"),
1534 }, "changes"); err != nil {
1535 t.Fatalf("changes push: %v", err)
1536 }
1537
1538 code, body := f.get("/" + f.repo + "/changes?state=all")
1539 if code != http.StatusOK {
1540 t.Fatalf("change list: %d\n%s", code, body)
1541 }
1542 for _, want := range []string{
1543 "<th>Status</th><th>Change</th><th>Base</th><th>Head</th><th>Updated</th>",
1544 `<code>main</code>`,
1545 `pinned to</span> <span class="sha"`,
1546 `title="the branch this request follows">feature-one</code>`,
1547 `class="row-status review"`,
1548 `title="0 of 1 required approvals."`,
1549 "Waiting for review",
1550 `class="row-status ready"`,
1551 "Ready to merge",
1552 `class="row-status merged"`,
1553 "Merged",
1554 `class="row-status closed"`,
1555 "Closed",
1556 `opened by <a class="user" href="/-/u/keenan">keenan</a>`,
1557 `opened by <a class="user" href="/-/u/wilbur">wilbur</a>`,
1558 } {
1559 if !strings.Contains(body, want) {
1560 t.Fatalf("change list is missing %q:\n%s", want, body)
1561 }
1562 }
1563 for _, gone := range []string{"<th>Author</th>", "<th>Approvals</th>", "<th>Age</th>", "<th>Replies</th>"} {
1564 if strings.Contains(body, gone) {
1565 t.Fatalf("change list still has %s:\n%s", gone, body)
1566 }
1567 }
1568}
1569