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.

Build out the forge web UI

f09ef39efe44de693748053a7b596ef328fcf06b · Keenan Nemetz · 15d ago · parent 70b70264

Build out the forge web UI

Turn the web surface into a compact, server-rendered forge with HTMX as the
progressive dynamic layer.

- Split templates and assets into embedded files; add a tokenised dark theme,
  Lucide icons, and vendored HTMX.
- Add the repository Overview page and a separate chronological Activity feed
  with type filters and HTMX fragment updates.
- Finish the issue and change workflows: create pages, comments, close/reopen,
  labels and assignees, approval, discussion, line-anchored review comments,
  and HTMX list filters.
- Add syntax-highlighted blobs, contributor/statistics derivation, branch-aware
  code browsing, and a repository Runs list.
- Add a loopback-only development login for local write testing, with explicit
  configuration and listener checks.
- Extend tests for the new pages, fragments, labels, assignees, anchors, and
  development login.

server/issues_changes_test.go +524

@@ -0,0 +1,524 @@
1 package 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
8 import (
9 "io"
10 "net/http"
11 "net/url"
12 "strings"
13 "testing"
14
15 "github.com/rodent-software/vole/forge/index"
16 "github.com/rodent-software/vole/metadata"
17 )
18
19 func TestIssueCanBeClosedAndReopenedInTheBrowser(t *testing.T) {
20 t.Parallel()
21 f := newFixture(t)
22 f.publish(f.repo)
23 sess, cookie := f.browserSession(t, f.ownerID)
24
25 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
26 "csrf": {sess.CSRF}, "title": {"Close me from the web"}, "body": {"body\n"},
27 })
28 if resp.StatusCode != http.StatusSeeOther {
29 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
30 }
31 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
32 if id == "" {
33 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
34 }
35
36 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{
37 "csrf": {sess.CSRF}, "state": {"closed"},
38 })
39 if resp.StatusCode != http.StatusSeeOther {
40 t.Fatalf("close issue: %d\n%s", resp.StatusCode, body)
41 }
42 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie)
43 if !strings.Contains(page, "Reopen issue") || !strings.Contains(page, `class="state closed"`) {
44 t.Fatalf("closed issue does not offer a reopen:\n%s", page)
45 }
46 // The default list is open issues; the closed one is behind the All filter.
47 if _, list := f.browserGet(t, "/r/"+f.repo+"/issues", cookie); strings.Contains(list, "Close me from the web") {
48 t.Fatalf("closed issue is still in the open list:\n%s", list)
49 }
50 if _, list := f.browserGet(t, "/r/"+f.repo+"/issues?state=all", cookie); !strings.Contains(list, "Close me from the web") {
51 t.Fatalf("closed issue is missing from the All list:\n%s", list)
52 }
53
54 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{
55 "csrf": {sess.CSRF}, "state": {"open"},
56 })
57 if resp.StatusCode != http.StatusSeeOther {
58 t.Fatalf("reopen issue: %d\n%s", resp.StatusCode, body)
59 }
60 if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie); !strings.Contains(page, "Close issue") {
61 t.Fatalf("reopened issue does not offer a close:\n%s", page)
62 }
63 }
64
65 func TestChangeCanBeDiscussedAndClosedInTheBrowser(t *testing.T) {
66 t.Parallel()
67 f := newFixture(t)
68 f.publish(f.repo)
69
70 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
71 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
72 t.Fatalf("main push: %s", errs)
73 }
74 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
75 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
76 t.Fatalf("feature push: %s", errs)
77 }
78
79 sess, cookie := f.browserSession(t, f.ownerID)
80 resp, body := f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{
81 "csrf": {sess.CSRF}, "title": {"Browser change"}, "body": {"please review\n"},
82 "base": {"main"}, "head": {"feature"},
83 })
84 if resp.StatusCode != http.StatusSeeOther {
85 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
86 }
87 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/")
88 if id == "" {
89 t.Fatalf("no change id in %q", resp.Header.Get("Location"))
90 }
91
92 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/comments", cookie, url.Values{
93 "csrf": {sess.CSRF}, "body": {"Looks good from the browser.\n"},
94 })
95 if resp.StatusCode != http.StatusSeeOther {
96 t.Fatalf("comment: %d\n%s", resp.StatusCode, body)
97 }
98 _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie)
99 if !strings.Contains(page, "Looks good from the browser.") {
100 t.Fatalf("the browser comment is not on the page:\n%s", page)
101 }
102
103 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/state", cookie, url.Values{
104 "csrf": {sess.CSRF}, "state": {"closed"},
105 })
106 if resp.StatusCode != http.StatusSeeOther {
107 t.Fatalf("close change: %d\n%s", resp.StatusCode, body)
108 }
109 if _, list := f.browserGet(t, "/r/"+f.repo+"/requests", cookie); strings.Contains(list, "Browser change") {
110 t.Fatalf("closed change is still in the open list:\n%s", list)
111 }
112 if _, list := f.browserGet(t, "/r/"+f.repo+"/requests?state=all", cookie); !strings.Contains(list, "Browser change") {
113 t.Fatalf("closed change is missing from the All list:\n%s", list)
114 }
115 if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie); !strings.Contains(page, "Reopen change") {
116 t.Fatalf("closed change does not offer a reopen:\n%s", page)
117 }
118
119 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/state", cookie, url.Values{
120 "csrf": {sess.CSRF}, "state": {"open"},
121 })
122 if resp.StatusCode != http.StatusSeeOther {
123 t.Fatalf("reopen change: %d\n%s", resp.StatusCode, body)
124 }
125 if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie); !strings.Contains(page, "Close change") {
126 t.Fatalf("reopened change does not offer a close:\n%s", page)
127 }
128 }
129
130 func TestNewIssueAndChangePages(t *testing.T) {
131 t.Parallel()
132 f := newFixture(t)
133 f.publish(f.repo)
134 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
135 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
136 t.Fatalf("main push: %s", errs)
137 }
138 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
139 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
140 t.Fatalf("feature push: %s", errs)
141 }
142 sess, cookie := f.browserSession(t, f.ownerID)
143
144 resp, body := f.browserGet(t, "/r/"+f.repo+"/issues/new", cookie)
145 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/r/`+f.repo+`/issues"`) || !strings.Contains(body, "Open issue") {
146 t.Fatalf("new issue page = %d:\n%s", resp.StatusCode, body)
147 }
148 // The CSRF token is on the form, not in a popover that has to be opened first.
149 if !strings.Contains(body, sess.CSRF) {
150 t.Fatal("new issue page has no CSRF token")
151 }
152
153 resp, body = f.browserGet(t, "/r/"+f.repo+"/requests/new", cookie)
154 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/r/`+f.repo+`/requests"`) ||
155 !strings.Contains(body, "feature") || !strings.Contains(body, "Open change request") {
156 t.Fatalf("new change page = %d:\n%s", resp.StatusCode, body)
157 }
158
159 // The list page links to the pages instead of embedding the forms.
160 if _, page := f.browserGet(t, "/r/"+f.repo+"/issues", cookie); !strings.Contains(page, "/issues/new") {
161 t.Fatal("issues list does not link to the new issue page")
162 }
163 if _, page := f.browserGet(t, "/r/"+f.repo+"/requests", cookie); !strings.Contains(page, "/requests/new") {
164 t.Fatal("changes list does not link to the new change page")
165 }
166 }
167
168 func TestIssueLabelsCanBeSetAndEditedInTheBrowser(t *testing.T) {
169 t.Parallel()
170 f := newFixture(t)
171 f.publish(f.repo)
172 sess, cookie := f.browserSession(t, f.ownerID)
173
174 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
175 "csrf": {sess.CSRF}, "title": {"Labels from the browser"}, "body": {"body\n"},
176 "label": {"bug", "web"}, "labels": {"perf, docs"},
177 })
178 if resp.StatusCode != http.StatusSeeOther {
179 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
180 }
181 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
182 if id == "" {
183 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
184 }
185 iss, err := index.LoadIssue(mustRepo(t, f), id)
186 if err != nil {
187 t.Fatal(err)
188 }
189 if got := strings.Join(iss.Labels, ","); got != "bug,docs,perf,web" {
190 t.Fatalf("created labels = %q", got)
191 }
192
193 // The detail page seeds the token input with the current labels and offers a
194 // save form. The browser turns that value into chips.
195 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie)
196 for _, want := range []string{`hx-trigger="token-change"`, `value="bug, docs, perf, web"`, `data-label-input`} {
197 if !strings.Contains(page, want) {
198 t.Fatalf("issue page does not show %q:\n%s", want, page)
199 }
200 }
201 // A new issue page offers the labels already used in the repository.
202 if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/new", cookie); !strings.Contains(page, `data-label-option="bug"`) {
203 t.Fatalf("new issue page has no existing label picker:\n%s", page)
204 }
205
206 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+id+"/labels", cookie, url.Values{
207 "csrf": {sess.CSRF}, "label": {"bug"}, "labels": {"ux"},
208 })
209 if resp.StatusCode != http.StatusSeeOther {
210 t.Fatalf("edit labels: %d\n%s", resp.StatusCode, body)
211 }
212 iss, err = index.LoadIssue(mustRepo(t, f), id)
213 if err != nil {
214 t.Fatal(err)
215 }
216 if got := strings.Join(iss.Labels, ","); got != "bug,ux" {
217 t.Fatalf("edited labels = %q", got)
218 }
219 _, page = f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie)
220 if !strings.Contains(page, `value="bug, ux"`) || strings.Contains(page, `value="bug, docs, perf, web"`) {
221 t.Fatalf("edited labels are not reflected in the picker:\n%s", page)
222 }
223 }
224
225 func TestIssueAssigneesCanBeSetAndEditedInTheBrowser(t *testing.T) {
226 t.Parallel()
227 f := newFixture(t)
228 f.publish(f.repo)
229 f.addWriter(t) // wilbur: maintain access, so assignable
230 sess, cookie := f.browserSession(t, f.ownerID)
231
232 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
233 "csrf": {sess.CSRF}, "title": {"Assignees from the browser"}, "body": {"body\n"},
234 "assignee": {"wilbur"}, "assignees": {"keenan"},
235 })
236 if resp.StatusCode != http.StatusSeeOther {
237 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
238 }
239 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
240 if id == "" {
241 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
242 }
243 iss, err := index.LoadIssue(mustRepo(t, f), id)
244 if err != nil {
245 t.Fatal(err)
246 }
247 if got := strings.Join(iss.Assignees, ","); got != "keenan,wilbur" {
248 t.Fatalf("created assignees = %q", got)
249 }
250
251 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie)
252 for _, want := range []string{`hx-trigger="token-change"`, `value="keenan, wilbur"`, `data-label-item="assignee"`} {
253 if !strings.Contains(page, want) {
254 t.Fatalf("issue page does not show %q:\n%s", want, page)
255 }
256 }
257 if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/new", cookie); !strings.Contains(page, `data-label-option="wilbur"`) {
258 t.Fatalf("new issue page does not offer wilbur as an assignee:\n%s", page)
259 }
260
261 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+id+"/assignees", cookie, url.Values{
262 "csrf": {sess.CSRF}, "assignee": {"wilbur"},
263 })
264 if resp.StatusCode != http.StatusSeeOther {
265 t.Fatalf("edit assignees: %d\n%s", resp.StatusCode, body)
266 }
267 iss, err = index.LoadIssue(mustRepo(t, f), id)
268 if err != nil {
269 t.Fatal(err)
270 }
271 if got := strings.Join(iss.Assignees, ","); got != "wilbur" {
272 t.Fatalf("edited assignees = %q", got)
273 }
274 _, page = f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie)
275 if !strings.Contains(page, `value="wilbur"`) || strings.Contains(page, `value="keenan, wilbur"`) {
276 t.Fatalf("edited assignees are not reflected in the picker:\n%s", page)
277 }
278
279 // An assignee nobody can resolve is a typo, not a person.
280 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
281 "csrf": {sess.CSRF}, "title": {"Bad assignee"}, "body": {"body\n"},
282 "assignee": {"nobody"},
283 })
284 if resp.StatusCode != http.StatusBadRequest {
285 t.Fatalf("unknown assignee accepted: %d\n%s", resp.StatusCode, body)
286 }
287 }
288
289 func (f *fixture) htmxPost(t *testing.T, path, cookie string, form url.Values) (*http.Response, string) {
290 t.Helper()
291 req, err := http.NewRequest("POST", f.http.URL+path, strings.NewReader(form.Encode()))
292 if err != nil {
293 t.Fatal(err)
294 }
295 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
296 req.Header.Set("Cookie", cookie)
297 req.Header.Set("HX-Request", "true")
298 resp, err := noRedirectAuth.Do(req)
299 if err != nil {
300 t.Fatal(err)
301 }
302 defer resp.Body.Close()
303 body, _ := io.ReadAll(resp.Body)
304 return resp, string(body)
305 }
306
307 func TestIssueSidebarFragmentsUpdateWithHtmx(t *testing.T) {
308 t.Parallel()
309 f := newFixture(t)
310 f.publish(f.repo)
311 f.addWriter(t)
312 sess, cookie := f.browserSession(t, f.ownerID)
313
314 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
315 "csrf": {sess.CSRF}, "title": {"HTMX issue"}, "body": {"body\n"},
316 })
317 if resp.StatusCode != http.StatusSeeOther {
318 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
319 }
320 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
321
322 // Labels: the response is the fragment, not a page and not a redirect.
323 resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+id+"/labels", cookie, url.Values{
324 "csrf": {sess.CSRF}, "label": {"bug"}, "labels": {"ux"},
325 })
326 if resp.StatusCode != http.StatusOK {
327 t.Fatalf("htmx labels: %d\n%s", resp.StatusCode, body)
328 }
329 if strings.Contains(body, "<!doctype html>") || !strings.Contains(body, `id="issue-labels"`) ||
330 !strings.Contains(body, `value="bug, ux"`) {
331 t.Fatalf("labels response is not the expected fragment:\n%s", body)
332 }
333
334 // State: closing returns the state fragment with the reopen action.
335 resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{
336 "csrf": {sess.CSRF}, "state": {"closed"},
337 })
338 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-state"`) ||
339 !strings.Contains(body, "Reopen issue") {
340 t.Fatalf("state response is not the expected fragment (%d):\n%s", resp.StatusCode, body)
341 }
342
343 // Assignees: the replacement fragment seeds the token input and keeps the
344 // save control.
345 resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+id+"/assignees", cookie, url.Values{
346 "csrf": {sess.CSRF}, "assignee": {"wilbur"},
347 })
348 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-assignees"`) ||
349 !strings.Contains(body, `value="wilbur"`) || !strings.Contains(body, `hx-trigger="token-change"`) {
350 t.Fatalf("assignees response is not the expected fragment (%d):\n%s", resp.StatusCode, body)
351 }
352
353 // Comments: the thread fragment includes the new comment and the composer.
354 resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+id+"/comments", cookie, url.Values{
355 "csrf": {sess.CSRF}, "body": {"Posted over HTMX.\n"},
356 })
357 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-thread"`) ||
358 !strings.Contains(body, "Posted over HTMX.") {
359 t.Fatalf("comment response is not the expected fragment (%d):\n%s", resp.StatusCode, body)
360 }
361 }
362
363 func TestRequestSidebarAndDiscussionUpdateWithHtmx(t *testing.T) {
364 t.Parallel()
365 f := newFixture(t)
366 f.addWriter(t) // wilbur: maintain, and not the request author
367 f.publish(f.repo)
368
369 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
370 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
371 t.Fatalf("main push: %s", errs)
372 }
373 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
374 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
375 t.Fatalf("feature push: %s", errs)
376 }
377
378 ownerSess, ownerCookie := f.browserSession(t, f.ownerID)
379 resp, body := f.browserPost(t, "/r/"+f.repo+"/requests", ownerCookie, url.Values{
380 "csrf": {ownerSess.CSRF}, "title": {"HTMX change"}, "body": {"body\n"},
381 "base": {"main"}, "head": {"feature"},
382 })
383 if resp.StatusCode != http.StatusSeeOther {
384 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
385 }
386 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/")
387
388 // A maintainer approves; the sidebar comes back with the approval and progress.
389 writerSess, writerCookie := f.browserSession(t, f.writerID)
390 resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/approve", writerCookie, url.Values{
391 "csrf": {writerSess.CSRF},
392 })
393 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-sidebar"`) ||
394 !strings.Contains(body, "1 of 1 required approval(s).") {
395 t.Fatalf("approval response is not the expected sidebar (%d):\n%s", resp.StatusCode, body)
396 }
397
398 // Posting a comment replaces only the discussion fragment.
399 resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/comments", ownerCookie, url.Values{
400 "csrf": {ownerSess.CSRF}, "body": {"Review note over HTMX.\n"},
401 })
402 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-discussion"`) ||
403 !strings.Contains(body, "Review note over HTMX.") {
404 t.Fatalf("discussion response is not the expected fragment (%d):\n%s", resp.StatusCode, body)
405 }
406
407 // Closing swaps the sidebar and changes the action to reopen.
408 resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/state", ownerCookie, url.Values{
409 "csrf": {ownerSess.CSRF}, "state": {"closed"},
410 })
411 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-sidebar"`) ||
412 !strings.Contains(body, "Reopen change") {
413 t.Fatalf("state response is not the expected sidebar (%d):\n%s", resp.StatusCode, body)
414 }
415 }
416
417 func (f *fixture) htmxGet(t *testing.T, path, cookie string) (*http.Response, string) {
418 t.Helper()
419 req, err := http.NewRequest("GET", f.http.URL+path, nil)
420 if err != nil {
421 t.Fatal(err)
422 }
423 req.Header.Set("Cookie", cookie)
424 req.Header.Set("HX-Request", "true")
425 resp, err := noRedirectAuth.Do(req)
426 if err != nil {
427 t.Fatal(err)
428 }
429 defer resp.Body.Close()
430 body, _ := io.ReadAll(resp.Body)
431 return resp, string(body)
432 }
433
434 func TestIssueAndChangeListsReturnFragmentsForHtmx(t *testing.T) {
435 t.Parallel()
436 f := newFixture(t)
437 f.publish(f.repo)
438 files := map[string]string{
439 "issues/01M5JR2JK29KKMAA1T9MDNTPD1/issue.md": issueFile("Open thing", "keenan", "open", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
440 "issues/01M5JR2JK29KKMAA1T9MDNTPD2/issue.md": issueFile("Closed thing", "keenan", "closed", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
441 }
442 work := f.issuesRepo(t, files)
443 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
444 t.Fatalf("push: %s", errs)
445 }
446
447 resp, body := f.htmxGet(t, "/r/"+f.repo+"/issues?state=closed", "")
448 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-list"`) ||
449 strings.Contains(body, "<!doctype html>") {
450 t.Fatalf("issues fragment = %d:\n%s", resp.StatusCode, body)
451 }
452 if !strings.Contains(body, "Closed thing") || strings.Contains(body, "Open thing") {
453 t.Fatalf("issues fragment did not apply the state filter:\n%s", body)
454 }
455 if !strings.Contains(body, `hx-target="#issue-list"`) {
456 t.Fatal("issues fragment lost its HTMX target")
457 }
458
459 resp, body = f.htmxGet(t, "/r/"+f.repo+"/requests?state=all", "")
460 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="change-list"`) ||
461 strings.Contains(body, "<!doctype html>") {
462 t.Fatalf("changes fragment = %d:\n%s", resp.StatusCode, body)
463 }
464 if !strings.Contains(body, `hx-target="#change-list"`) {
465 t.Fatal("changes fragment lost its HTMX target")
466 }
467 }
468
469 func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) {
470 t.Parallel()
471 f := newFixture(t)
472 f.publish(f.repo)
473 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
474 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
475 t.Fatalf("main push: %s", errs)
476 }
477 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
478 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
479 t.Fatalf("feature push: %s", errs)
480 }
481 sess, cookie := f.browserSession(t, f.ownerID)
482 resp, body := f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{
483 "csrf": {sess.CSRF}, "title": {"Line comments"}, "body": {"body\n"},
484 "base": {"main"}, "head": {"feature"},
485 })
486 if resp.StatusCode != http.StatusSeeOther {
487 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
488 }
489 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/")
490
491 // The diff offers a per-line comment link on the added line.
492 _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie)
493 if !strings.Contains(page, "path=readme.md") || !strings.Contains(page, "line=2") {
494 t.Fatalf("diff has no line comment link:\n%s", page)
495 }
496
497 // Following it opens the composer with the anchor pre-filled.
498 _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+id+"?path=readme.md&line=2&side=new", cookie)
499 if !strings.Contains(page, "Commenting on") || !strings.Contains(page, `name="path" value="readme.md"`) ||
500 !strings.Contains(page, `name="line" value="2"`) {
501 t.Fatalf("anchored composer is not pre-filled:\n%s", page)
502 }
503
504 // Posting it through HTMX stores the anchor and returns the discussion.
505 resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/comments", cookie, url.Values{
506 "csrf": {sess.CSRF}, "body": {"This line needs a second look."},
507 "path": {"readme.md"}, "line": {"2"}, "side": {"new"},
508 })
509 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-discussion"`) ||
510 !strings.Contains(body, "This line needs a second look.") {
511 t.Fatalf("anchored comment response = %d:\n%s", resp.StatusCode, body)
512 }
513 req, err := metadata.GetRequest(mustRepo(t, f).Git(), id)
514 if err != nil {
515 t.Fatal(err)
516 }
517 if len(req.Comments) != 1 || req.Comments[0].Anchor == nil {
518 t.Fatalf("anchored comment was not stored: %+v", req.Comments)
519 }
520 a := req.Comments[0].Anchor
521 if a.Path != "readme.md" || a.Line != 2 || a.Side != "new" || a.Blob == "" {
522 t.Fatalf("anchor = %+v", a)
523 }
524 }