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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software: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/integration_test.go +79−10

@@ -872,6 +872,22 @@ func TestIssueCLIEndToEnd(t *testing.T) {
872 872 if len(cid) < 8 {
873 873 t.Fatalf("no comment id printed: %q", cmtOut)
874 874 }
875 replyOut, errs, err := run("issue", "comment", "-m", "A threaded answer.", "--reply-to", cid, short)
876 if err != nil {
877 t.Fatalf("reply: %v\n%s", err, errs)
878 }
879 replyID := strings.TrimSpace(replyOut)
880 if len(replyID) < 8 {
881 t.Fatalf("no reply id printed: %q", replyOut)
882 }
883 _, issuePage := f.get("/r/" + f.repo + "/issues/" + id)
884 if !strings.Contains(issuePage, `class="comment-replies"`) || !strings.Contains(issuePage, `id="c-`+replyID+`"`) ||
885 !strings.Contains(issuePage, "A threaded answer.") {
886 t.Fatalf("the CLI reply is not nested:\n%s", issuePage)
887 }
888 if _, errs, err := run("issue", "rm-comment", short, replyID); err != nil {
889 t.Fatalf("rm reply: %v\n%s", err, errs)
890 }
875 891 if _, errs, err := run("issue", "edit-comment", short, cid, "-m", "A corrected reply."); err != nil {
876 892 t.Fatalf("edit-comment: %v\n%s", err, errs)
877 893 }
@@ -895,6 +911,30 @@ func TestIssueCLIEndToEnd(t *testing.T) {
895 911 t.Fatal("the deleted comment is still rendered")
896 912 }
897 913
914 // A parent can be deleted while its replies remain. The parent becomes a
915 // tombstone, so the replies keep the thread they answer.
916 rootOut, errs, err := run("issue", "comment", "-m", "Tombstone root.", short)
917 if err != nil {
918 t.Fatalf("tombstone root: %v\n%s", err, errs)
919 }
920 tombstoneRoot := strings.TrimSpace(rootOut)
921 replyOut, errs, err = run("issue", "comment", "-m", "Still here.", "--reply-to", tombstoneRoot, short)
922 if err != nil {
923 t.Fatalf("tombstone reply: %v\n%s", err, errs)
924 }
925 tombstoneReply := strings.TrimSpace(replyOut)
926 if _, errs, err := run("issue", "rm-comment", short, tombstoneRoot); err != nil {
927 t.Fatalf("delete parent: %v\n%s", err, errs)
928 }
929 _, body = f.get("/r/" + f.repo + "/issues/" + id)
930 if !strings.Contains(body, "Comment deleted") || !strings.Contains(body, `id="c-`+tombstoneReply+`"`) ||
931 !strings.Contains(body, "Still here.") || !strings.Contains(body, "a deleted comment") {
932 t.Fatalf("the deleted parent left no tombstone for its reply:\n%s", body)
933 }
934 if _, errs, err := run("issue", "rm-comment", short, tombstoneReply); err != nil {
935 t.Fatalf("rm tombstone reply: %v\n%s", err, errs)
936 }
937
898 938 if _, errs, err := run("issue", "close", short); err != nil {
899 939 t.Fatalf("close: %v\n%s", err, errs)
900 940 }
@@ -1307,6 +1347,19 @@ func TestRequestDiscussionIsPushedAndOwned(t *testing.T) {
1307 1347 !strings.Contains(out, "--- "+cid+" keenan") {
1308 1348 t.Fatalf("request show does not print the thread: %v\n%s", err, out)
1309 1349 }
1350 replyOut, errs, err := f.cli(clone, "change", "comment", reqID, "--reply-to", cid, "-m", "A threaded reply.")
1351 if err != nil {
1352 t.Fatalf("threaded request comment: %v\n%s", err, errs)
1353 }
1354 replyID := strings.TrimSpace(replyOut)
1355 if len(replyID) != 26 {
1356 t.Fatalf("no reply id printed: %q", replyOut)
1357 }
1358 _, body = f.get("/r/" + f.repo + "/requests/" + reqID)
1359 if !strings.Contains(body, `class="comment-replies"`) || !strings.Contains(body, `id="c-`+replyID+`"`) ||
1360 !strings.Contains(body, "A threaded reply.") {
1361 t.Fatalf("the CLI request reply is not nested:\n%s", body)
1362 }
1310 1363
1311 1364 commentPath := func(id string) string { return "requests/" + reqID + "/comments/" + id + ".md" }
1312 1365 const created = "2026-10-22T23:00:00Z"
@@ -1347,6 +1400,15 @@ func TestRequestDiscussionIsPushedAndOwned(t *testing.T) {
1347 1400 if _, _, err := f.cli(clone, "change", "comment", reqID, "-m", " "); err == nil {
1348 1401 t.Fatal("an empty comment was accepted")
1349 1402 }
1403
1404 // Deleting the parent leaves a tombstone so the reply keeps its thread.
1405 if _, errs, err := f.cli(clone, "change", "rm-comment", reqID, cid); err != nil {
1406 t.Fatalf("delete parent comment: %v\n%s", err, errs)
1407 }
1408 _, body = f.get("/r/" + f.repo + "/requests/" + reqID)
1409 if !strings.Contains(body, "Comment deleted") || !strings.Contains(body, "A threaded reply.") {
1410 t.Fatalf("the deleted request parent left no tombstone:\n%s", body)
1411 }
1350 1412 }
1351 1413
1352 1414 // TestReviewCommentAnchorsALine: a review comment can name the line it is about,
@@ -1397,13 +1459,18 @@ func TestReviewCommentAnchorsALine(t *testing.T) {
1397 1459 t.Fatalf("the anchor is missing %q:\n%s", want, got)
1398 1460 }
1399 1461 }
1400 // The page puts a marker on that line and labels the card with where it points.
1401 _, body := f.get("/r/" + f.repo + "/requests/" + reqID)
1402 if !strings.Contains(body, "keenan commented") || !strings.Contains(body, "#c-"+newCm) {
1403 t.Fatalf("the diff has no marker for the comment:\n%s", body)
1462 // The Files tab puts the comment beneath that line; the conversation still
1463 // labels the card with where it points.
1464 _, body := f.get("/r/" + f.repo + "/requests/" + reqID + "/files/code.txt")
1465 if !strings.Contains(body, `id="inline-c-`+newCm+`"`) || !strings.Contains(body, "#c-"+newCm) ||
1466 !strings.Contains(body, "why FEATURE?") {
1467 t.Fatalf("the diff has no inline note for the comment:\n%s", body)
1404 1468 }
1405 if !strings.Contains(body, "code.txt:2") || !strings.Contains(body, "(new side)") {
1406 t.Fatalf("the comment card does not name its line:\n%s", body)
1469 _, body = f.get("/r/" + f.repo + "/requests/" + reqID)
1470 if !strings.Contains(body, "code.txt:2") || !strings.Contains(body, "(new side)") ||
1471 !strings.Contains(body, `class="comment-snippet"`) || !strings.Contains(body, `class="snippet-line anchor"`) ||
1472 !strings.Contains(body, "View in Files") {
1473 t.Fatalf("the comment card does not show its code:\n%s", body)
1407 1474 }
1408 1475
1409 1476 // Old side: a line of the file as it stood at the merge base.
@@ -1456,11 +1523,13 @@ func TestReviewCommentAnchorsALine(t *testing.T) {
1456 1523 t.Fatalf("request update: %v\n%s", err, errs)
1457 1524 }
1458 1525 _, body = f.get("/r/" + f.repo + "/requests/" + reqID)
1459 if !strings.Contains(body, "the file has changed since this comment") {
1460 t.Fatalf("a moved file did not make the anchored comment stale:\n%s", body)
1526 if !strings.Contains(body, "the file has changed since this comment") ||
1527 !strings.Contains(body, `class="snippet-line anchor"`) || !strings.Contains(body, "original line") {
1528 t.Fatalf("a moved file did not keep the original snippet for the stale comment:\n%s", body)
1461 1529 }
1462 if strings.Contains(body, "#c-"+newCm+`">keenan commented`) {
1463 t.Fatalf("the stale comment kept its marker:\n%s", body)
1530 _, body = f.get("/r/" + f.repo + "/requests/" + reqID + "/files/code.txt")
1531 if strings.Contains(body, `id="inline-c-`+newCm+`"`) {
1532 t.Fatalf("the stale comment kept its inline note:\n%s", body)
1464 1533 }
1465 1534 _ = next
1466 1535 }