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/web/requests.go +68−1

@@ -132,13 +132,80 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) {
132 132 }
133 133 // The review thread. Comments arrive by push, so they cross the same markdown
134 134 // boundary every other pushed body does — sanitised, never rendered raw.
135 //
136 // An anchored comment names a line and the blob it saw. Whether it is still
137 // about that content is answered here rather than trusted from the file: one
138 // rev-parse per distinct (side, path) is a handful for a review thread, and it
139 // is exact where the diff's abbreviated ids would need prefix matching.
135 140 type renderedComment struct {
136 141 requests.Comment
137 142 Body template.HTML
143 // Current reports whether the file still holds the content this comment was
144 // written about. A stale comment keeps its place in the thread and loses its
145 // marker on the diff, because a marker on a line that has changed points at
146 // something the comment never saw.
147 Current bool
148 }
149 head := req.Head
150 baseTip := ""
151 if req.Base != "" {
152 if tip, err := m.Git().RevParse("refs/heads/" + req.Base); err == nil {
153 baseTip = tip
154 }
155 }
156 mergeBase := ""
157 if baseTip != "" && head != "" {
158 if mb, err := m.Git().MergeBase(baseTip, head); err == nil {
159 mergeBase = mb
160 }
161 }
162 blobs := map[string]string{}
163 currentBlob := func(side, path string) string {
164 key := side + "|" + path
165 if blob, ok := blobs[key]; ok {
166 return blob
167 }
168 commit := head
169 if side == "old" {
170 commit = mergeBase
171 }
172 blob := ""
173 if commit != "" {
174 blob, _ = m.Git().RevParseAny(commit + ":" + path)
175 }
176 blobs[key] = blob
177 return blob
138 178 }
139 179 comments := make([]renderedComment, 0, len(req.Comments))
140 180 for _, cm := range req.Comments {
141 comments = append(comments, renderedComment{Comment: cm, Body: w.renderMarkdown(m.Name, cm.Body)})
181 rc := renderedComment{Comment: cm, Body: w.renderMarkdown(m.Name, cm.Body)}
182 if cm.Anchor != nil {
183 rc.Current = currentBlob(cm.Anchor.Side, cm.Anchor.Path) == cm.Anchor.Blob
184 }
185 comments = append(comments, rc)
186 }
187 // A marker on the line each current anchored comment is about. The side decides
188 // which path and which line number: a comment on a deleted line belongs to the
189 // file as it stood at the merge base.
190 for i := range files {
191 f := &files[i]
192 for h := range f.Hunks {
193 lines := f.Hunks[h].Lines
194 for l := range lines {
195 for _, rc := range comments {
196 if rc.Anchor == nil || !rc.Current {
197 continue
198 }
199 path, no := f.NewPath, lines[l].NewNo
200 if rc.Anchor.Side == "old" {
201 path, no = f.OldPath, lines[l].OldNo
202 }
203 if path == rc.Anchor.Path && no == rc.Anchor.Line {
204 lines[l].Comments = append(lines[l].Comments, diffComment{ID: rc.ID, Author: rc.Author})
205 }
206 }
207 }
208 }
142 209 }
143 210 added, removed := diffSummary(files)
144 211