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.

List a change's linked issues in a Details section

cad93cfe2b6d4ed4c3ed5a1431af4ecb194484ce · Keenan Nemetz · 10d ago · parent 5c22953e

List a change's linked issues in a Details section

The conversation sidebar showed linked issues as one comma-separated line, so there was no way to see what each link was or change it without the CLI. The Details section lists them with the same rows the overview uses for recent issues, and the change's author or a maintainer can link another issue or remove one while it is open. Both controls go through SetRequestCloses, so the browser and 'vole change update --closes' write the same metadata.

server/issues_changes_test.go +123

@@ -9,6 +9,7 @@ import (
9 9 "io"
10 10 "net/http"
11 11 "net/url"
12 "strconv"
12 13 "strings"
13 14 "testing"
14 15
@@ -288,6 +289,128 @@ func TestIssueAssigneesCanBeSetAndEditedInTheBrowser(t *testing.T) {
288 289 }
289 290 }
290 291
292 func TestChangeLinksIssuesFromTheDetailsSection(t *testing.T) {
293 t.Parallel()
294 f := newFixture(t)
295 f.publish(f.repo)
296
297 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
298 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
299 t.Fatalf("main push: %s", errs)
300 }
301 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
302 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
303 t.Fatalf("feature push: %s", errs)
304 }
305
306 sess, cookie := f.browserSession(t, f.ownerID)
307 createIssue := func(title string) string {
308 t.Helper()
309 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
310 "csrf": {sess.CSRF}, "title": {title}, "body": {"body\n"},
311 })
312 if resp.StatusCode != http.StatusSeeOther {
313 t.Fatalf("create issue %q: %d\n%s", title, resp.StatusCode, body)
314 }
315 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
316 if id == "" {
317 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
318 }
319 return id
320 }
321 first := createIssue("First linked issue")
322 second := createIssue("Second linked issue")
323
324 resp, body := f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{
325 "csrf": {sess.CSRF}, "title": {"Linked issues"}, "body": {"body\n"},
326 "base": {"main"}, "head": {"feature"}, "closes": {first},
327 })
328 if resp.StatusCode != http.StatusSeeOther {
329 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
330 }
331 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/")
332 if id == "" {
333 t.Fatalf("no change id in %q", resp.Header.Get("Location"))
334 }
335
336 // The Details section uses the overview's issue rows and offers the link
337 // controls instead of the old one-line "Closes ... when merged".
338 _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie)
339 for _, want := range []string{
340 `id="request-details"`, "First linked issue", `value="` + first + `"`,
341 `value="add"`, `value="remove"`,
342 } {
343 if !strings.Contains(page, want) {
344 t.Fatalf("the request page is missing %q:\n%s", want, page)
345 }
346 }
347 if strings.Contains(page, "when merged.") {
348 t.Fatalf("the old closes line is still on the page:\n%s", page)
349 }
350
351 // A number works in the link field, and the section comes back in place.
352 m := mustRepo(t, f)
353 nums, err := metadata.LoadNumbers(m.Git(), metadata.Ref)
354 if err != nil {
355 t.Fatal(err)
356 }
357 number, ok := nums.Of(second)
358 if !ok {
359 t.Fatalf("the second issue has no number")
360 }
361 resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/issues", cookie, url.Values{
362 "csrf": {sess.CSRF}, "action": {"add"}, "issue": {"#" + strconv.Itoa(number)},
363 })
364 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-details"`) ||
365 !strings.Contains(body, "Second linked issue") || !strings.Contains(body, "First linked issue") {
366 t.Fatalf("add issue response is not the expected section (%d):\n%s", resp.StatusCode, body)
367 }
368 if got, err := metadata.GetRequest(m.Git(), id); err != nil || len(got.Closes) != 2 {
369 t.Fatalf("linked issues = %v, %v; want both", got, err)
370 }
371
372 // Removing a link is the same write, and the row disappears.
373 resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/issues", cookie, url.Values{
374 "csrf": {sess.CSRF}, "action": {"remove"}, "issue": {first},
375 })
376 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-details"`) ||
377 strings.Contains(body, "First linked issue") || !strings.Contains(body, "Second linked issue") {
378 t.Fatalf("remove issue response is not the expected section (%d):\n%s", resp.StatusCode, body)
379 }
380 got, err := metadata.GetRequest(m.Git(), id)
381 if err != nil || len(got.Closes) != 1 || got.Closes[0] != second {
382 t.Fatalf("linked issues = %v, %v; want only %s", got, err, second)
383 }
384
385 // A duplicate says so beside the control instead of rewriting the list.
386 resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/issues", cookie, url.Values{
387 "csrf": {sess.CSRF}, "action": {"add"}, "issue": {second},
388 })
389 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "already linked") {
390 t.Fatalf("duplicate link response (%d):\n%s", resp.StatusCode, body)
391 }
392
393 // A closed request keeps its record but offers no edits, and the writer
394 // refuses one posted anyway.
395 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/state", cookie, url.Values{
396 "csrf": {sess.CSRF}, "state": {"closed"},
397 })
398 if resp.StatusCode != http.StatusSeeOther {
399 t.Fatalf("close change: %d\n%s", resp.StatusCode, body)
400 }
401 _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie)
402 if !strings.Contains(page, "Second linked issue") || strings.Contains(page, `value="remove"`) ||
403 strings.Contains(page, `value="add"`) {
404 t.Fatalf("a closed request does not show the right Details section:\n%s", page)
405 }
406 resp, _ = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/issues", cookie, url.Values{
407 "csrf": {sess.CSRF}, "action": {"remove"}, "issue": {second},
408 })
409 if resp.StatusCode != http.StatusForbidden {
410 t.Fatalf("a closed request changed its issues: status=%d", resp.StatusCode)
411 }
412 }
413
291 414 func (f *fixture) htmxPost(t *testing.T, path, cookie string, form url.Values) (*http.Response, string) {
292 415 t.Helper()
293 416 req, err := http.NewRequest("POST", f.http.URL+path, strings.NewReader(form.Encode()))