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/server/integration_test.go +159
| @@ -758,6 +758,40 @@ func TestIssueCLIEndToEnd(t *testing.T) { | |||
| 758 | 758 | t.Fatal("comment not rendered") | |
| 759 | 759 | } | |
| 760 | 760 | ||
| 761 | // The comment id is on stdout so the editing commands have something to take. | ||
| 762 | // (A prefix works too, but two comments written in the same millisecond share | ||
| 763 | // their ULID timestamp, so this test uses the whole id.) | ||
| 764 | cmtOut, errs, err := run("issue", "comment", "-m", "A typo-ridden reply.", short) | ||
| 765 | if err != nil { | ||
| 766 | t.Fatalf("comment: %v\n%s", err, errs) | ||
| 767 | } | ||
| 768 | cid := strings.TrimSpace(cmtOut) | ||
| 769 | if len(cid) < 8 { | ||
| 770 | t.Fatalf("no comment id printed: %q", cmtOut) | ||
| 771 | } | ||
| 772 | if _, errs, err := run("issue", "edit-comment", short, cid, "-m", "A corrected reply."); err != nil { | ||
| 773 | t.Fatalf("edit-comment: %v\n%s", err, errs) | ||
| 774 | } | ||
| 775 | _, body := f.get("/r/" + f.repo + "/issues/" + id) | ||
| 776 | if !strings.Contains(body, "A corrected reply.") || strings.Contains(body, "A typo-ridden reply.") { | ||
| 777 | t.Fatalf("the edit did not replace the comment:\n%s", body) | ||
| 778 | } | ||
| 779 | // The issue's own fields are editable in the same way, without touching the | ||
| 780 | // comment thread. | ||
| 781 | if _, errs, err := run("issue", "edit", short, "--title", "Retitled from the CLI", "-l", "bug", "-l", "cli"); err != nil { | ||
| 782 | t.Fatalf("edit: %v\n%s", err, errs) | ||
| 783 | } | ||
| 784 | if _, body := f.get("/r/" + f.repo + "/issues/" + id); !strings.Contains(body, "Retitled from the CLI") || | ||
| 785 | !strings.Contains(body, "A corrected reply.") { | ||
| 786 | t.Fatalf("the issue edit lost the title or the thread:\n%s", body) | ||
| 787 | } | ||
| 788 | if _, errs, err := run("issue", "rm-comment", short, cid); err != nil { | ||
| 789 | t.Fatalf("rm-comment: %v\n%s", err, errs) | ||
| 790 | } | ||
| 791 | if _, body := f.get("/r/" + f.repo + "/issues/" + id); strings.Contains(body, "A corrected reply.") { | ||
| 792 | t.Fatal("the deleted comment is still rendered") | ||
| 793 | } | ||
| 794 | |||
| 761 | 795 | if _, errs, err := run("issue", "close", short); err != nil { | |
| 762 | 796 | t.Fatalf("close: %v\n%s", err, errs) | |
| 763 | 797 | } | |
| @@ -971,6 +1005,108 @@ func TestIssueAssigneesRenderedFilteredAndValidated(t *testing.T) { | |||
| 971 | 1005 | } | |
| 972 | 1006 | } | |
| 973 | 1007 | ||
| 1008 | // commentFile renders issues/<id>/comments/<cid>.md for fixtures. | ||
| 1009 | func commentFile(author, created, body string) string { | ||
| 1010 | d := frontmatter.New() | ||
| 1011 | d.Word("author", author) | ||
| 1012 | d.Word("created", created) | ||
| 1013 | out, err := d.Bytes(body) | ||
| 1014 | if err != nil { | ||
| 1015 | panic(err) | ||
| 1016 | } | ||
| 1017 | return string(out) | ||
| 1018 | } | ||
| 1019 | |||
| 1020 | // TestCommentEditsBelongToTheirAuthor is the boundary behind editable comments. | ||
| 1021 | // A comment is somebody's own words, so changing or deleting it is checked | ||
| 1022 | // against the author recorded on the *old* blob — the one that was validated when | ||
| 1023 | // it landed — and an admin may override only audibly. | ||
| 1024 | // | ||
| 1025 | // What is deliberately NOT covered: adding a comment, and any edit to the issue | ||
| 1026 | // file. Closing and relabelling somebody else's issue is triage, not authorship, | ||
| 1027 | // and refusing it would make the shared fields useless. | ||
| 1028 | func TestCommentEditsBelongToTheirAuthor(t *testing.T) { | ||
| 1029 | f := newFixture(t) | ||
| 1030 | writerKey := f.addWriter(t) // wilbur: write access, not admin | ||
| 1031 | f.publish(f.repo) | ||
| 1032 | const ( | ||
| 1033 | issueID = "01M5JR2JK29KKMAA1T9MDNTPD1" | ||
| 1034 | keenanCm = "01M5JR2JK29KKMAA1T9MDNTPD2" | ||
| 1035 | wilburCm = "01M5JR2JK29KKMAA1T9MDNTPD3" | ||
| 1036 | ) | ||
| 1037 | const created = "2026-10-22T21:01:00Z" | ||
| 1038 | files := map[string]string{ | ||
| 1039 | "issues/" + issueID + "/issue.md": issueFile("shared bug", "keenan", "open", | ||
| 1040 | "2026-10-22T21:00:00Z", nil, "body\n"), | ||
| 1041 | "issues/" + issueID + "/comments/" + keenanCm + ".md": commentFile("keenan", created, "original words\n"), | ||
| 1042 | } | ||
| 1043 | work := f.issuesRepo(t, files) | ||
| 1044 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 1045 | t.Fatalf("push: %s", errs) | ||
| 1046 | } | ||
| 1047 | commentPath := func(id string) string { return "issues/" + issueID + "/comments/" + id + ".md" } | ||
| 1048 | |||
| 1049 | meta := f.newMetaRepo(t) | ||
| 1050 | meta.key = writerKey | ||
| 1051 | |||
| 1052 | // A writer may not rewrite someone else's words... | ||
| 1053 | if err := meta.apply(map[string]string{commentPath(keenanCm): commentFile("keenan", created, "rewritten by wilbur\n")}, "edit foreign"); err == nil { | ||
| 1054 | t.Fatal("a writer changed someone else's comment") | ||
| 1055 | } else if !strings.Contains(err.Error(), "only keenan can change this comment") { | ||
| 1056 | t.Fatalf("refusal did not name the author: %v", err) | ||
| 1057 | } | ||
| 1058 | // ...nor adopt them by editing the author line in the same push: the old | ||
| 1059 | // blob decides, not the new one. | ||
| 1060 | if err := meta.apply(map[string]string{commentPath(keenanCm): commentFile("wilbur", created, "mine now\n")}, "forge"); err == nil { | ||
| 1061 | t.Fatal("a writer took ownership of a comment by rewriting its author field") | ||
| 1062 | } | ||
| 1063 | // ...nor delete them. | ||
| 1064 | if err := meta.remove(commentPath(keenanCm)); err == nil { | ||
| 1065 | t.Fatal("a writer deleted someone else's comment") | ||
| 1066 | } else if !strings.Contains(err.Error(), "only keenan can delete this comment") { | ||
| 1067 | t.Fatalf("refusal did not name the author: %v", err) | ||
| 1068 | } | ||
| 1069 | // None of those refusals touched the thread. | ||
| 1070 | if _, body := f.get("/r/" + f.repo + "/issues/" + issueID); !strings.Contains(body, "original words") { | ||
| 1071 | t.Fatalf("a refused edit changed the rendered thread:\n%s", body) | ||
| 1072 | } | ||
| 1073 | |||
| 1074 | // His own comment he may add, edit and delete freely. | ||
| 1075 | if err := meta.apply(map[string]string{commentPath(wilburCm): commentFile("wilbur", created, "first draft\n")}, "add own"); err != nil { | ||
| 1076 | t.Fatalf("add own comment: %v", err) | ||
| 1077 | } | ||
| 1078 | if err := meta.apply(map[string]string{commentPath(wilburCm): commentFile("wilbur", created, "second draft\n")}, "edit own"); err != nil { | ||
| 1079 | t.Fatalf("edit own comment: %v", err) | ||
| 1080 | } | ||
| 1081 | _, body := f.get("/r/" + f.repo + "/issues/" + issueID) | ||
| 1082 | if !strings.Contains(body, "second draft") || strings.Contains(body, "first draft") { | ||
| 1083 | t.Fatalf("own edit did not replace the body:\n%s", body) | ||
| 1084 | } | ||
| 1085 | |||
| 1086 | // Triage is not authorship: closing another person's issue is a normal write. | ||
| 1087 | if err := meta.apply(map[string]string{"issues/" + issueID + "/issue.md": issueFile("shared bug", "keenan", "closed", | ||
| 1088 | "2026-10-22T21:00:00Z", nil, "body\n")}, "close"); err != nil { | ||
| 1089 | t.Fatalf("closing another person's issue was refused: %v", err) | ||
| 1090 | } | ||
| 1091 | |||
| 1092 | // An admin may remove the comment of someone who has left, and it is never | ||
| 1093 | // silent. | ||
| 1094 | admin := f.newMetaRepo(t) | ||
| 1095 | if err := admin.remove(commentPath(wilburCm)); err != nil { | ||
| 1096 | t.Fatalf("admin delete: %v", err) | ||
| 1097 | } | ||
| 1098 | if _, body := f.get("/r/" + f.repo + "/issues/" + issueID); strings.Contains(body, "second draft") { | ||
| 1099 | t.Fatal("a deleted comment is still rendered") | ||
| 1100 | } | ||
| 1101 | audit, err := os.ReadFile(f.cfg.AuditLog()) | ||
| 1102 | if err != nil { | ||
| 1103 | t.Fatal(err) | ||
| 1104 | } | ||
| 1105 | if !strings.Contains(string(audit), "comment-D "+commentPath(wilburCm)) { | ||
| 1106 | t.Fatalf("the admin override was not audited:\n%s", audit) | ||
| 1107 | } | ||
| 1108 | } | ||
| 1109 | |||
| 974 | 1110 | // TestCrossReferencesLinksIssues: a #<ulid> in a body becomes a link, and a | |
| 975 | 1111 | // commit mentioning an issue shows as a backlink. | |
| 976 | 1112 | func TestCrossReferencesLinksIssues(t *testing.T) { | |
| @@ -1268,6 +1404,29 @@ func (m *metaRepo) apply(files map[string]string, msg string) error { | |||
| 1268 | 1404 | return nil | |
| 1269 | 1405 | } | |
| 1270 | 1406 | ||
| 1407 | // remove deletes paths from the metadata branch and pushes. apply only writes, | ||
| 1408 | // and a deletion is the interesting case for authorship. | ||
| 1409 | func (m *metaRepo) remove(paths ...string) error { | ||
| 1410 | m.t.Helper() | ||
| 1411 | m.sync() | ||
| 1412 | for _, p := range paths { | ||
| 1413 | if err := os.Remove(filepath.Join(m.dir, p)); err != nil { | ||
| 1414 | m.t.Fatalf("remove %s: %v", p, err) | ||
| 1415 | } | ||
| 1416 | } | ||
| 1417 | if _, errs, err := runGit(m.t, m.dir, "add", "-A"); err != nil { | ||
| 1418 | m.t.Fatalf("add: %v: %s", err, errs) | ||
| 1419 | } | ||
| 1420 | if _, errs, err := runGit(m.t, m.dir, "commit", "-qm", "remove"); err != nil { | ||
| 1421 | m.t.Fatalf("commit: %v: %s", err, errs) | ||
| 1422 | } | ||
| 1423 | _, errs, err := fgit(m.t, m.key, m.dir, "push", "origin", "HEAD:refs/heads/vole") | ||
| 1424 | if err != nil { | ||
| 1425 | return fmt.Errorf("%v: %s", err, errs) | ||
| 1426 | } | ||
| 1427 | return nil | ||
| 1428 | } | ||
| 1429 | |||
| 1271 | 1430 | // fgit runs git as a specific key holding user, for tests that need identities. | |
| 1272 | 1431 | func fgit(t *testing.T, key, dir string, args ...string) (string, string, error) { | |
| 1273 | 1432 | t.Helper() | |