6 Commits

Author SHA256 Message Date
Andrii Nikitin
96a908d0be t: Simplify create_gitea_pr using new_branch param
All checks were successful
go-generate-check / go-generate-check (pull_request) Successful in 8s
Integration tests / t (pull_request) Successful in 8m31s
Refactor create_gitea_pr() in common_test_utils.py to leverage the
'new_branch' parameter in the Gitea 'diffpatch' API call. This allows
for automatic creation of the target branch while applying the diff,
eliminating the need for explicit branch creation using the branches
endpoint. This also fixed strange behavior when diffpatch damaged
history of precreated branch
2026-03-03 13:03:32 +01:00
Andrii Nikitin
6aaff89179 t: test MergeMode of workflow-pr
- Add TC-MERGE-008 to 013 for testing MergeMode of workflow-pr
- Synchronize integration/test-plan.md with the actual test implementations
2026-03-03 13:03:00 +01:00
3a0445e857 rabbitmq: dial with timeout
Hardcoded 10 second timeout on no connection instead of waiting
forever.
2026-03-02 11:56:35 +01:00
ef5db8ca28 pr: No need to try to merge changes
We can reset current worktree and clobber it with the merged changes.
We want to emulate `git merge -s theirs` strategy while
`git merge -Xtheirs` only picks `theirs` in case of conflicts.
So, resetting the changes and reading exact is sufficient

`git read-tree -u` updates the current work tree so we do not have
unstaged changes.
2026-03-02 11:04:22 +01:00
10f74f681d pr: add function that checks and prepares PRs 2026-03-02 11:04:22 +01:00
b514f9784c pr: add merge modes documentation and config parsing 2026-03-02 11:04:22 +01:00
17 changed files with 963 additions and 266 deletions

View File

@@ -39,6 +39,10 @@ const (
Permission_ForceMerge = "force-merge"
Permission_Group = "release-engineering"
MergeModeFF = "ff-only"
MergeModeReplace = "replace"
MergeModeDevel = "devel"
)
type ConfigFile struct {
@@ -52,9 +56,9 @@ type ReviewGroup struct {
}
type QAConfig struct {
Name string
Origin string
Label string // requires this gitea lable to be set or skipped
Name string
Origin string
Label string // requires this gitea lable to be set or skipped
BuildDisableRepos []string // which repos to build disable in the new project
}
@@ -89,7 +93,8 @@ type AutogitConfig struct {
Committers []string // group in addition to Reviewers and Maintainers that can order the bot around, mostly as helper for factory-maintainers
Subdirs []string // list of directories to sort submodules into. Needed b/c _manifest cannot list non-existent directories
Labels map[string]string // list of tags, if not default, to apply
Labels map[string]string // list of tags, if not default, to apply
MergeMode string // project merge mode
NoProjectGitPR bool // do not automatically create project git PRs, just assign reviewers and assume somethign else creates the ProjectGit PR
ManualMergeOnly bool // only merge with "Merge OK" comment by Project Maintainers and/or Package Maintainers and/or reviewers
@@ -184,6 +189,17 @@ func ReadWorkflowConfig(gitea GiteaFileContentAndRepoFetcher, git_project string
}
}
config.GitProjectName = config.GitProjectName + "#" + branch
// verify merge modes
switch config.MergeMode {
case MergeModeFF, MergeModeDevel, MergeModeReplace:
break // good results
case "":
config.MergeMode = MergeModeFF
default:
return nil, fmt.Errorf("Unsupported merge mode in %s: %s", git_project, config.MergeMode)
}
return config, nil
}

View File

@@ -341,3 +341,67 @@ func TestConfigPermissions(t *testing.T) {
})
}
}
func TestConfigMergeModeParser(t *testing.T) {
tests := []struct {
name string
json string
mergeMode string
wantErr bool
}{
{
name: "empty",
json: "{}",
mergeMode: common.MergeModeFF,
},
{
name: "ff-only",
json: `{"MergeMode": "ff-only"}`,
mergeMode: common.MergeModeFF,
},
{
name: "replace",
json: `{"MergeMode": "replace"}`,
mergeMode: common.MergeModeReplace,
},
{
name: "devel",
json: `{"MergeMode": "devel"}`,
mergeMode: common.MergeModeDevel,
},
{
name: "unsupported",
json: `{"MergeMode": "invalid"}`,
wantErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repo := models.Repository{
DefaultBranch: "master",
}
ctl := gomock.NewController(t)
gitea := mock_common.NewMockGiteaFileContentAndRepoFetcher(ctl)
gitea.EXPECT().GetRepositoryFileContent("foo", "bar", "", "workflow.config").Return([]byte(test.json), "abc", nil)
gitea.EXPECT().GetRepository("foo", "bar").Return(&repo, nil)
config, err := common.ReadWorkflowConfig(gitea, "foo/bar")
if test.wantErr {
if err == nil {
t.Fatal("Expected error, got nil")
}
return
}
if err != nil {
t.Fatal(err)
}
if config.MergeMode != test.mergeMode {
t.Errorf("Expected MergeMode %s, got %s", test.mergeMode, config.MergeMode)
}
})
}
}

View File

@@ -554,6 +554,144 @@ func (rs *PRSet) IsApproved(gitea GiteaPRChecker, maintainers MaintainershipData
return is_manually_reviewed_ok
}
func (rs *PRSet) AddMergeCommit(git Git, remote string, pr int) bool {
prinfo := rs.PRs[pr]
LogDebug("Adding merge commit for %s", PRtoString(prinfo.PR))
if !prinfo.PR.AllowMaintainerEdit {
LogError(" PR is not editable by maintainer")
return false
}
repo := prinfo.PR.Base.Repo
head := prinfo.PR.Head
br := rs.Config.Branch
if len(br) == 0 {
br = prinfo.PR.Base.Name
}
msg := fmt.Sprintf("Merge branch '%s' into %s", br, head.Name)
if err := git.GitExec(repo.Name, "merge", "--no-ff", "--no-commit", "-X", "theirs", head.Sha); err != nil {
if err := git.GitExec(repo.Name, "merge", "--no-ff", "--no-commit", "--allow-unrelated-histories", "-X", "theirs", head.Sha); err != nil {
return false
}
LogError("WARNING: Merging unrelated histories")
}
// ensure only files that are in head.Sha are kept
git.GitExecOrPanic(repo.Name, "read-tree", "--reset", "-u", head.Sha)
git.GitExecOrPanic(repo.Name, "commit", "-m", msg)
if !IsDryRun {
git.GitExecOrPanic(repo.Name, "push", remote, "HEAD:"+head.Name)
prinfo.PR.Head.Sha = strings.TrimSpace(git.GitExecWithOutputOrPanic(repo.Name, "rev-list", "-1", "HEAD")) // need to update as it's pushed but pr not refetched
}
return true
}
func (rs *PRSet) HasMerge(git Git, pr int) bool {
prinfo := rs.PRs[pr]
repo := prinfo.PR.Base.Repo
head := prinfo.PR.Head
br := rs.Config.Branch
if len(br) == 0 {
br = prinfo.PR.Base.Name
}
parents, err := git.GitExecWithOutput(repo.Name, "show", "-s", "--format=%P", head.Sha)
if err == nil {
p := strings.Fields(strings.TrimSpace(parents))
if len(p) == 2 {
targetHead, _ := git.GitExecWithOutput(repo.Name, "rev-parse", "HEAD")
targetHead = strings.TrimSpace(targetHead)
if p[0] == targetHead || p[1] == targetHead {
return true
}
}
}
return false
}
func (rs *PRSet) PrepareForMerge(git Git) bool {
// verify that package can merge here. Checkout current target branch of each PRSet, make a temporary branch
// PR_#_mergetest and perform the merge based
if rs.Config.MergeMode == MergeModeDevel {
return true // always can merge as we set branch here, not merge anything
} else {
// make sure that all the package PRs are in mergeable state
for idx, prinfo := range rs.PRs {
if rs.IsPrjGitPR(prinfo.PR) {
continue
}
repo := prinfo.PR.Base.Repo
head := prinfo.PR.Head
br := rs.Config.Branch
if len(br) == 0 {
br = prinfo.PR.Base.Name
}
remote, err := git.GitClone(repo.Name, br, repo.SSHURL)
if err != nil {
return false
}
git.GitExecOrPanic(repo.Name, "fetch", remote, head.Sha)
switch rs.Config.MergeMode {
case MergeModeFF:
if err := git.GitExec(repo.Name, "merge-base", "--is-ancestor", "HEAD", head.Sha); err != nil {
return false
}
case MergeModeReplace:
Verify:
if err := git.GitExec(repo.Name, "merge-base", "--is-ancestor", "HEAD", head.Sha); err != nil {
if !rs.HasMerge(git, idx) {
forkRemote, err := git.GitClone(repo.Name, head.Name, head.Repo.SSHURL)
if err != nil {
LogError("Failed to clone head repo:", head.Name, head.Repo.SSHURL)
return false
}
LogDebug("Merge commit is missing and this is not FF merge possibility")
git.GitExecOrPanic(repo.Name, "checkout", remote+"/"+br)
if !rs.AddMergeCommit(git, forkRemote, idx) {
return false
}
if !IsDryRun {
goto Verify
}
}
}
}
}
}
// now we check project git if mergeable
prjgit_info, err := rs.GetPrjGitPR()
if err != nil {
return false
}
prjgit := prjgit_info.PR
_, _, prjgitBranch := rs.Config.GetPrjGit()
remote, err := git.GitClone(DefaultGitPrj, prjgitBranch, prjgit.Base.Repo.SSHURL)
if err != nil {
return false
}
testBranch := fmt.Sprintf("PR_%d_mergetest", prjgit.Index)
git.GitExecOrPanic(DefaultGitPrj, "fetch", remote, prjgit.Head.Sha)
if err := git.GitExec(DefaultGitPrj, "checkout", "-B", testBranch, prjgit.Base.Sha); err != nil {
return false
}
if err := git.GitExec(DefaultGitPrj, "merge", "--no-ff", "--no-commit", prjgit.Head.Sha); err != nil {
return false
}
return true
}
func (rs *PRSet) Merge(gitea GiteaReviewUnrequester, git Git) error {
prjgit_info, err := rs.GetPrjGitPR()
if err != nil {
@@ -718,11 +856,10 @@ func (rs *PRSet) Merge(gitea GiteaReviewUnrequester, git Git) error {
prinfo.RemoteName, err = git.GitClone(repo.Name, br, repo.SSHURL)
PanicOnError(err)
git.GitExecOrPanic(repo.Name, "fetch", prinfo.RemoteName, head.Sha)
if isNewRepo {
LogInfo("Force-pushing new repository branch", br, "to", head.Sha)
// we don't merge, we just set the branch to this commit
if rs.Config.MergeMode == MergeModeDevel || isNewRepo {
git.GitExecOrPanic(repo.Name, "checkout", "-B", br, head.Sha)
} else {
git.GitExecOrPanic(repo.Name, "fetch", prinfo.RemoteName, head.Sha)
git.GitExecOrPanic(repo.Name, "merge", "--ff", head.Sha)
}
}
@@ -748,11 +885,15 @@ func (rs *PRSet) Merge(gitea GiteaReviewUnrequester, git Git) error {
}
if !IsDryRun {
if isNewRepo {
git.GitExecOrPanic(repo.Name, "push", "-f", prinfo.RemoteName, prinfo.PR.Head.Sha+":"+prinfo.PR.Base.Name)
} else {
git.GitExecOrPanic(repo.Name, "push", prinfo.RemoteName)
params := []string{"push"}
if rs.Config.MergeMode == MergeModeDevel || isNewRepo {
params = append(params, "-f")
}
params = append(params, prinfo.RemoteName)
if isNewRepo {
params = append(params, prinfo.PR.Head.Sha+":"+prinfo.PR.Base.Name)
}
git.GitExecOrPanic(repo.Name, params...)
} else {
LogInfo("*** WOULD push", repo.Name, "to", prinfo.RemoteName)
}

View File

@@ -2,6 +2,7 @@ package common_test
import (
"errors"
"fmt"
"os"
"os/exec"
"path"
@@ -1267,7 +1268,7 @@ func TestPRMerge(t *testing.T) {
Owner: &models.User{
UserName: "org",
},
SSHURL: "file://" + path.Join(repoDir, "prjgit"),
SSHURL: "ssh://git@src.opensuse.org/org/prj.git",
},
},
Head: &models.PRBranchInfo{
@@ -1289,7 +1290,7 @@ func TestPRMerge(t *testing.T) {
Owner: &models.User{
UserName: "org",
},
SSHURL: "file://" + path.Join(cmd.Dir, "prjgit"),
SSHURL: "ssh://git@src.opensuse.org/org/prj.git",
},
},
Head: &models.PRBranchInfo{
@@ -1399,3 +1400,344 @@ func TestPRChanges(t *testing.T) {
})
}
}
func TestPRPrepareForMerge(t *testing.T) {
tests := []struct {
name string
setup func(*mock_common.MockGit, *models.PullRequest, *models.PullRequest)
config *common.AutogitConfig
expected bool
editable bool
}{
{
name: "Success Devel",
config: &common.AutogitConfig{
Organization: "org",
GitProjectName: "org/_ObsPrj#master",
MergeMode: common.MergeModeDevel,
},
setup: func(m *mock_common.MockGit, prjPR, pkgPR *models.PullRequest) {},
expected: true,
},
{
name: "Success FF",
config: &common.AutogitConfig{
Organization: "org",
GitProjectName: "org/_ObsPrj#master",
MergeMode: common.MergeModeFF,
},
setup: func(m *mock_common.MockGit, prjPR, pkgPR *models.PullRequest) {
m.EXPECT().GitClone("pkg", "master", pkgPR.Base.Repo.SSHURL).Return("origin", nil)
m.EXPECT().GitExecOrPanic("pkg", "fetch", "origin", pkgPR.Head.Sha)
m.EXPECT().GitExec("pkg", "merge-base", "--is-ancestor", "HEAD", pkgPR.Head.Sha).Return(nil)
m.EXPECT().GitClone("_ObsPrj", "master", prjPR.Base.Repo.SSHURL).Return("origin", nil)
m.EXPECT().GitExecOrPanic("_ObsPrj", "fetch", "origin", prjPR.Head.Sha)
m.EXPECT().GitExec("_ObsPrj", "checkout", "-B", "PR_1_mergetest", prjPR.Base.Sha).Return(nil)
m.EXPECT().GitExec("_ObsPrj", "merge", "--no-ff", "--no-commit", prjPR.Head.Sha).Return(nil)
},
expected: true,
},
{
name: "Success Replace MergeCommit",
config: &common.AutogitConfig{
Organization: "org",
GitProjectName: "org/_ObsPrj#master",
MergeMode: common.MergeModeReplace,
},
setup: func(m *mock_common.MockGit, prjPR, pkgPR *models.PullRequest) {
m.EXPECT().GitClone("pkg", "master", pkgPR.Base.Repo.SSHURL).Return("origin", nil)
m.EXPECT().GitExecOrPanic("pkg", "fetch", "origin", pkgPR.Head.Sha)
// merge-base fails initially
m.EXPECT().GitExec("pkg", "merge-base", "--is-ancestor", "HEAD", pkgPR.Head.Sha).Return(fmt.Errorf("not ancestor"))
// HasMerge returns true
m.EXPECT().GitExecWithOutput("pkg", "show", "-s", "--format=%P", pkgPR.Head.Sha).Return("parent1 target_head", nil)
m.EXPECT().GitExecWithOutput("pkg", "rev-parse", "HEAD").Return("target_head", nil)
m.EXPECT().GitClone("_ObsPrj", "master", prjPR.Base.Repo.SSHURL).Return("origin", nil)
m.EXPECT().GitExecOrPanic("_ObsPrj", "fetch", "origin", prjPR.Head.Sha)
m.EXPECT().GitExec("_ObsPrj", "checkout", "-B", "PR_1_mergetest", prjPR.Base.Sha).Return(nil)
m.EXPECT().GitExec("_ObsPrj", "merge", "--no-ff", "--no-commit", prjPR.Head.Sha).Return(nil)
},
expected: true,
},
{
name: "Merge Conflict in PrjGit",
config: &common.AutogitConfig{
Organization: "org",
GitProjectName: "org/_ObsPrj#master",
MergeMode: common.MergeModeFF,
},
setup: func(m *mock_common.MockGit, prjPR, pkgPR *models.PullRequest) {
m.EXPECT().GitClone("pkg", "master", pkgPR.Base.Repo.SSHURL).Return("origin", nil)
m.EXPECT().GitExecOrPanic("pkg", "fetch", "origin", pkgPR.Head.Sha)
m.EXPECT().GitExec("pkg", "merge-base", "--is-ancestor", "HEAD", pkgPR.Head.Sha).Return(nil)
m.EXPECT().GitClone("_ObsPrj", "master", prjPR.Base.Repo.SSHURL).Return("origin", nil)
m.EXPECT().GitExecOrPanic("_ObsPrj", "fetch", "origin", prjPR.Head.Sha)
m.EXPECT().GitExec("_ObsPrj", "checkout", "-B", "PR_1_mergetest", prjPR.Base.Sha).Return(nil)
m.EXPECT().GitExec("_ObsPrj", "merge", "--no-ff", "--no-commit", prjPR.Head.Sha).Return(fmt.Errorf("conflict"))
},
expected: false,
},
{
name: "Not FF in PkgGit",
config: &common.AutogitConfig{
Organization: "org",
GitProjectName: "org/_ObsPrj#master",
MergeMode: common.MergeModeFF,
},
setup: func(m *mock_common.MockGit, prjPR, pkgPR *models.PullRequest) {
m.EXPECT().GitClone("pkg", "master", pkgPR.Base.Repo.SSHURL).Return("origin", nil)
m.EXPECT().GitExecOrPanic("pkg", "fetch", "origin", pkgPR.Head.Sha)
m.EXPECT().GitExec("pkg", "merge-base", "--is-ancestor", "HEAD", pkgPR.Head.Sha).Return(fmt.Errorf("not ancestor"))
},
expected: false,
},
{
name: "Success Replace with AddMergeCommit",
config: &common.AutogitConfig{
Organization: "org",
GitProjectName: "org/_ObsPrj#master",
MergeMode: common.MergeModeReplace,
},
editable: true,
setup: func(m *mock_common.MockGit, prjPR, pkgPR *models.PullRequest) {
m.EXPECT().GitClone("pkg", "master", pkgPR.Base.Repo.SSHURL).Return("origin", nil)
m.EXPECT().GitExecOrPanic("pkg", "fetch", "origin", pkgPR.Head.Sha)
// First merge-base fails
m.EXPECT().GitExec("pkg", "merge-base", "--is-ancestor", "HEAD", pkgPR.Head.Sha).Return(fmt.Errorf("not ancestor"))
// HasMerge returns false
m.EXPECT().GitExecWithOutput("pkg", "show", "-s", "--format=%P", pkgPR.Head.Sha).Return("parent1", nil)
m.EXPECT().GitClone("pkg", pkgPR.Head.Name, pkgPR.Base.Repo.SSHURL).Return("origin_fork", nil)
// AddMergeCommit is called
m.EXPECT().GitExecOrPanic("pkg", "checkout", "origin/master")
m.EXPECT().GitExec("pkg", "merge", "--no-ff", "--no-commit", "-X", "theirs", pkgPR.Head.Sha).Return(nil)
m.EXPECT().GitExecOrPanic("pkg", "read-tree", "--reset", "-u", pkgPR.Head.Sha)
m.EXPECT().GitExecOrPanic("pkg", "commit", "-m", gomock.Any())
m.EXPECT().GitExecOrPanic("pkg", "push", "origin_fork", "HEAD:"+pkgPR.Head.Name)
m.EXPECT().GitExecWithOutputOrPanic("pkg", "rev-list", "-1", "HEAD").Return("new_pkg_head_sha")
// Second merge-base succeeds (after goto Verify)
m.EXPECT().GitExec("pkg", "merge-base", "--is-ancestor", "HEAD", "new_pkg_head_sha").Return(nil)
m.EXPECT().GitClone("_ObsPrj", "master", prjPR.Base.Repo.SSHURL).Return("origin", nil)
m.EXPECT().GitExecOrPanic("_ObsPrj", "fetch", "origin", prjPR.Head.Sha)
m.EXPECT().GitExec("_ObsPrj", "checkout", "-B", "PR_1_mergetest", prjPR.Base.Sha).Return(nil)
m.EXPECT().GitExec("_ObsPrj", "merge", "--no-ff", "--no-commit", prjPR.Head.Sha).Return(nil)
},
expected: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
prjPR := &models.PullRequest{
Index: 1,
Base: &models.PRBranchInfo{
Name: "master",
Sha: "base_sha",
Repo: &models.Repository{
Owner: &models.User{UserName: "org"},
Name: "_ObsPrj",
SSHURL: "ssh://git@src.opensuse.org/org/_ObsPrj.git",
},
},
Head: &models.PRBranchInfo{
Sha: "head_sha",
Repo: &models.Repository{
Owner: &models.User{UserName: "org"},
Name: "_ObsPrj",
SSHURL: "ssh://git@src.opensuse.org/org/_ObsPrj.git",
},
},
}
pkgPR := &models.PullRequest{
Index: 2,
Base: &models.PRBranchInfo{
Name: "master",
Sha: "pkg_base_sha",
Repo: &models.Repository{
Owner: &models.User{UserName: "org"},
Name: "pkg",
SSHURL: "ssh://git@src.opensuse.org/org/pkg.git",
},
},
Head: &models.PRBranchInfo{
Name: "branch_name",
Sha: "pkg_head_sha",
Repo: &models.Repository{
Owner: &models.User{UserName: "org"},
Name: "pkg",
SSHURL: "ssh://git@src.opensuse.org/org/pkg.git",
},
},
AllowMaintainerEdit: test.editable,
}
ctl := gomock.NewController(t)
git := mock_common.NewMockGit(ctl)
test.setup(git, prjPR, pkgPR)
prset := &common.PRSet{
Config: test.config,
PRs: []*common.PRInfo{
{PR: prjPR},
{PR: pkgPR},
},
}
if res := prset.PrepareForMerge(git); res != test.expected {
t.Errorf("Expected %v, got %v", test.expected, res)
}
})
}
}
func TestPRMergeMock(t *testing.T) {
tests := []struct {
name string
setup func(*mock_common.MockGit, *models.PullRequest, *models.PullRequest)
config *common.AutogitConfig
}{
{
name: "Success FF",
config: &common.AutogitConfig{
Organization: "org",
GitProjectName: "org/_ObsPrj#master",
MergeMode: common.MergeModeFF,
},
setup: func(m *mock_common.MockGit, prjPR, pkgPR *models.PullRequest) {
m.EXPECT().GitClone("_ObsPrj", "master", prjPR.Base.Repo.SSHURL).Return("origin", nil)
m.EXPECT().GitExecOrPanic("_ObsPrj", "fetch", "origin", prjPR.Head.Sha)
m.EXPECT().GitExec("_ObsPrj", "merge", "--no-ff", "-m", gomock.Any(), prjPR.Head.Sha).Return(nil)
m.EXPECT().GitClone("pkg", "master", pkgPR.Base.Repo.SSHURL).Return("origin_pkg", nil)
m.EXPECT().GitExecOrPanic("pkg", "fetch", "origin_pkg", pkgPR.Head.Sha)
m.EXPECT().GitExecOrPanic("pkg", "merge", "--ff", pkgPR.Head.Sha)
m.EXPECT().GitExecOrPanic("pkg", "push", "origin_pkg")
m.EXPECT().GitExecOrPanic("_ObsPrj", "push", "origin")
},
},
{
name: "Success Devel",
config: &common.AutogitConfig{
Organization: "org",
GitProjectName: "org/_ObsPrj#master",
MergeMode: common.MergeModeDevel,
},
setup: func(m *mock_common.MockGit, prjPR, pkgPR *models.PullRequest) {
m.EXPECT().GitClone("_ObsPrj", "master", prjPR.Base.Repo.SSHURL).Return("origin", nil)
m.EXPECT().GitExecOrPanic("_ObsPrj", "fetch", "origin", prjPR.Head.Sha)
m.EXPECT().GitExec("_ObsPrj", "merge", "--no-ff", "-m", gomock.Any(), prjPR.Head.Sha).Return(nil)
m.EXPECT().GitClone("pkg", "master", pkgPR.Base.Repo.SSHURL).Return("origin_pkg", nil)
m.EXPECT().GitExecOrPanic("pkg", "checkout", "-B", "master", pkgPR.Head.Sha)
m.EXPECT().GitExecOrPanic("pkg", "push", "-f", "origin_pkg")
m.EXPECT().GitExecOrPanic("_ObsPrj", "push", "origin")
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
prjPR := &models.PullRequest{
Index: 1,
Base: &models.PRBranchInfo{
Name: "master",
Sha: "prj_base_sha",
Repo: &models.Repository{
Owner: &models.User{UserName: "org"},
Name: "_ObsPrj",
SSHURL: "ssh://git@src.opensuse.org/org/_ObsPrj.git",
},
},
Head: &models.PRBranchInfo{
Sha: "prj_head_sha",
},
}
pkgPR := &models.PullRequest{
Index: 2,
Base: &models.PRBranchInfo{
Name: "master",
Sha: "pkg_base_sha",
Repo: &models.Repository{
Owner: &models.User{UserName: "org"},
Name: "pkg",
SSHURL: "ssh://git@src.opensuse.org/org/pkg.git",
},
},
Head: &models.PRBranchInfo{
Sha: "pkg_head_sha",
},
}
ctl := gomock.NewController(t)
git := mock_common.NewMockGit(ctl)
reviewUnrequestMock := mock_common.NewMockGiteaReviewUnrequester(ctl)
reviewUnrequestMock.EXPECT().UnrequestReview(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
test.setup(git, prjPR, pkgPR)
prset := &common.PRSet{
Config: test.config,
PRs: []*common.PRInfo{
{PR: prjPR},
{PR: pkgPR},
},
}
if err := prset.Merge(reviewUnrequestMock, git); err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
}
func TestPRAddMergeCommit(t *testing.T) {
pkgPR := &models.PullRequest{
Index: 2,
Base: &models.PRBranchInfo{
Name: "master",
Sha: "pkg_base_sha",
Repo: &models.Repository{
Owner: &models.User{UserName: "org"},
Name: "pkg",
SSHURL: "ssh://git@src.opensuse.org/org/pkg.git",
},
},
Head: &models.PRBranchInfo{
Name: "branch_name",
Sha: "pkg_head_sha",
},
AllowMaintainerEdit: true,
}
config := &common.AutogitConfig{
Organization: "org",
GitProjectName: "org/_ObsPrj#master",
MergeMode: common.MergeModeReplace,
}
ctl := gomock.NewController(t)
git := mock_common.NewMockGit(ctl)
git.EXPECT().GitExec("pkg", "merge", "--no-ff", "--no-commit", "-X", "theirs", pkgPR.Head.Sha).Return(nil)
git.EXPECT().GitExecOrPanic("pkg", "read-tree", "--reset", "-u", pkgPR.Head.Sha)
git.EXPECT().GitExecOrPanic("pkg", "commit", "-m", gomock.Any())
git.EXPECT().GitExecOrPanic("pkg", "push", "origin", "HEAD:branch_name")
git.EXPECT().GitExecWithOutputOrPanic("pkg", "rev-list", "-1", "HEAD").Return("new_head_sha")
prset := &common.PRSet{
Config: config,
PRs: []*common.PRInfo{
{PR: &models.PullRequest{}}, // prjgit at index 0
{PR: pkgPR}, // pkg at index 1
},
}
if res := prset.AddMergeCommit(git, "origin", 1); !res {
t.Errorf("Expected true, got %v", res)
}
}

View File

@@ -92,10 +92,13 @@ func ConnectToExchangeForPublish(host, username, password string) {
auth = username + ":" + password + "@"
}
connection, err := rabbitmq.DialTLS("amqps://"+auth+host, &tls.Config{
ServerName: host,
connection, err := rabbitmq.DialConfig("amqps://"+auth+host, rabbitmq.Config{
Dial: rabbitmq.DefaultDial(10 * time.Second),
TLSClientConfig: &tls.Config{
ServerName: host,
},
})
failOnError(err, "Cannot connect to rabbit.opensuse.org")
failOnError(err, "Cannot connect to "+host)
defer connection.Close()
ch, err := connection.Channel()

View File

@@ -7,4 +7,10 @@ markers =
t005: Test case 005
t006: Test case 006
t007: Test case 007
t008: Test case 008
t009: Test case 009
t010: Test case 010
t011: Test case 011
t012: Test case 012
t013: Test case 013
dependency: pytest-dependency marker

View File

@@ -76,6 +76,12 @@ The testing will be conducted in a dedicated test environment that mimics the pr
| **TC-MERGE-005** | - | **ManualMergeOnly with Project Maintainer** | 1. Create a PackageGit PR with `ManualMergeOnly` set to `true`.<br>2. Ensure all mandatory reviews are completed on both project and package PRs.<br>3. Comment "merge ok" on the package PR from the account of a project maintainer. | 1. The PR is merged. | High |
| **TC-MERGE-006** | - | **ManualMergeProject with Project Maintainer** | 1. Create a PackageGit PR with `ManualMergeProject` set to `true`.<br>2. Ensure all mandatory reviews are completed on both project and package PRs.<br>3. Comment "merge ok" on the project PR from the account of a project maintainer. | 1. The PR is merged. | High |
| **TC-MERGE-007** | - | **ManualMergeProject with unauthorized user** | 1. Create a PackageGit PR with `ManualMergeProject` set to `true`.<br>2. Ensure all mandatory reviews are completed on both project and package PRs.<br>3. Comment "merge ok" on the project PR from the account of a package maintainer. | 1. The PR is not merged. | High |
| **TC-MERGE-008** | P | **MergeMode: ff-only (Success)** | 1. Set `MergeMode = "ff-only"`.<br>2. Create a FF-mergeable PackageGit PR.<br>3. Approve reviews on both PRs. | 1. Both PRs are automatically merged successfully. | High |
| **TC-MERGE-009** | P | **MergeMode: ff-only (Failure)** | 1. Set `MergeMode = "ff-only"`.<br>2. Create a PackageGit PR that adds a new file.<br>3. Commit the same file with different content to the base branch to create a content conflict.<br>4. Approve reviews and trigger a sync by pushing another change. | 1. The bot detects it is not FF-mergeable.<br>2. The PR is NOT merged. | High |
| **TC-MERGE-010** | P | **MergeMode: devel (Force-push)** | 1. Set `MergeMode = "devel"`.<br>2. Create a PackageGit PR that adds a new file.<br>3. Commit the same file with different content to the base branch to create a content conflict.<br>4. Approve reviews. | 1. Both PRs are merged.<br>2. The `pkgA` submodule points to the PR's head SHA. | High |
| **TC-MERGE-011** | P | **MergeMode: replace (Merge-commit)** | 1. Set `MergeMode = "replace"`.<br>2. Create a PackageGit PR that adds a new file.<br>3. Enable "Allow edits from maintainers" on the PR.<br>4. Commit the same file with different content to the base branch to create a content conflict.<br>5. Approve reviews. | 1. Both PRs are merged.<br>2. The project branch HEAD is a merge commit (has >1 parent).<br>3. The `pkgA` submodule points to the PR's head SHA. | High |
| **TC-MERGE-012** | P | **MergeMode: devel (No Conflict, Fast-forward)** | 1. Set `MergeMode = "devel"`.<br>2. Create a FF-mergeable PackageGit PR.<br>3. Approve reviews. | 1. Both PRs are merged.<br>2. The package branch HEAD matches the PR head (FF). | High |
| **TC-MERGE-013** | P | **MergeMode: replace (No Conflict, Fast-forward)** | 1. Set `MergeMode = "replace"`.<br>2. Create a FF-mergeable PackageGit PR.<br>3. Approve reviews. | 1. Both PRs are merged.<br>2. The package branch HEAD matches the PR head (FF). | High |
| **TC-CONFIG-001** | - | **Invalid Configuration** | 1. Provide an invalid `workflow.config` file. | 1. The bot reports an error and does not process any PRs. | High |
| **TC-LABEL-001** | P | **Apply `staging/Auto` label** | 1. Create a new PackageGit PR. | 1. The `staging/Auto` label is applied to the ProjectGit PR. | High |
| **TC-LABEL-002** | x | **Apply `review/Pending` label** | 1. Create a new PackageGit PR. | 1. The `review/Pending` label is applied to the ProjectGit PR when there are pending reviews. | Medium |

View File

@@ -71,6 +71,21 @@ BRANCH_CONFIG_CUSTOM = {
"ReviewPending": "review/Pending"
}
}
},
"merge-ff": {
"workflow.config": {
"MergeMode": "ff-only"
}
},
"merge-replace": {
"workflow.config": {
"MergeMode": "replace"
}
},
"merge-devel": {
"workflow.config": {
"MergeMode": "devel"
}
}
}
@@ -275,6 +290,18 @@ def no_project_git_pr_env(gitea_env):
def label_env(gitea_env):
return gitea_env, "myproducts/mySLFO", "label-test"
@pytest.fixture(scope="session")
def merge_ff_env(gitea_env):
return gitea_env, "myproducts/mySLFO", "merge-ff"
@pytest.fixture(scope="session")
def merge_replace_env(gitea_env):
return gitea_env, "myproducts/mySLFO", "merge-replace"
@pytest.fixture(scope="session")
def merge_devel_env(gitea_env):
return gitea_env, "myproducts/mySLFO", "merge-devel"
@pytest.fixture(scope="session")
def usera_client(gitea_env):
return GiteaAPIClient(base_url=gitea_env.base_url, token=gitea_env.headers["Authorization"].split(" ")[1], sudo="usera")

View File

@@ -3,6 +3,7 @@ import time
import pytest
import requests
import json
import re
import xml.etree.ElementTree as ET
from pathlib import Path
import base64
@@ -272,12 +273,12 @@ index 0000000..{pkg_b_sha}
owner, repo = repo_full_name.split("/")
head_owner, head_repo = owner, repo
new_branch_name = f"pr-branch-{int(time.time()*1000)}"
if use_fork:
sudo_user = self.headers.get("Sudo")
head_owner = sudo_user
head_repo = repo
new_branch_name = f"pr-branch-{int(time.time()*1000)}"
print(f"--- Forking {repo_full_name} ---")
try:
@@ -290,29 +291,11 @@ index 0000000..{pkg_b_sha}
else:
raise
# Create a unique branch in the FORK
base_commit_sha = self._request("GET", f"repos/{owner}/{repo}/branches/{base_branch}").json()["commit"]["id"]
print(f"--- Creating branch {new_branch_name} in {head_owner}/{head_repo} from {base_branch} ({base_commit_sha}) ---")
self._request("POST", f"repos/{head_owner}/{head_repo}/branches", json={
"new_branch_name": new_branch_name,
"old_ref": base_commit_sha
})
else:
new_branch_name = f"pr-branch-{int(time.time()*1000)}"
# Get the latest commit SHA of the base branch from the ORIGINAL repo
base_commit_sha = self._request("GET", f"repos/{owner}/{repo}/branches/{base_branch}").json()["commit"]["id"]
# Try to create the branch in the ORIGINAL repo
print(f"--- Creating branch {new_branch_name} in {repo_full_name} ---")
self._request("POST", f"repos/{owner}/{repo}/branches", json={
"new_branch_name": new_branch_name,
"old_ref": base_commit_sha
})
# Apply the diff using diffpatch in the branch (wherever it is)
print(f"--- Applying diff to {head_owner}/{head_repo} branch {new_branch_name} ---")
# Apply the diff using diffpatch and create the new branch automatically
print(f"--- Applying diff to {head_owner}/{head_repo} from {base_branch} to new branch {new_branch_name} ---")
self._request("POST", f"repos/{head_owner}/{head_repo}/diffpatch", json={
"branch": new_branch_name,
"branch": base_branch,
"new_branch": new_branch_name,
"content": diff_content,
"message": title
})
@@ -530,3 +513,45 @@ index 0000000..{pkg_b_sha}
print(f"Error restarting service {service_name}: {e}")
raise
def wait_for_project_pr(self, package_pr_repo, package_pr_number, project_pr_repo="myproducts/mySLFO", timeout=60):
print(f"Polling {package_pr_repo} PR #{package_pr_number} timeline for forwarded PR event in {project_pr_repo}...")
for _ in range(timeout):
time.sleep(1)
timeline_events = self.get_timeline_events(package_pr_repo, package_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(fr"{project_pr_repo}/pulls/(\d+)", url_to_check)
if match:
return int(match.group(1))
return None
def approve_and_wait_merge(self, package_pr_repo, package_pr_number, project_pr_number, project_pr_repo="myproducts/mySLFO", timeout=30):
print(f"Approving reviews and verifying both PRs are merged ({package_pr_repo}#{package_pr_number} and {project_pr_repo}#{project_pr_number})...")
package_merged = False
project_merged = False
for i in range(timeout):
self.approve_requested_reviews(package_pr_repo, package_pr_number)
self.approve_requested_reviews(project_pr_repo, project_pr_number)
if not package_merged:
pkg_details = self.get_pr_details(package_pr_repo, package_pr_number)
if pkg_details.get("merged"):
package_merged = True
print(f"Package PR {package_pr_repo}#{package_pr_number} merged.")
if not project_merged:
prj_details = self.get_pr_details(project_pr_repo, project_pr_number)
if prj_details.get("merged"):
project_merged = True
print(f"Project PR {project_pr_repo}#{project_pr_number} merged.")
if package_merged and project_merged:
return True, True
time.sleep(1)
return package_merged, project_merged

View File

@@ -23,27 +23,10 @@ def test_pr_workflow_succeeded(staging_main_env, mock_build_result):
compose_dir = Path(__file__).parent.parent
forwarded_pr_number = None
print(
f"Polling mypool/pkgA PR #{initial_pr_number} timeline for forwarded PR event..."
)
for _ in range(20):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", initial_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
forwarded_pr_number = match.group(1)
break
if forwarded_pr_number:
break
forwarded_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", initial_pr_number)
assert (
forwarded_pr_number is not None
), "Workflow bot did not create a pull_ref event on the timeline."
), "Workflow bot did not create a project PR."
print(f"Found forwarded PR: myproducts/mySLFO #{forwarded_pr_number}")
print(f"Polling myproducts/mySLFO PR #{forwarded_pr_number} for reviewer assignment...")
@@ -94,27 +77,10 @@ def test_pr_workflow_failed(staging_main_env, mock_build_result):
compose_dir = Path(__file__).parent.parent
forwarded_pr_number = None
print(
f"Polling mypool/pkgA PR #{initial_pr_number} timeline for forwarded PR event..."
)
for _ in range(20):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", initial_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
forwarded_pr_number = match.group(1)
break
if forwarded_pr_number:
break
forwarded_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", initial_pr_number)
assert (
forwarded_pr_number is not None
), "Workflow bot did not create a pull_ref event on the timeline."
), "Workflow bot did not create a project PR."
print(f"Found forwarded PR: myproducts/mySLFO #{forwarded_pr_number}")
print(f"Polling myproducts/mySLFO PR #{forwarded_pr_number} for reviewer assignment...")

View File

@@ -35,23 +35,7 @@ index 0000000..e69de29
print(f"Created package PR mypool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = None
print(f"Polling mypool/pkgA PR #{package_pr_number} timeline for forwarded PR event...")
for _ in range(40):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", package_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
project_pr_number = int(match.group(1))
break
if project_pr_number:
break
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None, "Workflow bot did not create a project PR."
print(f"Found project PR: myproducts/mySLFO#{project_pr_number}")

View File

@@ -25,55 +25,14 @@ index 0000000..e69de29
print(f"Created package PR mypool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = None
print(f"Polling mypool/pkgA PR #{package_pr_number} timeline for forwarded PR event...")
for _ in range(40):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", package_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
project_pr_number = int(match.group(1))
break
if project_pr_number:
break
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None, "Workflow bot did not create a project PR."
print(f"Found project PR: myproducts/mySLFO#{project_pr_number}")
# 3. Approve reviews and verify merged
print("Approving reviews and verifying both PRs are merged...")
package_merged = False
project_merged = False
for i in range(20): # Poll for up to 20 seconds
gitea_env.approve_requested_reviews("mypool/pkgA", package_pr_number)
gitea_env.approve_requested_reviews("myproducts/mySLFO", project_pr_number)
if not package_merged:
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
if pkg_details.get("merged"):
package_merged = True
print(f"Package PR mypool/pkgA#{package_pr_number} merged.")
# Project PR
if not project_merged:
prj_details = gitea_env.get_pr_details("myproducts/mySLFO", project_pr_number)
if prj_details.get("merged"):
project_merged = True
print(f"Project PR myproducts/mySLFO#{project_pr_number} merged.")
if package_merged and project_merged:
break
time.sleep(1)
assert package_merged, f"Package PR mypool/pkgA#{package_pr_number} was not merged automatically."
assert project_merged, f"Project PR myproducts/mySLFO#{project_pr_number} was not merged automatically."
pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number)
assert pkg_merged, f"Package PR mypool/pkgA#{package_pr_number} was not merged automatically."
assert prj_merged, f"Project PR myproducts/mySLFO#{project_pr_number} was not merged automatically."
print("Both PRs merged successfully.")
@pytest.mark.t002
@@ -98,23 +57,7 @@ index 0000000..e69de29
print(f"Created package PR mypool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = None
print(f"Polling mypool/pkgA PR #{package_pr_number} timeline for forwarded PR event...")
for _ in range(40):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", package_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
project_pr_number = int(match.group(1))
break
if project_pr_number:
break
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None, "Workflow bot did not create a project PR."
print(f"Found project PR: myproducts/mySLFO#{project_pr_number}")
@@ -193,3 +136,253 @@ index 0000000..e69de29
assert package_merged, f"Package PR mypool/pkgA#{package_pr_number} was not merged after 'merge ok'."
assert project_merged, f"Project PR myproducts/mySLFO#{project_pr_number} was not merged after 'merge ok'."
print("Both PRs merged successfully after 'merge ok'.")
@pytest.mark.t008
def test_008_merge_mode_ff_only_success(merge_ff_env, test_user_client):
"""
Test MergeMode "ff-only" - Success case (FF-mergeable)
"""
gitea_env, test_full_repo_name, merge_branch_name = merge_ff_env
# 1. Create a package PR (this will be FF-mergeable by default)
diff = """diff --git a/ff_test.txt b/ff_test.txt
new file mode 100644
index 0000000..e69de29
"""
package_pr = test_user_client.create_gitea_pr("mypool/pkgA", diff, "Test FF Merge", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None
pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number)
assert pkg_merged and prj_merged
@pytest.mark.t009
def test_009_merge_mode_ff_only_failure(merge_ff_env, ownerA_client):
"""
Test MergeMode "ff-only" - Failure case (Content Conflict, should NOT merge)
"""
gitea_env, test_full_repo_name, merge_branch_name = merge_ff_env
ts = time.strftime("%H%M%S")
filename = f"ff_fail_test_{ts}.txt"
# 1. Create a package PR that adds a file
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
+PR content
"""
package_pr = ownerA_client.create_gitea_pr("mypool/pkgA", diff, "Test FF Merge Failure (Conflict)", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
# 2. Wait for project PR to be created
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None
print("Making PR non-FF by creating a content conflict in the base branch...")
gitea_env.create_file("mypool", "pkgA", filename, "Conflicting base content\n", branch=merge_branch_name)
print("Approving reviews initially...")
gitea_env.approve_requested_reviews("mypool/pkgA", package_pr_number)
gitea_env.approve_requested_reviews("myproducts/mySLFO", project_pr_number)
print("Pushing another change to PR branch to trigger sync...")
gitea_env.modify_gitea_pr("mypool/pkgA", package_pr_number,
"diff --git a/sync_test.txt b/sync_test.txt\nnew file mode 100644\nindex 0000000..e69de29\n",
"Trigger Sync")
# The bot should detect it's not FF and NOT merge, and re-request reviews because of the new commit
print("Waiting for reviews to be re-requested and approving again...")
time.sleep(10) # Wait for bot to process sync
# Approve again and verify it is NOT merged
print("Approving again and verifying PR is NOT merged (because it's not FF)...")
for i in range(15):
gitea_env.approve_requested_reviews("mypool/pkgA", package_pr_number)
gitea_env.approve_requested_reviews("myproducts/mySLFO", project_pr_number)
time.sleep(1)
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
assert not pkg_details.get("merged"), "Package PR merged despite NOT being FF-mergeable!"
print("FF-only failure (not merged after sync) verified.")
@pytest.mark.t010
def test_010_merge_mode_devel_success(merge_devel_env, ownerA_client):
"""
Test MergeMode "devel" - Success case (Content Conflict, should still merge via force-push)
"""
gitea_env, test_full_repo_name, merge_branch_name = merge_devel_env
ts = time.strftime("%H%M%S")
filename = f"devel_test_{ts}.txt"
# 1. Create a package PR that adds a file
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
+PR content
"""
package_pr = ownerA_client.create_gitea_pr("mypool/pkgA", diff, "Test Devel Merge (Conflict)", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
# 2. Create a content conflict by committing the same file to the base branch
gitea_env.create_file("mypool", "pkgA", filename, "Conflicting base content\n", branch=merge_branch_name)
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None
# Before merge, get the head sha of the package pr and project pr
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
pkg_head_sha = pkg_details["head"]["sha"]
prj_details = gitea_env.get_pr_details("myproducts/mySLFO", project_pr_number)
prj_head_sha = prj_details["head"]["sha"]
pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number)
assert pkg_merged and prj_merged
print("Devel merge (force-push) successful.")
# Verify that pkgA submodule points to the correct SHA
pkgA_submodule_info = gitea_env.get_file_info("myproducts", "mySLFO", "pkgA", branch=merge_branch_name)
assert pkgA_submodule_info["sha"] == pkg_head_sha, f"Submodule pkgA should point to {pkg_head_sha} but points to {pkgA_submodule_info['sha']}"
@pytest.mark.t011
def test_011_merge_mode_replace_success(merge_replace_env, ownerA_client):
"""
Test MergeMode "replace" - Success case (Content Conflict, bot should add merge commit)
"""
gitea_env, test_full_repo_name, merge_branch_name = merge_replace_env
ts = time.strftime("%H%M%S")
filename = f"replace_test_{ts}.txt"
# 1. Create a package PR that adds a file
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
+PR content
"""
package_pr = ownerA_client.create_gitea_pr("mypool/pkgA", diff, "Test Replace Merge (Conflict)", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
# Enable "Allow edits from maintainers"
ownerA_client.update_gitea_pr_properties("mypool/pkgA", package_pr_number, allow_maintainer_edit=True)
# 2. Create a content conflict by committing the same file to the base branch
gitea_env.create_file("mypool", "pkgA", filename, "Conflicting base content\n", branch=merge_branch_name)
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None
# Before merge, get the head sha of the package pr and project pr
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
pkg_head_sha = pkg_details["head"]["sha"]
prj_details = gitea_env.get_pr_details("myproducts/mySLFO", project_pr_number)
prj_head_sha = prj_details["head"]["sha"]
pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number, timeout=60)
assert pkg_merged and prj_merged
print("Replace merge successful.")
# Verify that the project branch HEAD is a merge commit
branch_info = gitea_env._request("GET", f"repos/myproducts/mySLFO/branches/{merge_branch_name}").json()
new_head_sha = branch_info["commit"]["id"]
commit_details = gitea_env._request("GET", f"repos/myproducts/mySLFO/git/commits/{new_head_sha}").json()
assert len(commit_details["parents"]) > 1, f"Project branch {merge_branch_name} HEAD should be a merge commit but has {len(commit_details['parents'])} parents"
# Verify that pkgA submodule points to the correct SHA
pkgA_submodule_info = gitea_env.get_file_info("myproducts", "mySLFO", "pkgA", branch=merge_branch_name)
assert pkgA_submodule_info["sha"] == pkg_head_sha, f"Submodule pkgA should point to {pkg_head_sha} but points to {pkgA_submodule_info['sha']}"
@pytest.mark.t012
def test_012_merge_mode_devel_ff_success(merge_devel_env, ownerA_client):
"""
Test MergeMode "devel" - Success case (No Conflict, should fast-forward)
"""
gitea_env, test_full_repo_name, merge_branch_name = merge_devel_env
ts = time.strftime("%H%M%S")
filename = f"devel_ff_test_{ts}.txt"
# 1. Create a package PR (this will be FF-mergeable by default)
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
+PR content
"""
package_pr = ownerA_client.create_gitea_pr("mypool/pkgA", diff, "Test Devel FF Merge", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
pkg_head_sha = pkg_details["head"]["sha"]
pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number)
assert pkg_merged and prj_merged
print("Devel FF merge successful.")
# Verify that the package base branch HEAD is the same as the PR head (FF)
branch_info = gitea_env._request("GET", f"repos/mypool/pkgA/branches/{merge_branch_name}").json()
new_head_sha = branch_info["commit"]["id"]
assert new_head_sha == pkg_head_sha, f"Package branch {merge_branch_name} HEAD should be {pkg_head_sha} but is {new_head_sha}"
commit_details = gitea_env._request("GET", f"repos/mypool/pkgA/git/commits/{new_head_sha}").json()
assert len(commit_details["parents"]) == 1, f"Package branch {merge_branch_name} HEAD should have 1 parent but has {len(commit_details['parents'])}"
@pytest.mark.t013
def test_013_merge_mode_replace_ff_success(merge_replace_env, ownerA_client):
"""
Test MergeMode "replace" - Success case (No Conflict, should fast-forward)
"""
gitea_env, test_full_repo_name, merge_branch_name = merge_replace_env
ts = time.strftime("%H%M%S")
filename = f"replace_ff_test_{ts}.txt"
# 1. Create a package PR (this will be FF-mergeable by default)
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
+PR content
"""
package_pr = ownerA_client.create_gitea_pr("mypool/pkgA", diff, "Test Replace FF Merge", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
pkg_head_sha = pkg_details["head"]["sha"]
pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number)
assert pkg_merged and prj_merged
print("Replace FF merge successful.")
# Verify that the package base branch HEAD is the same as the PR head (FF)
branch_info = gitea_env._request("GET", f"repos/mypool/pkgA/branches/{merge_branch_name}").json()
new_head_sha = branch_info["commit"]["id"]
assert new_head_sha == pkg_head_sha, f"Package branch {merge_branch_name} HEAD should be {pkg_head_sha} but is {new_head_sha}"
commit_details = gitea_env._request("GET", f"repos/mypool/pkgA/git/commits/{new_head_sha}").json()
assert len(commit_details["parents"]) == 1, f"Package branch {merge_branch_name} HEAD should have 1 parent but has {len(commit_details['parents'])}"

View File

@@ -94,23 +94,7 @@ index 0000000..e69de29
print(f"Created package PR mypool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = None
print(f"Polling mypool/pkgA PR #{package_pr_number} timeline for forwarded PR event...")
for _ in range(40):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", package_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
project_pr_number = int(match.group(1))
break
if project_pr_number:
break
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None, "Workflow bot did not create a project PR."
print(f"Found project PR: myproducts/mySLFO#{project_pr_number}")
@@ -179,23 +163,7 @@ index 0000000..e69de29
print(f"Created package PR mypool/pkgB#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = None
print(f"Polling mypool/pkgB PR #{package_pr_number} timeline for forwarded PR event...")
for _ in range(40):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgB", package_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
project_pr_number = int(match.group(1))
break
if project_pr_number:
break
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgB", package_pr_number)
assert project_pr_number is not None, "Workflow bot did not create a project PR."
print(f"Found project PR: myproducts/mySLFO#{project_pr_number}")
@@ -317,23 +285,7 @@ index 0000000..e69de29
print(f"Created package PR mypool/pkgB#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = None
print(f"Polling mypool/pkgB PR #{package_pr_number} timeline for forwarded PR event...")
for _ in range(40):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgB", package_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
project_pr_number = int(match.group(1))
break
if project_pr_number:
break
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgB", package_pr_number)
assert project_pr_number is not None, "Workflow bot did not create a project PR."
print(f"Found project PR: myproducts/mySLFO#{project_pr_number}")

View File

@@ -27,27 +27,8 @@ def test_001_project_pr(gitea_env):
pytest.initial_pr_number = pytest.pr["number"]
time.sleep(5) # Give Gitea some time to process the PR and make the timeline available
compose_dir = Path(__file__).parent.parent
pytest.forwarded_pr_number = None
print(
f"Polling mypool/pkgA PR #{pytest.initial_pr_number} timeline for forwarded PR event..."
)
# Instead of polling timeline, check if forwarded PR exists directly
for _ in range(20):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", pytest.initial_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
pytest.forwarded_pr_number = match.group(1)
break
if pytest.forwarded_pr_number:
break
pytest.forwarded_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", pytest.initial_pr_number)
assert (
pytest.forwarded_pr_number is not None
), "Workflow bot did not create a forwarded PR."
@@ -144,23 +125,9 @@ index 0000000..e69de29
print(f"Created Package PR #{package_pr_number}")
# 2. Verify that the workflow-pr bot did not create a Project PR
project_pr_created = False
for i in range(10): # Poll for some time
time.sleep(2)
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", package_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
project_pr_created = True
break
if project_pr_created:
break
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number, timeout=10)
assert not project_pr_created, "Workflow bot unexpectedly created a Project PR in myproducts/mySLFO."
assert project_pr_number is None, "Workflow bot unexpectedly created a Project PR in myproducts/mySLFO."
print("Verification complete: No Project PR was created by the bot.")
# 3. Manually create the Project PR
@@ -252,24 +219,9 @@ index 0000000..e69de29
pkgA_pr_head_sha = package_pr_details["head"]["sha"]
# 3. Assert that the workflow-pr bot did not create a Project PR in the myproducts/mySLFO repository
project_pr_created = False
for i in range(20): # Poll for a reasonable time
time.sleep(2) # Wait a bit longer to be sure
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", package_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
# Regex now searches for myproducts/mySLFO/pulls/(\d+)
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
project_pr_created = True
break
if project_pr_created:
break
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number, timeout=10)
assert not project_pr_created, "Workflow bot unexpectedly created a Project PR in myproducts/mySLFO."
assert project_pr_number is None, "Workflow bot unexpectedly created a Project PR in myproducts/mySLFO."
print("Verification complete: No Project PR was created in myproducts/mySLFO as expected.")
# 1. Create that Project PR from the test code.
@@ -322,5 +274,3 @@ index 0000000..f587a12
assert project_pr_updated, "Manually created Project PR was not updated by the bot."
print("Verification complete: Manually created Project PR was updated by the bot as expected.")

View File

@@ -6,5 +6,8 @@
"myproducts/mySLFO#maintainer-merge",
"myproducts/mySLFO#review-required",
"myproducts/mySLFO#label-test",
"myproducts/mySLFO#manual-merge"
"myproducts/mySLFO#manual-merge",
"myproducts/mySLFO#merge-ff",
"myproducts/mySLFO#merge-replace",
"myproducts/mySLFO#merge-devel"
]

View File

@@ -96,6 +96,19 @@ Package Deletion Requests
If you wish to re-add a package, create a new PrjGit PR which adds again the submodule on the branch that has the "-removed" suffix. The bot will automatically remove this suffix from the project branch in the pool.
Merge Modes
-----------
| Merge Mode | Description
|------------|--------------------------------------------------------------------------------
| ff-only | Only allow --ff-only merges in the package branch. This is best suited for
| | devel projects and openSUSE Tumbleweed development, where history should be linear
| replace | Merge is done via `-X theirs` strategy and old files are removed in the merge.
| | This works well for downstream codestreams, like Leap, that would update their branch
| | using latest version.
| devel | No merge, just set the project branch to PR HEAD. This is suitable for downstream
| | projects like Leap during development cycle, where keeping maintenance history is not important
Labels
------

View File

@@ -417,6 +417,12 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
}
common.LogInfo("fetched PRSet of size:", len(prset.PRs))
if !prset.PrepareForMerge(git) {
common.LogError("PRs are NOT mergeable.")
} else {
common.LogInfo("PRs are in mergeable state.")
}
prjGitPRbranch := prGitBranchNameForPR(prRepo, prNo)
prjGitPR, err := prset.GetPrjGitPR()
if err == common.PRSet_PrjGitMissing {