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 | } | |