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.

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