forgejo/services/migrations/gitea_uploader_test.go
patdyn 9a423c0e67 Fix migration failing when importing either issues or PRs but not the other (#8892)
Related to https://codeberg.org/Codeberg/Community/issues/1944

* Allowed the githubdownloaderv3 to know whether issues and, or PRs are requested to migrate
* Used this information to decide to filter for "/pulls/" or "/issues"
  * Or not to filter at all if issues == true && prs == true
* Added isolated test for the downloader and for the uploader
* Created a new test_repo in github.com/forgejo and set it up properly together with @Gusted
* Updated github_downloader_test with the new URLs and test data from the repo
* Recorded the API calls for local testing
* Added a minimal gitbucket test (which uses the github downloader under the hood)

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8892
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: patdyn <patdyn@noreply.codeberg.org>
Co-committed-by: patdyn <patdyn@noreply.codeberg.org>
2025-09-01 14:05:10 +02:00

641 lines
19 KiB
Go

// Copyright 2019 The Gitea Authors. All rights reserved.
// Copyright 2018 Jonas Franz. All rights reserved.
// SPDX-License-Identifier: MIT
package migrations
import (
"fmt"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"forgejo.org/models/db"
issues_model "forgejo.org/models/issues"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/git"
"forgejo.org/modules/gitrepo"
"forgejo.org/modules/log"
base "forgejo.org/modules/migration"
"forgejo.org/modules/optional"
"forgejo.org/modules/structs"
"forgejo.org/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCommentUpload(t *testing.T) {
unittest.PrepareTestEnv(t)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
var (
opts = base.MigrateOptions{
Issues: true,
}
repoName = "test_repo"
uploader = NewGiteaLocalUploader(t.Context(), user, user.Name, repoName)
)
defer uploader.Close()
fixturePath := "./testdata/github/full_download"
server := unittest.NewMockWebServer(t, "https://api.github.com", fixturePath, false)
defer server.Close()
// Mock Data
repoMock := &base.Repository{
Name: repoName,
Owner: "forgejo",
Description: "Some mock repo",
CloneURL: server.URL + "/forgejo/test_repo.git",
OriginalURL: server.URL + "/forgejo/test_repo",
DefaultBranch: "master",
Website: "https://codeberg.org/forgejo/forgejo/",
}
// Create Repo
require.NoError(t, uploader.CreateRepo(repoMock, opts))
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID, Name: repoName})
// Create and Test Issues Uploading
issueA := &base.Issue{
Title: "First issue",
Number: 0,
PosterID: 37243484,
PosterName: "PatDyn",
PosterEmail: "",
Content: "Mock Content",
Milestone: "Mock Milestone",
State: "open",
Created: time.Date(2025, 8, 7, 12, 44, 7, 0, time.UTC),
Updated: time.Date(2025, 8, 7, 12, 44, 47, 0, time.UTC),
Labels: nil,
Reactions: nil,
Closed: nil,
IsLocked: false,
Assignees: nil,
ForeignIndex: 0,
}
issueB := &base.Issue{
Title: "Second Issue",
Number: 1,
PosterID: 37243484,
PosterName: "PatDyn",
PosterEmail: "",
Content: "Mock Content",
Milestone: "Mock Milestone",
State: "open",
Created: time.Date(2025, 8, 7, 12, 45, 44, 0, time.UTC),
Updated: time.Date(2025, 8, 7, 13, 7, 25, 0, time.UTC),
Labels: nil,
Reactions: nil,
Closed: nil,
IsLocked: false,
Assignees: nil,
ForeignIndex: 1,
}
err := uploader.CreateIssues(issueA, issueB)
require.NoError(t, err)
issues, err := issues_model.Issues(db.DefaultContext, &issues_model.IssuesOptions{
RepoIDs: []int64{repo.ID},
IsPull: optional.Some(false),
SortType: "newest",
})
require.NoError(t, err)
assert.Len(t, issues, 2)
// Create and Test Comment Uploading
issueAComment := &base.Comment{
IssueIndex: 0,
Index: 0,
CommentType: "comment",
PosterID: 37243484,
PosterName: "PatDyn",
PosterEmail: "",
Created: time.Date(2025, 8, 7, 12, 44, 24, 0, time.UTC),
Updated: time.Date(2025, 8, 7, 12, 44, 24, 0, time.UTC),
Content: "First Mock Comment",
Reactions: nil,
Meta: nil,
}
issueBComment := &base.Comment{
IssueIndex: 1,
Index: 1,
CommentType: "comment",
PosterID: 37243484,
PosterName: "PatDyn",
PosterEmail: "",
Created: time.Date(2025, 8, 7, 13, 7, 25, 0, time.UTC),
Updated: time.Date(2025, 8, 7, 13, 7, 25, 0, time.UTC),
Content: "Second Mock Comment",
Reactions: nil,
Meta: nil,
}
require.NoError(t, uploader.CreateComments(issueBComment, issueAComment))
issues, err = issues_model.Issues(db.DefaultContext, &issues_model.IssuesOptions{
RepoIDs: []int64{repo.ID},
IsPull: optional.Some(false),
SortType: "newest",
})
require.NoError(t, err)
assert.Len(t, issues, 2)
require.NoError(t, issues[0].LoadDiscussComments(db.DefaultContext))
require.NoError(t, issues[1].LoadDiscussComments(db.DefaultContext))
assert.Len(t, issues[0].Comments, 1)
assert.Len(t, issues[1].Comments, 1)
}
func TestGiteaUploadRepo(t *testing.T) {
// FIXME: Since no accesskey or user/password will trigger rate limit of github, just skip
t.Skip()
unittest.PrepareTestEnv(t)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
var (
ctx = t.Context()
downloader = NewGithubDownloaderV3(ctx, "https://github.com", true, true, "", "", "", "go-xorm", "builder")
repoName = "builder-" + time.Now().Format("2006-01-02-15-04-05")
uploader = NewGiteaLocalUploader(t.Context(), user, user.Name, repoName)
)
err := migrateRepository(db.DefaultContext, user, downloader, uploader, base.MigrateOptions{
CloneAddr: "https://github.com/go-xorm/builder",
RepoName: repoName,
AuthUsername: "",
Wiki: true,
Issues: true,
Milestones: true,
Labels: true,
Releases: true,
Comments: true,
PullRequests: true,
Private: true,
Mirror: false,
}, nil)
require.NoError(t, err)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID, Name: repoName})
assert.True(t, repo.HasWiki())
assert.Equal(t, repo_model.RepositoryReady, repo.Status)
milestones, err := db.Find[issues_model.Milestone](db.DefaultContext, issues_model.FindMilestoneOptions{
RepoID: repo.ID,
IsClosed: optional.Some(false),
})
require.NoError(t, err)
assert.Len(t, milestones, 1)
milestones, err = db.Find[issues_model.Milestone](db.DefaultContext, issues_model.FindMilestoneOptions{
RepoID: repo.ID,
IsClosed: optional.Some(true),
})
require.NoError(t, err)
assert.Empty(t, milestones)
labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{})
require.NoError(t, err)
assert.Len(t, labels, 12)
releases, err := db.Find[repo_model.Release](db.DefaultContext, repo_model.FindReleasesOptions{
ListOptions: db.ListOptions{
PageSize: 10,
Page: 0,
},
IncludeTags: true,
RepoID: repo.ID,
})
require.NoError(t, err)
assert.Len(t, releases, 8)
releases, err = db.Find[repo_model.Release](db.DefaultContext, repo_model.FindReleasesOptions{
ListOptions: db.ListOptions{
PageSize: 10,
Page: 0,
},
IncludeTags: false,
RepoID: repo.ID,
})
require.NoError(t, err)
assert.Len(t, releases, 1)
issues, err := issues_model.Issues(db.DefaultContext, &issues_model.IssuesOptions{
RepoIDs: []int64{repo.ID},
IsPull: optional.Some(false),
SortType: "oldest",
})
require.NoError(t, err)
assert.Len(t, issues, 15)
require.NoError(t, issues[0].LoadDiscussComments(db.DefaultContext))
assert.Empty(t, issues[0].Comments)
pulls, _, err := issues_model.PullRequests(db.DefaultContext, repo.ID, &issues_model.PullRequestsOptions{
SortType: "oldest",
})
require.NoError(t, err)
assert.Len(t, pulls, 30)
require.NoError(t, pulls[0].LoadIssue(db.DefaultContext))
require.NoError(t, pulls[0].Issue.LoadDiscussComments(db.DefaultContext))
assert.Len(t, pulls[0].Issue.Comments, 2)
}
func TestGiteaUploadRemapLocalUser(t *testing.T) {
unittest.PrepareTestEnv(t)
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repoName := "migrated"
uploader := NewGiteaLocalUploader(t.Context(), doer, doer.Name, repoName)
// call remapLocalUser
uploader.sameApp = true
externalID := int64(1234567)
externalName := "username"
source := base.Release{
PublisherID: externalID,
PublisherName: externalName,
}
//
// The externalID does not match any existing user, everything
// belongs to the Ghost user
//
target := repo_model.Release{}
uploader.userMap = make(map[int64]int64)
err := uploader.remapUser(&source, &target)
require.NoError(t, err)
assert.EqualValues(t, user_model.GhostUserID, target.GetUserID())
//
// The externalID matches a known user but the name does not match,
// everything belongs to the Ghost user
//
source.PublisherID = user.ID
target = repo_model.Release{}
uploader.userMap = make(map[int64]int64)
err = uploader.remapUser(&source, &target)
require.NoError(t, err)
assert.EqualValues(t, user_model.GhostUserID, target.GetUserID())
//
// The externalID and externalName match an existing user, everything
// belongs to the existing user
//
source.PublisherName = user.Name
target = repo_model.Release{}
uploader.userMap = make(map[int64]int64)
err = uploader.remapUser(&source, &target)
require.NoError(t, err)
assert.Equal(t, user.ID, target.GetUserID())
}
func TestGiteaUploadRemapExternalUser(t *testing.T) {
unittest.PrepareTestEnv(t)
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
repoName := "migrated"
uploader := NewGiteaLocalUploader(t.Context(), doer, doer.Name, repoName)
uploader.gitServiceType = structs.GiteaService
// call remapExternalUser
uploader.sameApp = false
externalID := int64(1234567)
externalName := "username"
source := base.Release{
PublisherID: externalID,
PublisherName: externalName,
}
//
// When there is no user linked to the external ID, the migrated data is authored
// by the Ghost user
//
uploader.userMap = make(map[int64]int64)
target := repo_model.Release{}
err := uploader.remapUser(&source, &target)
require.NoError(t, err)
assert.EqualValues(t, user_model.GhostUserID, target.GetUserID())
//
// Link the external ID to an existing user
//
linkedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
externalLoginUser := &user_model.ExternalLoginUser{
ExternalID: strconv.FormatInt(externalID, 10),
UserID: linkedUser.ID,
LoginSourceID: 0,
Provider: structs.GiteaService.Name(),
}
err = user_model.LinkExternalToUser(db.DefaultContext, linkedUser, externalLoginUser)
require.NoError(t, err)
//
// When a user is linked to the external ID, it becomes the author of
// the migrated data
//
uploader.userMap = make(map[int64]int64)
target = repo_model.Release{}
err = uploader.remapUser(&source, &target)
require.NoError(t, err)
assert.Equal(t, linkedUser.ID, target.GetUserID())
}
func TestGiteaUploadUpdateGitForPullRequest(t *testing.T) {
unittest.PrepareTestEnv(t)
//
// fromRepo master
//
fromRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
baseRef := "master"
require.NoError(t, git.InitRepository(git.DefaultContext, fromRepo.RepoPath(), false, fromRepo.ObjectFormatName))
err := git.NewCommand(git.DefaultContext, "symbolic-ref").AddDynamicArguments("HEAD", git.BranchPrefix+baseRef).Run(&git.RunOpts{Dir: fromRepo.RepoPath()})
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(fromRepo.RepoPath(), "README.md"), []byte(fmt.Sprintf("# Testing Repository\n\nOriginally created in: %s", fromRepo.RepoPath())), 0o644))
require.NoError(t, git.AddChanges(fromRepo.RepoPath(), true))
signature := git.Signature{
Email: "test@example.com",
Name: "test",
When: time.Now(),
}
require.NoError(t, git.CommitChanges(fromRepo.RepoPath(), git.CommitChangesOptions{
Committer: &signature,
Author: &signature,
Message: "Initial Commit",
}))
fromGitRepo, err := gitrepo.OpenRepository(git.DefaultContext, fromRepo)
require.NoError(t, err)
defer fromGitRepo.Close()
baseSHA, err := fromGitRepo.GetBranchCommitID(baseRef)
require.NoError(t, err)
//
// fromRepo branch1
//
headRef := "branch1"
_, _, err = git.NewCommand(git.DefaultContext, "checkout", "-b").AddDynamicArguments(headRef).RunStdString(&git.RunOpts{Dir: fromRepo.RepoPath()})
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(fromRepo.RepoPath(), "README.md"), []byte("SOMETHING"), 0o644))
require.NoError(t, git.AddChanges(fromRepo.RepoPath(), true))
signature.When = time.Now()
require.NoError(t, git.CommitChanges(fromRepo.RepoPath(), git.CommitChangesOptions{
Committer: &signature,
Author: &signature,
Message: "Pull request",
}))
require.NoError(t, err)
headSHA, err := fromGitRepo.GetBranchCommitID(headRef)
require.NoError(t, err)
fromRepoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: fromRepo.OwnerID})
//
// forkRepo branch2
//
forkHeadRef := "branch2"
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 8})
require.NoError(t, git.CloneWithArgs(git.DefaultContext, nil, fromRepo.RepoPath(), forkRepo.RepoPath(), git.CloneRepoOptions{
Branch: headRef,
}))
_, _, err = git.NewCommand(git.DefaultContext, "checkout", "-b").AddDynamicArguments(forkHeadRef).RunStdString(&git.RunOpts{Dir: forkRepo.RepoPath()})
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(forkRepo.RepoPath(), "README.md"), []byte(fmt.Sprintf("# branch2 %s", forkRepo.RepoPath())), 0o644))
require.NoError(t, git.AddChanges(forkRepo.RepoPath(), true))
require.NoError(t, git.CommitChanges(forkRepo.RepoPath(), git.CommitChangesOptions{
Committer: &signature,
Author: &signature,
Message: "branch2 commit",
}))
forkGitRepo, err := gitrepo.OpenRepository(git.DefaultContext, forkRepo)
require.NoError(t, err)
defer forkGitRepo.Close()
forkHeadSHA, err := forkGitRepo.GetBranchCommitID(forkHeadRef)
require.NoError(t, err)
toRepoName := "migrated"
uploader := NewGiteaLocalUploader(t.Context(), fromRepoOwner, fromRepoOwner.Name, toRepoName)
uploader.gitServiceType = structs.GiteaService
require.NoError(t, uploader.CreateRepo(&base.Repository{
Description: "description",
OriginalURL: fromRepo.RepoPath(),
CloneURL: fromRepo.RepoPath(),
IsPrivate: false,
IsMirror: true,
}, base.MigrateOptions{
GitServiceType: structs.GiteaService,
Private: false,
Mirror: true,
}))
for _, testCase := range []struct {
name string
head string
logFilter []string
logFiltered []bool
pr base.PullRequest
}{
{
name: "fork, good Head.SHA",
head: fmt.Sprintf("%s/%s", forkRepo.OwnerName, forkHeadRef),
pr: base.PullRequest{
PatchURL: "",
Number: 1,
State: "open",
Base: base.PullRequestBranch{
CloneURL: fromRepo.RepoPath(),
Ref: baseRef,
SHA: baseSHA,
RepoName: fromRepo.Name,
OwnerName: fromRepo.OwnerName,
},
Head: base.PullRequestBranch{
CloneURL: forkRepo.RepoPath(),
Ref: forkHeadRef,
SHA: forkHeadSHA,
RepoName: forkRepo.Name,
OwnerName: forkRepo.OwnerName,
},
},
},
{
name: "fork, invalid Head.Ref",
head: "unknown repository",
pr: base.PullRequest{
PatchURL: "",
Number: 1,
State: "open",
Base: base.PullRequestBranch{
CloneURL: fromRepo.RepoPath(),
Ref: baseRef,
SHA: baseSHA,
RepoName: fromRepo.Name,
OwnerName: fromRepo.OwnerName,
},
Head: base.PullRequestBranch{
CloneURL: forkRepo.RepoPath(),
Ref: "INVALID",
SHA: forkHeadSHA,
RepoName: forkRepo.Name,
OwnerName: forkRepo.OwnerName,
},
},
logFilter: []string{"Fetch branch from"},
logFiltered: []bool{true},
},
{
name: "invalid fork CloneURL",
head: "unknown repository",
pr: base.PullRequest{
PatchURL: "",
Number: 1,
State: "open",
Base: base.PullRequestBranch{
CloneURL: fromRepo.RepoPath(),
Ref: baseRef,
SHA: baseSHA,
RepoName: fromRepo.Name,
OwnerName: fromRepo.OwnerName,
},
Head: base.PullRequestBranch{
CloneURL: "UNLIKELY",
Ref: forkHeadRef,
SHA: forkHeadSHA,
RepoName: forkRepo.Name,
OwnerName: "WRONG",
},
},
logFilter: []string{"AddRemote"},
logFiltered: []bool{true},
},
{
name: "no fork, good Head.SHA",
head: headRef,
pr: base.PullRequest{
PatchURL: "",
Number: 1,
State: "open",
Base: base.PullRequestBranch{
CloneURL: fromRepo.RepoPath(),
Ref: baseRef,
SHA: baseSHA,
RepoName: fromRepo.Name,
OwnerName: fromRepo.OwnerName,
},
Head: base.PullRequestBranch{
CloneURL: fromRepo.RepoPath(),
Ref: headRef,
SHA: headSHA,
RepoName: fromRepo.Name,
OwnerName: fromRepo.OwnerName,
},
},
},
{
name: "no fork, empty Head.SHA",
head: headRef,
pr: base.PullRequest{
PatchURL: "",
Number: 1,
State: "open",
Base: base.PullRequestBranch{
CloneURL: fromRepo.RepoPath(),
Ref: baseRef,
SHA: baseSHA,
RepoName: fromRepo.Name,
OwnerName: fromRepo.OwnerName,
},
Head: base.PullRequestBranch{
CloneURL: fromRepo.RepoPath(),
Ref: headRef,
SHA: "",
RepoName: fromRepo.Name,
OwnerName: fromRepo.OwnerName,
},
},
logFilter: []string{"Empty reference", "Cannot remove local head"},
logFiltered: []bool{true, false},
},
{
name: "no fork, invalid Head.SHA",
head: headRef,
pr: base.PullRequest{
PatchURL: "",
Number: 1,
State: "open",
Base: base.PullRequestBranch{
CloneURL: fromRepo.RepoPath(),
Ref: baseRef,
SHA: baseSHA,
RepoName: fromRepo.Name,
OwnerName: fromRepo.OwnerName,
},
Head: base.PullRequestBranch{
CloneURL: fromRepo.RepoPath(),
Ref: headRef,
SHA: "brokenSHA",
RepoName: fromRepo.Name,
OwnerName: fromRepo.OwnerName,
},
},
logFilter: []string{"Deprecated local head"},
logFiltered: []bool{true},
},
{
name: "no fork, not found Head.SHA",
head: headRef,
pr: base.PullRequest{
PatchURL: "",
Number: 1,
State: "open",
Base: base.PullRequestBranch{
CloneURL: fromRepo.RepoPath(),
Ref: baseRef,
SHA: baseSHA,
RepoName: fromRepo.Name,
OwnerName: fromRepo.OwnerName,
},
Head: base.PullRequestBranch{
CloneURL: fromRepo.RepoPath(),
Ref: headRef,
SHA: "2697b352310fcd01cbd1f3dbd43b894080027f68",
RepoName: fromRepo.Name,
OwnerName: fromRepo.OwnerName,
},
},
logFilter: []string{"Deprecated local head", "Cannot remove local head"},
logFiltered: []bool{true, false},
},
} {
t.Run(testCase.name, func(t *testing.T) {
stopMark := fmt.Sprintf(">>>>>>>>>>>>>STOP: %s<<<<<<<<<<<<<<<", testCase.name)
logChecker, cleanup := test.NewLogChecker(log.DEFAULT, log.INFO)
logChecker.Filter(testCase.logFilter...).StopMark(stopMark)
defer cleanup()
testCase.pr.EnsuredSafe = true
head, err := uploader.updateGitForPullRequest(&testCase.pr)
require.NoError(t, err)
assert.Equal(t, testCase.head, head)
log.Info(stopMark)
logFiltered, logStopped := logChecker.Check(5 * time.Second)
assert.True(t, logStopped)
if len(testCase.logFilter) > 0 {
assert.Equal(t, testCase.logFiltered, logFiltered, "for log message filters: %v", testCase.logFilter)
}
})
}
}