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.
Phase 3: a review comment points at a line, and stays glued to it
9cc2f08757a36a49c7baa3ce502f5934341e7a5b · Keenan Nemetz · 17d ago · parent 7c6e52e7
Phase 3: a review comment points at a line, and stays glued to it A review thread can now say where it applies. A comment may carry an anchor — path, side, line and blob — on the same file it already lives in, written by the same reader and writer as before (four extra frontmatter lines, refused as a half-written set if any is missing). The interesting part is what it anchors on. A line number is not an identity: the file moves and the number points at different code. A commit id is too coarse: any unrelated change to the branch would invalidate every anchor. The **blob** of the content reviewed is content-addressed, so a comment is glued to exactly what was read: it stays current for as long as that file is unchanged — the same file at a later head has the same blob id — and becomes stale the moment it changes, rather than sliding to a line that now says something else. That is what "survive a rewrite" has to mean, and the page enforces it by resolving the blob at the head (or at the merge base, for `side: old`) and comparing. Which is also why nothing is verified at the push boundary. An anchor is a display hint, not an authorization: an anchor that lies can only make itself stale, and a rule in pre-receive would have to re-check every old anchor against a head that has since moved — rejecting pushes for comments nobody touched. `vole request comment <id> -m … --path f --line n [--side old]` computes the blob from the clone, refuses a line past the end of the file, and says `git fetch` rather than writing an anchor it cannot compute. On the page, a current anchor puts a marker under the line that links to the comment, and the card says `path:line (side)`; a stale one keeps its place in the thread with "the file has changed since this comment" and loses the marker. `side: old` covers reviewing a deletion, since that line exists only at the merge base. Verified: TestAnchorRoundTrips and TestAnchorMustBeWhole (blob is full-length, side is new or old, line is 1 or more, path stays inside the repository, and half an anchor is a refusal naming the file); and end to end through the binary, TestReviewCommentAnchorsALine — a new-side comment whose blob matches `rev-parse <head>:code.txt` on the server, the marker and the labelled card on the page, an old-side comment whose blob matches the merge base, a line past the end refused with the branch unmoved, and then a new head that changes the file, after which the new-side comment is labelled stale and loses its marker while the old-side one does not. scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).
internal/issues/issues_test.go +63
| @@ -203,6 +203,69 @@ func TestAssigneeRoundTrip(t *testing.T) { | |||
| 203 | 203 | } | |
| 204 | 204 | } | |
| 205 | 205 | ||
| 206 | // TestAnchorRoundTrips: an anchored comment is four extra fields on the same file | ||
| 207 | // shape, and the writer and reader have to agree about all of them. | ||
| 208 | func TestAnchorRoundTrips(t *testing.T) { | ||
| 209 | c := &Comment{ | ||
| 210 | ID: idB, | ||
| 211 | Author: "keenan", | ||
| 212 | Created: time.Date(2026, 10, 23, 9, 0, 0, 0, time.UTC), | ||
| 213 | Body: "this line is wrong\n", | ||
| 214 | Anchor: &Anchor{ | ||
| 215 | Path: "internal/hook/hook.go", | ||
| 216 | Side: "new", | ||
| 217 | Line: 412, | ||
| 218 | Blob: "0123456789abcdef0123456789abcdef01234567", | ||
| 219 | }, | ||
| 220 | } | ||
| 221 | raw, err := RenderComment(c) | ||
| 222 | if err != nil { | ||
| 223 | t.Fatal(err) | ||
| 224 | } | ||
| 225 | got, prob := ParseComment(idB, string(raw), "issues/"+idA+"/comments/"+idB+".md") | ||
| 226 | if prob != "" { | ||
| 227 | t.Fatalf("parse: %s\n%s", prob, raw) | ||
| 228 | } | ||
| 229 | if got.Anchor == nil { | ||
| 230 | t.Fatalf("the anchor was lost:\n%s", raw) | ||
| 231 | } | ||
| 232 | if *got.Anchor != *c.Anchor { | ||
| 233 | t.Fatalf("anchor = %+v, want %+v", *got.Anchor, *c.Anchor) | ||
| 234 | } | ||
| 235 | // A comment with no anchor stays a comment with no anchor. | ||
| 236 | plain, err := RenderComment(&Comment{ID: idC, Author: "keenan", Created: c.Created, Body: "hi\n"}) | ||
| 237 | if err != nil { | ||
| 238 | t.Fatal(err) | ||
| 239 | } | ||
| 240 | if strings.Contains(string(plain), "path:") { | ||
| 241 | t.Fatalf("an anchorless comment grew an anchor:\n%s", plain) | ||
| 242 | } | ||
| 243 | } | ||
| 244 | |||
| 245 | // TestAnchorMustBeWhole: half an anchor, or one pointing outside the repository, | ||
| 246 | // is a mistake that would render as a link to nowhere. | ||
| 247 | func TestAnchorMustBeWhole(t *testing.T) { | ||
| 248 | const blob = "0123456789abcdef0123456789abcdef01234567" | ||
| 249 | cases := []struct{ name, head string }{ | ||
| 250 | {"line alone", "author: keenan\ncreated: 2026-10-23T09:00:00Z\nline: 4\n"}, | ||
| 251 | {"path without line", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: a.go\nside: new\n"}, | ||
| 252 | {"bad side", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: a.go\nside: middle\nline: 4\nblob: " + blob + "\n"}, | ||
| 253 | {"line zero", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: a.go\nside: new\nline: 0\nblob: " + blob + "\n"}, | ||
| 254 | {"short blob", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: a.go\nside: new\nline: 4\nblob: abc123\n"}, | ||
| 255 | {"absolute path", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: /etc/passwd\nside: new\nline: 4\nblob: " + blob + "\n"}, | ||
| 256 | {"escaping path", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: ../secret\nside: new\nline: 4\nblob: " + blob + "\n"}, | ||
| 257 | } | ||
| 258 | for _, c := range cases { | ||
| 259 | _, prob := ParseComment(idB, "---\n"+c.head+"---\nbody\n", "requests/"+idA+"/comments/"+idB+".md") | ||
| 260 | if prob == "" { | ||
| 261 | t.Fatalf("%s was accepted", c.name) | ||
| 262 | } | ||
| 263 | if !strings.Contains(prob, "comments/"+idB+".md") { | ||
| 264 | t.Fatalf("%s: refusal does not name the file: %s", c.name, prob) | ||
| 265 | } | ||
| 266 | } | ||
| 267 | } | ||
| 268 | |||
| 206 | 269 | // TestAuthorOfReadsAnyMetadataFile: the hook asks this before letting anybody | |
| 207 | 270 | // change a comment, so it has to read an author out of a file whose other fields | |
| 208 | 271 | // belong to a different entity — an issue file's title, state and labels are not | |