Files
forge/models/issues/pull_test.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

445 lines
16 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"strings"
"testing"
"time"
"github.com/hanzoai/git/models/db"
issues_model "github.com/hanzoai/git/models/issues"
repo_model "github.com/hanzoai/git/models/repo"
"github.com/hanzoai/git/models/unittest"
user_model "github.com/hanzoai/git/models/user"
"github.com/hanzoai/git/modules/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPullRequest(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("LoadAttributes", testPullRequestLoadAttributes)
t.Run("LoadIssue", testPullRequestLoadIssue)
t.Run("LoadBaseRepo", testPullRequestLoadBaseRepo)
t.Run("LoadHeadRepo", testPullRequestLoadHeadRepo)
t.Run("PullRequestsNewest", testPullRequestsNewest)
t.Run("PullRequestsOldest", testPullRequestsOldest)
t.Run("GetUnmergedPullRequest", testGetUnmergedPullRequest)
t.Run("HasUnmergedPullRequestsByHeadInfo", testHasUnmergedPullRequestsByHeadInfo)
t.Run("GetUnmergedPullRequestsByHeadInfo", testGetUnmergedPullRequestsByHeadInfo)
t.Run("GetUnmergedPullRequestsByBaseInfo", testGetUnmergedPullRequestsByBaseInfo)
t.Run("GetPullRequestByIndex", testGetPullRequestByIndex)
t.Run("GetPullRequestByID", testGetPullRequestByID)
t.Run("GetPullRequestByIssueID", testGetPullRequestByIssueID)
t.Run("PullRequest_UpdateCols", testPullRequestUpdateCols)
t.Run("PullRequest_IsWorkInProgress", testPullRequestIsWorkInProgress)
t.Run("PullRequest_GetWorkInProgressPrefixWorkInProgress", testPullRequestGetWorkInProgressPrefixWorkInProgress)
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
t.Run("CodeOwnerPatternMatchTimeout", testCodeOwnerPatternMatchTimeout)
t.Run("GetApprovers", testGetApprovers)
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
t.Run("PullRequestsClosedRecentSortType", testPullRequestsClosedRecentSortType)
t.Run("LoadRequestedReviewers", testLoadRequestedReviewers)
}
func testPullRequestLoadAttributes(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
assert.NoError(t, pr.LoadAttributes(t.Context()))
assert.NotNil(t, pr.Merger)
assert.Equal(t, pr.MergerID, pr.Merger.ID)
}
func testPullRequestLoadIssue(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
assert.NoError(t, pr.LoadIssue(t.Context()))
assert.NotNil(t, pr.Issue)
assert.Equal(t, int64(2), pr.Issue.ID)
assert.NoError(t, pr.LoadIssue(t.Context()))
assert.NotNil(t, pr.Issue)
assert.Equal(t, int64(2), pr.Issue.ID)
}
func testPullRequestLoadBaseRepo(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
assert.NotNil(t, pr.BaseRepo)
assert.Equal(t, pr.BaseRepoID, pr.BaseRepo.ID)
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
assert.NotNil(t, pr.BaseRepo)
assert.Equal(t, pr.BaseRepoID, pr.BaseRepo.ID)
}
func testPullRequestLoadHeadRepo(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
assert.NoError(t, pr.LoadHeadRepo(t.Context()))
assert.NotNil(t, pr.HeadRepo)
assert.Equal(t, pr.HeadRepoID, pr.HeadRepo.ID)
}
// TODO TestMerge
// TODO TestNewPullRequest
func testPullRequestsNewest(t *testing.T) {
prs, count, err := issues_model.PullRequests(t.Context(), 1, &issues_model.PullRequestsOptions{
ListOptions: db.ListOptions{
Page: 1,
},
State: "open",
SortType: "newest",
})
assert.NoError(t, err)
assert.EqualValues(t, 3, count)
if assert.Len(t, prs, 3) {
assert.EqualValues(t, 5, prs[0].ID)
assert.EqualValues(t, 2, prs[1].ID)
assert.EqualValues(t, 1, prs[2].ID)
}
}
func testPullRequestsClosedRecentSortType(t *testing.T) {
// Issue ID | Closed At. | Updated At
// 2 | 1707270001 | 1707270001
// 3 | 1707271000 | 1707279999
// 11 | 1707279999 | 1707275555
tests := []struct {
sortType string
expectedIssueIDOrder []int64
}{
{"recentupdate", []int64{3, 11, 2}},
{"recentclose", []int64{11, 3, 2}},
}
_, err := db.Exec(t.Context(), "UPDATE issue SET closed_unix = 1707270001, updated_unix = 1707270001, is_closed = true WHERE id = 2")
require.NoError(t, err)
_, err = db.Exec(t.Context(), "UPDATE issue SET closed_unix = 1707271000, updated_unix = 1707279999, is_closed = true WHERE id = 3")
require.NoError(t, err)
_, err = db.Exec(t.Context(), "UPDATE issue SET closed_unix = 1707279999, updated_unix = 1707275555, is_closed = true WHERE id = 11")
require.NoError(t, err)
for _, test := range tests {
t.Run(test.sortType, func(t *testing.T) {
prs, _, err := issues_model.PullRequests(t.Context(), 1, &issues_model.PullRequestsOptions{
ListOptions: db.ListOptions{
Page: 1,
},
State: "closed",
SortType: test.sortType,
})
require.NoError(t, err)
if assert.Len(t, prs, len(test.expectedIssueIDOrder)) {
for i := range test.expectedIssueIDOrder {
assert.Equal(t, test.expectedIssueIDOrder[i], prs[i].IssueID)
}
}
})
}
}
func testLoadRequestedReviewers(t *testing.T) {
pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
assert.NoError(t, pull.LoadIssue(t.Context()))
issue := pull.Issue
assert.NoError(t, issue.LoadRepo(t.Context()))
assert.Empty(t, pull.RequestedReviewers)
user1, err := user_model.GetUserByID(t.Context(), 1)
assert.NoError(t, err)
comment, err := issues_model.AddReviewRequest(t.Context(), issue, user1, &user_model.User{}, false)
assert.NoError(t, err)
assert.NotNil(t, comment)
assert.NoError(t, pull.LoadRequestedReviewers(t.Context()))
assert.Len(t, pull.RequestedReviewers, 6)
comment, err = issues_model.RemoveReviewRequest(t.Context(), issue, user1, &user_model.User{})
assert.NoError(t, err)
assert.NotNil(t, comment)
pull.RequestedReviewers = nil
assert.NoError(t, pull.LoadRequestedReviewers(t.Context()))
assert.Empty(t, pull.RequestedReviewers)
}
func testPullRequestsOldest(t *testing.T) {
prs, count, err := issues_model.PullRequests(t.Context(), 1, &issues_model.PullRequestsOptions{
ListOptions: db.ListOptions{
Page: 1,
},
State: "open",
SortType: "oldest",
})
assert.NoError(t, err)
assert.EqualValues(t, 3, count)
if assert.Len(t, prs, 3) {
assert.EqualValues(t, 1, prs[0].ID)
assert.EqualValues(t, 2, prs[1].ID)
assert.EqualValues(t, 5, prs[2].ID)
}
}
func testGetUnmergedPullRequest(t *testing.T) {
pr, err := issues_model.GetUnmergedPullRequest(t.Context(), 1, 1, "branch2", "master", issues_model.PullRequestFlowGithub)
assert.NoError(t, err)
assert.Equal(t, int64(2), pr.ID)
_, err = issues_model.GetUnmergedPullRequest(t.Context(), 1, 9223372036854775807, "branch1", "master", issues_model.PullRequestFlowGithub)
assert.Error(t, err)
assert.True(t, issues_model.IsErrPullRequestNotExist(err))
}
func testHasUnmergedPullRequestsByHeadInfo(t *testing.T) {
exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(t.Context(), 1, "branch2")
assert.NoError(t, err)
assert.True(t, exist)
exist, err = issues_model.HasUnmergedPullRequestsByHeadInfo(t.Context(), 1, "not_exist_branch")
assert.NoError(t, err)
assert.False(t, exist)
}
func testGetUnmergedPullRequestsByHeadInfo(t *testing.T) {
prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(t.Context(), 1, "branch2")
assert.NoError(t, err)
assert.Len(t, prs, 1)
for _, pr := range prs {
assert.Equal(t, int64(1), pr.HeadRepoID)
assert.Equal(t, "branch2", pr.HeadBranch)
}
}
func testGetUnmergedPullRequestsByBaseInfo(t *testing.T) {
prs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(t.Context(), 1, "master")
assert.NoError(t, err)
assert.Len(t, prs, 1)
pr := prs[0]
assert.Equal(t, int64(2), pr.ID)
assert.Equal(t, int64(1), pr.BaseRepoID)
assert.Equal(t, "master", pr.BaseBranch)
}
func testGetPullRequestByIndex(t *testing.T) {
pr, err := issues_model.GetPullRequestByIndex(t.Context(), 1, 2)
assert.NoError(t, err)
assert.Equal(t, int64(1), pr.BaseRepoID)
assert.Equal(t, int64(2), pr.Index)
_, err = issues_model.GetPullRequestByIndex(t.Context(), 9223372036854775807, 9223372036854775807)
assert.Error(t, err)
assert.True(t, issues_model.IsErrPullRequestNotExist(err))
_, err = issues_model.GetPullRequestByIndex(t.Context(), 1, 0)
assert.Error(t, err)
assert.True(t, issues_model.IsErrPullRequestNotExist(err))
}
func testGetPullRequestByID(t *testing.T) {
pr, err := issues_model.GetPullRequestByID(t.Context(), 1)
assert.NoError(t, err)
assert.Equal(t, int64(1), pr.ID)
assert.Equal(t, int64(2), pr.IssueID)
_, err = issues_model.GetPullRequestByID(t.Context(), 9223372036854775807)
assert.Error(t, err)
assert.True(t, issues_model.IsErrPullRequestNotExist(err))
}
func testGetPullRequestByIssueID(t *testing.T) {
pr, err := issues_model.GetPullRequestByIssueID(t.Context(), 2)
assert.NoError(t, err)
assert.Equal(t, int64(2), pr.IssueID)
_, err = issues_model.GetPullRequestByIssueID(t.Context(), 9223372036854775807)
assert.Error(t, err)
assert.True(t, issues_model.IsErrPullRequestNotExist(err))
}
func testPullRequestUpdateCols(t *testing.T) {
pr := &issues_model.PullRequest{
ID: 1,
BaseBranch: "baseBranch",
HeadBranch: "headBranch",
}
assert.NoError(t, pr.UpdateCols(t.Context(), "head_branch"))
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
assert.Equal(t, "master", pr.BaseBranch)
assert.Equal(t, "headBranch", pr.HeadBranch)
unittest.CheckConsistencyFor(t, pr)
}
// TODO TestAddTestPullRequestTask
func testPullRequestIsWorkInProgress(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
pr.LoadIssue(t.Context())
assert.False(t, pr.IsWorkInProgress(t.Context()))
pr.Issue.Title = "WIP: " + pr.Issue.Title
assert.True(t, pr.IsWorkInProgress(t.Context()))
pr.Issue.Title = "[wip]: " + pr.Issue.Title
assert.True(t, pr.IsWorkInProgress(t.Context()))
}
func testPullRequestGetWorkInProgressPrefixWorkInProgress(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
pr.LoadIssue(t.Context())
assert.Empty(t, pr.GetWorkInProgressPrefix(t.Context()))
original := pr.Issue.Title
pr.Issue.Title = "WIP: " + original
assert.Equal(t, "WIP:", pr.GetWorkInProgressPrefix(t.Context()))
pr.Issue.Title = "[wip] " + original
assert.Equal(t, "[wip]", pr.GetWorkInProgressPrefix(t.Context()))
}
func testDeleteOrphanedObjects(t *testing.T) {
countBefore, err := db.GetEngine(t.Context()).Count(&issues_model.PullRequest{})
assert.NoError(t, err)
_, err = db.GetEngine(t.Context()).Insert(&issues_model.PullRequest{IssueID: 1000}, &issues_model.PullRequest{IssueID: 1001}, &issues_model.PullRequest{IssueID: 1003})
assert.NoError(t, err)
orphaned, err := db.CountOrphanedObjects(t.Context(), "pull_request", "issue", "pull_request.issue_id=issue.id")
assert.NoError(t, err)
assert.EqualValues(t, 3, orphaned)
err = db.DeleteOrphanedObjects(t.Context(), "pull_request", "issue", "pull_request.issue_id=issue.id")
assert.NoError(t, err)
countAfter, err := db.GetEngine(t.Context()).Count(&issues_model.PullRequest{})
assert.NoError(t, err)
assert.Equal(t, countBefore, countAfter)
}
func testParseCodeOwnersLine(t *testing.T) {
type CodeOwnerTest struct {
Line string
Tokens []string
}
given := []CodeOwnerTest{
{Line: "", Tokens: nil},
{Line: "# comment", Tokens: []string{}},
{Line: "!.* @user1 @org1/team1", Tokens: []string{"!.*", "@user1", "@org1/team1"}},
{Line: `.*\\.js @user2 #comment`, Tokens: []string{`.*\.js`, "@user2"}},
{Line: `docs/(aws|google|azure)/[^/]*\\.(md|txt) @org3 @org2/team2`, Tokens: []string{`docs/(aws|google|azure)/[^/]*\.(md|txt)`, "@org3", "@org2/team2"}},
{Line: `\#path @org3`, Tokens: []string{`#path`, "@org3"}},
{Line: `path\ with\ spaces/ @org3`, Tokens: []string{`path with spaces/`, "@org3"}},
{Line: `/docs/.*\\.md @user1`, Tokens: []string{`/docs/.*\.md`, "@user1"}},
{Line: `!/assets/.*\\.(bin|exe|msi) @user1`, Tokens: []string{`!/assets/.*\.(bin|exe|msi)`, "@user1"}},
}
for _, g := range given {
tokens := issues_model.TokenizeCodeOwnersLine(g.Line)
assert.Equal(t, g.Tokens, tokens, "Codeowners tokenizer failed")
}
}
func testCodeOwnerAbsolutePathPatterns(t *testing.T) {
type testCase struct {
content string
file string
expected bool
}
cases := []testCase{
// Absolute path pattern should match (leading "/" stripped)
{content: "/README.md @user5\n", file: "README.md", expected: true},
// Absolute path pattern in subdirectory
{content: "/docs/.* @user5\n", file: "docs/foo.md", expected: true},
// Absolute path should not match nested paths it shouldn't
{content: "/docs/.* @user5\n", file: "other/docs/foo.md", expected: false},
// Relative path still works
{content: "README.md @user5\n", file: "README.md", expected: true},
// Negated absolute path pattern
{content: "!/.* @user5\n", file: "README.md", expected: false},
}
for _, c := range cases {
rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), c.content)
require.NotEmpty(t, rules)
rule := rules[0]
regexpMatched, _ := rule.Rule.MatchString(c.file)
ruleMatched := regexpMatched == !rule.Negative
assert.Equal(t, c.expected, ruleMatched, "pattern %q against file %q", c.content, c.file)
}
}
// testCodeOwnerPatternMatchTimeout ensures user-supplied CODEOWNERS patterns
// cannot stall pull request processing through catastrophic regex backtracking:
// each compiled rule must enforce a bounded match time.
func testCodeOwnerPatternMatchTimeout(t *testing.T) {
rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), "(a+)+ @user5\n")
require.Len(t, rules, 1)
maliciousInput := strings.Repeat("a", 30) + "X"
start := time.Now()
_, err := rules[0].Rule.MatchString(maliciousInput)
elapsed := time.Since(start)
require.Error(t, err, "expected MatchTimeout error on pathological input")
assert.Less(t, elapsed, time.Second, "match timeout did not bound regex evaluation; took %s", elapsed)
}
func testGetApprovers(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
// Official reviews are already deduplicated. Allow unofficial reviews
// to assert that there are no duplicated approvers.
setting.Repository.PullRequest.DefaultMergeMessageOfficialApproversOnly = false
approvers := pr.GetApprovers(t.Context())
expected := "Reviewed-by: User Five <[email protected]>\nReviewed-by: Org Six <[email protected]>\n"
assert.Equal(t, expected, approvers)
}
func testGetPullRequestByMergedCommit(t *testing.T) {
pr, err := issues_model.GetPullRequestByMergedCommit(t.Context(), 1, "1a8823cd1a9549fde083f992f6b9b87a7ab74fb3")
assert.NoError(t, err)
assert.EqualValues(t, 1, pr.ID)
_, err = issues_model.GetPullRequestByMergedCommit(t.Context(), 0, "1a8823cd1a9549fde083f992f6b9b87a7ab74fb3")
assert.ErrorAs(t, err, &issues_model.ErrPullRequestNotExist{})
_, err = issues_model.GetPullRequestByMergedCommit(t.Context(), 1, "")
assert.ErrorAs(t, err, &issues_model.ErrPullRequestNotExist{})
}
func testMigrateInsertPullRequests(t *testing.T) {
reponame := "repo1"
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: reponame})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
i := &issues_model.Issue{
RepoID: repo.ID,
Repo: repo,
Title: "title1",
Content: "issuecontent1",
IsPull: true,
PosterID: owner.ID,
Poster: owner,
}
p := &issues_model.PullRequest{
Issue: i,
}
err := issues_model.InsertPullRequests(t.Context(), p)
assert.NoError(t, err)
_ = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{IssueID: i.ID})
unittest.CheckConsistencyFor(t, &issues_model.Issue{}, &issues_model.PullRequest{})
}