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 { | |