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 change review and threaded discussions

f6f4b1db24292d1df877680714952a9c568e2b78 · Keenan Nemetz · 15d ago · parent 564acd4b

Build out change review and threaded discussions

- Add Conversation, Files and Runs tabs to change requests. Files uses the
  code-browser shape with a changed-file tree and one selected-file diff;
  Runs lists the head commit's workflow results.
- Show anchored review comments beneath the line, with a collapsed excerpt
  read from the exact blob that was reviewed, and return posted anchors to
  their line note.
- Add comment threads: any comment can be replied to, the UI flattens the
  descendants under one root, reply context links back to the parent, and
  reply targets feed a single composer at the foot of the discussion.
- Collapse reply groups while keeping the root visible, and scroll to the
  composer when Reply is selected.
- Add comment context menus with Reply, Copy text, Copy link, Edit and
  Delete. Add web edit/delete routes with author or admin checks; deletion
  leaves a tombstone so replies keep their thread.
- Support --reply-to in the issue and change CLI commands, and treat a
  missing parent as a tombstone rather than an invalid tree.
- Fix the diff comment gutter, action button colours, reply labels, and
  keep the Files tree and diff pane aligned.

Update metadata validation, web templates, CSS, JavaScript, tests, and the
CLI/change-request/issue docs.

server/issues_changes_test.go +277−9

@@ -40,7 +40,7 @@ func TestIssueCanBeClosedAndReopenedInTheBrowser(t *testing.T) {
40 40 t.Fatalf("close issue: %d\n%s", resp.StatusCode, body)
41 41 }
42 42 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie)
43 if !strings.Contains(page, "Reopen issue") || !strings.Contains(page, `class="state closed"`) {
43 if !strings.Contains(page, "Reopen</button>") || !strings.Contains(page, `class="state closed"`) {
44 44 t.Fatalf("closed issue does not offer a reopen:\n%s", page)
45 45 }
46 46 // The default list is open issues; the closed one is behind the All filter.
@@ -57,7 +57,8 @@ func TestIssueCanBeClosedAndReopenedInTheBrowser(t *testing.T) {
57 57 if resp.StatusCode != http.StatusSeeOther {
58 58 t.Fatalf("reopen issue: %d\n%s", resp.StatusCode, body)
59 59 }
60 if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie); !strings.Contains(page, "Close issue") {
60 if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie); !strings.Contains(page, "Close</button>") ||
61 !strings.Contains(page, `class="danger"`) {
61 62 t.Fatalf("reopened issue does not offer a close:\n%s", page)
62 63 }
63 64 }
@@ -112,7 +113,7 @@ func TestChangeCanBeDiscussedAndClosedInTheBrowser(t *testing.T) {
112 113 if _, list := f.browserGet(t, "/r/"+f.repo+"/requests?state=all", cookie); !strings.Contains(list, "Browser change") {
113 114 t.Fatalf("closed change is missing from the All list:\n%s", list)
114 115 }
115 if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie); !strings.Contains(page, "Reopen change") {
116 if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie); !strings.Contains(page, "Reopen</button>") {
116 117 t.Fatalf("closed change does not offer a reopen:\n%s", page)
117 118 }
118 119
@@ -122,7 +123,8 @@ func TestChangeCanBeDiscussedAndClosedInTheBrowser(t *testing.T) {
122 123 if resp.StatusCode != http.StatusSeeOther {
123 124 t.Fatalf("reopen change: %d\n%s", resp.StatusCode, body)
124 125 }
125 if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie); !strings.Contains(page, "Close change") {
126 if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie); !strings.Contains(page, "Close</button>") ||
127 !strings.Contains(page, `class="danger"`) {
126 128 t.Fatalf("reopened change does not offer a close:\n%s", page)
127 129 }
128 130 }
@@ -470,13 +472,36 @@ func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) {
470 472 }
471 473 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/")
472 474
473 // The diff offers a per-line comment link on the added line.
474 _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie)
475 if !strings.Contains(page, "path=readme.md") || !strings.Contains(page, "line=2") {
476 t.Fatalf("diff has no line comment link:\n%s", page)
475 // The Files tab brings the changed file and its per-line comment button.
476 filesURL := "/r/" + f.repo + "/requests/" + id + "/files"
477 _, page := f.browserGet(t, filesURL, cookie)
478 if !strings.Contains(page, `id="review-tree"`) || !strings.Contains(page, `data-status="M"`) ||
479 !strings.Contains(page, filesURL+"/readme.md") {
480 t.Fatalf("files tab has no changed-file tree:\n%s", page)
481 }
482 if !strings.Contains(page, "path=readme.md") || !strings.Contains(page, "line=2") ||
483 !strings.Contains(page, "/comment-form?path=readme.md") {
484 t.Fatalf("diff has no line comment control:\n%s", page)
485 }
486
487 // A tree click returns only the review pane.
488 resp, piece := f.htmxGet(t, filesURL+"/readme.md", cookie)
489 if resp.StatusCode != http.StatusOK || strings.Contains(piece, "<!doctype html>") ||
490 !strings.Contains(piece, `id="review-main"`) || !strings.Contains(piece, "readme.md") {
491 t.Fatalf("review fragment = %d:\n%s", resp.StatusCode, piece)
477 492 }
478 493
479 // Following it opens the composer with the anchor pre-filled.
494 // HTMX inserts the composer under the line, with the anchor already filled in.
495 resp, form := f.htmxGet(t, "/r/"+f.repo+"/requests/"+id+"/comment-form?path=readme.md&line=2&side=new", cookie)
496 if resp.StatusCode != http.StatusOK || strings.Contains(form, "<!doctype html>") ||
497 !strings.Contains(form, `class="inline-comment"`) ||
498 !strings.Contains(form, `name="path" value="readme.md"`) ||
499 !strings.Contains(form, `name="line" value="2"`) ||
500 !strings.Contains(form, "data-inline-comment-cancel") {
501 t.Fatalf("inline composer = %d:\n%s", resp.StatusCode, form)
502 }
503
504 // The old full-page fallback still opens the composer with the anchor filled in.
480 505 _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+id+"?path=readme.md&line=2&side=new", cookie)
481 506 if !strings.Contains(page, "Commenting on") || !strings.Contains(page, `name="path" value="readme.md"`) ||
482 507 !strings.Contains(page, `name="line" value="2"`) {
@@ -503,4 +528,247 @@ func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) {
503 528 if a.Path != "readme.md" || a.Line != 2 || a.Side != "new" || a.Blob == "" {
504 529 t.Fatalf("anchor = %+v", a)
505 530 }
531
532 // The Files full page puts the comment beneath the line it belongs to.
533 _, page = f.browserGet(t, filesURL+"/readme.md", cookie)
534 if !strings.Contains(page, `id="inline-c-`+req.Comments[0].ID+`"`) ||
535 !strings.Contains(page, "This line needs a second look.") {
536 t.Fatalf("anchored comment is not rendered inline:\n%s", page)
537 }
538
539 // A normal form post from the Files tab returns to that file's line note.
540 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/comments", cookie, url.Values{
541 "csrf": {sess.CSRF}, "body": {"Another line note."},
542 "path": {"readme.md"}, "line": {"2"}, "side": {"new"},
543 })
544 if resp.StatusCode != http.StatusSeeOther {
545 t.Fatalf("inline comment post = %d:\n%s", resp.StatusCode, body)
546 }
547 if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, filesURL+"/readme.md#inline-c-") {
548 t.Fatalf("inline comment redirect = %q", loc)
549 }
550 }
551
552 func TestIssueAndChangeCommentsThreadOneLevel(t *testing.T) {
553 t.Parallel()
554 f := newFixture(t)
555 f.publish(f.repo)
556 sess, cookie := f.browserSession(t, f.ownerID)
557 commentID := func(resp *http.Response) string {
558 loc := resp.Header.Get("Location")
559 if i := strings.LastIndex(loc, "#c-"); i >= 0 {
560 return loc[i+3:]
561 }
562 return ""
563 }
564
565 // Issue: replies are rendered under their root, even when replying to a reply.
566 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
567 "csrf": {sess.CSRF}, "title": {"Threaded issue"}, "body": {"body\n"},
568 })
569 if resp.StatusCode != http.StatusSeeOther {
570 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
571 }
572 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
573 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
574 "csrf": {sess.CSRF}, "body": {"Root comment."},
575 })
576 rootID := commentID(resp)
577 if resp.StatusCode != http.StatusSeeOther || rootID == "" {
578 t.Fatalf("issue root comment: %d %q\n%s", resp.StatusCode, rootID, body)
579 }
580 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
581 "csrf": {sess.CSRF}, "body": {"Issue reply."}, "reply_to": {rootID},
582 })
583 replyID := commentID(resp)
584 if resp.StatusCode != http.StatusSeeOther || replyID == "" {
585 t.Fatalf("issue reply: %d %q\n%s", resp.StatusCode, replyID, body)
586 }
587 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie)
588 if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+replyID+`"`) ||
589 !strings.Contains(page, "Issue reply.") || !strings.Contains(page, "data-comment-menu") ||
590 !strings.Contains(page, `data-reply-to="`+replyID+`"`) ||
591 !strings.Contains(page, `class="reply-context"`) || !strings.Contains(page, `href="#c-`+rootID+`"`) {
592 t.Fatalf("issue reply is not nested:\n%s", page)
593 }
594 if !strings.Contains(page, `<div id="issue-thread">`) ||
595 !strings.Contains(page, `class="thread-replies"`) ||
596 strings.Contains(page, `class="discussion-panel"`) || strings.Contains(page, `class="comment-scroll"`) ||
597 strings.Count(page, `discussion-composer`) != 1 ||
598 !strings.Contains(page, "data-reply-input") || !strings.Contains(page, "Cancel reply") {
599 t.Fatalf("issue discussion is not a full thread with one composer:\n%s", page)
600 }
601 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
602 "csrf": {sess.CSRF}, "body": {"Reply to a reply."}, "reply_to": {replyID},
603 })
604 deeperID := commentID(resp)
605 if resp.StatusCode != http.StatusSeeOther || deeperID == "" {
606 t.Fatalf("reply to a reply: %d %q\n%s", resp.StatusCode, deeperID, body)
607 }
608 _, page = f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie)
609 if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+deeperID+`"`) ||
610 !strings.Contains(page, "Reply to a reply.") || !strings.Contains(page, `data-reply-to="`+deeperID+`"`) ||
611 !strings.Contains(page, `href="#c-`+replyID+`"`) {
612 t.Fatalf("a reply to a reply was not flattened under its root:\n%s", page)
613 }
614
615 // Change: the same flattening rule applies to the review discussion.
616 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
617 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
618 t.Fatalf("main push: %s", errs)
619 }
620 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
621 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
622 t.Fatalf("feature push: %s", errs)
623 }
624 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{
625 "csrf": {sess.CSRF}, "title": {"Threaded change"}, "body": {"body\n"},
626 "base": {"main"}, "head": {"feature"},
627 })
628 if resp.StatusCode != http.StatusSeeOther {
629 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
630 }
631 requestID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/")
632 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+requestID+"/comments", cookie, url.Values{
633 "csrf": {sess.CSRF}, "body": {"Change root."},
634 })
635 rootID = commentID(resp)
636 if resp.StatusCode != http.StatusSeeOther || rootID == "" {
637 t.Fatalf("change root comment: %d %q\n%s", resp.StatusCode, rootID, body)
638 }
639 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+requestID+"/comments", cookie, url.Values{
640 "csrf": {sess.CSRF}, "body": {"Change reply."}, "reply_to": {rootID},
641 })
642 replyID = commentID(resp)
643 if resp.StatusCode != http.StatusSeeOther || replyID == "" {
644 t.Fatalf("change reply: %d %q\n%s", resp.StatusCode, replyID, body)
645 }
646 _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+requestID, cookie)
647 if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+replyID+`"`) ||
648 !strings.Contains(page, "Change reply.") || !strings.Contains(page, `data-reply-to="`+replyID+`"`) ||
649 !strings.Contains(page, `class="reply-context"`) || !strings.Contains(page, `href="#c-`+rootID+`"`) ||
650 !strings.Contains(page, `<div id="request-discussion">`) ||
651 !strings.Contains(page, `class="thread-replies"`) ||
652 strings.Count(page, `discussion-composer`) != 1 {
653 t.Fatalf("change reply is not nested:\n%s", page)
654 }
655 }
656
657 func TestCommentEditAndDeleteFromTheWeb(t *testing.T) {
658 t.Parallel()
659 f := newFixture(t)
660 f.publish(f.repo)
661 f.addWriter(t)
662 sess, cookie := f.browserSession(t, f.ownerID)
663 commentID := func(resp *http.Response) string {
664 loc := resp.Header.Get("Location")
665 if i := strings.LastIndex(loc, "#c-"); i >= 0 {
666 return loc[i+3:]
667 }
668 return ""
669 }
670
671 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
672 "csrf": {sess.CSRF}, "title": {"Editable issue"}, "body": {"body\n"},
673 })
674 if resp.StatusCode != http.StatusSeeOther {
675 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
676 }
677 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
678 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
679 "csrf": {sess.CSRF}, "body": {"Original root."},
680 })
681 rootID := commentID(resp)
682 if resp.StatusCode != http.StatusSeeOther || rootID == "" {
683 t.Fatalf("root comment: %d %q\n%s", resp.StatusCode, rootID, body)
684 }
685 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
686 "csrf": {sess.CSRF}, "body": {"A surviving reply."}, "reply_to": {rootID},
687 })
688 if resp.StatusCode != http.StatusSeeOther {
689 t.Fatalf("reply: %d\n%s", resp.StatusCode, body)
690 }
691
692 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie)
693 for _, want := range []string{"data-comment-menu-toggle", "Copy text", "Copy link", "data-comment-edit", "Delete"} {
694 if !strings.Contains(page, want) {
695 t.Fatalf("comment menu is missing %q:\n%s", want, page)
696 }
697 }
698
699 // A maintainer who is not the author cannot rewrite or remove the comment.
700 writerSess, writerCookie := f.browserSession(t, f.writerID)
701 resp, _ = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/edit", writerCookie, url.Values{
702 "csrf": {writerSess.CSRF}, "body": {"Not mine."},
703 })
704 if resp.StatusCode != http.StatusForbidden {
705 t.Fatalf("foreign edit = %d, want 403", resp.StatusCode)
706 }
707
708 resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/edit", cookie, url.Values{
709 "csrf": {sess.CSRF}, "body": {"Edited root."},
710 })
711 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-thread"`) ||
712 !strings.Contains(body, "Edited root.") || strings.Contains(body, "Original root.") {
713 t.Fatalf("edit response = %d:\n%s", resp.StatusCode, body)
714 }
715
716 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/delete", cookie, url.Values{
717 "csrf": {sess.CSRF},
718 })
719 if resp.StatusCode != http.StatusSeeOther {
720 t.Fatalf("delete = %d:\n%s", resp.StatusCode, body)
721 }
722 _, page = f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie)
723 if !strings.Contains(page, "Comment deleted") || !strings.Contains(page, "A surviving reply.") {
724 t.Fatalf("deleted root did not become a tombstone:\n%s", page)
725 }
726
727 // The same routes work for a change discussion.
728 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
729 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
730 t.Fatalf("main push: %s", errs)
731 }
732 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
733 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
734 t.Fatalf("feature push: %s", errs)
735 }
736 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{
737 "csrf": {sess.CSRF}, "title": {"Editable change"}, "body": {"body\n"},
738 "base": {"main"}, "head": {"feature"},
739 })
740 if resp.StatusCode != http.StatusSeeOther {
741 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
742 }
743 requestID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/")
744 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+requestID+"/comments", cookie, url.Values{
745 "csrf": {sess.CSRF}, "body": {"Original change root."},
746 })
747 rootID = commentID(resp)
748 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+requestID+"/comments", cookie, url.Values{
749 "csrf": {sess.CSRF}, "body": {"Change reply survives."}, "reply_to": {rootID},
750 })
751 if resp.StatusCode != http.StatusSeeOther {
752 t.Fatalf("change reply: %d\n%s", resp.StatusCode, body)
753 }
754 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+requestID+"/comments/"+rootID+"/edit", cookie, url.Values{
755 "csrf": {sess.CSRF}, "body": {"Edited change root."},
756 })
757 if resp.StatusCode != http.StatusSeeOther {
758 t.Fatalf("change edit: %d\n%s", resp.StatusCode, body)
759 }
760 _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+requestID, cookie)
761 if !strings.Contains(page, "Edited change root.") || strings.Contains(page, "Original change root.") {
762 t.Fatalf("change edit did not replace the comment:\n%s", page)
763 }
764 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+requestID+"/comments/"+rootID+"/delete", cookie, url.Values{
765 "csrf": {sess.CSRF},
766 })
767 if resp.StatusCode != http.StatusSeeOther {
768 t.Fatalf("change delete: %d\n%s", resp.StatusCode, body)
769 }
770 _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+requestID, cookie)
771 if !strings.Contains(page, "Comment deleted") || !strings.Contains(page, "Change reply survives.") {
772 t.Fatalf("deleted change root did not become a tombstone:\n%s", page)
773 }
506 774 }