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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 3: a change request can be discussed

7c6e52e75f3a33be57a81663bc840edd6e14f7f9 · Keenan Nemetz · 17d ago · parent 3c5d11fa

Phase 3: a change request can be discussed

Until now the only ways to say something about a request were editing its body or
approving it. A review thread is now a pushed file like everything else:

	requests/<ulid>/comments/<ulid>.md    author + created, then the body

It is the *same file shape* as a comment on an issue and is read and written by the
same code (`issues.ParseComment`, `issues.RenderComment`, with `requests.Comment` as
an alias), so the thread inherited the validation, the markdown boundary and the
ownership rule instead of reimplementing them. That last one is the part worth
having: `pre-receive` compares the author on the old blob to the pushing account, so
a review comment is its author's to edit or delete, an admin may repair a thread
(audited), and rewriting the author line in the same push does not launder it.

`vole request comment <id> -m` prints the comment's id and the page anchor
(`#c-<ulid>`), `edit-comment` and `rm-comment` are the author's own words, and
`request show` prints the thread so the ids are discoverable at all — an id nobody
can see is an id nobody can use. Adding a comment is free, like adding one to an
issue: a new file's author is a claim, and the boundary has never pretended
otherwise.

The request's own file stays outside the ownership rule, for the reason an issue's
does: state, head and base are shared, and updating them is the author's normal edit.

Verified: TestParseReadsTheDiscussion (a thread plus an approval in one tree,
ordered by id), TestCommentNeedsAnAuthorAndATime and
TestUnknownFileInARequestIsRefused (the subtree stays a closed format, with both
expected shapes named), TestCommentPaths (a request comment is not an issue comment
and not an approval); and end to end through the binary,
TestRequestDiscussionIsPushedAndOwned — the empty state prints the command, the CLI
adds a comment, the page renders it with its anchor and `request show` lists it, a
writer is refused rewriting, adopting and deleting somebody else's comment, may join
the thread and may edit his own — with the issue-comment empty state assertion added
to TestIssuePushedThenRendered earlier. scripts/ci.sh green (gofmt, vet, build,
markdown links, go test -race ./...).

docs/roadmap.md +18−5

@@ -213,13 +213,14 @@ Exit criteria: a user with an editor and a key files, comments on and closes an
213 213 issue without installing Vole; an invalid tree is rejected with the offending path
214 214 named and the branch unmoved; deleting the index loses no issues.
215 215
216 ### Phase 3 — Change requests — ✅ complete (review comments deferred; diffs landed in Phase 7)
216 ### Phase 3 — Change requests — ✅ complete (line-anchored review comments deferred)
217 217
218 218 ✅ **Done.** A change request is a directory on `refs/heads/vole`:
219 219
220 220 ```
221 221 requests/<ulid>/request.md title, author, base branch, head commit, state, then the body
222 222 requests/<ulid>/approvals/<principal>.md base + head + created, no body
223 requests/<ulid>/comments/<ulid>.md author + created, then the discussion
223 224 ```
224 225
225 226 - **Approvals are bound to one commit and one branch.** `git push` is the merge, so the
@@ -243,12 +244,23 @@ requests/<ulid>/approvals/<principal>.md base + head + created, no body
243 244 having refused forgeries. Publishing signed attestations (Phase 7) is what would make
244 245 it verifiable offline.
245 246
247 ✅ **Discussion, since this phase closed.** A request can be talked about without
248 inventing a format: a comment on a request is the *same file shape* as a comment on
249 an issue — `requests/<ulid>/comments/<ulid>.md`, author and created in frontmatter,
250 body as markdown — so one reader, one writer and one ownership rule cover both.
251 `vole request comment <id> -m` pushes it like everything else, `edit-comment` and
252 `rm-comment` are the author's own words to change (an admin may repair a thread,
253 audited), and the page renders the thread with an anchor per comment, so a link to
254 one remark is a URL. Until this landed the only ways to say something about a
255 request were editing its body or approving it.
256
246 257 Deferred:
247 258
248 - Review comments, line-anchored on `(path, blob-sha, line)` so they survive a
249 rewrite. There is no discussion on a request at all yet — the only ways to say
250 something about one are editing its body or approving it — so this is two
251 features: a thread, and then anchors on it.
259 - Line anchors on review comments: `(path, side, line)` plus the blob id of the
260 content reviewed, so a comment stays glued to what it was written about and is
261 marked outdated when that file changes rather than sliding down the page. The
262 thread above is the substrate, and the diff renderer already numbers both sides
263 of every line.
252 264 - The *collapsible* part of the per-file diff. The diff itself landed in Phase 7
253 265 slice 1 (three-dot range, per-file hunks, line numbers), and required status
254 266 checks landed in Phase 4 slice 3, so both are struck off the original list
@@ -805,6 +817,7 @@ Filled in as phases land. Append; don't rewrite.
805 817 | 2026-10-23 | The author of a metadata write is the account the key resolves to, asked with `vole-whoami`; `--author` is gone | `git config user.name` is a name somebody typed for commits: "Keenan Nemetz" sanitises to `keenan-nemetz`, which is not an account, so every issue filed from a normal machine was attributed to nobody and nothing complained. The instance is the only place that maps a key to an account, and it is one SSH verb away on the connection already open. A fallback exists for `--no-push` with the instance unreachable, and it warns loudly rather than guessing silently |
806 818 | 2026-10-23 | A remote given as an SSH URL is fetched like a named one | Skipping the fetch for a URL is what made `--remote ssh://…` write on a branch that had never been updated: the first write in a fresh repository succeeded (nothing to fast-forward from) and every later one failed as a "lost race" that never happened, re-applying on the same stale tip and reporting "already exists". `git init` plus a URL is now the real no-clone filing path |
807 819 | 2026-10-23 | The instance tells the client where it is served (`public_url`), so a command can print the page it wrote | A command that answers with an id leaves the reader to assemble a URL, and the instance is the only party that knows its own address behind a proxy. Optional: unset means ids only, which is what it printed before |
820 | 2026-10-23 | A review comment is an issue-comment file inside the request, not a new format | `requests/<ulid>/comments/<ulid>.md` is parsed by the same code as `issues/<ulid>/comments/<ulid>.md` (`issues.ParseComment`), so the thread inherited the validation, the author-edits-own-words rule and the markdown boundary instead of reimplementing them. A request's own file stays outside the ownership rule for the reason an issue's does: state, head and base are shared, and updating them is the author's normal edit |
808 821 | 2026-10-23 | "Would this merge?" is answered in memory and surfaced, never gated | `merge-tree --write-tree` merges without a worktree or an index, so the cost is one git process on a page view; gating on it would be wrong because the base can move after an approval, and the roadmap asked for detection before the push rather than a second refusal. The exit status alone is ambiguous — 1 is both "conflicts" and "bad revision" — so the answer is believed only when the output starts with a tree id, and silence otherwise |
809 822
810 823 ## Open questions