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/issues.go +72
| @@ -34,6 +34,7 @@ import ( | |||
| 34 | 34 | "time" | |
| 35 | 35 | ||
| 36 | 36 | "github.com/rodent-software/vole/core/git" | |
| 37 | voleid "github.com/rodent-software/vole/core/id" | ||
| 37 | 38 | ) | |
| 38 | 39 | ||
| 39 | 40 | const ( | |
| @@ -88,6 +89,12 @@ type Comment struct { | |||
| 88 | 89 | Author string | |
| 89 | 90 | Created time.Time | |
| 90 | 91 | Body string | |
| 92 | // ReplyTo names the comment this one answers. Any comment may be replied to; | ||
| 93 | // the web renders one level of nesting by collecting a root's descendants. | ||
| 94 | ReplyTo string | ||
| 95 | // Deleted marks a tombstone left in place when a comment is removed, so its | ||
| 96 | // replies keep their thread and can still say what they were answering. | ||
| 97 | Deleted bool | ||
| 91 | 98 | // Anchor, when set, says which line of which file the comment is about. It is | |
| 92 | 99 | // only meaningful on a review thread — the issue page ignores it — but it is | |
| 93 | 100 | // part of the comment's file so that a comment and its subject travel together | |
| @@ -207,6 +214,7 @@ func ParseIssues(contents map[string]string) ([]*Issue, error) { | |||
| 207 | 214 | } | |
| 208 | 215 | } | |
| 209 | 216 | sort.Slice(iss.Comments, func(a, b int) bool { return iss.Comments[a].ID < iss.Comments[b].ID }) | |
| 217 | problems = append(problems, commentReplyProblems(iss.Comments, iss.Path)...) | ||
| 210 | 218 | out = append(out, iss) | |
| 211 | 219 | } | |
| 212 | 220 | sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) | |
| @@ -296,6 +304,8 @@ func ParseComment(id, raw, path string) (Comment, string) { | |||
| 296 | 304 | var m struct { | |
| 297 | 305 | Author string `yaml:"author"` | |
| 298 | 306 | Created string `yaml:"created"` | |
| 307 | ReplyTo string `yaml:"reply_to"` | ||
| 308 | Deleted bool `yaml:"deleted"` | ||
| 299 | 309 | Path string `yaml:"path"` | |
| 300 | 310 | Side string `yaml:"side"` | |
| 301 | 311 | Line int `yaml:"line"` | |
| @@ -316,13 +326,75 @@ func ParseComment(id, raw, path string) (Comment, string) { | |||
| 316 | 326 | return c, fmt.Sprintf("%s: created is required and must be RFC3339: %v", path, err) | |
| 317 | 327 | } | |
| 318 | 328 | c.Created = t.UTC() | |
| 329 | c.ReplyTo = strings.TrimSpace(m.ReplyTo) | ||
| 330 | if c.ReplyTo != "" && !voleid.Valid(c.ReplyTo) { | ||
| 331 | return c, fmt.Sprintf("%s: reply_to must be the id of the comment being answered", path) | ||
| 332 | } | ||
| 333 | c.Deleted = m.Deleted | ||
| 319 | 334 | c.Body = strings.TrimLeft(body, "\n") | |
| 335 | if c.Deleted && strings.TrimSpace(c.Body) != "" { | ||
| 336 | return c, fmt.Sprintf("%s: a deleted comment keeps no body", path) | ||
| 337 | } | ||
| 320 | 338 | if prob := applyAnchor(&c, m.Path, m.Side, m.Line, m.Blob, path); prob != "" { | |
| 321 | 339 | return c, prob | |
| 322 | 340 | } | |
| 323 | 341 | return c, "" | |
| 324 | 342 | } | |
| 325 | 343 | ||
| 344 | // checkCommentReply answers whether c may be a reply when it is written. Any | ||
| 345 | // comment can be a parent, including one that already has replies. | ||
| 346 | func checkCommentReply(comments []Comment, c *Comment) string { | ||
| 347 | if c.ReplyTo == "" { | ||
| 348 | return "" | ||
| 349 | } | ||
| 350 | if c.ReplyTo == c.ID { | ||
| 351 | return "a comment cannot reply to itself" | ||
| 352 | } | ||
| 353 | for i := range comments { | ||
| 354 | if comments[i].ID == c.ReplyTo { | ||
| 355 | if comments[i].Deleted { | ||
| 356 | return fmt.Sprintf("reply_to %s is a deleted comment", c.ReplyTo) | ||
| 357 | } | ||
| 358 | return "" | ||
| 359 | } | ||
| 360 | } | ||
| 361 | return fmt.Sprintf("reply_to %s is not a comment in this thread", c.ReplyTo) | ||
| 362 | } | ||
| 363 | |||
| 364 | // commentReplyProblems refuses self-replies and cycles. A missing parent is not | ||
| 365 | // an error: the parent may have been deleted after these replies were written. | ||
| 366 | func commentReplyProblems(comments []Comment, where string) []string { | ||
| 367 | byID := make(map[string]Comment, len(comments)) | ||
| 368 | for _, c := range comments { | ||
| 369 | byID[c.ID] = c | ||
| 370 | } | ||
| 371 | var problems []string | ||
| 372 | for i := range comments { | ||
| 373 | c := &comments[i] | ||
| 374 | if c.ReplyTo == "" { | ||
| 375 | continue | ||
| 376 | } | ||
| 377 | if c.ReplyTo == c.ID { | ||
| 378 | problems = append(problems, fmt.Sprintf("%s: comment %s cannot reply to itself", where, c.ID)) | ||
| 379 | continue | ||
| 380 | } | ||
| 381 | seen := map[string]bool{c.ID: true} | ||
| 382 | for next := c.ReplyTo; next != ""; { | ||
| 383 | if seen[next] { | ||
| 384 | problems = append(problems, fmt.Sprintf("%s: comment %s is in a reply cycle", where, c.ID)) | ||
| 385 | break | ||
| 386 | } | ||
| 387 | seen[next] = true | ||
| 388 | parent, ok := byID[next] | ||
| 389 | if !ok { | ||
| 390 | break | ||
| 391 | } | ||
| 392 | next = parent.ReplyTo | ||
| 393 | } | ||
| 394 | } | ||
| 395 | return problems | ||
| 396 | } | ||
| 397 | |||
| 326 | 398 | // applyAnchor validates the four anchor fields as a set: an anchor is all of them | |
| 327 | 399 | // or none, and a half-written one is a mistake rather than a comment on line zero. | |
| 328 | 400 | func applyAnchor(c *Comment, path, side string, line int, blob, file string) string { | |