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.
Build out change review and threaded discussions
f6f4b1db24292d1df877680714952a9c568e2b78 · Keenan Nemetz · 15d ago · parent 564acd4b
Build out change review and threaded discussions - Add Conversation, Files and Runs tabs to change requests. Files uses the code-browser shape with a changed-file tree and one selected-file diff; Runs lists the head commit's workflow results. - Show anchored review comments beneath the line, with a collapsed excerpt read from the exact blob that was reviewed, and return posted anchors to their line note. - Add comment threads: any comment can be replied to, the UI flattens the descendants under one root, reply context links back to the parent, and reply targets feed a single composer at the foot of the discussion. - Collapse reply groups while keeping the root visible, and scroll to the composer when Reply is selected. - Add comment context menus with Reply, Copy text, Copy link, Edit and Delete. Add web edit/delete routes with author or admin checks; deletion leaves a tombstone so replies keep their thread. - Support --reply-to in the issue and change CLI commands, and treat a missing parent as a tombstone rather than an invalid tree. - Fix the diff comment gutter, action button colours, reply labels, and keep the Files tree and diff pane aligned. Update metadata validation, web templates, CSS, JavaScript, tests, and the CLI/change-request/issue docs.
metadata/issue_write.go +27−7
| @@ -110,16 +110,19 @@ func AddIssueComment(g *git.Repo, id string, c *Comment, actor string) error { | |||
| 110 | 110 | if err != nil { | |
| 111 | 111 | return err | |
| 112 | 112 | } | |
| 113 | found := false | ||
| 113 | var target *Issue | ||
| 114 | 114 | for _, iss := range list { | |
| 115 | 115 | if iss.ID == id { | |
| 116 | found = true | ||
| 116 | target = iss | ||
| 117 | 117 | break | |
| 118 | 118 | } | |
| 119 | 119 | } | |
| 120 | if !found { | ||
| 120 | if target == nil { | ||
| 121 | 121 | return fmt.Errorf("issues: no issue %s", id) | |
| 122 | 122 | } | |
| 123 | if prob := checkCommentReply(target.Comments, c); prob != "" { | ||
| 124 | return fmt.Errorf("issues: %s", prob) | ||
| 125 | } | ||
| 123 | 126 | if strings.TrimSpace(c.Author) == "" { | |
| 124 | 127 | c.Author = actor | |
| 125 | 128 | } | |
| @@ -158,6 +161,9 @@ func EditIssueComment(g *git.Repo, issueID, commentID, body, actor string) (*Com | |||
| 158 | 161 | if err != nil { | |
| 159 | 162 | return nil, err | |
| 160 | 163 | } | |
| 164 | if c.Deleted { | ||
| 165 | return nil, fmt.Errorf("issues: cannot edit a deleted comment") | ||
| 166 | } | ||
| 161 | 167 | body = strings.TrimRight(body, "\n") | |
| 162 | 168 | if strings.TrimSpace(body) == "" { | |
| 163 | 169 | return nil, fmt.Errorf("issues: a comment needs a body") | |
| @@ -180,19 +186,29 @@ func EditIssueComment(g *git.Repo, issueID, commentID, body, actor string) (*Com | |||
| 180 | 186 | return c, nil | |
| 181 | 187 | } | |
| 182 | 188 | ||
| 183 | // DeleteComment removes a comment's file. The text stays in history — deleting is | ||
| 184 | // not redaction, which is still an admin force-push — but the thread no longer | ||
| 185 | // shows it. A flat thread keyed by ULID has no replies to orphan. | ||
| 189 | // DeleteComment replaces a comment with a tombstone. The text stays in history — | ||
| 190 | // deleting is not redaction, which is still an admin force-push — and the marker | ||
| 191 | // remains so replies keep the parent they answer. | ||
| 186 | 192 | func DeleteIssueComment(g *git.Repo, issueID, commentID, actor string) (*Comment, error) { | |
| 187 | 193 | c, path, err := findIssueComment(g, issueID, commentID) | |
| 188 | 194 | if err != nil { | |
| 189 | 195 | return nil, err | |
| 190 | 196 | } | |
| 197 | if c.Deleted { | ||
| 198 | return nil, fmt.Errorf("issues: comment %s is already deleted", commentID) | ||
| 199 | } | ||
| 200 | c.Deleted = true | ||
| 201 | c.Body = "" | ||
| 202 | c.Anchor = nil | ||
| 203 | raw, err := RenderComment(c) | ||
| 204 | if err != nil { | ||
| 205 | return nil, err | ||
| 206 | } | ||
| 191 | 207 | _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error { | |
| 192 | 208 | if _, ok := files[path]; !ok { | |
| 193 | 209 | return fmt.Errorf("issues: no comment %s", commentID) | |
| 194 | 210 | } | |
| 195 | delete(files, path) | ||
| 211 | files[path] = raw | ||
| 196 | 212 | return nil | |
| 197 | 213 | }, fmt.Sprintf("issue: delete comment %s on %s", commentID, issueID), actorOr(actor, c.Author)) | |
| 198 | 214 | if err != nil { | |
| @@ -229,6 +245,10 @@ func RenderComment(c *Comment) ([]byte, error) { | |||
| 229 | 245 | d := NewDoc() | |
| 230 | 246 | d.Word("author", c.Author) | |
| 231 | 247 | d.Word("created", c.Created.UTC().Format(time.RFC3339)) | |
| 248 | d.Word("reply_to", c.ReplyTo) | ||
| 249 | if c.Deleted { | ||
| 250 | d.Word("deleted", "true") | ||
| 251 | } | ||
| 232 | 252 | if c.Anchor != nil { | |
| 233 | 253 | // The anchor goes after the header fields so a reader that only wants the | |
| 234 | 254 | // thread still sees author and time first, and one added anchor is one | |