vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0: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/request_write.go +22−4
| @@ -240,9 +240,13 @@ func AddRequestComment(g *git.Repo, requestID string, c *Comment, actor string) | |||
| 240 | 240 | } | |
| 241 | 241 | path := RequestCommentPath(requestID, c.ID) | |
| 242 | 242 | _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error { | |
| 243 | if _, err := loadFrom(files, requestID); err != nil { | ||
| 243 | r, err := loadFrom(files, requestID) | ||
| 244 | if err != nil { | ||
| 244 | 245 | return err | |
| 245 | 246 | } | |
| 247 | if prob := checkCommentReply(r.Comments, c); prob != "" { | ||
| 248 | return fmt.Errorf("requests: %s", prob) | ||
| 249 | } | ||
| 246 | 250 | if _, exists := files[path]; exists { | |
| 247 | 251 | return fmt.Errorf("requests: comment %s already exists", c.ID) | |
| 248 | 252 | } | |
| @@ -260,6 +264,9 @@ func EditRequestComment(g *git.Repo, requestID, commentID, body, actor string) ( | |||
| 260 | 264 | if err != nil { | |
| 261 | 265 | return nil, err | |
| 262 | 266 | } | |
| 267 | if c.Deleted { | ||
| 268 | return nil, fmt.Errorf("requests: cannot edit a deleted comment") | ||
| 269 | } | ||
| 263 | 270 | c.Body = strings.TrimRight(body, "\n") | |
| 264 | 271 | if err := validateComment(c); err != nil { | |
| 265 | 272 | return nil, err | |
| @@ -281,18 +288,29 @@ func EditRequestComment(g *git.Repo, requestID, commentID, body, actor string) ( | |||
| 281 | 288 | return c, nil | |
| 282 | 289 | } | |
| 283 | 290 | ||
| 284 | // DeleteComment removes a comment's file. The text stays in history: deleting is | ||
| 285 | // not redaction, which is an admin force-push. | ||
| 291 | // DeleteComment replaces a comment with a tombstone. The text stays in history: | ||
| 292 | // deleting is not redaction, which is an admin force-push. Replies keep the | ||
| 293 | // parent they answer. | ||
| 286 | 294 | func DeleteRequestComment(g *git.Repo, requestID, commentID, actor string) (*Comment, error) { | |
| 287 | 295 | c, path, err := findRequestComment(g, requestID, commentID) | |
| 288 | 296 | if err != nil { | |
| 289 | 297 | return nil, err | |
| 290 | 298 | } | |
| 299 | if c.Deleted { | ||
| 300 | return nil, fmt.Errorf("requests: comment %s is already deleted", commentID) | ||
| 301 | } | ||
| 302 | c.Deleted = true | ||
| 303 | c.Body = "" | ||
| 304 | c.Anchor = nil | ||
| 305 | raw, err := RenderComment(c) | ||
| 306 | if err != nil { | ||
| 307 | return nil, err | ||
| 308 | } | ||
| 291 | 309 | _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error { | |
| 292 | 310 | if _, ok := files[path]; !ok { | |
| 293 | 311 | return fmt.Errorf("requests: no comment %s", commentID) | |
| 294 | 312 | } | |
| 295 | delete(files, path) | ||
| 313 | files[path] = raw | ||
| 296 | 314 | return nil | |
| 297 | 315 | }, fmt.Sprintf("request: delete comment %s on %s", commentID, requestID), actorOr(actor, c.Author)) | |
| 298 | 316 | if err != nil { | |