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.go +33
| @@ -377,3 +377,36 @@ func NewULID(t time.Time) (string, error) { | |||
| 377 | 377 | } | |
| 378 | 378 | ||
| 379 | 379 | func ValidULID(s string) bool { return ulidRE.MatchString(s) } | |
| 380 | |||
| 381 | // CommentPath is where one comment's file lives. One function, so the writer and | ||
| 382 | // the validator cannot disagree about the shape. | ||
| 383 | func CommentPath(issueID, commentID string) string { | ||
| 384 | return fmt.Sprintf("%s/%s/%s/%s.md", Dir, issueID, commentsDir, commentID) | ||
| 385 | } | ||
| 386 | |||
| 387 | // IsCommentPath reports whether path is a comment file rather than an issue file. | ||
| 388 | // The hook uses it to apply comment authorship to comments and not to the issue | ||
| 389 | // file, which carries shared state (state, labels, assignees). | ||
| 390 | func IsCommentPath(path string) bool { | ||
| 391 | m := pathRE.FindStringSubmatch(path) | ||
| 392 | return m != nil && m[3] != "" | ||
| 393 | } | ||
| 394 | |||
| 395 | // AuthorOf reads the author field from a metadata file. The hook calls it on the | ||
| 396 | // *old* blob of a comment before letting anybody change it: a name in a file is | ||
| 397 | // not authentication, but a file that already landed was validated when it did. | ||
| 398 | func AuthorOf(raw []byte) (string, error) { | ||
| 399 | head, _, err := frontmatter.Split(raw) | ||
| 400 | if err != nil { | ||
| 401 | return "", err | ||
| 402 | } | ||
| 403 | author, ok := frontmatter.Value(head, "author") | ||
| 404 | if !ok { | ||
| 405 | return "", fmt.Errorf("no author field") | ||
| 406 | } | ||
| 407 | author = strings.TrimSpace(author) | ||
| 408 | if author == "" { | ||
| 409 | return "", fmt.Errorf("no author field") | ||
| 410 | } | ||
| 411 | return author, nil | ||
| 412 | } | ||