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.

server/web/writes.go +311−1

@@ -239,6 +239,7 @@ func (w *Web) AddIssueComment(rw http.ResponseWriter, r *http.Request) {
239 239 return
240 240 }
241 241 body := strings.TrimSpace(r.PostFormValue("body"))
242 replyTo := strings.TrimSpace(r.PostFormValue("reply_to"))
242 243 if body == "" {
243 244 if htmxRequest(r) {
244 245 w.render(rw, "issue_thread", w.issueThread(r, m, iss, "a comment needs a body"), 0)
@@ -257,6 +258,7 @@ func (w *Web) AddIssueComment(rw http.ResponseWriter, r *http.Request) {
257 258 http.Error(rw, err.Error(), http.StatusBadRequest)
258 259 return
259 260 }
261 c.ReplyTo = replyTo
260 262 if err := metadata.AddIssueComment(m.Git(), id, c, pr.account.Name); err != nil {
261 263 if htmxRequest(r) {
262 264 w.render(rw, "issue_thread", w.issueThread(r, m, iss, "cannot add the comment: "+err.Error()), 0)
@@ -281,6 +283,165 @@ func (w *Web) AddIssueComment(rw http.ResponseWriter, r *http.Request) {
281 283 http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id+"#c-"+c.ID, http.StatusSeeOther)
282 284 }
283 285
286 func canManageComment(pr *principal, acl *access.ACL, author string) bool {
287 if pr == nil {
288 return false
289 }
290 if pr.account.Name == author {
291 return true
292 }
293 return acl != nil && acl.RoleFor(pr.account.ID).AtLeast(access.Admin)
294 }
295
296 func issueCommentByID(iss *metadata.Issue, id string) *metadata.Comment {
297 for i := range iss.Comments {
298 if iss.Comments[i].ID == id {
299 return &iss.Comments[i]
300 }
301 }
302 return nil
303 }
304
305 func requestCommentByID(req *metadata.Request, id string) *metadata.Comment {
306 for i := range req.Comments {
307 if req.Comments[i].ID == id {
308 return &req.Comments[i]
309 }
310 }
311 return nil
312 }
313
314 // EditIssueComment rewrites one comment. Only its author, or an admin repairing
315 // a thread, may do so.
316 func (w *Web) EditIssueComment(rw http.ResponseWriter, r *http.Request) {
317 pr, ok := w.requireHTMLUser(rw, r)
318 if !ok {
319 return
320 }
321 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
322 if !w.checkCSRF(r) {
323 http.Error(rw, "bad request", http.StatusForbidden)
324 return
325 }
326 m, ok := w.open(rw, r)
327 if !ok {
328 return
329 }
330 id, commentID := r.PathValue("id"), r.PathValue("comment")
331 iss, err := index.LoadIssue(m, id)
332 if err != nil {
333 http.NotFound(rw, r)
334 return
335 }
336 c := issueCommentByID(iss, commentID)
337 if c == nil {
338 http.NotFound(rw, r)
339 return
340 }
341 acl, _ := m.ACL()
342 if !canManageComment(pr, acl, c.Author) {
343 http.Error(rw, "only the author or an admin may edit this comment", http.StatusForbidden)
344 return
345 }
346 if err := r.ParseForm(); err != nil {
347 http.Error(rw, "cannot read the form", http.StatusBadRequest)
348 return
349 }
350 body := strings.TrimSpace(r.PostFormValue("body"))
351 if body == "" {
352 if htmxRequest(r) {
353 w.render(rw, "issue_thread", w.issueThread(r, m, iss, "a comment needs a body"), 0)
354 return
355 }
356 http.Error(rw, "a comment needs a body", http.StatusBadRequest)
357 return
358 }
359 if _, err := metadata.EditIssueComment(m.Git(), id, commentID, body, pr.account.Name); err != nil {
360 if htmxRequest(r) {
361 w.render(rw, "issue_thread", w.issueThread(r, m, iss, "cannot edit the comment: "+err.Error()), 0)
362 return
363 }
364 http.Error(rw, "cannot edit the comment: "+err.Error(), http.StatusBadRequest)
365 return
366 }
367 detail := m.Name + " " + id + " " + commentID
368 if pr.account.Name != c.Author {
369 detail += " (author " + c.Author + ", admin override)"
370 }
371 w.audit("web.issue.comment.edit", pr.account.ID, detail)
372 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
373 w.Logger("web: edit comment %s left the metadata tree invalid: %v", commentID, problems)
374 }
375 if htmxRequest(r) {
376 updated, err := index.LoadIssue(m, id)
377 if err != nil {
378 w.render(rw, "issue_thread", w.issueThread(r, m, iss, err.Error()), 0)
379 return
380 }
381 w.render(rw, "issue_thread", w.issueThread(r, m, updated, ""), 0)
382 return
383 }
384 http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id+"#c-"+commentID, http.StatusSeeOther)
385 }
386
387 // DeleteIssueComment replaces a comment with its tombstone.
388 func (w *Web) DeleteIssueComment(rw http.ResponseWriter, r *http.Request) {
389 pr, ok := w.requireHTMLUser(rw, r)
390 if !ok {
391 return
392 }
393 if !w.checkCSRF(r) {
394 http.Error(rw, "bad request", http.StatusForbidden)
395 return
396 }
397 m, ok := w.open(rw, r)
398 if !ok {
399 return
400 }
401 id, commentID := r.PathValue("id"), r.PathValue("comment")
402 iss, err := index.LoadIssue(m, id)
403 if err != nil {
404 http.NotFound(rw, r)
405 return
406 }
407 c := issueCommentByID(iss, commentID)
408 if c == nil {
409 http.NotFound(rw, r)
410 return
411 }
412 acl, _ := m.ACL()
413 if !canManageComment(pr, acl, c.Author) {
414 http.Error(rw, "only the author or an admin may delete this comment", http.StatusForbidden)
415 return
416 }
417 if _, err := metadata.DeleteIssueComment(m.Git(), id, commentID, pr.account.Name); err != nil {
418 if htmxRequest(r) {
419 w.render(rw, "issue_thread", w.issueThread(r, m, iss, "cannot delete the comment: "+err.Error()), 0)
420 return
421 }
422 http.Error(rw, "cannot delete the comment: "+err.Error(), http.StatusBadRequest)
423 return
424 }
425 detail := m.Name + " " + id + " " + commentID
426 if pr.account.Name != c.Author {
427 detail += " (author " + c.Author + ", admin override)"
428 }
429 w.audit("web.issue.comment.delete", pr.account.ID, detail)
430 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
431 w.Logger("web: delete comment %s left the metadata tree invalid: %v", commentID, problems)
432 }
433 if htmxRequest(r) {
434 updated, err := index.LoadIssue(m, id)
435 if err != nil {
436 w.render(rw, "issue_thread", w.issueThread(r, m, iss, err.Error()), 0)
437 return
438 }
439 w.render(rw, "issue_thread", w.issueThread(r, m, updated, ""), 0)
440 return
441 }
442 http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id+"#c-"+commentID, http.StatusSeeOther)
443 }
444
284 445 func (w *Web) ApproveRequest(rw http.ResponseWriter, r *http.Request) {
285 446 pr, ok := w.requireHTMLUser(rw, r)
286 447 if !ok {
@@ -515,12 +676,22 @@ func (w *Web) AddRequestComment(rw http.ResponseWriter, r *http.Request) {
515 676 return
516 677 }
517 678 body := strings.TrimSpace(r.PostFormValue("body"))
679 replyTo := strings.TrimSpace(r.PostFormValue("reply_to"))
518 680 path := strings.TrimSpace(r.PostFormValue("path"))
519 681 line, _ := strconv.Atoi(r.PostFormValue("line"))
520 682 side := strings.TrimSpace(r.PostFormValue("side"))
521 683 if side == "" {
522 684 side = "new"
523 685 }
686 if replyTo != "" && path != "" {
687 if htmxRequest(r) {
688 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil,
689 "a reply belongs to its parent thread, not a line"), 0)
690 return
691 }
692 http.Error(rw, "a reply belongs to its parent thread, not a line", http.StatusBadRequest)
693 return
694 }
524 695 var target *commentTarget
525 696 var anchor *metadata.Anchor
526 697 if path != "" {
@@ -553,6 +724,7 @@ func (w *Web) AddRequestComment(rw http.ResponseWriter, r *http.Request) {
553 724 return
554 725 }
555 726 c.Anchor = anchor
727 c.ReplyTo = replyTo
556 728 if err := metadata.AddRequestComment(m.Git(), id, c, pr.account.Name); err != nil {
557 729 if htmxRequest(r) {
558 730 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, "cannot add the comment: "+err.Error()), 0)
@@ -574,7 +746,145 @@ func (w *Web) AddRequestComment(rw http.ResponseWriter, r *http.Request) {
574 746 w.render(rw, "request_discussion", w.requestDiscussion(r, m, updated, nil, ""), 0)
575 747 return
576 748 }
577 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id+"#c-"+c.ID, http.StatusSeeOther)
749 // An anchored comment belongs to the Files tab, so return to the note beneath
750 // its line. A general comment still lands in the conversation thread.
751 next := "/r/" + m.Name + "/requests/" + id + "#c-" + c.ID
752 if c.Anchor != nil {
753 path := w.reviewPathFor(m, req, c.Anchor.Path)
754 next = "/r/" + m.Name + "/requests/" + id + "/files/" + pathURL(path) + "#inline-c-" + c.ID
755 }
756 http.Redirect(rw, r, next, http.StatusSeeOther)
757 }
758
759 // EditRequestComment rewrites one discussion comment. Only its author, or an
760 // admin repairing a thread, may do so.
761 func (w *Web) EditRequestComment(rw http.ResponseWriter, r *http.Request) {
762 pr, ok := w.requireHTMLUser(rw, r)
763 if !ok {
764 return
765 }
766 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
767 if !w.checkCSRF(r) {
768 http.Error(rw, "bad request", http.StatusForbidden)
769 return
770 }
771 m, ok := w.open(rw, r)
772 if !ok {
773 return
774 }
775 id, commentID := r.PathValue("id"), r.PathValue("comment")
776 req, err := metadata.GetRequest(m.Git(), id)
777 if err != nil {
778 http.NotFound(rw, r)
779 return
780 }
781 c := requestCommentByID(req, commentID)
782 if c == nil {
783 http.NotFound(rw, r)
784 return
785 }
786 acl, _ := m.ACL()
787 if !canManageComment(pr, acl, c.Author) {
788 http.Error(rw, "only the author or an admin may edit this comment", http.StatusForbidden)
789 return
790 }
791 if err := r.ParseForm(); err != nil {
792 http.Error(rw, "cannot read the form", http.StatusBadRequest)
793 return
794 }
795 body := strings.TrimSpace(r.PostFormValue("body"))
796 if body == "" {
797 if htmxRequest(r) {
798 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, "a comment needs a body"), 0)
799 return
800 }
801 http.Error(rw, "a comment needs a body", http.StatusBadRequest)
802 return
803 }
804 if _, err := metadata.EditRequestComment(m.Git(), id, commentID, body, pr.account.Name); err != nil {
805 if htmxRequest(r) {
806 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, "cannot edit the comment: "+err.Error()), 0)
807 return
808 }
809 http.Error(rw, "cannot edit the comment: "+err.Error(), http.StatusBadRequest)
810 return
811 }
812 detail := m.Name + " " + id + " " + commentID
813 if pr.account.Name != c.Author {
814 detail += " (author " + c.Author + ", admin override)"
815 }
816 w.audit("web.request.comment.edit", pr.account.ID, detail)
817 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
818 w.Logger("web: edit comment %s left the metadata tree invalid: %v", commentID, problems)
819 }
820 if htmxRequest(r) {
821 updated, err := metadata.GetRequest(m.Git(), id)
822 if err != nil {
823 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, err.Error()), 0)
824 return
825 }
826 w.render(rw, "request_discussion", w.requestDiscussion(r, m, updated, nil, ""), 0)
827 return
828 }
829 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id+"#c-"+commentID, http.StatusSeeOther)
830 }
831
832 // DeleteRequestComment replaces a discussion comment with its tombstone.
833 func (w *Web) DeleteRequestComment(rw http.ResponseWriter, r *http.Request) {
834 pr, ok := w.requireHTMLUser(rw, r)
835 if !ok {
836 return
837 }
838 if !w.checkCSRF(r) {
839 http.Error(rw, "bad request", http.StatusForbidden)
840 return
841 }
842 m, ok := w.open(rw, r)
843 if !ok {
844 return
845 }
846 id, commentID := r.PathValue("id"), r.PathValue("comment")
847 req, err := metadata.GetRequest(m.Git(), id)
848 if err != nil {
849 http.NotFound(rw, r)
850 return
851 }
852 c := requestCommentByID(req, commentID)
853 if c == nil {
854 http.NotFound(rw, r)
855 return
856 }
857 acl, _ := m.ACL()
858 if !canManageComment(pr, acl, c.Author) {
859 http.Error(rw, "only the author or an admin may delete this comment", http.StatusForbidden)
860 return
861 }
862 if _, err := metadata.DeleteRequestComment(m.Git(), id, commentID, pr.account.Name); err != nil {
863 if htmxRequest(r) {
864 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, "cannot delete the comment: "+err.Error()), 0)
865 return
866 }
867 http.Error(rw, "cannot delete the comment: "+err.Error(), http.StatusBadRequest)
868 return
869 }
870 detail := m.Name + " " + id + " " + commentID
871 if pr.account.Name != c.Author {
872 detail += " (author " + c.Author + ", admin override)"
873 }
874 w.audit("web.request.comment.delete", pr.account.ID, detail)
875 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
876 w.Logger("web: delete comment %s left the metadata tree invalid: %v", commentID, problems)
877 }
878 if htmxRequest(r) {
879 updated, err := metadata.GetRequest(m.Git(), id)
880 if err != nil {
881 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, err.Error()), 0)
882 return
883 }
884 w.render(rw, "request_discussion", w.requestDiscussion(r, m, updated, nil, ""), 0)
885 return
886 }
887 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id+"#c-"+commentID, http.StatusSeeOther)
578 888 }
579 889
580 890 // ChangeRequestState closes or reopens a change request. Closing is not merging: