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_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,8 @@ 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
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-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-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
@@ -530,3 +531,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,172 @@ 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']}"

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 {
@@ -470,8 +476,7 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
if pr.PR.State == "open" {
org, repo, idx := pr.PRComponents()
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)
if _, ok := err.(*repository.RepoMergePullRequestConflict); !ok {
common.PanicOnError(err)