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/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()