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 2: a comment is editable, by the person who wrote it

79c88d72d36d8974dca5859125f8f37f0b06c1f7 · Keenan Nemetz · 17d ago · parent 67dd8854

Phase 2: a comment is editable, by the person who wrote it

Comment editing was the last open follow-up on Phase 2, and the workaround was
"append another comment, or rewrite history and force-push" — a redaction tool
used for a typo. An edit is now an ordinary commit that rewrites the comment's
file in place: the path stays, so the id, the thread order and one `git log -p --
issues/<id>/comments/<cid>.md` that answers "what did it say" all survive. A
delete removes the file and leaves the text in history.

No `edited` field, for the same reason `updated` was dropped from issues: it
would be a line rewritten on every edit, and the commit that changed the file
already carries the time. Editing also does not re-surface anything — the feed
question is a timestamp, not a field.

Ownership is the interesting part. A comment's author is a name in a file, and a
name is not authentication, so `pre-receive` compares the author on the OLD blob
against the account the key resolved to. The old blob is the one that was
validated when it landed; the new one is whatever the pusher typed, and reading
it would let anyone adopt a comment by editing its author line in the same push.
Names are never reissued, so a former name still resolves through the roster and
an account keeps its own comments across a rename. An admin may override — a
thread written by somebody who has left is otherwise unrepairable — and the
override is written to data/audit.log, like a force-push or a release rewrite.

Two things are deliberately outside the rule:

- Adding a comment. A new file's author is a claim, exactly like an issue's
  `author`, and the boundary has never pretended otherwise.
- The issue file itself. `state`, `labels` and `assignees` are shared fields, so
  closing or assigning somebody else's issue is triage; refusing it would make
  the fields useless, and it is the same write any writer could already do by
  hand. A description can therefore be corrected by any writer — unchanged
  behaviour, now stated.

`vole issue edit` (title, body, labels, assignees) covers the issue file, and
`issue comment` now prints the comment id it created while `issue show` and the
web page print it too — otherwise `edit-comment` would take an id nobody could
see. Two comments written in the same millisecond share their ULID timestamp
prefix, so a short prefix is ambiguous there; the CLI names both matches instead
of guessing.

Also fixed on the way: `frontmatter.Value` reads one field without strict
decoding. `AuthorOf` first used Decode, whose KnownFields is right for validation
but wrong for "who wrote this" — the hook refused every comment edit with
`field created not found`.

Verified: TestCommentEditsBelongToTheirAuthor pushes as two accounts and drives
all four refusals (rewrite, adopt-by-author-line, delete, and the untouched
thread afterwards), plus an author editing his own comment, a writer closing
someone else's issue, and an admin delete that lands only with its audit line.
TestAuthorOfReadsAnyMetadataFile covers the strict-decoder bug directly;
TestCommentPathMatchesTheReader pins the writer/validator path agreement; the CLI
end-to-end test edits a comment, retitles an issue and deletes the comment
through the binary against a real instance. scripts/ci.sh green (gofmt, vet,
build, markdown links, go test -race ./...).

internal/issues/issues_test.go +40

@@ -203,6 +203,46 @@ func TestAssigneeRoundTrip(t *testing.T) {
203 203 }
204 204 }
205 205
206 // TestAuthorOfReadsAnyMetadataFile: the hook asks this before letting anybody
207 // change a comment, so it has to read an author out of a file whose other fields
208 // belong to a different entity — an issue file's title, state and labels are not
209 // the reader's business.
210 func TestAuthorOfReadsAnyMetadataFile(t *testing.T) {
211 comment := "---\nauthor: wilbur\ncreated: 2026-10-22T21:01:00Z\n---\nwords\n"
212 if got, err := AuthorOf([]byte(comment)); err != nil || got != "wilbur" {
213 t.Fatalf("comment author = %q, %v", got, err)
214 }
215 issue := "---\ntitle: \"bug\"\nstate: open\nauthor: keenan\ncreated: 2026-10-22T21:00:00Z\n" +
216 "labels:\n - bug\nassignees:\n - wilbur\n---\nbody\n"
217 if got, err := AuthorOf([]byte(issue)); err != nil || got != "keenan" {
218 t.Fatalf("issue author = %q, %v", got, err)
219 }
220 if _, err := AuthorOf([]byte("---\ncreated: 2026-10-22T21:00:00Z\n---\nbody\n")); err == nil {
221 t.Fatal("a file with no author was accepted")
222 }
223 if _, err := AuthorOf([]byte("no frontmatter\n")); err == nil {
224 t.Fatal("a file with no frontmatter was accepted")
225 }
226 }
227
228 // TestCommentPathMatchesTheReader: the writer and the validator have to agree
229 // about where a comment lives, or an edit commits to a path nobody reads.
230 func TestCommentPathMatchesTheReader(t *testing.T) {
231 path := CommentPath(idA, idB)
232 if path != "issues/"+idA+"/comments/"+idB+".md" {
233 t.Fatalf("path = %q", path)
234 }
235 if !IsCommentPath(path) {
236 t.Fatal("a comment path was not recognised")
237 }
238 if IsCommentPath("issues/" + idA + "/issue.md") {
239 t.Fatal("an issue file was mistaken for a comment")
240 }
241 if IsCommentPath("requests/" + idA + "/request.md") {
242 t.Fatal("a request file was mistaken for a comment")
243 }
244 }
245
206 246 // TestUnquotedTitleIsRefused: a hand-edit that drops the quotes on a title with
207 247 // " # " would otherwise store a shorter title and say nothing.
208 248 func TestUnquotedTitleIsRefused(t *testing.T) {