5 Commits

Author SHA256 Message Date
Andrii Nikitin
af2d4094be t: merge mode with content conflicts
All checks were successful
go-generate-check / go-generate-check (pull_request) Successful in 11s
Integration tests / t (pull_request) Successful in 9m34s
- Add TC-MERGE- 008, 009, 010, t011 for testing MergeMode of weokflow-pr
- Synchronize integration/test-plan.md with the actual test implementations
2026-03-02 16:59:26 +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 873 additions and 244 deletions

View File

@@ -39,6 +39,10 @@ const (
Permission_ForceMerge = "force-merge" Permission_ForceMerge = "force-merge"
Permission_Group = "release-engineering" Permission_Group = "release-engineering"
MergeModeFF = "ff-only"
MergeModeReplace = "replace"
MergeModeDevel = "devel"
) )
type ConfigFile struct { type ConfigFile struct {
@@ -52,9 +56,9 @@ type ReviewGroup struct {
} }
type QAConfig struct { type QAConfig struct {
Name string Name string
Origin string Origin string
Label string // requires this gitea lable to be set or skipped Label string // requires this gitea lable to be set or skipped
BuildDisableRepos []string // which repos to build disable in the new project 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 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 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 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 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 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 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 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 { func (rs *PRSet) Merge(gitea GiteaReviewUnrequester, git Git) error {
prjgit_info, err := rs.GetPrjGitPR() prjgit_info, err := rs.GetPrjGitPR()
if err != nil { 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) prinfo.RemoteName, err = git.GitClone(repo.Name, br, repo.SSHURL)
PanicOnError(err) PanicOnError(err)
git.GitExecOrPanic(repo.Name, "fetch", prinfo.RemoteName, head.Sha) git.GitExecOrPanic(repo.Name, "fetch", prinfo.RemoteName, head.Sha)
if rs.Config.MergeMode == MergeModeDevel || isNewRepo {
if isNewRepo { git.GitExecOrPanic(repo.Name, "checkout", "-B", br, head.Sha)
LogInfo("Force-pushing new repository branch", br, "to", head.Sha)
// we don't merge, we just set the branch to this commit
} else { } else {
git.GitExecOrPanic(repo.Name, "fetch", prinfo.RemoteName, head.Sha)
git.GitExecOrPanic(repo.Name, "merge", "--ff", 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 !IsDryRun {
if isNewRepo { params := []string{"push"}
git.GitExecOrPanic(repo.Name, "push", "-f", prinfo.RemoteName, prinfo.PR.Head.Sha+":"+prinfo.PR.Base.Name) if rs.Config.MergeMode == MergeModeDevel || isNewRepo {
} else { params = append(params, "-f")
git.GitExecOrPanic(repo.Name, "push", prinfo.RemoteName)
} }
params = append(params, prinfo.RemoteName)
if isNewRepo {
params = append(params, prinfo.PR.Head.Sha+":"+prinfo.PR.Base.Name)
}
git.GitExecOrPanic(repo.Name, params...)
} else { } else {
LogInfo("*** WOULD push", repo.Name, "to", prinfo.RemoteName) LogInfo("*** WOULD push", repo.Name, "to", prinfo.RemoteName)
} }

View File

@@ -2,6 +2,7 @@ package common_test
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path" "path"
@@ -1267,7 +1268,7 @@ func TestPRMerge(t *testing.T) {
Owner: &models.User{ Owner: &models.User{
UserName: "org", UserName: "org",
}, },
SSHURL: "file://" + path.Join(repoDir, "prjgit"), SSHURL: "ssh://git@src.opensuse.org/org/prj.git",
}, },
}, },
Head: &models.PRBranchInfo{ Head: &models.PRBranchInfo{
@@ -1289,7 +1290,7 @@ func TestPRMerge(t *testing.T) {
Owner: &models.User{ Owner: &models.User{
UserName: "org", UserName: "org",
}, },
SSHURL: "file://" + path.Join(cmd.Dir, "prjgit"), SSHURL: "ssh://git@src.opensuse.org/org/prj.git",
}, },
}, },
Head: &models.PRBranchInfo{ 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 + "@" auth = username + ":" + password + "@"
} }
connection, err := rabbitmq.DialTLS("amqps://"+auth+host, &tls.Config{ connection, err := rabbitmq.DialConfig("amqps://"+auth+host, rabbitmq.Config{
ServerName: host, 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() defer connection.Close()
ch, err := connection.Channel() ch, err := connection.Channel()

View File

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

View File

@@ -76,6 +76,10 @@ 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-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-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-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-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-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-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 | | **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" "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): def label_env(gitea_env):
return gitea_env, "myproducts/mySLFO", "label-test" 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") @pytest.fixture(scope="session")
def usera_client(gitea_env): def usera_client(gitea_env):
return GiteaAPIClient(base_url=gitea_env.base_url, token=gitea_env.headers["Authorization"].split(" ")[1], sudo="usera") 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 pytest
import requests import requests
import json import json
import re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
import base64 import base64
@@ -530,3 +531,45 @@ index 0000000..{pkg_b_sha}
print(f"Error restarting service {service_name}: {e}") print(f"Error restarting service {service_name}: {e}")
raise 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 compose_dir = Path(__file__).parent.parent
forwarded_pr_number = None forwarded_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", initial_pr_number)
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
assert ( assert (
forwarded_pr_number is not None 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"Found forwarded PR: myproducts/mySLFO #{forwarded_pr_number}")
print(f"Polling myproducts/mySLFO PR #{forwarded_pr_number} for reviewer assignment...") 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 compose_dir = Path(__file__).parent.parent
forwarded_pr_number = None forwarded_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", initial_pr_number)
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
assert ( assert (
forwarded_pr_number is not None 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"Found forwarded PR: myproducts/mySLFO #{forwarded_pr_number}")
print(f"Polling myproducts/mySLFO PR #{forwarded_pr_number} for reviewer assignment...") 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}") print(f"Created package PR mypool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR # 2. Make sure the workflow-pr service created related project PR
project_pr_number = None project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
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
assert project_pr_number is not None, "Workflow bot did not create a project PR." 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}") 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}") print(f"Created package PR mypool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR # 2. Make sure the workflow-pr service created related project PR
project_pr_number = None project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
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
assert project_pr_number is not None, "Workflow bot did not create a project PR." 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}") print(f"Found project PR: myproducts/mySLFO#{project_pr_number}")
# 3. Approve reviews and verify merged # 3. Approve reviews and verify merged
print("Approving reviews and verifying both PRs are merged...") pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number)
package_merged = False assert pkg_merged, f"Package PR mypool/pkgA#{package_pr_number} was not merged automatically."
project_merged = False assert prj_merged, f"Project PR myproducts/mySLFO#{project_pr_number} was not merged automatically."
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."
print("Both PRs merged successfully.") print("Both PRs merged successfully.")
@pytest.mark.t002 @pytest.mark.t002
@@ -98,23 +57,7 @@ index 0000000..e69de29
print(f"Created package PR mypool/pkgA#{package_pr_number}") print(f"Created package PR mypool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR # 2. Make sure the workflow-pr service created related project PR
project_pr_number = None project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
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
assert project_pr_number is not None, "Workflow bot did not create a project PR." 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}") print(f"Found project PR: myproducts/mySLFO#{project_pr_number}")
@@ -193,3 +136,172 @@ index 0000000..e69de29
assert package_merged, f"Package PR mypool/pkgA#{package_pr_number} was not merged after 'merge ok'." 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'." 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'.") 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']}"

View File

@@ -94,23 +94,7 @@ index 0000000..e69de29
print(f"Created package PR mypool/pkgA#{package_pr_number}") print(f"Created package PR mypool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR # 2. Make sure the workflow-pr service created related project PR
project_pr_number = None project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
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
assert project_pr_number is not None, "Workflow bot did not create a project PR." 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}") 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}") print(f"Created package PR mypool/pkgB#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR # 2. Make sure the workflow-pr service created related project PR
project_pr_number = None project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgB", package_pr_number)
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
assert project_pr_number is not None, "Workflow bot did not create a project PR." 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}") 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}") print(f"Created package PR mypool/pkgB#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR # 2. Make sure the workflow-pr service created related project PR
project_pr_number = None project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgB", package_pr_number)
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
assert project_pr_number is not None, "Workflow bot did not create a project PR." 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}") 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"] pytest.initial_pr_number = pytest.pr["number"]
time.sleep(5) # Give Gitea some time to process the PR and make the timeline available 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 = gitea_env.wait_for_project_pr("mypool/pkgA", pytest.initial_pr_number)
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
assert ( assert (
pytest.forwarded_pr_number is not None pytest.forwarded_pr_number is not None
), "Workflow bot did not create a forwarded PR." ), "Workflow bot did not create a forwarded PR."
@@ -144,23 +125,9 @@ index 0000000..e69de29
print(f"Created Package PR #{package_pr_number}") print(f"Created Package PR #{package_pr_number}")
# 2. Verify that the workflow-pr bot did not create a Project PR # 2. Verify that the workflow-pr bot did not create a Project PR
project_pr_created = False project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number, timeout=10)
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
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.") print("Verification complete: No Project PR was created by the bot.")
# 3. Manually create the Project PR # 3. Manually create the Project PR
@@ -252,24 +219,9 @@ index 0000000..e69de29
pkgA_pr_head_sha = package_pr_details["head"]["sha"] 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 # 3. Assert that the workflow-pr bot did not create a Project PR in the myproducts/mySLFO repository
project_pr_created = False project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number, timeout=10)
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
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.") print("Verification complete: No Project PR was created in myproducts/mySLFO as expected.")
# 1. Create that Project PR from the test code. # 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." 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.") 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#maintainer-merge",
"myproducts/mySLFO#review-required", "myproducts/mySLFO#review-required",
"myproducts/mySLFO#label-test", "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. 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 Labels
------ ------

View File

@@ -417,6 +417,12 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
} }
common.LogInfo("fetched PRSet of size:", len(prset.PRs)) 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) prjGitPRbranch := prGitBranchNameForPR(prRepo, prNo)
prjGitPR, err := prset.GetPrjGitPR() prjGitPR, err := prset.GetPrjGitPR()
if err == common.PRSet_PrjGitMissing { if err == common.PRSet_PrjGitMissing {
@@ -470,8 +476,7 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
if pr.PR.State == "open" { if pr.PR.State == "open" {
org, repo, idx := pr.PRComponents() org, repo, idx := pr.PRComponents()
if prjGitPR.PR.HasMerged { if prjGitPR.PR.HasMerged {
// TODO: use timeline here because this can spam if ManualMergePR fails Gitea.AddComment(pr.PR, "This PR is merged via the associated Project PR.")
// Gitea.AddComment(pr.PR, "This PR is merged via the associated Project PR.")
err = Gitea.ManualMergePR(org, repo, idx, pr.PR.Head.Sha, false) err = Gitea.ManualMergePR(org, repo, idx, pr.PR.Head.Sha, false)
if _, ok := err.(*repository.RepoMergePullRequestConflict); !ok { if _, ok := err.(*repository.RepoMergePullRequestConflict); !ok {
common.PanicOnError(err) common.PanicOnError(err)