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/write.go +79
| @@ -143,6 +143,85 @@ func AddComment(g *gitx.Repo, id string, c *Comment, actor string) error { | |||
| 143 | 143 | return err | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | // EditComment rewrites a comment's body in place. The path stays the same, so the | ||
| 147 | // comment keeps its id, its place in the thread, and one file to log: `git log -p | ||
| 148 | // -- issues/<id>/comments/<cid>.md` is what it used to say. There is no `edited` | ||
| 149 | // field for the same reason issues have no `updated` field — the commit that | ||
| 150 | // changed the file carries the time. | ||
| 151 | // | ||
| 152 | // Ownership is not checked here: the person with the key can only be themselves. | ||
| 153 | // Only the named author (or an admin, audited) may change someone else's comment, | ||
| 154 | // and that rule belongs at the push boundary where the account is known. | ||
| 155 | func EditComment(g *gitx.Repo, issueID, commentID, body, actor string) (*Comment, error) { | ||
| 156 | c, path, err := findComment(g, issueID, commentID) | ||
| 157 | if err != nil { | ||
| 158 | return nil, err | ||
| 159 | } | ||
| 160 | body = strings.TrimRight(body, "\n") | ||
| 161 | if strings.TrimSpace(body) == "" { | ||
| 162 | return nil, fmt.Errorf("issues: a comment needs a body") | ||
| 163 | } | ||
| 164 | c.Body = body | ||
| 165 | raw, err := renderCommentFile(c) | ||
| 166 | if err != nil { | ||
| 167 | return nil, err | ||
| 168 | } | ||
| 169 | _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error { | ||
| 170 | if _, ok := files[path]; !ok { | ||
| 171 | return fmt.Errorf("issues: no comment %s", commentID) | ||
| 172 | } | ||
| 173 | files[path] = raw | ||
| 174 | return nil | ||
| 175 | }, fmt.Sprintf("issue: edit comment %s on %s", commentID, issueID), actorOr(actor, c.Author)) | ||
| 176 | if err != nil { | ||
| 177 | return nil, err | ||
| 178 | } | ||
| 179 | return c, nil | ||
| 180 | } | ||
| 181 | |||
| 182 | // DeleteComment removes a comment's file. The text stays in history — deleting is | ||
| 183 | // not redaction, which is still an admin force-push — but the thread no longer | ||
| 184 | // shows it. A flat thread keyed by ULID has no replies to orphan. | ||
| 185 | func DeleteComment(g *gitx.Repo, issueID, commentID, actor string) (*Comment, error) { | ||
| 186 | c, path, err := findComment(g, issueID, commentID) | ||
| 187 | if err != nil { | ||
| 188 | return nil, err | ||
| 189 | } | ||
| 190 | _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error { | ||
| 191 | if _, ok := files[path]; !ok { | ||
| 192 | return fmt.Errorf("issues: no comment %s", commentID) | ||
| 193 | } | ||
| 194 | delete(files, path) | ||
| 195 | return nil | ||
| 196 | }, fmt.Sprintf("issue: delete comment %s on %s", commentID, issueID), actorOr(actor, c.Author)) | ||
| 197 | if err != nil { | ||
| 198 | return nil, err | ||
| 199 | } | ||
| 200 | return c, nil | ||
| 201 | } | ||
| 202 | |||
| 203 | // findComment locates one comment on the branch and returns it with the path it | ||
| 204 | // lives at, which is the file the edit or delete rewrites. | ||
| 205 | func findComment(g *gitx.Repo, issueID, commentID string) (*Comment, string, error) { | ||
| 206 | list, err := Load(g, Ref) | ||
| 207 | if err != nil { | ||
| 208 | return nil, "", err | ||
| 209 | } | ||
| 210 | for _, iss := range list { | ||
| 211 | if iss.ID != issueID { | ||
| 212 | continue | ||
| 213 | } | ||
| 214 | for i := range iss.Comments { | ||
| 215 | if iss.Comments[i].ID == commentID { | ||
| 216 | c := iss.Comments[i] | ||
| 217 | return &c, CommentPath(issueID, commentID), nil | ||
| 218 | } | ||
| 219 | } | ||
| 220 | return nil, "", fmt.Errorf("issues: no comment %s on %s", commentID, issueID) | ||
| 221 | } | ||
| 222 | return nil, "", fmt.Errorf("issues: no issue %s", issueID) | ||
| 223 | } | ||
| 224 | |||
| 146 | 225 | func renderCommentFile(c *Comment) ([]byte, error) { | |
| 147 | 226 | d := frontmatter.New() | |
| 148 | 227 | d.Word("author", c.Author) | |