jojo/tests/integration/pull_review_test.go
2026-05-12 13:08:20 +02:00

2148 lines
85 KiB
Go

// Copyright 2019 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path"
"slices"
"strconv"
"strings"
"testing"
"time"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
issues_model "forgejo.org/models/issues"
repo_model "forgejo.org/models/repo"
unit_model "forgejo.org/models/unit"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/git"
"forgejo.org/modules/gitrepo"
"forgejo.org/modules/optional"
repo_module "forgejo.org/modules/repository"
api "forgejo.org/modules/structs"
"forgejo.org/modules/test"
issue_service "forgejo.org/services/issue"
"forgejo.org/services/mailer"
repo_service "forgejo.org/services/repository"
files_service "forgejo.org/services/repository/files"
"forgejo.org/tests"
"forgejo.org/tests/forgery"
"github.com/PuerkitoBio/goquery"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/html"
)
func TestPullView_ReviewerMissed(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user1")
req := NewRequest(t, "GET", "/pulls")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
req = NewRequest(t, "GET", "/user2/repo1/pulls/3")
resp = session.MakeRequest(t, req, http.StatusOK)
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
// if some reviews are missing, the page shouldn't fail
reviews, err := issues_model.FindReviews(db.DefaultContext, issues_model.FindReviewOptions{
IssueID: 2,
})
require.NoError(t, err)
for _, r := range reviews {
require.NoError(t, issues_model.DeleteReview(db.DefaultContext, r))
}
req = NewRequest(t, "GET", "/user2/repo1/pulls/2")
resp = session.MakeRequest(t, req, http.StatusOK)
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
}
func TestPullRequestParticipants(t *testing.T) {
defer unittest.OverrideFixtures("tests/integration/fixtures/TestPullRequestParticipants")()
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user1")
req := NewRequest(t, "GET", "/user2/repo1/pulls/2")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "2 participants")
assert.Contains(t, resp.Body.String(), `<a href="/user1" data-tooltip-content="user1">`)
assert.Contains(t, resp.Body.String(), `<a href="/user2" data-tooltip-content="user2">`)
// does not contain user10 which has a pending review for this issue
assert.NotContains(t, resp.Body.String(), `<a href="/user10" data-tooltip-content="user10">`)
}
func loadComment(t *testing.T, commentID string) *issues_model.Comment {
t.Helper()
id, err := strconv.ParseInt(commentID, 10, 64)
require.NoError(t, err)
return unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: id})
}
func TestPullView_SelfReviewNotification(t *testing.T) {
onApplicationRun(t, func(t *testing.T, giteaURL *url.URL) {
user1Session := loginUser(t, "user1")
user2Session := loginUser(t, "user2")
oldUser1NotificationCount := getUserNotificationCount(t, user1Session)
oldUser2NotificationCount := getUserNotificationCount(t, user2Session)
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo, _, f := tests.CreateDeclarativeRepo(t, user2, "test_reviewer", nil, nil, []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "CODEOWNERS",
ContentReader: strings.NewReader("README.md @user5\n"),
},
})
defer f()
// we need to add user1 as collaborator so it can be added as reviewer
err := repo_module.AddCollaborator(db.DefaultContext, repo, user1)
require.NoError(t, err)
// create a new branch to prepare for pull request
err = updateFileInBranch(user2, repo, "README.md", "codeowner-basebranch",
strings.NewReader("# This is a new project\n"),
)
require.NoError(t, err)
// Create a pull request.
resp := testPullCreate(t, user2Session, "user2", "test_reviewer", false, repo.DefaultBranch, "codeowner-basebranch", "Test Pull Request")
prURL := test.RedirectURL(resp)
elem := strings.Split(prURL, "/")
assert.Equal(t, "pulls", elem[3])
req := NewRequest(t, http.MethodGet, prURL)
resp = MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
attributeFilter := fmt.Sprintf("[data-update-url='/%s/%s/issues/request_review']", user2.Name, repo.Name)
issueID, ok := doc.Find(attributeFilter).Attr("data-issue-id")
assert.True(t, ok, "doc must contain data-issue-id")
testAssignReviewer(t, user1Session, user2.Name, repo.Name, issueID, "1", http.StatusOK)
// both user notification should keep the same notification count since
// user2 added itself as reviewer.
notificationCount := getUserNotificationCount(t, user1Session)
assert.Equal(t, oldUser1NotificationCount, notificationCount)
notificationCount = getUserNotificationCount(t, user2Session)
assert.Equal(t, oldUser2NotificationCount, notificationCount)
})
}
func TestPullView_ResolveInvalidatedReviewComment(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user1")
req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files")
session.MakeRequest(t, req, http.StatusOK)
t.Run("single outdated review (line 1)", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files/reviews/new_comment")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{
"origin": doc.GetInputValueByName("origin"),
"latest_commit_id": doc.GetInputValueByName("latest_commit_id"),
"side": "proposed",
"line": "1",
"path": "iso-8859-1.txt",
"diff_start_cid": doc.GetInputValueByName("diff_start_cid"),
"diff_end_cid": doc.GetInputValueByName("diff_end_cid"),
"diff_base_cid": doc.GetInputValueByName("diff_base_cid"),
"content": "nitpicking comment",
"pending_review": "",
})
session.MakeRequest(t, req, http.StatusOK)
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{
"commit_id": doc.GetInputValueByName("latest_commit_id"),
"content": "looks good",
"type": "comment",
})
session.MakeRequest(t, req, http.StatusOK)
// retrieve comment_id by reloading the comment page
req = NewRequest(t, "GET", "/user2/repo1/pulls/3")
resp = session.MakeRequest(t, req, http.StatusOK)
doc = NewHTMLParser(t, resp.Body)
commentID, ok := doc.Find(`[data-action="Resolve"]`).Attr("data-comment-id")
assert.True(t, ok)
// adjust the database to mark the comment as invalidated
// (to invalidate it properly, one should push a commit which should trigger this logic,
// in the meantime, use this quick-and-dirty trick)
comment := loadComment(t, commentID)
require.NoError(t, issues_model.UpdateCommentInvalidate(t.Context(), &issues_model.Comment{
ID: comment.ID,
Invalidated: true,
}))
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/resolve_conversation", map[string]string{
"origin": "timeline",
"action": "Resolve",
"comment_id": commentID,
})
resp = session.MakeRequest(t, req, http.StatusOK)
// even on template error, the page returns HTTP 200
// count the comments to ensure success.
doc = NewHTMLParser(t, resp.Body)
assert.Len(t, doc.Find(`.comment-code-cloud > .comment`).Nodes, 1)
})
t.Run("outdated and newer review (line 2)", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files/reviews/new_comment")
resp := session.MakeRequest(t, req, http.StatusOK)
newCommentForm := NewHTMLParser(t, resp.Body)
var firstReviewID int64
{
// first (outdated) review
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{
"origin": newCommentForm.GetInputValueByName("origin"),
"latest_commit_id": newCommentForm.GetInputValueByName("latest_commit_id"),
"side": "proposed",
"line": "2",
"path": "iso-8859-1.txt",
"diff_start_cid": newCommentForm.GetInputValueByName("diff_start_cid"),
"diff_end_cid": newCommentForm.GetInputValueByName("diff_end_cid"),
"diff_base_cid": newCommentForm.GetInputValueByName("diff_base_cid"),
"content": "nitpicking comment",
"pending_review": "",
})
session.MakeRequest(t, req, http.StatusOK)
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{
"commit_id": newCommentForm.GetInputValueByName("latest_commit_id"),
"content": "looks good",
"type": "comment",
})
session.MakeRequest(t, req, http.StatusOK)
// retrieve comment_id by reloading the comment page
req = NewRequest(t, "GET", "/user2/repo1/pulls/3")
resp = session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
commentID, ok := doc.Find(`[data-action="Resolve"]`).Attr("data-comment-id")
assert.True(t, ok)
// adjust the database to mark the comment as invalidated
// (to invalidate it properly, one should push a commit which should trigger this logic,
// in the meantime, use this quick-and-dirty trick)
comment := loadComment(t, commentID)
require.NoError(t, issues_model.UpdateCommentInvalidate(t.Context(), &issues_model.Comment{
ID: comment.ID,
Invalidated: true,
}))
firstReviewID = comment.ReviewID
assert.NotZero(t, firstReviewID)
}
// ID of the first comment for the second (up-to-date) review
var commentID string
{
// second (up-to-date) review on the same line
// make a second review
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{
"origin": newCommentForm.GetInputValueByName("origin"),
"latest_commit_id": newCommentForm.GetInputValueByName("latest_commit_id"),
"side": "proposed",
"line": "2",
"path": "iso-8859-1.txt",
"diff_start_cid": newCommentForm.GetInputValueByName("diff_start_cid"),
"diff_end_cid": newCommentForm.GetInputValueByName("diff_end_cid"),
"diff_base_cid": newCommentForm.GetInputValueByName("diff_base_cid"),
"content": "nitpicking comment",
"pending_review": "",
})
session.MakeRequest(t, req, http.StatusOK)
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{
"commit_id": newCommentForm.GetInputValueByName("latest_commit_id"),
"content": "looks better",
"type": "comment",
})
session.MakeRequest(t, req, http.StatusOK)
// retrieve comment_id by reloading the comment page
req = NewRequest(t, "GET", "/user2/repo1/pulls/3")
resp = session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
commentIDs := doc.Find(`[data-action="Resolve"]`).Map(func(i int, elt *goquery.Selection) string {
v, _ := elt.Attr("data-comment-id")
return v
})
assert.Len(t, commentIDs, 2) // 1 for the outdated review, 1 for the current review
// check that the first comment is for the previous review
comment := loadComment(t, commentIDs[0])
assert.Equal(t, comment.ReviewID, firstReviewID)
// check that the second comment is for a different review
comment = loadComment(t, commentIDs[1])
assert.NotZero(t, comment.ReviewID)
assert.NotEqual(t, comment.ReviewID, firstReviewID)
commentID = commentIDs[1] // save commentID for later
}
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/resolve_conversation", map[string]string{
"origin": "timeline",
"action": "Resolve",
"comment_id": commentID,
})
resp = session.MakeRequest(t, req, http.StatusOK)
// even on template error, the page returns HTTP 200
// count the comments to ensure success.
doc := NewHTMLParser(t, resp.Body)
comments := doc.Find(`.comment-code-cloud > .comment`)
assert.Len(t, comments.Nodes, 1) // the outdated comment belongs to another review and should not be shown
})
t.Run("Files Changed tab", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
for _, c := range []struct {
style, outdated string
expectedCount int
}{
{"unified", "true", 3}, // 1 comment on line 1 + 2 comments on line 3
{"unified", "false", 1}, // 1 comment on line 3 is not outdated
{"split", "true", 3}, // 1 comment on line 1 + 2 comments on line 3
{"split", "false", 1}, // 1 comment on line 3 is not outdated
} {
t.Run(c.style+"+"+c.outdated, func(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files?style="+c.style+"&show-outdated="+c.outdated)
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
comments := doc.Find(`.comments > .comment`)
assert.Len(t, comments.Nodes, c.expectedCount)
})
}
})
t.Run("Conversation tab", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/pulls/3")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
comments := doc.Find(`.comment-code-cloud > .comment`)
assert.Len(t, comments.Nodes, 3) // 1 comment on line 1 + 2 comments on line 3
})
}
func TestPullView_CodeOwner(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo, _, f := tests.CreateDeclarativeRepo(t, user2, "test_codeowner", nil, nil, []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "CODEOWNERS",
ContentReader: strings.NewReader("README.md @user5\n"),
},
})
defer f()
t.Run("First Pull Request", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// create a new branch to prepare for pull request
err := updateFileInBranch(user2, repo, "README.md", "codeowner-basebranch",
strings.NewReader("# This is a new project\n"),
)
require.NoError(t, err)
// Create a pull request.
session := loginUser(t, "user2")
testPullCreate(t, session, "user2", "test_codeowner", false, repo.DefaultBranch, "codeowner-basebranch", "Test Pull Request")
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: repo.ID, HeadBranch: "codeowner-basebranch"})
unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 5})
require.NoError(t, pr.LoadIssue(db.DefaultContext))
err = issue_service.ChangeTitle(db.DefaultContext, pr.Issue, user2, "[WIP] Test Pull Request")
require.NoError(t, err)
prUpdated1 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
require.NoError(t, prUpdated1.LoadIssue(db.DefaultContext))
assert.Equal(t, "[WIP] Test Pull Request", prUpdated1.Issue.Title)
err = issue_service.ChangeTitle(db.DefaultContext, prUpdated1.Issue, user2, "Test Pull Request2")
require.NoError(t, err)
prUpdated2 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
require.NoError(t, prUpdated2.LoadIssue(db.DefaultContext))
assert.Equal(t, "Test Pull Request2", prUpdated2.Issue.Title)
})
// change the default branch CODEOWNERS file to change README.md's codeowner
err := updateFileInBranch(user2, repo, "CODEOWNERS", "",
strings.NewReader("README.md @user8\n"),
)
require.NoError(t, err)
t.Run("Second Pull Request", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// create a new branch to prepare for pull request
err := updateFileInBranch(user2, repo, "README.md", "codeowner-basebranch2",
strings.NewReader("# This is a new project2\n"),
)
require.NoError(t, err)
// Create a pull request.
session := loginUser(t, "user2")
testPullCreate(t, session, "user2", "test_codeowner", false, repo.DefaultBranch, "codeowner-basebranch2", "Test Pull Request2")
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadBranch: "codeowner-basebranch2"})
unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 8})
})
t.Run("Forked Repo Pull Request", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
forkedRepo, err := repo_service.ForkRepositoryAndUpdates(db.DefaultContext, user2, user5, repo_service.ForkRepoOptions{
BaseRepo: repo,
Name: "test_codeowner_fork",
})
require.NoError(t, err)
// create a new branch to prepare for pull request
err = updateFileInBranch(user5, forkedRepo, "README.md", "codeowner-basebranch-forked",
strings.NewReader("# This is a new forked project\n"),
)
require.NoError(t, err)
session := loginUser(t, "user5")
// create a pull request on the forked repository, code reviewers should not be mentioned
testPullCreateDirectly(t, session, "user5", "test_codeowner_fork", forkedRepo.DefaultBranch, "", "", "codeowner-basebranch-forked", "Test Pull Request on Forked Repository")
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: forkedRepo.ID, HeadBranch: "codeowner-basebranch-forked"})
unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 8})
// create a pull request to base repository, code reviewers should be mentioned
testPullCreateDirectly(t, session, repo.OwnerName, repo.Name, repo.DefaultBranch, forkedRepo.OwnerName, forkedRepo.Name, "codeowner-basebranch-forked", "Test Pull Request3")
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: forkedRepo.ID, HeadBranch: "codeowner-basebranch-forked"})
unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 8})
})
})
}
func TestPullView_GivenApproveOrRejectReviewOnClosedPR(t *testing.T) {
onApplicationRun(t, func(t *testing.T, giteaURL *url.URL) {
user1Session := loginUser(t, "user1")
user2Session := loginUser(t, "user2")
// Have user1 create a fork of repo1.
testRepoFork(t, user1Session, "user2", "repo1", "user1", "repo1")
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: "repo1"})
baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo)
require.NoError(t, err)
defer baseGitRepo.Close()
t.Run("Submit approve/reject review on merged PR", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Create a merged PR (made by user1) in the upstream repo1.
testEditFile(t, user1Session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
resp := testPullCreate(t, user1Session, "user1", "repo1", false, "master", "master", "This is a pull title")
elem := strings.Split(test.RedirectURL(resp), "/")
assert.Equal(t, "pulls", elem[3])
testPullMerge(t, user1Session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false)
// Get the commit SHA
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
BaseRepoID: baseRepo.ID,
BaseBranch: "master",
HeadRepoID: forkedRepo.ID,
HeadBranch: "master",
})
sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
require.NoError(t, err)
// Submit an approve review on the PR.
testSubmitReview(t, user2Session, "user2", "repo1", elem[4], sha, "approve", http.StatusOK)
// Submit a reject review on the PR.
testSubmitReview(t, user2Session, "user2", "repo1", elem[4], sha, "reject", http.StatusOK)
})
t.Run("Submit approve/reject review on closed PR", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Created a closed PR (made by user1) in the upstream repo1.
testEditFileToNewBranch(t, user1Session, "user1", "repo1", "master", "a-test-branch", "README.md", "Hello, World (Edited...again)\n")
resp := testPullCreate(t, user1Session, "user1", "repo1", false, "master", "a-test-branch", "This is a pull title")
elem := strings.Split(test.RedirectURL(resp), "/")
assert.Equal(t, "pulls", elem[3])
testIssueClose(t, user1Session, elem[1], elem[2], elem[4], true)
// Get the commit SHA
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
BaseRepoID: baseRepo.ID,
BaseBranch: "master",
HeadRepoID: forkedRepo.ID,
HeadBranch: "a-test-branch",
})
sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
require.NoError(t, err)
// Submit an approve review on the PR.
testSubmitReview(t, user2Session, "user2", "repo1", elem[4], sha, "approve", http.StatusOK)
// Submit a reject review on the PR.
testSubmitReview(t, user2Session, "user2", "repo1", elem[4], sha, "reject", http.StatusOK)
})
})
}
func TestPullReview_OldLatestCommitId(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user1")
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: baseRepo.ID, Index: 3})
baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo)
require.NoError(t, err)
defer baseGitRepo.Close()
headCommitSHA, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
require.NoError(t, err)
headCommit, err := baseGitRepo.GetCommit(headCommitSHA)
require.NoError(t, err)
require.GreaterOrEqual(t, headCommit.ParentCount(), 1)
parentCommit, err := headCommit.Parent(0)
require.NoError(t, err)
oldCommitSHA := parentCommit.ID.String()
require.NotEqual(t, headCommitSHA, oldCommitSHA)
req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files/reviews/new_comment")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
const content = "TestPullReview_OldLatestCommitId"
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{
"origin": doc.GetInputValueByName("origin"),
"latest_commit_id": oldCommitSHA,
"side": "proposed",
"line": "2",
"path": "iso-8859-1.txt",
"diff_start_cid": doc.GetInputValueByName("diff_start_cid"),
"diff_end_cid": doc.GetInputValueByName("diff_end_cid"),
"diff_base_cid": doc.GetInputValueByName("diff_base_cid"),
"content": content,
"single_review": "true",
})
session.MakeRequest(t, req, http.StatusOK)
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: pr.IssueID, Content: content})
require.NotZero(t, comment.ReviewID)
assert.Equal(t, oldCommitSHA, comment.CommitSHA)
assert.NotEqual(t, headCommitSHA, comment.CommitSHA)
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: comment.ReviewID})
assert.Equal(t, issues_model.ReviewTypeComment, review.Type)
assert.Equal(t, oldCommitSHA, review.CommitID)
assert.NotEqual(t, headCommitSHA, review.CommitID)
}
func TestPullReviewInArchivedRepo(t *testing.T) {
onApplicationRun(t, func(t *testing.T, giteaURL *url.URL) {
session := loginUser(t, "user2")
// Open a PR
testEditFileToNewBranch(t, session, "user2", "repo1", "master", "for-pr", "README.md", "Hi!\n")
resp := testPullCreate(t, session, "user2", "repo1", true, "master", "for-pr", "PR title")
elem := strings.Split(test.RedirectURL(resp), "/")
t.Run("Review box normally", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// The "Finish review button" must be available
resp = session.MakeRequest(t, NewRequest(t, "GET", path.Join(elem[1], elem[2], "pulls", elem[4], "files")), http.StatusOK)
button := NewHTMLParser(t, resp.Body).Find("#review-box button")
assert.False(t, button.HasClass("disabled"))
})
t.Run("Review box in archived repo", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Archive the repo
resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", path.Join(elem[1], elem[2], "settings"), map[string]string{
"action": "archive",
}), http.StatusSeeOther)
// The "Finish review button" must be disabled
resp = session.MakeRequest(t, NewRequest(t, "GET", path.Join(elem[1], elem[2], "pulls", elem[4], "files")), http.StatusOK)
button := NewHTMLParser(t, resp.Body).Find("#review-box button")
assert.True(t, button.HasClass("disabled"))
})
})
}
func testNotificationCount(t *testing.T, session *TestSession, expectedSubmitStatus int) *httptest.ResponseRecorder {
options := map[string]string{}
req := NewRequestWithValues(t, "GET", "/", options)
return session.MakeRequest(t, req, expectedSubmitStatus)
}
func testAssignReviewer(t *testing.T, session *TestSession, owner, repo, pullID, reviewer string, expectedSubmitStatus int) *httptest.ResponseRecorder {
options := map[string]string{
"action": "attach",
"issue_ids": pullID,
"id": reviewer,
}
submitURL := path.Join(owner, repo, "issues", "request_review")
req := NewRequestWithValues(t, "POST", submitURL, options)
return session.MakeRequest(t, req, expectedSubmitStatus)
}
func testSubmitReview(t *testing.T, session *TestSession, owner, repo, pullNumber, commitID, reviewType string, expectedSubmitStatus int) *httptest.ResponseRecorder {
options := map[string]string{
"commit_id": commitID,
"content": "test",
"type": reviewType,
}
submitURL := path.Join(owner, repo, "pulls", pullNumber, "files", "reviews", "submit")
req := NewRequestWithValues(t, "POST", submitURL, options)
return session.MakeRequest(t, req, expectedSubmitStatus)
}
func testIssueClose(t *testing.T, session *TestSession, owner, repo, issueNumber string, isPull bool) *httptest.ResponseRecorder {
closeURL := path.Join(owner, repo, "issues", issueNumber, "comments")
req := NewRequestWithValues(t, "POST", closeURL, map[string]string{
"status": "close",
})
return session.MakeRequest(t, req, http.StatusOK)
}
func getUserNotificationCount(t *testing.T, session *TestSession) string {
resp := testNotificationCount(t, session, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
return doc.Find(`.notification_count`).Text()
}
func TestPullRequestReplyMail(t *testing.T) {
defer unittest.OverrideFixtures("tests/integration/fixtures/TestPullRequestReplyMail")()
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user.Name)
t.Run("Reply to pending review comment", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
called := false
defer test.MockVariableValue(&mailer.SendAsync, func(...*mailer.Message) {
called = true
})()
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 1002}, "type = 0")
req := NewRequest(t, "GET", "/user2/repo1/pulls/2/files/reviews/new_comment")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/2/files/reviews/comments", map[string]string{
"origin": "diff",
"latest_commit_id": doc.GetInputValueByName("latest_commit_id"),
"content": "Just a comment!",
"side": "proposed",
"line": "4",
"path": "README.md",
"reply": strconv.FormatInt(review.ID, 10),
})
session.MakeRequest(t, req, http.StatusOK)
assert.False(t, called)
unittest.AssertExistsIf(t, true, &issues_model.Comment{Content: "Just a comment!", ReviewID: review.ID, IssueID: 2})
})
t.Run("Start a review", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
called := false
defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
called = true
})()
req := NewRequest(t, "GET", "/user2/repo1/pulls/2/files/reviews/new_comment")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/2/files/reviews/comments", map[string]string{
"origin": "diff",
"latest_commit_id": doc.GetInputValueByName("latest_commit_id"),
"content": "Notification time 2!",
"side": "proposed",
"line": "2",
"path": "README.md",
})
session.MakeRequest(t, req, http.StatusOK)
assert.False(t, called)
unittest.AssertExistsIf(t, true, &issues_model.Comment{Content: "Notification time 2!", IssueID: 2})
})
t.Run("Create a single comment", func(t *testing.T) {
t.Run("As a reply", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
called := false
defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
assert.Len(t, msgs, 2)
SortMailerMessages(msgs)
assert.Equal(t, "user1@example.com", msgs[0].To)
assert.Equal(t, "Re: [user2/repo1] issue2 (PR #2)", msgs[0].Subject)
assert.Contains(t, msgs[0].Body, "Notification time!")
called = true
})()
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 1001, Type: issues_model.ReviewTypeComment})
req := NewRequest(t, "GET", "/user2/repo1/pulls/2/files/reviews/new_comment")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/2/files/reviews/comments", map[string]string{
"origin": "diff",
"latest_commit_id": doc.GetInputValueByName("latest_commit_id"),
"content": "Notification time!",
"side": "proposed",
"line": "3",
"path": "README.md",
"reply": strconv.FormatInt(review.ID, 10),
})
session.MakeRequest(t, req, http.StatusOK)
assert.True(t, called)
unittest.AssertExistsIf(t, true, &issues_model.Comment{Content: "Notification time!", ReviewID: review.ID, IssueID: 2})
})
t.Run("On a new line", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
called := false
defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
assert.Len(t, msgs, 2)
SortMailerMessages(msgs)
assert.Equal(t, "user1@example.com", msgs[0].To)
assert.Equal(t, "Re: [user2/repo1] issue2 (PR #2)", msgs[0].Subject)
assert.Contains(t, msgs[0].Body, "Notification time 2!")
called = true
})()
req := NewRequest(t, "GET", "/user2/repo1/pulls/2/files/reviews/new_comment")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/2/files/reviews/comments", map[string]string{
"origin": "diff",
"latest_commit_id": doc.GetInputValueByName("latest_commit_id"),
"content": "Notification time 2!",
"side": "proposed",
"line": "5",
"path": "README.md",
"single_review": "true",
})
session.MakeRequest(t, req, http.StatusOK)
assert.True(t, called)
unittest.AssertExistsIf(t, true, &issues_model.Comment{Content: "Notification time 2!", IssueID: 2})
})
})
}
func updateFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, newBranch string, content io.ReadSeeker) error {
oldBranch, err := gitrepo.GetDefaultBranch(git.DefaultContext, repo)
if err != nil {
return err
}
commitID, err := gitrepo.GetBranchCommitID(git.DefaultContext, repo, oldBranch)
if err != nil {
return err
}
opts := &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "update",
TreePath: treePath,
ContentReader: content,
},
},
OldBranch: oldBranch,
NewBranch: newBranch,
Author: nil,
Committer: nil,
LastCommitID: commitID,
}
_, err = files_service.ChangeRepoFiles(git.DefaultContext, repo, user, opts)
return err
}
func TestPullRequestStaleReview(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
// Create temporary repository.
repo, _, f := tests.CreateDeclarativeRepo(t, user2, "",
[]unit_model.Type{unit_model.TypePullRequests}, nil,
[]*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "FUNFACT",
ContentReader: strings.NewReader("Smithy was the runner up to be Forgejo's name"),
},
},
)
defer f()
clone := func(t *testing.T, clone string) string {
t.Helper()
dstPath := t.TempDir()
cloneURL, _ := url.Parse(clone)
cloneURL.User = url.UserPassword("user2", userPassword)
require.NoError(t, git.CloneWithArgs(t.Context(), nil, cloneURL.String(), dstPath, git.CloneRepoOptions{}))
doGitSetRemoteURL(dstPath, "origin", cloneURL)(t)
return dstPath
}
firstCommit := func(t *testing.T, dstPath string) string {
t.Helper()
require.NoError(t, os.WriteFile(path.Join(dstPath, "README.md"), []byte("## test content"), 0o600))
require.NoError(t, git.AddChanges(dstPath, true))
require.NoError(t, git.CommitChanges(dstPath, git.CommitChangesOptions{
Committer: &git.Signature{
Email: "user2@example.com",
Name: "user2",
When: time.Now(),
},
Author: &git.Signature{
Email: "user2@example.com",
Name: "user2",
When: time.Now(),
},
Message: "Add README.",
}))
stdout := &bytes.Buffer{}
require.NoError(t, git.NewCommand(t.Context(), "rev-parse", "HEAD").Run(&git.RunOpts{Dir: dstPath, Stdout: stdout}))
return strings.TrimSpace(stdout.String())
}
secondCommit := func(t *testing.T, dstPath string) {
require.NoError(t, os.WriteFile(path.Join(dstPath, "README.md"), []byte("## I prefer this heading"), 0o600))
require.NoError(t, git.AddChanges(dstPath, true))
require.NoError(t, git.CommitChanges(dstPath, git.CommitChangesOptions{
Committer: &git.Signature{
Email: "user2@example.com",
Name: "user2",
When: time.Now(),
},
Author: &git.Signature{
Email: "user2@example.com",
Name: "user2",
When: time.Now(),
},
Message: "Add README.",
}))
}
firstReview := func(t *testing.T, firstCommitID string, index int64) {
t.Helper()
resp := session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/%s/pulls/%d/files/reviews/new_comment", repo.FullName(), index)), http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/pulls/%d/files/reviews/comments", repo.FullName(), index), map[string]string{
"origin": doc.GetInputValueByName("origin"),
"latest_commit_id": firstCommitID,
"side": "proposed",
"line": "1",
"path": "FUNFACT",
"diff_start_cid": doc.GetInputValueByName("diff_start_cid"),
"diff_end_cid": doc.GetInputValueByName("diff_end_cid"),
"diff_base_cid": doc.GetInputValueByName("diff_base_cid"),
"content": "nitpicking comment",
"pending_review": "",
})
session.MakeRequest(t, req, http.StatusOK)
req = NewRequestWithValues(t, "POST", "/"+repo.FullName()+"/pulls/1/files/reviews/submit", map[string]string{
"commit_id": firstCommitID,
"content": "looks good",
"type": "comment",
})
session.MakeRequest(t, req, http.StatusOK)
}
staleReview := func(t *testing.T, firstCommitID string, index int64) {
// Review based on the first commit, which is a stale review because the
// PR's head is at the seconnd commit.
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/pulls/%d/files/reviews/submit", repo.FullName(), index), map[string]string{
"commit_id": firstCommitID,
"content": "looks good",
"type": "approve",
})
session.MakeRequest(t, req, http.StatusOK)
}
t.Run("Across repositories", func(t *testing.T) {
testRepoFork(t, session, "user2", repo.Name, "org3", "forked-repo")
// Clone it.
dstPath := clone(t, fmt.Sprintf("%sorg3/forked-repo.git", u.String()))
// Create first commit.
firstCommitID := firstCommit(t, dstPath)
// Create PR across repositories.
require.NoError(t, git.NewCommand(t.Context(), "push", "origin", "main").Run(&git.RunOpts{Dir: dstPath}))
session.MakeRequest(t, NewRequestWithValues(t, "POST", repo.FullName()+"/compare/main...org3/forked-repo:main", map[string]string{
"title": "pull request",
}), http.StatusOK)
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{Index: 1, BaseRepoID: repo.ID})
t.Run("Mark review as stale", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Create first review
firstReview(t, firstCommitID, pr.Index)
// Review is not stale.
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{IssueID: pr.IssueID})
assert.False(t, review.Stale)
// Create second commit
secondCommit(t, dstPath)
// Push to PR.
require.NoError(t, git.NewCommand(t.Context(), "push", "origin", "main").Run(&git.RunOpts{Dir: dstPath}))
// Review is stale.
assert.Eventually(t, func() bool {
return unittest.AssertExistsAndLoadBean(t, &issues_model.Review{IssueID: pr.IssueID}).Stale == true
}, time.Second*10, time.Microsecond*100)
})
t.Run("Create stale review", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Review based on the first commit, which is a stale review because the
// PR's head is at the seconnd commit.
staleReview(t, firstCommitID, pr.Index)
// There does not exist a review that is not stale, because all reviews
// are based on the first commit and the PR's head is at the second commit.
unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID}, "stale = false")
})
t.Run("Mark unstale", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Force push the PR to the first commit.
require.NoError(t, git.NewCommand(t.Context(), "reset", "--hard", "HEAD~1").Run(&git.RunOpts{Dir: dstPath}))
require.NoError(t, git.NewCommand(t.Context(), "push", "--force-with-lease", "origin", "main").Run(&git.RunOpts{Dir: dstPath}))
// There does not exist a review that is stale, because all reviews
// are based on the first commit and thus all reviews are no longer marked
// as stale.
assert.Eventually(t, func() bool {
return !unittest.BeanExists(t, &issues_model.Review{IssueID: pr.IssueID}, "stale = true")
}, time.Second*10, time.Microsecond*100)
})
t.Run("Diff did not change", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Create a empty commit and push it to the PR.
require.NoError(t, git.NewCommand(t.Context(), "commit", "--allow-empty", "-m", "Empty commit").Run(&git.RunOpts{Dir: dstPath}))
require.NoError(t, git.NewCommand(t.Context(), "push", "origin", "main").Run(&git.RunOpts{Dir: dstPath}))
// There does not exist a review that is stale, because the diff did not
// change.
unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID}, "stale = true")
})
})
t.Run("AGit", func(t *testing.T) {
dstPath := clone(t, fmt.Sprintf("%suser2/%s.git", u.String(), repo.Name))
// Create first commit.
firstCommitID := firstCommit(t, dstPath)
// Create agit PR.
require.NoError(t, git.NewCommand(t.Context(), "push", "origin", "HEAD:refs/for/main", "-o", "topic=agit-pr").Run(&git.RunOpts{Dir: dstPath}))
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{Index: 2, BaseRepoID: repo.ID})
t.Run("Mark review as stale", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
firstReview(t, firstCommitID, pr.Index)
// Review is not stale.
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{IssueID: pr.IssueID})
assert.False(t, review.Stale)
// Create second commit
secondCommit(t, dstPath)
// Push to agit PR.
require.NoError(t, git.NewCommand(t.Context(), "push", "origin", "HEAD:refs/for/main", "-o", "topic=agit-pr").Run(&git.RunOpts{Dir: dstPath}))
// Review is stale.
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{IssueID: pr.IssueID})
assert.True(t, review.Stale)
})
t.Run("Create stale review", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Review based on the first commit, which is a stale review because the
// PR's head is at the seconnd commit.
staleReview(t, firstCommitID, pr.Index)
// There does not exist a review that is not stale, because all reviews
// are based on the first commit and the PR's head is at the second commit.
unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID}, "stale = false")
})
t.Run("Mark unstale", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Force push the PR to the first commit.
require.NoError(t, git.NewCommand(t.Context(), "reset", "--hard", "HEAD~1").Run(&git.RunOpts{Dir: dstPath}))
require.NoError(t, git.NewCommand(t.Context(), "push", "origin", "HEAD:refs/for/main", "-o", "topic=agit-pr", "-o", "force-push").Run(&git.RunOpts{Dir: dstPath}))
// There does not exist a review that is stale, because all reviews
// are based on the first commit and thus all reviews are no longer marked
// as stale.
unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID}, "stale = true")
})
t.Run("Diff did not change", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Create a empty commit and push it to the PR.
require.NoError(t, git.NewCommand(t.Context(), "commit", "--allow-empty", "-m", "Empty commit").Run(&git.RunOpts{Dir: dstPath}))
require.NoError(t, git.NewCommand(t.Context(), "push", "origin", "HEAD:refs/for/main", "-o", "topic=agit-pr").Run(&git.RunOpts{Dir: dstPath}))
// There does not exist a review that is stale, because the diff did not
// change.
unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID}, "stale = true")
})
})
})
}
func TestPullRequestCommentPlacement(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
t.Run("comment directly on change in PR", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
commitSHA := tester.changeFile("file1.md",
strings.Replace(tester.fileContent, "Line 50\n", "Line 50--modified\n", 1))
tester.createPR()
comment := tester.commentFromFilesChanged("file1.md", 50)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -48,3 +48,3 @@
Line 48
Line 49
-Line 50
+Line 50--modified`, comment.PatchQuoted)
assert.Equal(t, "proposed", comment.DiffSide())
assert.EqualValues(t, 50, comment.Line)
assert.Equal(t, commitSHA, comment.CommitSHA)
diff := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowDelCode, code: "Line 50"},
{rowType: RowAddCode, code: "Line 50--modified"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertFilesChangedDiff(diff)
tester.assertCommitDiff(commitSHA, diff)
})
t.Run("comment lands on blame from commit within PR", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Modify an earlier part of the file in one commit, and a later part of the file in a second commit.
content := tester.fileContent
content = strings.Replace(content, "Line 25\n", "Line 25--modified\n", 1)
commit1 := tester.changeFile("file1.md", content)
content = strings.Replace(content, "Line 75\n", "Line 75--modified\n", 1)
commit2 := tester.changeFile("file1.md", content)
tester.createPR()
// Comment on the earlier change, from the "Files changed" view; this should "git blame" and be asociated
// with the first commit where that change was made, therefore appearing on the commit-specific diff later:
comment := tester.commentFromFilesChanged("file1.md", 25)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -23,3 +23,3 @@
Line 23
Line 24
-Line 25
+Line 25--modified`, comment.PatchQuoted)
assert.Equal(t, "proposed", comment.DiffSide())
assert.EqualValues(t, 25, comment.Line)
assert.Equal(t, commit1, comment.CommitSHA)
diff25 := []diffTableRow{
{rowType: RowHasCode, code: "Line 24"},
{rowType: RowDelCode, code: "Line 25"},
{rowType: RowAddCode, code: "Line 25--modified"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowHasCode, code: "Line 26"},
}
tester.assertFilesChangedDiff(diff25)
tester.assertCommitDiff(commit1, diff25)
diff75 := []diffTableRow{
{rowType: RowHasCode, code: "Line 74"},
{rowType: RowDelCode, code: "Line 75"},
{rowType: RowAddCode, code: "Line 75--modified"},
{rowType: RowHasCode, code: "Line 76"},
}
tester.assertFilesChangedDiff(diff75)
tester.assertCommitDiff(commit2, diff75)
})
t.Run("comment lands on blame commit from before PR", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Modify line 50...
commitSHA := tester.changeFile("file1.md",
strings.Replace(tester.fileContent, "Line 50\n", "Line 50--modified\n", 1))
tester.createPR()
// But while viewing line 50's diff, place a comment on line 49. This will "git blame" to a commit outside
// of this PR, but that's fine...
comment := tester.commentFromFilesChanged("file1.md", 49)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -47,7 +47,7 @@ Line 46
Line 47
Line 48
Line 49`, comment.PatchQuoted)
assert.Equal(t, "proposed", comment.DiffSide())
assert.EqualValues(t, 49, comment.Line)
assert.NotEqual(t, commitSHA, comment.CommitSHA)
diff := []diffTableRow{
{rowType: RowHasCode, code: "Line 48"},
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowDelCode, code: "Line 50"},
{rowType: RowAddCode, code: "Line 50--modified"},
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertFilesChangedDiff(diff)
tester.assertCommitDiff(commitSHA, diff)
})
t.Run("comment on line moves due to a following commit", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Modify line 50
content := tester.fileContent
content = strings.Replace(content, "Line 50\n", "Line 50--modified\n", 1)
commit1 := tester.changeFile("file1.md", content)
tester.createPR()
// Place a comment on "Line 50--modified"
comment := tester.commentFromFilesChanged("file1.md", 50)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -48,3 +48,3 @@
Line 48
Line 49
-Line 50
+Line 50--modified`, comment.PatchQuoted)
assert.Equal(t, "proposed", comment.DiffSide())
assert.EqualValues(t, 50, comment.Line)
assert.Equal(t, commit1, comment.CommitSHA)
// Add a second commit to the PR which removes "Line 1" - "Line 10".
content = strings.Replace(content, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n", "", 1)
commit2 := tester.changeFile("file1.md", content)
diff2 := []diffTableRow{
{rowType: RowDelCode, code: "Line 9"},
{rowType: RowDelCode, code: "Line 10"},
{rowType: RowHasCode, code: "Line 11"},
}
tester.assertFilesChangedDiff(diff2, "checking commit2 contents in full PR diff")
tester.assertCommitDiff(commit2, diff2, "checking commit2 contents in single-commit diff")
diff1 := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowDelCode, code: "Line 50"},
{rowType: RowAddCode, code: "Line 50--modified"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertFilesChangedDiff(diff1, "checking commit1 contents in full PR diff")
tester.assertCommitDiff(commit1, diff1, "checking commit1 contents in single-commit diff")
})
t.Run("comment on line moves due to a following commit, following commit is rewritten and force-push'd", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Modify line 50
content := tester.fileContent
content = strings.Replace(content, "Line 50\n", "Line 50--modified\n", 1)
commit1 := tester.changeFile("file1.md", content)
tester.createPR()
// Place a comment on "Line 50--modified"
comment := tester.commentFromFilesChanged("file1.md", 50)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -48,3 +48,3 @@
Line 48
Line 49
-Line 50
+Line 50--modified`, comment.PatchQuoted)
assert.Equal(t, "proposed", comment.DiffSide())
assert.EqualValues(t, 50, comment.Line)
assert.Equal(t, commit1, comment.CommitSHA)
assert.False(t, comment.Invalidated)
// Add a second commit to the PR which removes "Line 1" - "Line 10".
content = strings.Replace(content, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n", "", 1)
tester.changeFile("file1.md", content)
// Now amend commit2v1 with an additional change, causing a force push of the branch
tester.withBranchCheckout(func(repoPath string) {
content = strings.Replace(content, "Line 11\n", "", 1) // Remove Line 11 as well
require.NoError(t, os.WriteFile(path.Join(repoPath, "file1.md"), []byte(content), 0o644))
require.NoError(t, git.NewCommand(t.Context(), "commit", "-a", "--amend", "--no-edit").Run(&git.RunOpts{Dir: repoPath}))
require.NoError(t, git.NewCommand(t.Context(), "push", "--force").Run(&git.RunOpts{Dir: repoPath}))
})
diff2 := []diffTableRow{
{rowType: RowDelCode, code: "Line 10"},
{rowType: RowDelCode, code: "Line 11"},
{rowType: RowHasCode, code: "Line 12"},
}
tester.assertFilesChangedDiff(diff2, "checking commit2 (force push) contents in full PR diff")
diff1 := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowDelCode, code: "Line 50"},
{rowType: RowAddCode, code: "Line 50--modified"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertFilesChangedDiff(diff1, "checking commit1 contents in full PR diff")
tester.assertCommitDiff(commit1, diff1, "checking commit1 contents in single-commit diff")
// This comment can still be located in the diff, so it should not be marked as Invalidated/Outdated --
// which is kinda guaranteed by it being loaded in the diff, but for test sanity assert specifically.
commentReloaded := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID})
assert.False(t, commentReloaded.Invalidated)
})
t.Run("comment on line commit is rewritten and force-push'd", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Modify line 50
content := tester.fileContent
content = strings.Replace(content, "Line 50\n", "Line 50--modified\n", 1)
commit1 := tester.changeFile("file1.md", content)
tester.createPR()
// Place a comment on "Line 50--modified"
comment := tester.commentFromFilesChanged("file1.md", 50)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -48,3 +48,3 @@
Line 48
Line 49
-Line 50
+Line 50--modified`, comment.PatchQuoted)
assert.Equal(t, "proposed", comment.DiffSide())
assert.EqualValues(t, 50, comment.Line)
assert.Equal(t, commit1, comment.CommitSHA)
assert.False(t, comment.Invalidated)
// Add a second commit to the PR which removes "Line 1" - "Line 10".
content = strings.Replace(content, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n", "", 1)
commit2 := tester.changeFile("file1.md", content)
// Now, reorganize these commits, so that it's main->commit2->commit1 on the branch, rather than
// main->commit1->commit2. Then force push the branch.
tester.withBranchCheckout(func(repoPath string) {
// move commit2 onto main, off commit1
require.NoError(t,
git.NewCommand(t.Context(), "rebase").
AddArguments("--onto").AddDynamicArguments(tester.initialSHA).
AddDynamicArguments(commit1).
AddDynamicArguments(commit2).
Run(&git.RunOpts{Dir: repoPath}))
// move commit1 onto HEAD, off main
require.NoError(t,
git.NewCommand(t.Context(), "rebase").
AddArguments("--onto").AddDynamicArguments("HEAD").
AddDynamicArguments(tester.initialSHA).
AddDynamicArguments(commit1).
Run(&git.RunOpts{Dir: repoPath}))
// delete branch for the PR
has, branch := tester.branch.Get()
require.True(t, has)
require.NoError(t,
git.NewCommand(t.Context(), "branch").
AddArguments("-D").AddDynamicArguments(branch).
Run(&git.RunOpts{Dir: repoPath}))
// call HEAD as the branch
require.NoError(t,
git.NewCommand(t.Context(), "branch").
AddDynamicArguments(branch).
Run(&git.RunOpts{Dir: repoPath}))
// force push the rebuilt branch
require.NoError(t, git.NewCommand(t.Context(), "push", "--force", "origin").AddDynamicArguments(branch).Run(&git.RunOpts{Dir: repoPath}))
})
diff2 := []diffTableRow{
{rowType: RowDelCode, code: "Line 10"},
{rowType: RowHasCode, code: "Line 11"},
}
tester.assertFilesChangedDiff(diff2, "checking commit2 (force push) contents in full PR diff")
diff1 := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowDelCode, code: "Line 50"},
{rowType: RowAddCode, code: "Line 50--modified"},
// no comment visible anymore; force push has lost its place at this time
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertFilesChangedDiff(diff1, "checking commit1 contents in full PR diff")
// After the force push, the comment we originally left should be marked as invalidated since it can no
// longer be resolved to a code location in the PR head. The above tests validate that it no longer appears
// in the diff, but this will also happen because of the diff-side check for the correct location -- so
// let's check that it's invalidated as well, indicating that it will be shown in the UI as "Outdated". This
// usually passes on the first check but is wrapped in Eventually because the async goroutine used in the
// pull request testing when the branch is pushed may not be immediately complete.
assert.EventuallyWithT(t, func(t *assert.CollectT) {
commentReloaded := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID})
assert.True(t, commentReloaded.Invalidated)
}, 1*time.Second, 50*time.Millisecond)
})
t.Run("comment lands on blame with original line number varying from current", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Remove "Line 1" - "Line 10", on the base branch. If you "git blame" Line 50 at that point, it will have
// an original line number 50, but actually be appearing at line number index 40, causing wrong outputs.
content := tester.fileContent
content = strings.Replace(content, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n", "", 1)
tester.changeFileOnBase("file1.md", content)
// Now modify "Line 51" in a PR:
commitSHA := tester.changeFile("file1.md", strings.Replace(content, "Line 51\n", "Line 51--modified\n", 1))
tester.createPR()
// Place a comment on "Line 50", which would "git blame" to the original commit and line number 50, even
// though it's now actually at line number 40.
comment := tester.commentFromFilesChanged("file1.md", lineNumber(content, "Line 50"))
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -38,7 +38,7 @@ Line 47
Line 48
Line 49
Line 50`, comment.PatchQuoted)
assert.Equal(t, "proposed", comment.DiffSide())
assert.EqualValues(t, 50, comment.Line)
assert.Equal(t, tester.initialSHA, comment.CommitSHA)
diff := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowHasCode, code: "Line 50"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowDelCode, code: "Line 51"},
{rowType: RowAddCode, code: "Line 51--modified"},
{rowType: RowHasCode, code: "Line 52"},
}
tester.assertFilesChangedDiff(diff)
tester.assertCommitDiff(commitSHA, diff)
})
t.Run("comment on specific commit adjusts correctly to later changes in the PR", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Modify an earlier part of the file in one commit, and then change line numbers in a second commit by
// removing some content from the file earlier than the first commit
content := tester.fileContent
content = strings.Replace(content, "Line 50\n", "Line 50--modified\n", 1)
commit1 := tester.changeFile("file1.md", content)
content = strings.Replace(content, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n", "", 1)
commit2 := tester.changeFile("file1.md", content)
tester.createPR()
// Create a comment on commit1's "Line 50" change, from the commit-specific view:
comment := tester.commentFromSpecificCommit(commit1, "file1.md", 50)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -48,3 +48,3 @@
Line 48
Line 49
-Line 50
+Line 50--modified`, comment.PatchQuoted)
assert.Equal(t, "proposed", comment.DiffSide())
assert.EqualValues(t, 50, comment.Line)
assert.Equal(t, commit1, comment.CommitSHA)
diff50 := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowDelCode, code: "Line 50"},
{rowType: RowAddCode, code: "Line 50--modified"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertFilesChangedDiff(diff50)
tester.assertCommitDiff(commit1, diff50)
diff10 := []diffTableRow{
{rowType: RowDelCode, code: "Line 9"},
{rowType: RowDelCode, code: "Line 10"},
{rowType: RowHasCode, code: "Line 11"},
}
tester.assertFilesChangedDiff(diff10)
tester.assertCommitDiff(commit2, diff10)
})
t.Run("comment on removed line", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Remove line 50, place a comment on the removed line.
content := tester.fileContent
content = strings.Replace(content, "Line 50\n", "", 1)
commit := tester.changeFile("file1.md", content)
tester.createPR()
comment := tester.commentOnPreviousFromFilesChanged("file1.md", 50)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -47,7 +47,6 @@ Line 46
Line 47
Line 48
Line 49
-Line 50`, comment.PatchQuoted)
assert.Equal(t, "previous", comment.DiffSide())
assert.EqualValues(t, -50, comment.Line)
assert.Equal(t, tester.initialSHA, comment.CommitSHA) // tracked back to the previous commit where it was line num 50
diff50 := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowDelCode, code: "Line 50"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertFilesChangedDiff(diff50)
tester.assertCommitDiff(commit, diff50)
})
t.Run("comment on removed line moves due to a following commit", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Remove line 50, place a comment on the removed line.
content := tester.fileContent
content = strings.Replace(content, "Line 50\n", "", 1)
commit1 := tester.changeFile("file1.md", content)
tester.createPR()
comment := tester.commentOnPreviousFromFilesChanged("file1.md", 50)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -47,7 +47,6 @@ Line 46
Line 47
Line 48
Line 49
-Line 50`, comment.PatchQuoted)
assert.Equal(t, "previous", comment.DiffSide())
assert.EqualValues(t, -50, comment.Line)
assert.Equal(t, tester.initialSHA, comment.CommitSHA) // tracked back to the previous commit where it was line num 50
// Add a second commit to the PR which removes "Line 1" - "Line 10".
content = strings.Replace(content, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n", "", 1)
commit2 := tester.changeFile("file1.md", content)
diff2 := []diffTableRow{
{rowType: RowDelCode, code: "Line 9"},
{rowType: RowDelCode, code: "Line 10"},
{rowType: RowHasCode, code: "Line 11"},
}
tester.assertFilesChangedDiff(diff2, "checking commit2 contents in full PR diff")
tester.assertCommitDiff(commit2, diff2, "checking commit2 contents in single-commit diff")
diff1 := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowDelCode, code: "Line 50"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertFilesChangedDiff(diff1, "checking commit1 contents in full PR diff")
tester.assertCommitDiff(commit1, diff1, "checking commit1 contents in single-commit diff")
// This comment can still be located in the diff, so it should not be marked as Invalidated/Outdated --
// which is kinda guaranteed by it being loaded in the diff, but for test sanity assert specifically.
commentReloaded := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID})
assert.False(t, commentReloaded.Invalidated)
})
t.Run("comment on previous side line unchanged by PR appears", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Change line 50
content := tester.fileContent
content = strings.Replace(content, "Line 50\n", "Line 50--modified\n", 1)
commit1 := tester.changeFile("file1.md", content)
content = strings.Replace(content, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n", "", 1)
tester.changeFile("file1.md", content)
tester.createPR()
// While viewing the PR, the reviewer made a comment on the previous side on a line of code that wasn't
// actually changed in the PR:
comment := tester.commentOnPreviousFromFilesChanged("file1.md", 49)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -47,7 +37,7 @@ Line 46
Line 47
Line 48
Line 49`, comment.PatchQuoted)
assert.Equal(t, "proposed", comment.DiffSide()) // will be moved from previous(LHS)->proposed(RHS) because it wasn't a comment on a change in the PR
assert.EqualValues(t, 49, comment.Line)
assert.Equal(t, tester.initialSHA, comment.CommitSHA)
diff := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowDelCode, code: "Line 50"},
{rowType: RowAddCode, code: "Line 50--modified"},
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertFilesChangedDiff(diff, "checking commit1 contents in full PR diff")
tester.assertCommitDiff(commit1, diff, "checking commit1 contents in single-commit diff")
})
t.Run("comment on first line of file removed", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Remove the first line of the file which will then be commented on, as an edge-case
content := tester.fileContent
content = strings.Replace(content, "Line 1\n", "", 1)
commit1 := tester.changeFile("file1.md", content)
tester.createPR()
comment := tester.commentOnPreviousFromSpecificCommit(commit1, "file1.md", 1)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -1,4 +1,3 @@
-Line 1`, comment.PatchQuoted)
assert.Equal(t, "previous", comment.DiffSide())
assert.EqualValues(t, -1, comment.Line)
assert.Equal(t, tester.initialSHA, comment.CommitSHA)
diff := []diffTableRow{
{rowType: RowDelCode, code: "Line 1"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowHasCode, code: "Line 2"},
}
tester.assertFilesChangedDiff(diff, "checking commit1 contents in full PR diff")
tester.assertCommitDiff(commit1, diff, "checking commit1 contents in single-commit diff")
})
t.Run("comment on removed line moves due to a following commit, following commit is rewritten and force-push'd", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Modify line 50
content := tester.fileContent
content = strings.Replace(content, "Line 50\n", "Line 50--modified\n", 1)
commit1 := tester.changeFile("file1.md", content)
tester.createPR()
// Place a comment on "Line 50" (removed side)
comment := tester.commentOnPreviousFromFilesChanged("file1.md", 50)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -47,7 +47,7 @@ Line 46
Line 47
Line 48
Line 49
-Line 50`, comment.PatchQuoted)
assert.Equal(t, "previous", comment.DiffSide())
assert.EqualValues(t, -50, comment.Line)
assert.Equal(t, tester.initialSHA, comment.CommitSHA)
assert.False(t, comment.Invalidated)
// Add a second commit to the PR which removes "Line 1" - "Line 10".
content = strings.Replace(content, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n", "", 1)
tester.changeFile("file1.md", content)
// Now amend commit2v1 with an additional change, causing a force push of the branch
tester.withBranchCheckout(func(repoPath string) {
content = strings.Replace(content, "Line 11\n", "", 1) // Remove Line 11 as well
require.NoError(t, os.WriteFile(path.Join(repoPath, "file1.md"), []byte(content), 0o644))
require.NoError(t, git.NewCommand(t.Context(), "commit", "-a", "--amend", "--no-edit").Run(&git.RunOpts{Dir: repoPath}))
require.NoError(t, git.NewCommand(t.Context(), "push", "--force").Run(&git.RunOpts{Dir: repoPath}))
})
diff2 := []diffTableRow{
{rowType: RowDelCode, code: "Line 10"},
{rowType: RowDelCode, code: "Line 11"},
{rowType: RowHasCode, code: "Line 12"},
}
tester.assertFilesChangedDiff(diff2, "checking commit2 (force push) contents in full PR diff")
diff1 := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowDelCode, code: "Line 50"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowAddCode, code: "Line 50--modified"},
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertFilesChangedDiff(diff1, "checking commit1 contents in full PR diff")
tester.assertCommitDiff(commit1, diff1, "checking commit1 contents in single-commit diff")
// This comment can still be located in the diff, so it should not be marked as Invalidated/Outdated --
// which is kinda guaranteed by it being loaded in the diff, but for test sanity assert specifically.
commentReloaded := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID})
assert.False(t, commentReloaded.Invalidated)
})
t.Run("comment on removed line invalidated due to force push", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// Modify line 50
content := tester.fileContent
content = strings.Replace(content, "Line 50\n", "", 1)
tester.changeFile("file1.md", content)
tester.createPR()
// Place a comment on "Line 50" (removed side)
comment := tester.commentOnPreviousFromFilesChanged("file1.md", 50)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -47,7 +47,6 @@ Line 46
Line 47
Line 48
Line 49
-Line 50`, comment.PatchQuoted)
assert.Equal(t, "previous", comment.DiffSide())
assert.EqualValues(t, -50, comment.Line)
assert.Equal(t, tester.initialSHA, comment.CommitSHA)
assert.False(t, comment.Invalidated)
// Now amend commit1 with an additional change that undoes the earlier change, changes something else instead
tester.withBranchCheckout(func(repoPath string) {
content = strings.Replace(content, "Line 49\n", "Line 49\nLine 50\n", 1)
content = strings.Replace(content, "Line 52\n", "", 1)
require.NoError(t, os.WriteFile(path.Join(repoPath, "file1.md"), []byte(content), 0o644))
require.NoError(t, git.NewCommand(t.Context(), "commit", "-a", "--amend", "--no-edit").Run(&git.RunOpts{Dir: repoPath}))
require.NoError(t, git.NewCommand(t.Context(), "push", "--force").Run(&git.RunOpts{Dir: repoPath}))
})
diff := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowHasCode, code: "Line 50"},
{rowType: RowHasCode, code: "Line 51"},
{rowType: RowDelCode, code: "Line 52"},
{rowType: RowHasCode, code: "Line 53"},
}
tester.assertFilesChangedDiff(diff, "checking commit2 (force push) contents in full PR diff")
// The comment on "Line 50" can't be valid anymore since that's not in the diff:
assert.EventuallyWithT(t, func(t *assert.CollectT) {
commentReloaded := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID})
assert.True(t, commentReloaded.Invalidated)
}, 1*time.Second, 50*time.Millisecond)
})
t.Run("comment on removed line in specific commit adjusts to correct location", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tester := newPullRequestCommentPlacementTester(t)
// 3 commits: modify line 50, remove line 50, remove some earlier lines
content := tester.fileContent
content = strings.Replace(content, "Line 50\n", "Line 50--modified\n", 1)
commit1 := tester.changeFile("file1.md", content)
t.Logf("commit1 = %q", commit1)
content = strings.Replace(content, "Line 50--modified\n", "", 1)
commit2 := tester.changeFile("file1.md", content)
t.Logf("commit2 = %q", commit2)
content = strings.Replace(content, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n", "", 1)
commit3 := tester.changeFile("file1.md", content)
t.Logf("commit3 = %q", commit3)
tester.createPR()
comment := tester.commentOnPreviousFromSpecificCommit(commit2, "file1.md", 50)
assert.Equal(t, `diff --git a/file1.md b/file1.md
--- a/file1.md
+++ b/file1.md
@@ -47,7 +47,6 @@ Line 46
Line 47
Line 48
Line 49
-Line 50--modified`, comment.PatchQuoted)
assert.Equal(t, "previous", comment.DiffSide())
assert.EqualValues(t, -50, comment.Line)
assert.Equal(t, commit1, comment.CommitSHA)
diff1 := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowDelCode, code: "Line 50"},
{rowType: RowAddCode, code: "Line 50--modified"},
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertCommitDiff(commit1, diff1, "checking commit1 contents in single-commit diff")
diff2 := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowDelCode, code: "Line 50--modified"},
{rowType: RowComment, commentID: comment.ID},
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertCommitDiff(commit2, diff2, "checking commit2 contents in single-commit diff")
diff3 := []diffTableRow{
{rowType: RowHasCode, code: "Line 49"},
{rowType: RowDelCode, code: "Line 50"},
// This is a small bug -- the comment was placed on the code "Line 50--modified" in commit1, which was
// later amended by commit2. This comment should be marked out-of-date and not appear here. But the
// comment's `ResolveCurrentLine` doesn't quite detect this case correctly -- as the comment's CommitSHA
// is commit1, it performs a diff commit1..PR-HEAD, not mergebase..PR-HEAD, and it believes that this
// line of code still exists because it exists in that diff range. It's a rare edge case that is defered
// for future repair.
{rowType: RowComment, commentID: comment.ID},
{rowType: RowHasCode, code: "Line 51"},
}
tester.assertFilesChangedDiff(diff3, "checking overall contents in full PR diff")
})
})
}
type PullRequestCommentPlacementTester struct {
t *testing.T
user *user_model.User
session *TestSession
apiToken string
fileContent string
repo *repo_model.Repository
initialSHA string
branch optional.Option[string]
pr *api.PullRequest
}
func newPullRequestCommentPlacementTester(t *testing.T) *PullRequestCommentPlacementTester {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteIssue)
session := loginUser(t, user2.Name)
var content strings.Builder
for i := range 100 {
content.WriteString(fmt.Sprintf("Line %d\n", i+1)) // +1 -> make "Line N" appear on the Nth line and avoid off-by-one confusions
}
var initialSHA string
repo := forgery.CreateRepository(t, user2, &forgery.CreateRepositoryOptions{
Files: forgery.MapFS{
"file1.md": forgery.MapFile(content.String()),
"file2.md": forgery.MapFile(content.String()),
},
LatestSha: &initialSHA,
})
return &PullRequestCommentPlacementTester{
t: t,
user: user2,
session: session,
apiToken: token,
fileContent: content.String(),
repo: repo,
initialSHA: initialSHA,
}
}
func (tester *PullRequestCommentPlacementTester) changeFileOnBranch(sourceBranch, targetBranch string, targetBranchIsNew bool, filename, newContent string) string {
req := NewRequest(tester.t,
"GET",
fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?ref=%s", tester.repo.OwnerName, tester.repo.Name, filename, sourceBranch)).
AddTokenAuth(tester.apiToken)
resp := MakeRequest(tester.t, req, http.StatusOK)
var existingFile api.ContentsResponse
DecodeJSON(tester.t, resp, &existingFile)
opts := api.UpdateFileOptions{
DeleteFileOptions: api.DeleteFileOptions{
SHA: existingFile.SHA,
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte(newContent)),
}
if targetBranchIsNew {
opts.DeleteFileOptions.FileOptions.NewBranchName = targetBranch
} else {
opts.DeleteFileOptions.FileOptions.BranchName = targetBranch
}
req = NewRequestWithJSON(tester.t,
"PUT",
fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", tester.repo.OwnerName, tester.repo.Name, filename),
opts).AddTokenAuth(tester.apiToken)
resp = MakeRequest(tester.t, req, http.StatusOK)
var updateFileResponse api.FileResponse
DecodeJSON(tester.t, resp, &updateFileResponse)
return updateFileResponse.Commit.SHA
}
func (tester *PullRequestCommentPlacementTester) changeFileOnBase(filename, newContent string) string {
return tester.changeFileOnBranch(tester.repo.DefaultBranch, tester.repo.DefaultBranch, false, filename, newContent)
}
func (tester *PullRequestCommentPlacementTester) changeFile(filename, newContent string) string {
var sourceBranch string // where to get the file's last SHA from
branchExists, branch := tester.branch.Get()
if !branchExists {
branch = fmt.Sprintf("branch-%s", uuid.New().String())
tester.branch = optional.Some(branch)
sourceBranch = tester.repo.DefaultBranch
} else {
sourceBranch = branch
}
return tester.changeFileOnBranch(sourceBranch, branch, !branchExists, filename, newContent)
}
func (tester *PullRequestCommentPlacementTester) createPR() {
branchExists, branch := tester.branch.Get()
require.True(tester.t, branchExists)
req := NewRequestWithJSON(tester.t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/pulls", tester.repo.OwnerName, tester.repo.Name),
&api.CreatePullRequestOption{
Head: branch,
Base: tester.repo.DefaultBranch,
Title: fmt.Sprintf("PR from branch %s", tester.branch),
}).AddTokenAuth(tester.apiToken)
resp := MakeRequest(tester.t, req, http.StatusCreated)
var pr api.PullRequest
DecodeJSON(tester.t, resp, &pr)
tester.pr = &pr
}
func (tester *PullRequestCommentPlacementTester) commentFromFilesChanged(filename string, line int) *issues_model.Comment {
req := NewRequest(tester.t, "GET",
// omit after_commit_id -- new_comment form defaults to fetching the PR head
fmt.Sprintf("/%s/%s/pulls/%d/files/reviews/new_comment", tester.repo.OwnerName, tester.repo.Name, tester.pr.Index))
resp := tester.session.MakeRequest(tester.t, req, http.StatusOK)
return tester.commentFromNewCommentForm(resp, filename, line, "proposed")
}
func (tester *PullRequestCommentPlacementTester) commentOnPreviousFromFilesChanged(filename string, line int) *issues_model.Comment {
req := NewRequest(tester.t, "GET",
// omit after_commit_id -- new_comment form defaults to fetching the PR head
fmt.Sprintf("/%s/%s/pulls/%d/files/reviews/new_comment", tester.repo.OwnerName, tester.repo.Name, tester.pr.Index))
resp := tester.session.MakeRequest(tester.t, req, http.StatusOK)
return tester.commentFromNewCommentForm(resp, filename, line, "previous")
}
func (tester *PullRequestCommentPlacementTester) getCommitParent(commitID string) string {
repo, err := gitrepo.OpenRepository(tester.t.Context(), tester.repo)
require.NoError(tester.t, err)
defer repo.Close()
commit, err := repo.GetCommit(commitID)
require.NoError(tester.t, err)
require.Len(tester.t, commit.Parents, 1)
return commit.Parents[0].String()
}
func (tester *PullRequestCommentPlacementTester) commentFromSpecificCommit(commitID, filename string, line int) *issues_model.Comment {
beforeCommitID := tester.getCommitParent(commitID)
req := NewRequest(tester.t, "GET",
fmt.Sprintf("/%s/%s/pulls/%d/files/reviews/new_comment?before_commit_id=%s&after_commit_id=%s", tester.repo.OwnerName, tester.repo.Name, tester.pr.Index, beforeCommitID, commitID))
resp := tester.session.MakeRequest(tester.t, req, http.StatusOK)
return tester.commentFromNewCommentForm(resp, filename, line, "proposed")
}
func (tester *PullRequestCommentPlacementTester) commentOnPreviousFromSpecificCommit(commitID, filename string, line int) *issues_model.Comment {
beforeCommitID := tester.getCommitParent(commitID)
tester.t.Logf("beforeCommitID(%q) = %q", commitID, beforeCommitID)
req := NewRequest(tester.t, "GET",
fmt.Sprintf("/%s/%s/pulls/%d/files/reviews/new_comment?before_commit_id=%s&after_commit_id=%s", tester.repo.OwnerName, tester.repo.Name, tester.pr.Index, beforeCommitID, commitID))
resp := tester.session.MakeRequest(tester.t, req, http.StatusOK)
return tester.commentFromNewCommentForm(resp, filename, line, "previous")
}
func (tester *PullRequestCommentPlacementTester) commentFromNewCommentForm(resp *httptest.ResponseRecorder, filename string, line int, side string) *issues_model.Comment {
commentContent := uuid.New().String()
doc := NewHTMLParser(tester.t, resp.Body)
tester.t.Logf("doc.before = %q", doc.GetInputValueByName("before_commit_id"))
tester.t.Logf("doc.latest = %q", doc.GetInputValueByName("latest_commit_id"))
req := NewRequestWithValues(tester.t, "POST",
fmt.Sprintf("/%s/%s/pulls/%d/files/reviews/comments", tester.repo.OwnerName, tester.repo.Name, tester.pr.Index),
map[string]string{
"origin": doc.GetInputValueByName("origin"),
"before_commit_id": doc.GetInputValueByName("before_commit_id"),
"latest_commit_id": doc.GetInputValueByName("latest_commit_id"),
"side": side, // "proposed" (RHS) or "previous" (LHS)
"line": strconv.Itoa(line),
"path": filename,
"diff_start_cid": doc.GetInputValueByName("diff_start_cid"),
"diff_end_cid": doc.GetInputValueByName("diff_end_cid"),
"diff_base_cid": doc.GetInputValueByName("diff_base_cid"),
"content": commentContent,
"single_review": "true",
})
tester.session.MakeRequest(tester.t, req, http.StatusOK)
comment := unittest.AssertExistsAndLoadBean(tester.t, &issues_model.Comment{Content: commentContent})
return comment
}
func (tester *PullRequestCommentPlacementTester) withBranchCheckout(action func(string)) {
dstPath := tester.t.TempDir()
cloneURL, _ := url.Parse(tester.repo.CloneLink().HTTPS)
cloneURL.User = url.UserPassword(tester.user.LoginName, userPassword)
require.NoError(tester.t, git.CloneWithArgs(tester.t.Context(), nil, cloneURL.String(), dstPath, git.CloneRepoOptions{}))
doGitSetRemoteURL(dstPath, "origin", cloneURL)(tester.t)
branchExists, branch := tester.branch.Get()
require.True(tester.t, branchExists)
require.NoError(tester.t, git.NewCommand(tester.t.Context(), "checkout").AddDynamicArguments(branch).Run(&git.RunOpts{Dir: dstPath}))
action(dstPath)
}
func (tester *PullRequestCommentPlacementTester) assertFilesChangedDiff(rowAssertions []diffTableRow, note ...string) {
req := NewRequest(tester.t, "GET",
fmt.Sprintf("/%s/%s/pulls/%d/files", tester.repo.OwnerName, tester.repo.Name, tester.pr.Index))
resp := tester.session.MakeRequest(tester.t, req, http.StatusOK)
doc := NewHTMLParser(tester.t, resp.Body)
var testNote string
if len(note) == 0 {
testNote = "contents in single-commit diff"
} else {
testNote = note[0]
}
assertDiffTable(tester.t, doc, rowAssertions, testNote)
}
func (tester *PullRequestCommentPlacementTester) assertCommitDiff(commitSHA string, rowAssertions []diffTableRow, note ...string) {
req := NewRequest(tester.t, "GET",
fmt.Sprintf("/%s/%s/pulls/%d/commits/%s", tester.repo.OwnerName, tester.repo.Name, tester.pr.Index, commitSHA))
resp := tester.session.MakeRequest(tester.t, req, http.StatusOK)
doc := NewHTMLParser(tester.t, resp.Body)
var testNote string
if len(note) == 0 {
testNote = "contents in full PR diff"
} else {
testNote = note[0]
}
assertDiffTable(tester.t, doc, rowAssertions, testNote)
}
func lineNumber(content, line string) int {
return slices.Index(strings.Split(content, "\n"), line) + 1
}
type diffTableRowType int
const (
RowHasCode diffTableRowType = iota
RowAddCode
RowDelCode
RowComment
)
type diffTableRow struct {
rowType diffTableRowType
// RowHasCode, RowAddCode, RowDelCode
code string
// RowComment
commentID int64
}
func nodeText(node *html.Node) string {
if node.Type == html.TextNode {
return node.Data
}
var builder strings.Builder
for child := range node.ChildNodes() {
childText := strings.TrimSpace(nodeText(child))
builder.WriteString(childText)
}
return builder.String()
}
func nodeAttr(node *html.Node, key string) string {
for _, attr := range node.Attr {
if attr.Key == key {
return attr.Val
}
}
return ""
}
func checkDiffTableRow(t *testing.T, tableRow *html.Node, rowAssertion diffTableRow) string {
switch rowAssertion.rowType {
case RowHasCode:
text := nodeText(tableRow)
if text != rowAssertion.code {
return fmt.Sprintf("wanted diff %q, but found diff %q", rowAssertion.code, text)
}
case RowDelCode:
dataLineType := nodeAttr(tableRow, "data-line-type")
if dataLineType != "del" {
return fmt.Sprintf("wanted delete code in diff, but found data-line-type=%q", dataLineType)
}
text := nodeText(tableRow)
if text != rowAssertion.code {
return fmt.Sprintf("wanted delete code with line %q, but found diff %q", rowAssertion.code, text)
}
case RowAddCode:
dataLineType := nodeAttr(tableRow, "data-line-type")
if dataLineType != "add" {
return fmt.Sprintf("wanted add code in diff, but found data-line-type=%q", dataLineType)
}
text := nodeText(tableRow)
if text != rowAssertion.code {
return fmt.Sprintf("wanted add code with line %q, but found diff %q", rowAssertion.code, text)
}
case RowComment:
class := nodeAttr(tableRow, "class")
if class != "add-comment" {
return fmt.Sprintf("wanted comment in diff, but found class=%q", class)
}
found := false
for desc := range tableRow.Descendants() {
descID := nodeAttr(desc, "id")
if descID == fmt.Sprintf("code-comments-%d", rowAssertion.commentID) {
found = true
break
}
}
if !found {
return fmt.Sprintf("wanted comment with ID %d, but could not be identified", rowAssertion.commentID)
}
}
return ""
}
func assertDiffTable(t *testing.T, doc *HTMLDoc, rowAssertions []diffTableRow, note string) {
require.NotEmpty(t, rowAssertions)
diffTable := doc.Find("table.chroma")
require.Equal(t, 1, diffTable.Length())
rows := diffTable.Find("tbody > tr[data-line-type]") // [data-line-type] is used to avoid matching tables within comment boxes
// Find the first row to match rowAssertions[0], and then we'll iterate from there matching each row exactly.
tableFirstRowIndex := 0
foundFirst := false
firstRowMismatches := []string{}
for ; tableFirstRowIndex < rows.Length(); tableFirstRowIndex++ {
mismatch := checkDiffTableRow(t, rows.Get(tableFirstRowIndex), rowAssertions[0])
if mismatch == "" {
foundFirst = true
break
}
firstRowMismatches = append(firstRowMismatches, mismatch)
}
if !foundFirst {
// We're going to fail because we couldn't find the first row in rowAssertions -- this can be tricky to debug so
// help out by outputting all the rows we looked at that didn't match:
t.Log("first row mismatches:")
for _, mm := range firstRowMismatches {
t.Logf("\t%s", mm)
}
require.Failf(t, "unable to find first row", "test %s: failed to find first row assertion", note)
}
for idx, assertion := range rowAssertions {
if idx == 0 { // skip first row assertion, already checked to find tableFirstRowIndex
continue
}
tableIdx := tableFirstRowIndex + idx
if tableIdx >= rows.Length() {
require.Failf(t, "ran out of table rows", "test %s: row assertion at index %d couldn't be satisfied", note, idx)
}
tableRow := rows.Get(tableIdx)
check := checkDiffTableRow(t, tableRow, assertion)
if check != "" {
assert.Failf(t, check, "test %s: row assertion at index %d couldn't be satisfied", note, idx)
}
}
}