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.

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 }