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: | |