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.
server/issues_changes_test.go +277−9
| @@ -40,7 +40,7 @@ func TestIssueCanBeClosedAndReopenedInTheBrowser(t *testing.T) { | |||
| 40 | 40 | t.Fatalf("close issue: %d\n%s", resp.StatusCode, body) | |
| 41 | 41 | } | |
| 42 | 42 | _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie) | |
| 43 | if !strings.Contains(page, "Reopen issue") || !strings.Contains(page, `class="state closed"`) { | ||
| 43 | if !strings.Contains(page, "Reopen</button>") || !strings.Contains(page, `class="state closed"`) { | ||
| 44 | 44 | t.Fatalf("closed issue does not offer a reopen:\n%s", page) | |
| 45 | 45 | } | |
| 46 | 46 | // The default list is open issues; the closed one is behind the All filter. | |
| @@ -57,7 +57,8 @@ func TestIssueCanBeClosedAndReopenedInTheBrowser(t *testing.T) { | |||
| 57 | 57 | if resp.StatusCode != http.StatusSeeOther { | |
| 58 | 58 | t.Fatalf("reopen issue: %d\n%s", resp.StatusCode, body) | |
| 59 | 59 | } | |
| 60 | if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie); !strings.Contains(page, "Close issue") { | ||
| 60 | if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie); !strings.Contains(page, "Close</button>") || | ||
| 61 | !strings.Contains(page, `class="danger"`) { | ||
| 61 | 62 | t.Fatalf("reopened issue does not offer a close:\n%s", page) | |
| 62 | 63 | } | |
| 63 | 64 | } | |
| @@ -112,7 +113,7 @@ func TestChangeCanBeDiscussedAndClosedInTheBrowser(t *testing.T) { | |||
| 112 | 113 | if _, list := f.browserGet(t, "/r/"+f.repo+"/requests?state=all", cookie); !strings.Contains(list, "Browser change") { | |
| 113 | 114 | t.Fatalf("closed change is missing from the All list:\n%s", list) | |
| 114 | 115 | } | |
| 115 | if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie); !strings.Contains(page, "Reopen change") { | ||
| 116 | if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie); !strings.Contains(page, "Reopen</button>") { | ||
| 116 | 117 | t.Fatalf("closed change does not offer a reopen:\n%s", page) | |
| 117 | 118 | } | |
| 118 | 119 | ||
| @@ -122,7 +123,8 @@ func TestChangeCanBeDiscussedAndClosedInTheBrowser(t *testing.T) { | |||
| 122 | 123 | if resp.StatusCode != http.StatusSeeOther { | |
| 123 | 124 | t.Fatalf("reopen change: %d\n%s", resp.StatusCode, body) | |
| 124 | 125 | } | |
| 125 | if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie); !strings.Contains(page, "Close change") { | ||
| 126 | if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie); !strings.Contains(page, "Close</button>") || | ||
| 127 | !strings.Contains(page, `class="danger"`) { | ||
| 126 | 128 | t.Fatalf("reopened change does not offer a close:\n%s", page) | |
| 127 | 129 | } | |
| 128 | 130 | } | |
| @@ -470,13 +472,36 @@ func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) { | |||
| 470 | 472 | } | |
| 471 | 473 | id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/") | |
| 472 | 474 | ||
| 473 | // The diff offers a per-line comment link on the added line. | ||
| 474 | _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie) | ||
| 475 | if !strings.Contains(page, "path=readme.md") || !strings.Contains(page, "line=2") { | ||
| 476 | t.Fatalf("diff has no line comment link:\n%s", page) | ||
| 475 | // The Files tab brings the changed file and its per-line comment button. | ||
| 476 | filesURL := "/r/" + f.repo + "/requests/" + id + "/files" | ||
| 477 | _, page := f.browserGet(t, filesURL, cookie) | ||
| 478 | if !strings.Contains(page, `id="review-tree"`) || !strings.Contains(page, `data-status="M"`) || | ||
| 479 | !strings.Contains(page, filesURL+"/readme.md") { | ||
| 480 | t.Fatalf("files tab has no changed-file tree:\n%s", page) | ||
| 481 | } | ||
| 482 | if !strings.Contains(page, "path=readme.md") || !strings.Contains(page, "line=2") || | ||
| 483 | !strings.Contains(page, "/comment-form?path=readme.md") { | ||
| 484 | t.Fatalf("diff has no line comment control:\n%s", page) | ||
| 485 | } | ||
| 486 | |||
| 487 | // A tree click returns only the review pane. | ||
| 488 | resp, piece := f.htmxGet(t, filesURL+"/readme.md", cookie) | ||
| 489 | if resp.StatusCode != http.StatusOK || strings.Contains(piece, "<!doctype html>") || | ||
| 490 | !strings.Contains(piece, `id="review-main"`) || !strings.Contains(piece, "readme.md") { | ||
| 491 | t.Fatalf("review fragment = %d:\n%s", resp.StatusCode, piece) | ||
| 477 | 492 | } | |
| 478 | 493 | ||
| 479 | // Following it opens the composer with the anchor pre-filled. | ||
| 494 | // HTMX inserts the composer under the line, with the anchor already filled in. | ||
| 495 | resp, form := f.htmxGet(t, "/r/"+f.repo+"/requests/"+id+"/comment-form?path=readme.md&line=2&side=new", cookie) | ||
| 496 | if resp.StatusCode != http.StatusOK || strings.Contains(form, "<!doctype html>") || | ||
| 497 | !strings.Contains(form, `class="inline-comment"`) || | ||
| 498 | !strings.Contains(form, `name="path" value="readme.md"`) || | ||
| 499 | !strings.Contains(form, `name="line" value="2"`) || | ||
| 500 | !strings.Contains(form, "data-inline-comment-cancel") { | ||
| 501 | t.Fatalf("inline composer = %d:\n%s", resp.StatusCode, form) | ||
| 502 | } | ||
| 503 | |||
| 504 | // The old full-page fallback still opens the composer with the anchor filled in. | ||
| 480 | 505 | _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+id+"?path=readme.md&line=2&side=new", cookie) | |
| 481 | 506 | if !strings.Contains(page, "Commenting on") || !strings.Contains(page, `name="path" value="readme.md"`) || | |
| 482 | 507 | !strings.Contains(page, `name="line" value="2"`) { | |
| @@ -503,4 +528,247 @@ func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) { | |||
| 503 | 528 | if a.Path != "readme.md" || a.Line != 2 || a.Side != "new" || a.Blob == "" { | |
| 504 | 529 | t.Fatalf("anchor = %+v", a) | |
| 505 | 530 | } | |
| 531 | |||
| 532 | // The Files full page puts the comment beneath the line it belongs to. | ||
| 533 | _, page = f.browserGet(t, filesURL+"/readme.md", cookie) | ||
| 534 | if !strings.Contains(page, `id="inline-c-`+req.Comments[0].ID+`"`) || | ||
| 535 | !strings.Contains(page, "This line needs a second look.") { | ||
| 536 | t.Fatalf("anchored comment is not rendered inline:\n%s", page) | ||
| 537 | } | ||
| 538 | |||
| 539 | // A normal form post from the Files tab returns to that file's line note. | ||
| 540 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/comments", cookie, url.Values{ | ||
| 541 | "csrf": {sess.CSRF}, "body": {"Another line note."}, | ||
| 542 | "path": {"readme.md"}, "line": {"2"}, "side": {"new"}, | ||
| 543 | }) | ||
| 544 | if resp.StatusCode != http.StatusSeeOther { | ||
| 545 | t.Fatalf("inline comment post = %d:\n%s", resp.StatusCode, body) | ||
| 546 | } | ||
| 547 | if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, filesURL+"/readme.md#inline-c-") { | ||
| 548 | t.Fatalf("inline comment redirect = %q", loc) | ||
| 549 | } | ||
| 550 | } | ||
| 551 | |||
| 552 | func TestIssueAndChangeCommentsThreadOneLevel(t *testing.T) { | ||
| 553 | t.Parallel() | ||
| 554 | f := newFixture(t) | ||
| 555 | f.publish(f.repo) | ||
| 556 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 557 | commentID := func(resp *http.Response) string { | ||
| 558 | loc := resp.Header.Get("Location") | ||
| 559 | if i := strings.LastIndex(loc, "#c-"); i >= 0 { | ||
| 560 | return loc[i+3:] | ||
| 561 | } | ||
| 562 | return "" | ||
| 563 | } | ||
| 564 | |||
| 565 | // Issue: replies are rendered under their root, even when replying to a reply. | ||
| 566 | resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 567 | "csrf": {sess.CSRF}, "title": {"Threaded issue"}, "body": {"body\n"}, | ||
| 568 | }) | ||
| 569 | if resp.StatusCode != http.StatusSeeOther { | ||
| 570 | t.Fatalf("create issue: %d\n%s", resp.StatusCode, body) | ||
| 571 | } | ||
| 572 | issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/") | ||
| 573 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{ | ||
| 574 | "csrf": {sess.CSRF}, "body": {"Root comment."}, | ||
| 575 | }) | ||
| 576 | rootID := commentID(resp) | ||
| 577 | if resp.StatusCode != http.StatusSeeOther || rootID == "" { | ||
| 578 | t.Fatalf("issue root comment: %d %q\n%s", resp.StatusCode, rootID, body) | ||
| 579 | } | ||
| 580 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{ | ||
| 581 | "csrf": {sess.CSRF}, "body": {"Issue reply."}, "reply_to": {rootID}, | ||
| 582 | }) | ||
| 583 | replyID := commentID(resp) | ||
| 584 | if resp.StatusCode != http.StatusSeeOther || replyID == "" { | ||
| 585 | t.Fatalf("issue reply: %d %q\n%s", resp.StatusCode, replyID, body) | ||
| 586 | } | ||
| 587 | _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie) | ||
| 588 | if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+replyID+`"`) || | ||
| 589 | !strings.Contains(page, "Issue reply.") || !strings.Contains(page, "data-comment-menu") || | ||
| 590 | !strings.Contains(page, `data-reply-to="`+replyID+`"`) || | ||
| 591 | !strings.Contains(page, `class="reply-context"`) || !strings.Contains(page, `href="#c-`+rootID+`"`) { | ||
| 592 | t.Fatalf("issue reply is not nested:\n%s", page) | ||
| 593 | } | ||
| 594 | if !strings.Contains(page, `<div id="issue-thread">`) || | ||
| 595 | !strings.Contains(page, `class="thread-replies"`) || | ||
| 596 | strings.Contains(page, `class="discussion-panel"`) || strings.Contains(page, `class="comment-scroll"`) || | ||
| 597 | strings.Count(page, `discussion-composer`) != 1 || | ||
| 598 | !strings.Contains(page, "data-reply-input") || !strings.Contains(page, "Cancel reply") { | ||
| 599 | t.Fatalf("issue discussion is not a full thread with one composer:\n%s", page) | ||
| 600 | } | ||
| 601 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{ | ||
| 602 | "csrf": {sess.CSRF}, "body": {"Reply to a reply."}, "reply_to": {replyID}, | ||
| 603 | }) | ||
| 604 | deeperID := commentID(resp) | ||
| 605 | if resp.StatusCode != http.StatusSeeOther || deeperID == "" { | ||
| 606 | t.Fatalf("reply to a reply: %d %q\n%s", resp.StatusCode, deeperID, body) | ||
| 607 | } | ||
| 608 | _, page = f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie) | ||
| 609 | if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+deeperID+`"`) || | ||
| 610 | !strings.Contains(page, "Reply to a reply.") || !strings.Contains(page, `data-reply-to="`+deeperID+`"`) || | ||
| 611 | !strings.Contains(page, `href="#c-`+replyID+`"`) { | ||
| 612 | t.Fatalf("a reply to a reply was not flattened under its root:\n%s", page) | ||
| 613 | } | ||
| 614 | |||
| 615 | // Change: the same flattening rule applies to the review discussion. | ||
| 616 | base := workRepo(t, map[string]string{"readme.md": "v1\n"}) | ||
| 617 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 618 | t.Fatalf("main push: %s", errs) | ||
| 619 | } | ||
| 620 | feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature") | ||
| 621 | if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil { | ||
| 622 | t.Fatalf("feature push: %s", errs) | ||
| 623 | } | ||
| 624 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{ | ||
| 625 | "csrf": {sess.CSRF}, "title": {"Threaded change"}, "body": {"body\n"}, | ||
| 626 | "base": {"main"}, "head": {"feature"}, | ||
| 627 | }) | ||
| 628 | if resp.StatusCode != http.StatusSeeOther { | ||
| 629 | t.Fatalf("create change: %d\n%s", resp.StatusCode, body) | ||
| 630 | } | ||
| 631 | requestID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/") | ||
| 632 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+requestID+"/comments", cookie, url.Values{ | ||
| 633 | "csrf": {sess.CSRF}, "body": {"Change root."}, | ||
| 634 | }) | ||
| 635 | rootID = commentID(resp) | ||
| 636 | if resp.StatusCode != http.StatusSeeOther || rootID == "" { | ||
| 637 | t.Fatalf("change root comment: %d %q\n%s", resp.StatusCode, rootID, body) | ||
| 638 | } | ||
| 639 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+requestID+"/comments", cookie, url.Values{ | ||
| 640 | "csrf": {sess.CSRF}, "body": {"Change reply."}, "reply_to": {rootID}, | ||
| 641 | }) | ||
| 642 | replyID = commentID(resp) | ||
| 643 | if resp.StatusCode != http.StatusSeeOther || replyID == "" { | ||
| 644 | t.Fatalf("change reply: %d %q\n%s", resp.StatusCode, replyID, body) | ||
| 645 | } | ||
| 646 | _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+requestID, cookie) | ||
| 647 | if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+replyID+`"`) || | ||
| 648 | !strings.Contains(page, "Change reply.") || !strings.Contains(page, `data-reply-to="`+replyID+`"`) || | ||
| 649 | !strings.Contains(page, `class="reply-context"`) || !strings.Contains(page, `href="#c-`+rootID+`"`) || | ||
| 650 | !strings.Contains(page, `<div id="request-discussion">`) || | ||
| 651 | !strings.Contains(page, `class="thread-replies"`) || | ||
| 652 | strings.Count(page, `discussion-composer`) != 1 { | ||
| 653 | t.Fatalf("change reply is not nested:\n%s", page) | ||
| 654 | } | ||
| 655 | } | ||
| 656 | |||
| 657 | func TestCommentEditAndDeleteFromTheWeb(t *testing.T) { | ||
| 658 | t.Parallel() | ||
| 659 | f := newFixture(t) | ||
| 660 | f.publish(f.repo) | ||
| 661 | f.addWriter(t) | ||
| 662 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 663 | commentID := func(resp *http.Response) string { | ||
| 664 | loc := resp.Header.Get("Location") | ||
| 665 | if i := strings.LastIndex(loc, "#c-"); i >= 0 { | ||
| 666 | return loc[i+3:] | ||
| 667 | } | ||
| 668 | return "" | ||
| 669 | } | ||
| 670 | |||
| 671 | resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 672 | "csrf": {sess.CSRF}, "title": {"Editable issue"}, "body": {"body\n"}, | ||
| 673 | }) | ||
| 674 | if resp.StatusCode != http.StatusSeeOther { | ||
| 675 | t.Fatalf("create issue: %d\n%s", resp.StatusCode, body) | ||
| 676 | } | ||
| 677 | issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/") | ||
| 678 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{ | ||
| 679 | "csrf": {sess.CSRF}, "body": {"Original root."}, | ||
| 680 | }) | ||
| 681 | rootID := commentID(resp) | ||
| 682 | if resp.StatusCode != http.StatusSeeOther || rootID == "" { | ||
| 683 | t.Fatalf("root comment: %d %q\n%s", resp.StatusCode, rootID, body) | ||
| 684 | } | ||
| 685 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{ | ||
| 686 | "csrf": {sess.CSRF}, "body": {"A surviving reply."}, "reply_to": {rootID}, | ||
| 687 | }) | ||
| 688 | if resp.StatusCode != http.StatusSeeOther { | ||
| 689 | t.Fatalf("reply: %d\n%s", resp.StatusCode, body) | ||
| 690 | } | ||
| 691 | |||
| 692 | _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie) | ||
| 693 | for _, want := range []string{"data-comment-menu-toggle", "Copy text", "Copy link", "data-comment-edit", "Delete"} { | ||
| 694 | if !strings.Contains(page, want) { | ||
| 695 | t.Fatalf("comment menu is missing %q:\n%s", want, page) | ||
| 696 | } | ||
| 697 | } | ||
| 698 | |||
| 699 | // A maintainer who is not the author cannot rewrite or remove the comment. | ||
| 700 | writerSess, writerCookie := f.browserSession(t, f.writerID) | ||
| 701 | resp, _ = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/edit", writerCookie, url.Values{ | ||
| 702 | "csrf": {writerSess.CSRF}, "body": {"Not mine."}, | ||
| 703 | }) | ||
| 704 | if resp.StatusCode != http.StatusForbidden { | ||
| 705 | t.Fatalf("foreign edit = %d, want 403", resp.StatusCode) | ||
| 706 | } | ||
| 707 | |||
| 708 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/edit", cookie, url.Values{ | ||
| 709 | "csrf": {sess.CSRF}, "body": {"Edited root."}, | ||
| 710 | }) | ||
| 711 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-thread"`) || | ||
| 712 | !strings.Contains(body, "Edited root.") || strings.Contains(body, "Original root.") { | ||
| 713 | t.Fatalf("edit response = %d:\n%s", resp.StatusCode, body) | ||
| 714 | } | ||
| 715 | |||
| 716 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/delete", cookie, url.Values{ | ||
| 717 | "csrf": {sess.CSRF}, | ||
| 718 | }) | ||
| 719 | if resp.StatusCode != http.StatusSeeOther { | ||
| 720 | t.Fatalf("delete = %d:\n%s", resp.StatusCode, body) | ||
| 721 | } | ||
| 722 | _, page = f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie) | ||
| 723 | if !strings.Contains(page, "Comment deleted") || !strings.Contains(page, "A surviving reply.") { | ||
| 724 | t.Fatalf("deleted root did not become a tombstone:\n%s", page) | ||
| 725 | } | ||
| 726 | |||
| 727 | // The same routes work for a change discussion. | ||
| 728 | base := workRepo(t, map[string]string{"readme.md": "v1\n"}) | ||
| 729 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 730 | t.Fatalf("main push: %s", errs) | ||
| 731 | } | ||
| 732 | feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature") | ||
| 733 | if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil { | ||
| 734 | t.Fatalf("feature push: %s", errs) | ||
| 735 | } | ||
| 736 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{ | ||
| 737 | "csrf": {sess.CSRF}, "title": {"Editable change"}, "body": {"body\n"}, | ||
| 738 | "base": {"main"}, "head": {"feature"}, | ||
| 739 | }) | ||
| 740 | if resp.StatusCode != http.StatusSeeOther { | ||
| 741 | t.Fatalf("create change: %d\n%s", resp.StatusCode, body) | ||
| 742 | } | ||
| 743 | requestID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/") | ||
| 744 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+requestID+"/comments", cookie, url.Values{ | ||
| 745 | "csrf": {sess.CSRF}, "body": {"Original change root."}, | ||
| 746 | }) | ||
| 747 | rootID = commentID(resp) | ||
| 748 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+requestID+"/comments", cookie, url.Values{ | ||
| 749 | "csrf": {sess.CSRF}, "body": {"Change reply survives."}, "reply_to": {rootID}, | ||
| 750 | }) | ||
| 751 | if resp.StatusCode != http.StatusSeeOther { | ||
| 752 | t.Fatalf("change reply: %d\n%s", resp.StatusCode, body) | ||
| 753 | } | ||
| 754 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+requestID+"/comments/"+rootID+"/edit", cookie, url.Values{ | ||
| 755 | "csrf": {sess.CSRF}, "body": {"Edited change root."}, | ||
| 756 | }) | ||
| 757 | if resp.StatusCode != http.StatusSeeOther { | ||
| 758 | t.Fatalf("change edit: %d\n%s", resp.StatusCode, body) | ||
| 759 | } | ||
| 760 | _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+requestID, cookie) | ||
| 761 | if !strings.Contains(page, "Edited change root.") || strings.Contains(page, "Original change root.") { | ||
| 762 | t.Fatalf("change edit did not replace the comment:\n%s", page) | ||
| 763 | } | ||
| 764 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+requestID+"/comments/"+rootID+"/delete", cookie, url.Values{ | ||
| 765 | "csrf": {sess.CSRF}, | ||
| 766 | }) | ||
| 767 | if resp.StatusCode != http.StatusSeeOther { | ||
| 768 | t.Fatalf("change delete: %d\n%s", resp.StatusCode, body) | ||
| 769 | } | ||
| 770 | _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+requestID, cookie) | ||
| 771 | if !strings.Contains(page, "Comment deleted") || !strings.Contains(page, "Change reply survives.") { | ||
| 772 | t.Fatalf("deleted change root did not become a tombstone:\n%s", page) | ||
| 773 | } | ||
| 506 | 774 | } | |