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.

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 {