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.
metadata/requests_test.go +49
| @@ -9,6 +9,7 @@ package metadata | |||
| 9 | 9 | import ( | |
| 10 | 10 | "strings" | |
| 11 | 11 | "testing" | |
| 12 | "time" | ||
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | const ( | |
| @@ -131,3 +132,51 @@ func TestCommentPaths(t *testing.T) { | |||
| 131 | 132 | } | |
| 132 | 133 | } | |
| 133 | 134 | } | |
| 135 | |||
| 136 | func TestRepliesAllowAnyCommentAsParent(t *testing.T) { | ||
| 137 | t.Parallel() | ||
| 138 | const ( | ||
| 139 | rootID = "01M5JR2JK29KKMAA1T9MDNTRC1" | ||
| 140 | replyID = "01M5JR2JK29KKMAA1T9MDNTRC2" | ||
| 141 | deeperID = "01M5JR2JK29KKMAA1T9MDNTRC3" | ||
| 142 | ) | ||
| 143 | requestPath := RequestsDir + "/" + reqIDA + "/request.md" | ||
| 144 | commentPath := func(id string) string { | ||
| 145 | return RequestsDir + "/" + reqIDA + "/comments/" + id + ".md" | ||
| 146 | } | ||
| 147 | root := "---\nauthor: wilbur\ncreated: 2026-10-22T23:00:00Z\n---\nRoot.\n" | ||
| 148 | reply := "---\nauthor: keenan\ncreated: 2026-10-22T23:01:00Z\nreply_to: " + rootID + "\n---\nReply.\n" | ||
| 149 | deeper := "---\nauthor: wilbur\ncreated: 2026-10-22T23:02:00Z\nreply_to: " + replyID + "\n---\nDeeper.\n" | ||
| 150 | |||
| 151 | list, err := ParseRequests(map[string]string{ | ||
| 152 | requestPath: requestBody(headSHA), | ||
| 153 | commentPath(rootID): root, | ||
| 154 | commentPath(replyID): reply, | ||
| 155 | commentPath(deeperID): deeper, | ||
| 156 | }) | ||
| 157 | if err != nil { | ||
| 158 | t.Fatalf("nested replies: %v", err) | ||
| 159 | } | ||
| 160 | comments := list[0].Comments | ||
| 161 | if len(comments) != 3 || comments[1].ReplyTo != rootID || comments[2].ReplyTo != replyID { | ||
| 162 | t.Fatalf("replies were not read: %+v", comments) | ||
| 163 | } | ||
| 164 | |||
| 165 | raw, err := RenderComment(&Comment{ | ||
| 166 | ID: replyID, Author: "keenan", Created: time.Date(2026, 10, 22, 23, 1, 0, 0, time.UTC), | ||
| 167 | Body: "Reply.\n", ReplyTo: rootID, | ||
| 168 | }) | ||
| 169 | if err != nil || !strings.Contains(string(raw), "reply_to: "+rootID) { | ||
| 170 | t.Fatalf("render reply: %v\n%s", err, raw) | ||
| 171 | } | ||
| 172 | |||
| 173 | // A missing parent is not a parse error: the UI synthesizes a tombstone for | ||
| 174 | // it, which also keeps older metadata readable. | ||
| 175 | _, err = ParseRequests(map[string]string{ | ||
| 176 | requestPath: requestBody(headSHA), | ||
| 177 | commentPath(replyID): "---\nauthor: keenan\ncreated: 2026-10-22T23:01:00Z\nreply_to: 01M5JR2JK29KKMAA1T9MDNTRZZ\n---\nOrphan.\n", | ||
| 178 | }) | ||
| 179 | if err != nil { | ||
| 180 | t.Fatalf("orphan reply was rejected: %v", err) | ||
| 181 | } | ||
| 182 | } | ||