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.go +56

@@ -62,6 +62,8 @@ var (
62 62 // typo rather than a new kind of label. Kept in step with
63 63 // identity.ValidateName by TestAssigneeRuleMatchesAccountNames.
64 64 assigneeRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`)
65 // blobRE is a full git object id, which is what an anchor records.
66 blobRE = regexp.MustCompile(`^[0-9a-f]{40}$`)
65 67 )
66 68
67 69 type Issue struct {
@@ -82,6 +84,26 @@ type Comment struct {
82 84 Author string
83 85 Created time.Time
84 86 Body string
87 // Anchor, when set, says which line of which file the comment is about. It is
88 // only meaningful on a review thread — the issue page ignores it — but it is
89 // part of the comment's file so that a comment and its subject travel together
90 // and one reader covers both.
91 Anchor *Anchor
92 }
93
94 // Anchor points a comment at one line of one file, as that file stood when the
95 // comment was written. Blob is the id of the content reviewed, which is what makes
96 // "has this changed since?" answerable without a second index: the same file at a
97 // later head has the same blob id, so the comment stays current while the file is
98 // untouched and is marked stale the moment it changes — rather than sliding to a
99 // line that now says something else.
100 type Anchor struct {
101 Path string
102 // Side is "new" for the head's copy of the file, "old" for the merge-base's:
103 // commenting on a deleted line is the point of reviewing a deletion.
104 Side string
105 Line int
106 Blob string
85 107 }
86 108
87 109 func (i *Issue) Open() bool { return i.State == "open" }
@@ -270,6 +292,10 @@ func ParseComment(id, raw, path string) (Comment, string) {
270 292 var m struct {
271 293 Author string `yaml:"author"`
272 294 Created string `yaml:"created"`
295 Path string `yaml:"path"`
296 Side string `yaml:"side"`
297 Line int `yaml:"line"`
298 Blob string `yaml:"blob"`
273 299 }
274 300 if err := frontmatter.Decode(head, &m); err != nil {
275 301 return c, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err)
@@ -287,9 +313,39 @@ func ParseComment(id, raw, path string) (Comment, string) {
287 313 }
288 314 c.Created = t.UTC()
289 315 c.Body = strings.TrimLeft(body, "\n")
316 if prob := applyAnchor(&c, m.Path, m.Side, m.Line, m.Blob, path); prob != "" {
317 return c, prob
318 }
290 319 return c, ""
291 320 }
292 321
322 // applyAnchor validates the four anchor fields as a set: an anchor is all of them
323 // or none, and a half-written one is a mistake rather than a comment on line zero.
324 func applyAnchor(c *Comment, path, side string, line int, blob, file string) string {
325 if path == "" {
326 if side != "" || line != 0 || blob != "" {
327 return fmt.Sprintf("%s: an anchor needs a path (path, side, line and blob go together)", file)
328 }
329 return ""
330 }
331 if strings.HasPrefix(path, "/") || strings.Contains(path, "..") || path != strings.TrimSpace(path) {
332 return fmt.Sprintf("%s: anchor path %q must be relative to the repository root", file, path)
333 }
334 switch side {
335 case "new", "old":
336 default:
337 return fmt.Sprintf("%s: anchor side must be \"new\" (the head) or \"old\" (the merge base), got %q", file, side)
338 }
339 if line < 1 {
340 return fmt.Sprintf("%s: anchor line must be 1 or more, got %d", file, line)
341 }
342 if !blobRE.MatchString(blob) {
343 return fmt.Sprintf("%s: anchor blob must be the full 40-character blob id of the content reviewed", file)
344 }
345 c.Anchor = &Anchor{Path: path, Side: side, Line: line, Blob: blob}
346 return ""
347 }
348
293 349 // ValidationError carries every problem found in one tree, so a push is rejected
294 350 // with the whole list rather than one error per attempt.
295 351 type ValidationError struct {