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