3 Commits

Author SHA256 Message Date
Andrii Nikitin
3e1b3c5c84 Add some merge and review tests 2026-02-20 15:16:33 +01:00
Andrii Nikitin
fc4899b75a t: more workflow-pr sync integration tests 2026-02-20 15:16:33 +01:00
0b479bcbfa spec: use target in scriptlets for services 2026-02-20 12:49:52 +01:00
24 changed files with 1752 additions and 693 deletions

View File

@@ -181,8 +181,10 @@ install -D -m0755 obs-status-service/obs-status-service
install -D -m0644 systemd/obs-status-service.service %{buildroot}%{_unitdir}/obs-status-service.service install -D -m0644 systemd/obs-status-service.service %{buildroot}%{_unitdir}/obs-status-service.service
install -D -m0755 workflow-direct/workflow-direct %{buildroot}%{_bindir}/workflow-direct install -D -m0755 workflow-direct/workflow-direct %{buildroot}%{_bindir}/workflow-direct
install -D -m0644 systemd/workflow-direct@.service %{buildroot}%{_unitdir}/workflow-direct@.service install -D -m0644 systemd/workflow-direct@.service %{buildroot}%{_unitdir}/workflow-direct@.service
install -D -m0644 systemd/workflow-direct.target %{buildroot}%{_unitdir}/workflow-direct.target
install -D -m0755 workflow-pr/workflow-pr %{buildroot}%{_bindir}/workflow-pr install -D -m0755 workflow-pr/workflow-pr %{buildroot}%{_bindir}/workflow-pr
install -D -m0644 systemd/workflow-pr@.service %{buildroot}%{_unitdir}/workflow-pr@.service install -D -m0644 systemd/workflow-pr@.service %{buildroot}%{_unitdir}/workflow-pr@.service
install -D -m0644 systemd/workflow-pr.target %{buildroot}%{_unitdir}/workflow-pr.target
install -D -m0755 utils/hujson/hujson %{buildroot}%{_bindir}/hujson install -D -m0755 utils/hujson/hujson %{buildroot}%{_bindir}/hujson
install -D -m0755 utils/maintainer-update/maintainer-update %{buildroot}%{_bindir}/maintainer-update install -D -m0755 utils/maintainer-update/maintainer-update %{buildroot}%{_bindir}/maintainer-update
@@ -235,28 +237,28 @@ install -D -m0755 utils/maintainer-update/maintainer-update
%service_del_postun obs-status-service.service %service_del_postun obs-status-service.service
%pre workflow-direct %pre workflow-direct
%service_add_pre workflow-direct.service %service_add_pre workflow-direct.target
%post workflow-direct %post workflow-direct
%service_add_post workflow-direct.service %service_add_post workflow-direct.target
%preun workflow-direct %preun workflow-direct
%service_del_preun workflow-direct.service %service_del_preun workflow-direct.target
%postun workflow-direct %postun workflow-direct
%service_del_postun workflow-direct.service %service_del_postun workflow-direct.target
%pre workflow-pr %pre workflow-pr
%service_add_pre workflow-pr.service %service_add_pre workflow-pr.target
%post workflow-pr %post workflow-pr
%service_add_post workflow-pr.service %service_add_post workflow-pr.target
%preun workflow-pr %preun workflow-pr
%service_del_preun workflow-pr.service %service_del_preun workflow-pr.target
%postun workflow-pr %postun workflow-pr
%service_del_postun workflow-pr.service %service_del_postun workflow-pr.target
%files devel-importer %files devel-importer
%license COPYING %license COPYING
@@ -310,10 +312,12 @@ install -D -m0755 utils/maintainer-update/maintainer-update
%doc workflow-direct/README.md %doc workflow-direct/README.md
%{_bindir}/workflow-direct %{_bindir}/workflow-direct
%{_unitdir}/workflow-direct@.service %{_unitdir}/workflow-direct@.service
%{_unitdir}/workflow-direct.target
%files workflow-pr %files workflow-pr
%license COPYING %license COPYING
%doc workflow-pr/README.md %doc workflow-pr/README.md
%{_bindir}/workflow-pr %{_bindir}/workflow-pr
%{_unitdir}/workflow-pr@.service %{_unitdir}/workflow-pr@.service
%{_unitdir}/workflow-pr.target

View File

@@ -39,10 +39,6 @@ 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 {
@@ -56,9 +52,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
} }
@@ -92,8 +88,7 @@ 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
@@ -188,17 +183,6 @@ 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

@@ -342,67 +342,3 @@ 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

@@ -552,145 +552,6 @@ 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", "-m", head.Sha)
git.GitExecOrPanic(repo.Name, "commit", "-m", msg)
git.GitExecOrPanic(repo.Name, "clean", "-fxd")
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 {
@@ -829,12 +690,8 @@ 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)
if rs.Config.MergeMode == MergeModeDevel { git.GitExecOrPanic(repo.Name, "fetch", prinfo.RemoteName, head.Sha)
git.GitExecOrPanic(repo.Name, "checkout", "-B", br, head.Sha) git.GitExecOrPanic(repo.Name, "merge", "--ff", head.Sha)
} else {
git.GitExecOrPanic(repo.Name, "fetch", prinfo.RemoteName, head.Sha)
git.GitExecOrPanic(repo.Name, "merge", "--ff", head.Sha)
}
} }
@@ -851,12 +708,7 @@ func (rs *PRSet) Merge(gitea GiteaReviewUnrequester, git Git) error {
repo := prinfo.PR.Base.Repo repo := prinfo.PR.Base.Repo
if !IsDryRun { if !IsDryRun {
params := []string{"push"} git.GitExecOrPanic(repo.Name, "push", prinfo.RemoteName)
if rs.Config.MergeMode == MergeModeDevel {
params = append(params, "-f")
}
params = append(params, prinfo.RemoteName)
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,7 +2,6 @@ package common_test
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path" "path"
@@ -1229,7 +1228,7 @@ func TestPRMerge(t *testing.T) {
Owner: &models.User{ Owner: &models.User{
UserName: "org", UserName: "org",
}, },
SSHURL: "ssh://git@src.opensuse.org/org/prj.git", SSHURL: "file://" + path.Join(repoDir, "prjgit"),
}, },
}, },
Head: &models.PRBranchInfo{ Head: &models.PRBranchInfo{
@@ -1249,7 +1248,7 @@ func TestPRMerge(t *testing.T) {
Owner: &models.User{ Owner: &models.User{
UserName: "org", UserName: "org",
}, },
SSHURL: "ssh://git@src.opensuse.org/org/prj.git", SSHURL: "file://" + path.Join(cmd.Dir, "prjgit"),
}, },
}, },
Head: &models.PRBranchInfo{ Head: &models.PRBranchInfo{
@@ -1339,346 +1338,3 @@ 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", "-m", pkgPR.Head.Sha)
m.EXPECT().GitExecOrPanic("pkg", "commit", "-m", gomock.Any())
m.EXPECT().GitExecOrPanic("pkg", "clean", "-fxd")
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", "-m", pkgPR.Head.Sha)
git.EXPECT().GitExecOrPanic("pkg", "commit", "-m", gomock.Any())
git.EXPECT().GitExecOrPanic("pkg", "clean", "-fxd")
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

@@ -16,10 +16,10 @@
# 3. 'pytest -v tests/*' - run tests # 3. 'pytest -v tests/*' - run tests
# 4. 'make down' - once the containers are not needed # 4. 'make down' - once the containers are not needed
# B2: (make sure the go binaries in the parent folder are built) # B2: (make sure the go binaries in the parent folder are built)
# 4. 'make build_local' - prepared images (recommended, otherwise there might be surprises if image fails to build during `make up`) # 1. 'make build_local' - prepared images (recommended, otherwise there might be surprises if image fails to build during `make up`)
# 5. 'make up' - spawns podman-compose # 2. 'make up' - spawns podman-compose
# 6. 'pytest -v tests/*' - run tests # 3. 'pytest -v tests/*' - run tests
# 7. 'make down' - once the containers are not needed # 4. 'make down' - once the containers are not needed
AUTO_DETECT_MODE := $(shell if test -e ../workflow-pr/workflow-pr; then echo .local; else echo .package; fi) AUTO_DETECT_MODE := $(shell if test -e ../workflow-pr/workflow-pr; then echo .local; else echo .package; fi)

57
integration/Makefile.txt Normal file
View File

@@ -0,0 +1,57 @@
+-------------------------------------------------------------------------------------------------+
| Makefile Targets |
+-------------------------------------------------------------------------------------------------+
| |
| [Default Test Workflow] |
| test (Auto-detects mode: .local or .package) |
| > build_container |
| > test_container |
| |
| [Specific Test Workflows - Topology 1: Privileged Container] |
| test_package (Mode A1: Bots from official packages) |
| > build_container |
| > test_container |
| |
| test_local (Mode B1: Bots from local binaries) |
| > build_container |
| > test_container |
| |
| build_container |
| - Action: Builds the `autogits_integration` privileged container image. |
| - Purpose: Prepares an environment for running tests within a single container. |
| |
| test_container |
| - Action: Runs `autogits_integration` container, executes `make build`, `make up`, and |
| `pytest -v tests/*` inside it. |
| - Purpose: Executes the full test suite in Topology 1 (privileged container). |
| |
| [Build & Orchestration Workflows - Topology 2: podman-compose] |
| |
| build_package (Mode A: Builds service images from official packages) |
| > build |
| |
| build_local (Mode B: Builds service images from local binaries) |
| > build |
| |
| build |
| - Action: Pulls `rabbitmq` image and iterates through `podman-compose.yml` services |
| to build each one. |
| - Purpose: Prepares all necessary service images for Topology 2 deployment. |
| |
| up |
| - Action: Starts all services defined in `podman-compose.yml` in detached mode. |
| - Purpose: Deploys the application topology (containers) for testing or development. |
| |
| down |
| - Action: Stops and removes all services started by `up`. |
| - Purpose: Cleans up the deployed application topology. |
| |
| up-bots-package (Mode A: Spawns Topology 2 with official package bots) |
| - Action: Calls `podman-compose up -d` with `GIWTF_IMAGE_SUFFIX=.package`. |
| - Purpose: Specifically brings up the environment using official package bots. |
| |
| up-bots-local (Mode B: Spawns Topology 2 with local binaries) |
| - Action: Calls `podman-compose up -d` with `GIWTF_IMAGE_SUFFIX=.local`. |
| - Purpose: Specifically brings up the environment using local binaries. |
| |
+-------------------------------------------------------------------------------------------------+

View File

@@ -0,0 +1,77 @@
+-------------------------------------------------------------------------------------------------+
| Podman-Compose Services Diagram |
+-------------------------------------------------------------------------------------------------+
| |
| [Network] |
| gitea-network (Bridge network for inter-service communication) |
| |
|-------------------------------------------------------------------------------------------------|
| |
| [Service: gitea] |
| Description: Self-hosted Git service, central hub for repositories and code management. |
| Container Name: gitea-test |
| Image: Built from ./gitea Dockerfile |
| Ports: 3000 (HTTP), 3022 (SSH) |
| Volumes: ./gitea-data (for persistent data), ./gitea-logs (for logs) |
| Network: gitea-network |
| |
|-------------------------------------------------------------------------------------------------|
| |
| [Service: rabbitmq] |
| Description: Message broker for asynchronous communication between services. |
| Container Name: rabbitmq-test |
| Image: rabbitmq:3.13.7-management |
| Ports: 5671 (AMQP), 15672 (Management UI) |
| Volumes: ./rabbitmq-data (for persistent data), ./rabbitmq-config/certs (TLS certs), |
| ./rabbitmq-config/rabbitmq.conf (config), ./rabbitmq-config/definitions.json (exchanges)|
| Healthcheck: Ensures RabbitMQ is running and healthy. |
| Network: gitea-network |
| |
|-------------------------------------------------------------------------------------------------|
| |
| [Service: gitea-publisher] |
| Description: Publishes events from Gitea to the RabbitMQ message queue. |
| Container Name: gitea-publisher |
| Image: Built from ../gitea-events-rabbitmq-publisher/Dockerfile (local/package) |
| Dependencies: gitea (started), rabbitmq (healthy) |
| Environment: RABBITMQ_HOST, RABBITMQ_USERNAME, RABBITMQ_PASSWORD, SSL_CERT_FILE |
| Command: Listens for Gitea events, publishes to 'suse' topic, debug enabled. |
| Network: gitea-network |
| |
|-------------------------------------------------------------------------------------------------|
| |
| [Service: workflow-pr] |
| Description: Manages pull request workflows, likely consuming events from RabbitMQ and |
| interacting with Gitea. |
| Container Name: workflow-pr |
| Image: Built from ../workflow-pr/Dockerfile (local/package) |
| Dependencies: gitea (started), rabbitmq (healthy) |
| Environment: AMQP_USERNAME, AMQP_PASSWORD, SSL_CERT_FILE |
| Volumes: ./gitea-data (read-only), ./workflow-pr/workflow-pr.json (config), |
| ./workflow-pr-repos (for repositories) |
| Command: Configures Gitea/RabbitMQ URLs, enables debug, manages repositories. |
| Network: gitea-network |
| |
|-------------------------------------------------------------------------------------------------|
| |
| [Service: mock-obs] |
| Description: A mock (simulated) service for the Open Build Service (OBS) for testing. |
| Container Name: mock-obs |
| Image: Built from ./mock-obs Dockerfile |
| Ports: 8080 |
| Volumes: ./mock-obs/responses (for mock API responses) |
| Network: gitea-network |
| |
|-------------------------------------------------------------------------------------------------|
| |
| [Service: obs-staging-bot] |
| Description: A bot that interacts with Gitea and the mock OBS, likely for staging processes. |
| Container Name: obs-staging-bot |
| Image: Built from ../obs-staging-bot/Dockerfile (local/package) |
| Dependencies: gitea (started), mock-obs (started) |
| Environment: OBS_USER, OBS_PASSWORD |
| Volumes: ./gitea-data (read-only) |
| Command: Configures Gitea/OBS URLs, enables debug. |
| Network: gitea-network |
| |
+-------------------------------------------------------------------------------------------------+

10
integration/pytest.ini Normal file
View File

@@ -0,0 +1,10 @@
[pytest]
markers =
t001: Test case 001
t002: Test case 002
t003: Test case 003
t004: Test case 004
t005: Test case 005
t006: Test case 006
t007: Test case 007
dependency: pytest-dependency marker

View File

@@ -31,7 +31,7 @@ def gitea_env():
# Wait for Gitea to be available # Wait for Gitea to be available
print(f"Waiting for Gitea at {gitea_url}...") print(f"Waiting for Gitea at {gitea_url}...")
max_retries = 30 max_retries = 5
for i in range(max_retries): for i in range(max_retries):
try: try:
# Check a specific API endpoint that indicates readiness # Check a specific API endpoint that indicates readiness
@@ -41,8 +41,8 @@ def gitea_env():
break break
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
pass pass
print(f"Gitea not ready ({response.status_code if 'response' in locals() else 'ConnectionError'}), retrying in 5 seconds... ({i+1}/{max_retries})") print(f"Gitea not ready ({response.status_code if 'response' in locals() else 'ConnectionError'}), retrying in 1 seconds... ({i+1}/{max_retries})")
time.sleep(5) time.sleep(1)
else: else:
raise Exception("Gitea did not become available within the expected time.") raise Exception("Gitea did not become available within the expected time.")
@@ -59,6 +59,23 @@ def gitea_env():
# The add_submodules method also creates workflow.config and staging.config # The add_submodules method also creates workflow.config and staging.config
client.add_submodules("products", "SLFO") client.add_submodules("products", "SLFO")
time.sleep(1)
workflow_config_content = """{
"Workflows": ["pr"],
"GitProjectName": "products/SLFO#main",
"Organization": "pool",
"Branch": "main",
"ManualMergeProject": true,
"Reviewers": [ "-autogits_obs_staging_bot" ]
}"""
client.create_file("products", "SLFO", "workflow.config", workflow_config_content)
staging_config_content = """{
"ObsProject": "openSUSE:Leap:16.0",
"StagingProject": "openSUSE:Leap:16.0:PullRequest"
}"""
client.create_file("products", "SLFO", "staging.config", staging_config_content)
client.add_collaborator("products", "SLFO", "autogits_obs_staging_bot", "write") client.add_collaborator("products", "SLFO", "autogits_obs_staging_bot", "write")
client.add_collaborator("products", "SLFO", "workflow-pr", "write") client.add_collaborator("products", "SLFO", "workflow-pr", "write")
@@ -69,10 +86,635 @@ def gitea_env():
client.update_repo_settings("pool", "pkgA") client.update_repo_settings("pool", "pkgA")
client.update_repo_settings("pool", "pkgB") client.update_repo_settings("pool", "pkgB")
print("--- Gitea Dummy Data Setup Complete ---") print("--- Gitea Dummy Data Setup Complete ---")
time.sleep(5) # Add a small delay for Gitea to fully process changes time.sleep(1) # Give workflow-pr bot time to become fully active
yield client yield client
@pytest.fixture(scope="session")
def configured_dev_branch_env(gitea_env: GiteaAPIClient, request):
"""
Fixture to set up a 'dev' branch in products/SLFO and pool/pkgA,
and configure workflow.config in products/SLFO#dev with specific content.
Yields (gitea_env, test_full_repo_name, dev_branch_name).
"""
test_org_name = "products"
test_repo_name = "SLFO"
test_full_repo_name = f"{test_org_name}/{test_repo_name}"
dev_branch_name = "dev"
workflow_config_content = request.param # Get config content from parametrization
print(f"--- Setting up 'dev' branch and workflow.config in {test_full_repo_name}#{dev_branch_name} ---")
# Get the latest commit SHA of the main branch
main_branch_sha = gitea_env._request("GET", f"repos/{test_org_name}/{test_repo_name}/branches/main").json()["commit"]["id"]
# Create 'dev' branch from 'main' in products/SLFO
gitea_env.create_branch(test_org_name, test_repo_name, dev_branch_name, main_branch_sha)
# Create 'dev' branch in pool/pkgA as well
pool_pkga_main_sha = gitea_env._request("GET", "repos/pool/pkgA/branches/main").json()["commit"]["id"]
gitea_env.create_branch("pool", "pkgA", dev_branch_name, pool_pkga_main_sha)
# Create 'dev' branch in pool/pkgB as well
pool_pkgb_main_sha = gitea_env._request("GET", "repos/pool/pkgB/branches/main").json()["commit"]["id"]
gitea_env.create_branch("pool", "pkgB", dev_branch_name, pool_pkgb_main_sha)
# Create/update workflow.config with the provided content
gitea_env.create_file(test_org_name, test_repo_name, "workflow.config", workflow_config_content, branch=dev_branch_name)
print(f"Created workflow.config with specific content in {test_full_repo_name}#{dev_branch_name}")
# Restart workflow-pr service to pick up new project config
gitea_env.restart_service("workflow-pr")
time.sleep(1) # Give the service time to restart and re-initialize
yield gitea_env, test_full_repo_name, dev_branch_name
# Teardown (optional, depending on test strategy) # Teardown (optional, depending on test strategy)
# For now, we'll leave resources for inspection. If a clean slate is needed for each test, # For now, we'll leave resources for inspection. If a clean slate is needed for each test,
# this fixture's scope would be 'function' and teardown logic would be added here. # this fixture's scope would be 'function' and teardown logic would be added here.
@pytest.fixture(scope="session")
def no_project_git_pr_env(gitea_env: GiteaAPIClient):
"""
Sets up 'dev' branch in products/SLFO and pool/pkgA,
and configures workflow.config in products/SLFO#dev with NoProjectGitPR: true.
"""
test_org_name = "products"
test_repo_name = "SLFO"
test_full_repo_name = f"{test_org_name}/{test_repo_name}"
dev_branch_name = "dev"
print(f"--- Setting up workflow.config in {test_full_repo_name}#{dev_branch_name} for No Project PR ---")
# Get the latest commit SHA of the main branch
main_branch_sha = gitea_env._request("GET", f"repos/{test_org_name}/{test_repo_name}/branches/main").json()["commit"]["id"]
# Create 'dev' branch from 'main' in products/SLFO
try:
gitea_env.create_branch(test_org_name, test_repo_name, dev_branch_name, main_branch_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Create 'dev' branch in pool/pkgA as well
pool_pkga_main_sha = gitea_env._request("GET", "repos/pool/pkgA/branches/main").json()["commit"]["id"]
try:
gitea_env.create_branch("pool", "pkgA", dev_branch_name, pool_pkga_main_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Create 'dev' branch in pool/pkgB as well
pool_pkgb_main_sha = gitea_env._request("GET", "repos/pool/pkgB/branches/main").json()["commit"]["id"]
try:
gitea_env.create_branch("pool", "pkgB", dev_branch_name, pool_pkgb_main_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Setup workflow.config to have "NoProjectGitPR": true
workflow_config_content_no_project_pr = f"""{{
"Workflows": ["pr"],
"GitProjectName": "{test_full_repo_name}#{dev_branch_name}",
"Organization": "pool",
"Branch": "dev",
"ManualMergeProject": true,
"Reviewers": [ "-autogits_obs_staging_bot" ],
"NoProjectGitPR": true
}}"""
gitea_env.create_file(test_org_name, test_repo_name, "workflow.config", workflow_config_content_no_project_pr, branch=dev_branch_name)
print(f"Created workflow.config with NoProjectGitPR: true in {test_full_repo_name}#{dev_branch_name}")
# Restart workflow-pr service
gitea_env.restart_service("workflow-pr")
time.sleep(1) # Give the service time to restart and re-initialize
return gitea_env, test_full_repo_name, dev_branch_name
@pytest.fixture(scope="session")
def test_user_client(gitea_env: GiteaAPIClient):
"""
Creates a new unique user and returns a GiteaAPIClient instance for them using sudo.
This user should not have write permissions to the test repositories by default.
"""
username = f"user-{int(time.time())}"
password = "password123"
email = f"{username}@example.com"
gitea_env.create_user(username, password, email)
# Grant write access to pool/pkgA
gitea_env.add_collaborator("pool", "pkgA", username, "write")
# Use admin token with Sudo header
admin_token = gitea_env.headers["Authorization"].split(" ")[1]
return GiteaAPIClient(base_url=gitea_env.base_url, token=admin_token, sudo=username)
def setup_users_from_config(client: GiteaAPIClient, workflow_config: str, maintainership_config: str):
"""
Parses workflow.config and _maintainership.json, creates users, and adds them as collaborators.
"""
import json
wf = json.loads(workflow_config)
mt = json.loads(maintainership_config)
all_users = set()
# Extract from workflow.config Reviewers
reviewers = wf.get("Reviewers", [])
for r in reviewers:
# Strip +, - prefixes
username = r.lstrip("+-")
if username and username not in ["autogits_obs_staging_bot", "workflow-pr"]:
all_users.add(username)
# Extract from maintainership
for pkg, users in mt.items():
for username in users:
all_users.add(username)
# Create all users
for username in all_users:
client.create_user(username, "password123", f"{username}@example.com")
# Global maintainers (empty key) get write access to everything
# Actually, let's just make them collaborators on SLFO, pkgA, pkgB for simplicity in tests
client.add_collaborator("products", "SLFO", username, "write")
# Set specific repository permissions based on maintainership
for pkg, users in mt.items():
repo_name = pkg if pkg else None
for username in users:
if not repo_name:
# Global maintainer - already added to SLFO, add to pkgA/pkgB
client.add_collaborator("pool", "pkgA", username, "write")
client.add_collaborator("pool", "pkgB", username, "write")
else:
client.add_collaborator("pool", repo_name, username, "write")
@pytest.fixture(scope="session")
def gitea_env():
"""
Sets up the Gitea environment with dummy data and provides a GiteaAPIClient instance.
"""
gitea_url = "http://127.0.0.1:3000"
# Read admin token
admin_token_path = "./gitea-data/admin.token" # Corrected path
admin_token = None
try:
with open(admin_token_path, "r") as f:
admin_token = f.read().strip()
except FileNotFoundError:
raise Exception(f"Admin token file not found at {admin_token_path}. Ensure it's generated and accessible.")
# Headers for authenticated requests
auth_headers = {"Authorization": f"token {admin_token}", "Content-Type": "application/json"}
# Wait for Gitea to be available
print(f"Waiting for Gitea at {gitea_url}...")
max_retries = 5
for i in range(max_retries):
try:
# Check a specific API endpoint that indicates readiness
response = requests.get(f"{gitea_url}/api/v1/version", headers=auth_headers, timeout=5)
if response.status_code == 200:
print("Gitea API is available.")
break
except requests.exceptions.ConnectionError:
pass
print(f"Gitea not ready ({response.status_code if 'response' in locals() else 'ConnectionError'}), retrying in 1 seconds... ({i+1}/{max_retries})")
time.sleep(1)
else:
raise Exception("Gitea did not become available within the expected time.")
client = GiteaAPIClient(base_url=gitea_url, token=admin_token)
# Setup dummy data
print("--- Starting Gitea Dummy Data Setup from Pytest Fixture ---")
client.create_org("products")
client.create_org("pool")
client.create_repo("products", "SLFO")
client.create_repo("pool", "pkgA")
client.create_repo("pool", "pkgB")
# The add_submodules method also creates workflow.config and staging.config
client.add_submodules("products", "SLFO")
time.sleep(1)
workflow_config_content = """{
"Workflows": ["pr"],
"GitProjectName": "products/SLFO#main",
"Organization": "pool",
"Branch": "main",
"ManualMergeProject": true,
"Reviewers": [ "-autogits_obs_staging_bot" ]
}"""
client.create_file("products", "SLFO", "workflow.config", workflow_config_content)
staging_config_content = """{
"ObsProject": "openSUSE:Leap:16.0",
"StagingProject": "openSUSE:Leap:16.0:PullRequest"
}"""
client.create_file("products", "SLFO", "staging.config", staging_config_content)
maintainership_content = """{
"": ["ownerX","ownerY"],
"pkgA": ["ownerA"],
"pkgB": ["ownerB","ownerBB"]
}"""
# Create users from default main config
setup_users_from_config(client, workflow_config_content, maintainership_content)
client.add_collaborator("products", "SLFO", "autogits_obs_staging_bot", "write")
client.add_collaborator("products", "SLFO", "workflow-pr", "write")
client.add_collaborator("pool", "pkgA", "workflow-pr", "write")
client.add_collaborator("pool", "pkgB", "workflow-pr", "write")
client.update_repo_settings("products", "SLFO")
client.update_repo_settings("pool", "pkgA")
client.update_repo_settings("pool", "pkgB")
print("--- Gitea Dummy Data Setup Complete ---")
time.sleep(1) # Give workflow-pr bot time to become fully active
yield client
@pytest.fixture(scope="session")
def configured_dev_branch_env(gitea_env: GiteaAPIClient, request):
"""
Fixture to set up a 'dev' branch in products/SLFO and pool/pkgA,
and configure workflow.config in products/SLFO#dev with specific content.
Yields (gitea_env, test_full_repo_name, dev_branch_name).
"""
test_org_name = "products"
test_repo_name = "SLFO"
test_full_repo_name = f"{test_org_name}/{test_repo_name}"
dev_branch_name = "dev"
workflow_config_content = request.param # Get config content from parametrization
print(f"--- Setting up 'dev' branch and workflow.config in {test_full_repo_name}#{dev_branch_name} ---")
# Get the latest commit SHA of the main branch
gitea_env.ensure_branch_exists(test_org_name, test_repo_name, "main")
main_branch_sha = gitea_env._request("GET", f"repos/{test_org_name}/{test_repo_name}/branches/main").json()["commit"]["id"]
# Create 'dev' branch from 'main' in products/SLFO
gitea_env.create_branch(test_org_name, test_repo_name, dev_branch_name, main_branch_sha)
# Create 'dev' branch in pool/pkgA as well
gitea_env.ensure_branch_exists("pool", "pkgA", "main")
pool_pkga_main_sha = gitea_env._request("GET", "repos/pool/pkgA/branches/main").json()["commit"]["id"]
gitea_env.create_branch("pool", "pkgA", dev_branch_name, pool_pkga_main_sha)
# Create 'dev' branch in pool/pkgB as well
gitea_env.ensure_branch_exists("pool", "pkgB", "main")
pool_pkgb_main_sha = gitea_env._request("GET", "repos/pool/pkgB/branches/main").json()["commit"]["id"]
gitea_env.create_branch("pool", "pkgB", dev_branch_name, pool_pkgb_main_sha)
# Create/update workflow.config with the provided content
gitea_env.create_file(test_org_name, test_repo_name, "workflow.config", workflow_config_content, branch=dev_branch_name)
# For this fixture, we use default maintainership as we don't receive it in request.param
maintainership_content = """{
"": ["ownerX","ownerY"],
"pkgA": ["ownerA"],
"pkgB": ["ownerB","ownerBB"]
}"""
setup_users_from_config(gitea_env, workflow_config_content, maintainership_content)
print(f"Created workflow.config with specific content in {test_full_repo_name}#{dev_branch_name}")
# Restart workflow-pr service to pick up new project config
gitea_env.restart_service("workflow-pr")
time.sleep(1) # Give the service time to restart and re-initialize
yield gitea_env, test_full_repo_name, dev_branch_name
@pytest.fixture(scope="session")
def no_project_git_pr_env(gitea_env: GiteaAPIClient):
"""
Sets up 'dev' branch in products/SLFO and pool/pkgA,
and configures workflow.config in products/SLFO#dev with NoProjectGitPR: true.
"""
test_org_name = "products"
test_repo_name = "SLFO"
test_full_repo_name = f"{test_org_name}/{test_repo_name}"
dev_branch_name = "dev"
print(f"--- Setting up workflow.config in {test_full_repo_name}#{dev_branch_name} for No Project PR ---")
# Get the latest commit SHA of the main branch
gitea_env.ensure_branch_exists(test_org_name, test_repo_name, "main")
main_branch_sha = gitea_env._request("GET", f"repos/{test_org_name}/{test_repo_name}/branches/main").json()["commit"]["id"]
# Create 'dev' branch from 'main' in products/SLFO
try:
gitea_env.create_branch(test_org_name, test_repo_name, dev_branch_name, main_branch_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Create 'dev' branch in pool/pkgA as well
gitea_env.ensure_branch_exists("pool", "pkgA", "main")
pool_pkga_main_sha = gitea_env._request("GET", "repos/pool/pkgA/branches/main").json()["commit"]["id"]
try:
gitea_env.create_branch("pool", "pkgA", dev_branch_name, pool_pkga_main_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Create 'dev' branch in pool/pkgB as well
gitea_env.ensure_branch_exists("pool", "pkgB", "main")
pool_pkgb_main_sha = gitea_env._request("GET", "repos/pool/pkgB/branches/main").json()["commit"]["id"]
try:
gitea_env.create_branch("pool", "pkgB", dev_branch_name, pool_pkgb_main_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Setup workflow.config to have "NoProjectGitPR": true
workflow_config_content = f"""{{
"Workflows": ["pr"],
"GitProjectName": "{test_full_repo_name}#{dev_branch_name}",
"Organization": "pool",
"Branch": "dev",
"ManualMergeProject": true,
"Reviewers": [ "-autogits_obs_staging_bot" ],
"NoProjectGitPR": true
}}"""
gitea_env.create_file(test_org_name, test_repo_name, "workflow.config", workflow_config_content, branch=dev_branch_name)
maintainership_content = """{
"": ["ownerX","ownerY"],
"pkgA": ["ownerA"],
"pkgB": ["ownerB","ownerBB"]
}"""
setup_users_from_config(gitea_env, workflow_config_content, maintainership_content)
print(f"Created workflow.config with NoProjectGitPR: true in {test_full_repo_name}#{dev_branch_name}")
# Restart workflow-pr service
gitea_env.restart_service("workflow-pr")
time.sleep(1) # Give the service time to restart and re-initialize
return gitea_env, test_full_repo_name, dev_branch_name
@pytest.fixture(scope="session")
def test_user_client(gitea_env: GiteaAPIClient):
"""
Creates a new unique user and returns a GiteaAPIClient instance for them using sudo.
This user should not have write permissions to the test repositories by default.
"""
username = f"user-{int(time.time())}"
password = "password123"
email = f"{username}@example.com"
gitea_env.create_user(username, password, email)
# Grant write access to pool/pkgA
gitea_env.add_collaborator("pool", "pkgA", username, "write")
# Use admin token with Sudo header
admin_token = gitea_env.headers["Authorization"].split(" ")[1]
return GiteaAPIClient(base_url=gitea_env.base_url, token=admin_token, sudo=username)
@pytest.fixture(scope="session")
def automerge_env(gitea_env: GiteaAPIClient):
"""
Sets up 'merge' branch and custom workflow.config for automerge tests.
"""
test_org_name = "products"
test_repo_name = "SLFO"
test_full_repo_name = f"{test_org_name}/{test_repo_name}"
merge_branch_name = "merge"
print(f"--- Setting up '{merge_branch_name}' branch and workflow.config in {test_full_repo_name} ---")
# Get the latest commit SHA of the main branch
gitea_env.ensure_branch_exists(test_org_name, test_repo_name, "main")
main_branch_sha = gitea_env._request("GET", f"repos/{test_org_name}/{test_repo_name}/branches/main").json()["commit"]["id"]
# Create 'merge' branch from 'main' in products/SLFO
try:
gitea_env.create_branch(test_org_name, test_repo_name, merge_branch_name, main_branch_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Create 'merge' branch in pool/pkgA as well
gitea_env.ensure_branch_exists("pool", "pkgA", "main")
pool_pkga_main_sha = gitea_env._request("GET", "repos/pool/pkgA/branches/main").json()["commit"]["id"]
try:
gitea_env.create_branch("pool", "pkgA", merge_branch_name, pool_pkga_main_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Create 'merge' branch in pool/pkgB as well
gitea_env.ensure_branch_exists("pool", "pkgB", "main")
pool_pkgb_main_sha = gitea_env._request("GET", "repos/pool/pkgB/branches/main").json()["commit"]["id"]
try:
gitea_env.create_branch("pool", "pkgB", merge_branch_name, pool_pkgb_main_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
custom_workflow_config = f"""{{
"Workflows": ["pr"],
"GitProjectName": "{test_full_repo_name}#{merge_branch_name}",
"Organization": "pool",
"Branch": "{merge_branch_name}",
"Reviewers": [ "+usera", "+userb", "-autogits_obs_staging_bot" ]
}}"""
gitea_env.create_file(test_org_name, test_repo_name, "workflow.config", custom_workflow_config, branch=merge_branch_name)
maintainership_content = """{
"": ["ownerX","ownerY"],
"pkgA": ["ownerA"],
"pkgB": ["ownerB","ownerBB"]
}"""
gitea_env.create_file(test_org_name, test_repo_name, "_maintainership.json", maintainership_content, branch=merge_branch_name)
setup_users_from_config(gitea_env, custom_workflow_config, maintainership_content)
# Restart workflow-pr service
gitea_env.restart_service("workflow-pr")
time.sleep(1)
return gitea_env, test_full_repo_name, merge_branch_name
@pytest.fixture(scope="session")
def maintainer_env(gitea_env: GiteaAPIClient):
"""
Sets up 'maintainer-merge' branch and workflow.config without mandatory reviewers.
"""
test_org_name = "products"
test_repo_name = "SLFO"
test_full_repo_name = f"{test_org_name}/{test_repo_name}"
branch_name = "maintainer-merge"
print(f"--- Setting up '{branch_name}' branch and workflow.config in {test_full_repo_name} ---")
# Get the latest commit SHA of the main branch
gitea_env.ensure_branch_exists(test_org_name, test_repo_name, "main")
main_branch_sha = gitea_env._request("GET", f"repos/{test_org_name}/{test_repo_name}/branches/main").json()["commit"]["id"]
# Create branch in products/SLFO
try:
gitea_env.create_branch(test_org_name, test_repo_name, branch_name, main_branch_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Create branch in pool/pkgA
gitea_env.ensure_branch_exists("pool", "pkgA", "main")
pool_pkga_main_sha = gitea_env._request("GET", "repos/pool/pkgA/branches/main").json()["commit"]["id"]
try:
gitea_env.create_branch("pool", "pkgA", branch_name, pool_pkga_main_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Create branch in pool/pkgB
gitea_env.ensure_branch_exists("pool", "pkgB", "main")
pool_pkgb_main_sha = gitea_env._request("GET", "repos/pool/pkgB/branches/main").json()["commit"]["id"]
try:
gitea_env.create_branch("pool", "pkgB", branch_name, pool_pkgb_main_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
custom_workflow_config = f"""{{
"Workflows": ["pr"],
"GitProjectName": "{test_full_repo_name}#{branch_name}",
"Organization": "pool",
"Branch": "{branch_name}",
"Reviewers": [ "-autogits_obs_staging_bot" ]
}}"""
gitea_env.create_file(test_org_name, test_repo_name, "workflow.config", custom_workflow_config, branch=branch_name)
maintainership_content = """{
"": ["ownerX","ownerY"],
"pkgA": ["ownerA"],
"pkgB": ["ownerB","ownerBB"]
}"""
gitea_env.create_file(test_org_name, test_repo_name, "_maintainership.json", maintainership_content, branch=branch_name)
setup_users_from_config(gitea_env, custom_workflow_config, maintainership_content)
gitea_env.add_collaborator(test_org_name, test_repo_name, "autogits_obs_staging_bot", "write")
# Restart workflow-pr service
gitea_env.restart_service("workflow-pr")
time.sleep(1)
return gitea_env, test_full_repo_name, branch_name
@pytest.fixture(scope="session")
def review_required_env(gitea_env: GiteaAPIClient):
"""
Sets up 'review-required' branch and workflow.config with ReviewRequired: true.
"""
test_org_name = "products"
test_repo_name = "SLFO"
test_full_repo_name = f"{test_org_name}/{test_repo_name}"
branch_name = "review-required"
print(f"--- Setting up '{branch_name}' branch and workflow.config in {test_full_repo_name} ---")
# Get the latest commit SHA of the main branch
gitea_env.ensure_branch_exists(test_org_name, test_repo_name, "main")
main_branch_sha = gitea_env._request("GET", f"repos/{test_org_name}/{test_repo_name}/branches/main").json()["commit"]["id"]
# Create branch in products/SLFO
try:
gitea_env.create_branch(test_org_name, test_repo_name, branch_name, main_branch_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Create branch in pool/pkgA
gitea_env.ensure_branch_exists("pool", "pkgA", "main")
pool_pkga_main_sha = gitea_env._request("GET", "repos/pool/pkgA/branches/main").json()["commit"]["id"]
try:
gitea_env.create_branch("pool", "pkgA", branch_name, pool_pkga_main_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Create branch in pool/pkgB
gitea_env.ensure_branch_exists("pool", "pkgB", "main")
pool_pkgb_main_sha = gitea_env._request("GET", "repos/pool/pkgB/branches/main").json()["commit"]["id"]
try:
gitea_env.create_branch("pool", "pkgB", branch_name, pool_pkgb_main_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
custom_workflow_config = f"""{{
"Workflows": ["pr"],
"GitProjectName": "{test_full_repo_name}#{branch_name}",
"Organization": "pool",
"Branch": "{branch_name}",
"Reviewers": [ "-autogits_obs_staging_bot" ],
"ReviewRequired": true
}}"""
gitea_env.create_file(test_org_name, test_repo_name, "workflow.config", custom_workflow_config, branch=branch_name)
maintainership_content = """{
"": ["ownerX","ownerY"],
"pkgA": ["ownerA"],
"pkgB": ["ownerB","ownerBB"]
}"""
gitea_env.create_file(test_org_name, test_repo_name, "_maintainership.json", maintainership_content, branch=branch_name)
setup_users_from_config(gitea_env, custom_workflow_config, maintainership_content)
gitea_env.add_collaborator(test_org_name, test_repo_name, "autogits_obs_staging_bot", "write")
# Restart workflow-pr service
gitea_env.restart_service("workflow-pr")
time.sleep(1)
return gitea_env, test_full_repo_name, branch_name
@pytest.fixture(scope="session")
def ownerA_client(gitea_env: GiteaAPIClient):
"""
Returns a GiteaAPIClient instance for ownerA.
"""
admin_token = gitea_env.headers["Authorization"].split(" ")[1]
return GiteaAPIClient(base_url=gitea_env.base_url, token=admin_token, sudo="ownerA")
@pytest.fixture(scope="session")
def ownerB_client(gitea_env: GiteaAPIClient):
"""
Returns a GiteaAPIClient instance for ownerB.
"""
admin_token = gitea_env.headers["Authorization"].split(" ")[1]
return GiteaAPIClient(base_url=gitea_env.base_url, token=admin_token, sudo="ownerB")
@pytest.fixture(scope="session")
def ownerBB_client(gitea_env: GiteaAPIClient):
"""
Returns a GiteaAPIClient instance for ownerBB.
"""
admin_token = gitea_env.headers["Authorization"].split(" ")[1]
return GiteaAPIClient(base_url=gitea_env.base_url, token=admin_token, sudo="ownerBB")

View File

@@ -6,6 +6,7 @@ import json
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
import base64 import base64
import subprocess
TEST_DATA_DIR = Path(__file__).parent.parent / "data" TEST_DATA_DIR = Path(__file__).parent.parent / "data"
BUILD_RESULT_TEMPLATE = TEST_DATA_DIR / "build_result.xml.template" BUILD_RESULT_TEMPLATE = TEST_DATA_DIR / "build_result.xml.template"
@@ -43,9 +44,11 @@ def mock_build_result():
class GiteaAPIClient: class GiteaAPIClient:
def __init__(self, base_url, token): def __init__(self, base_url, token, sudo=None):
self.base_url = base_url self.base_url = base_url
self.headers = {"Authorization": f"token {token}", "Content-Type": "application/json"} self.headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
if sudo:
self.headers["Sudo"] = sudo
def _request(self, method, path, **kwargs): def _request(self, method, path, **kwargs):
url = f"{self.base_url}/api/v1/{path}" url = f"{self.base_url}/api/v1/{path}"
@@ -58,6 +61,48 @@ class GiteaAPIClient:
raise raise
return response return response
def get_file_info(self, owner: str, repo: str, file_path: str, branch: str = "main"):
url = f"repos/{owner}/{repo}/contents/{file_path}"
if branch and branch != "main":
url += f"?ref={branch}"
try:
response = self._request("GET", url)
return response.json()
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
return None
raise
def create_user(self, username, password, email):
print(f"--- Creating user: {username} ---")
data = {
"username": username,
"password": password,
"email": email,
"must_change_password": False,
"send_notify": False
}
try:
self._request("POST", "admin/users", json=data)
print(f"User '{username}' created.")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 422: # Already exists
print(f"User '{username}' already exists. Updating password...")
# Update password to be sure it matches our expectation
self._request("PATCH", f"admin/users/{username}", json={"password": password, "login_name": username})
else:
raise
def get_user_token(self, username, password, token_name="test-token"):
print(f"--- Getting token for user: {username} ---")
url = f"{self.base_url}/api/v1/users/{username}/tokens"
# Create new token using Basic Auth
response = requests.post(url, auth=(username, password), json={"name": token_name})
if response.status_code == 201:
return response.json()["sha1"]
response.raise_for_status()
def create_org(self, org_name): def create_org(self, org_name):
print(f"--- Checking organization: {org_name} ---") print(f"--- Checking organization: {org_name} ---")
try: try:
@@ -71,6 +116,18 @@ class GiteaAPIClient:
print(f"Organization '{org_name}' created.") print(f"Organization '{org_name}' created.")
else: else:
raise raise
print(f"--- Checking organization: {org_name} ---")
try:
self._request("GET", f"orgs/{org_name}")
print(f"Organization '{org_name}' already exists.")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
print(f"Creating organization '{org_name}'...")
data = {"username": org_name, "full_name": org_name}
self._request("POST", "orgs", json=data)
print(f"Organization '{org_name}' created.")
else:
raise
def create_repo(self, org_name, repo_name): def create_repo(self, org_name, repo_name):
print(f"--- Checking repository: {org_name}/{repo_name} ---") print(f"--- Checking repository: {org_name}/{repo_name} ---")
@@ -91,7 +148,7 @@ class GiteaAPIClient:
} }
self._request("POST", f"orgs/{org_name}/repos", json=data) self._request("POST", f"orgs/{org_name}/repos", json=data)
print(f"Repository '{org_name}/{repo_name}' created with a README.") print(f"Repository '{org_name}/{repo_name}' created with a README.")
time.sleep(1) # Added delay to allow Git operations to become available time.sleep(0.1) # Added delay to allow Git operations to become available
else: else:
raise raise
@@ -147,30 +204,8 @@ index 0000000..{pkg_b_sha}
+++ b/pkgB +++ b/pkgB
@@ -0,0 +1 @@ @@ -0,0 +1 @@
+Subproject commit {pkg_b_sha} +Subproject commit {pkg_b_sha}
diff --git a/workflow.config b/workflow.config
new file mode 100644
--- /dev/null
+++ b/workflow.config
@@ -0,0 +7 @@
+{{
+ "Workflows": ["pr"],
+ "GitProjectName": "products/SLFO#main",
+ "Organization": "pool",
+ "Branch": "main",
+ "ManualMergeProject": true,
+ "Reviewers": [ "-autogits_obs_staging_bot" ]
+}}
diff --git a/staging.config b/staging.config
new file mode 100644
--- /dev/null
+++ b/staging.config
@@ -0,0 +3 @@
+{{
+ "ObsProject": "openSUSE:Leap:16.0",
+ "StagingProject": "openSUSE:Leap:16.0:PullRequest"
+}}
""" """
message = "Add pkgA and pkgB as submodules and config files" message = "Add pkgA and pkgB as submodules"
data = { data = {
"branch": "main", "branch": "main",
"content": diff_content, "content": diff_content,
@@ -192,56 +227,145 @@ new file mode 100644
print(f"Repository settings for '{org_name}/{repo_name}' updated.") print(f"Repository settings for '{org_name}/{repo_name}' updated.")
def create_gitea_pr(self, repo_full_name: str, diff_content: str, title: str): def create_file(self, owner: str, repo: str, file_path: str, content: str, branch: str = "main", message: str = "Add file"):
owner, repo = repo_full_name.split("/") file_info = self.get_file_info(owner, repo, file_path, branch=branch)
url = f"repos/{owner}/{repo}/pulls"
base_branch = "main"
# Create a new branch for the PR
new_branch_name = f"pr-branch-{int(time.time())}"
# Get the latest commit SHA of the base branch
base_commit_sha = self._request("GET", f"repos/{owner}/{repo}/branches/{base_branch}").json()["commit"]["id"]
# Create the new branch
self._request("POST", f"repos/{owner}/{repo}/branches", json={
"new_branch_name": new_branch_name,
"old_ref": base_commit_sha # Use the commit SHA directly
})
# Create a new file or modify an existing one in the new branch
file_path = f"test-file-{int(time.time())}.txt"
file_content = "This is a test file for the PR."
self._request("POST", f"repos/{owner}/{repo}/contents/{file_path}", json={
"content": base64.b64encode(file_content.encode('utf-8')).decode('ascii'),
"message": "Add test file",
"branch": new_branch_name
})
# Now create the PR
data = { data = {
"head": new_branch_name, # Use the newly created branch as head "content": base64.b64encode(content.encode('utf-8')).decode('ascii'),
"branch": branch,
"message": message
}
if file_info:
print(f"--- Updating file {file_path} in {owner}/{repo} ---")
# Re-fetch file_info to get the latest SHA right before update
latest_file_info = self.get_file_info(owner, repo, file_path, branch=branch)
if not latest_file_info:
raise Exception(f"File {file_path} disappeared during update attempt.")
data["sha"] = latest_file_info["sha"]
data["message"] = f"Update {file_path}"
method = "PUT"
else:
print(f"--- Creating file {file_path} in {owner}/{repo} ---")
method = "POST"
url = f"repos/{owner}/{repo}/contents/{file_path}"
self._request(method, url, json=data)
print(f"File {file_path} {'updated' if file_info else 'created'} in {owner}/{repo}.")
def create_gitea_pr(self, repo_full_name: str, diff_content: str, title: str, use_fork: bool, base_branch: str = "main", body: str = ""):
owner, repo = repo_full_name.split("/")
head_owner, head_repo = owner, repo
if use_fork:
sudo_user = self.headers.get("Sudo")
head_owner = sudo_user
head_repo = repo
new_branch_name = f"pr-branch-{int(time.time()*1000)}"
print(f"--- Forking {repo_full_name} ---")
try:
self._request("POST", f"repos/{owner}/{repo}/forks", json={})
print(f"--- Forked to {head_owner}/{head_repo} ---")
time.sleep(0.5) # Give more time for fork to be ready
except requests.exceptions.HTTPError as e:
if e.response.status_code == 409: # Already forked
print(f"--- Already forked to {head_owner}/{head_repo} ---")
else:
raise
# Create a unique branch in the FORK
base_commit_sha = self._request("GET", f"repos/{owner}/{repo}/branches/{base_branch}").json()["commit"]["id"]
print(f"--- Creating branch {new_branch_name} in {head_owner}/{head_repo} from {base_branch} ({base_commit_sha}) ---")
self._request("POST", f"repos/{head_owner}/{head_repo}/branches", json={
"new_branch_name": new_branch_name,
"old_ref": base_commit_sha
})
else:
new_branch_name = f"pr-branch-{int(time.time()*1000)}"
# Get the latest commit SHA of the base branch from the ORIGINAL repo
base_commit_sha = self._request("GET", f"repos/{owner}/{repo}/branches/{base_branch}").json()["commit"]["id"]
# Try to create the branch in the ORIGINAL repo
print(f"--- Creating branch {new_branch_name} in {repo_full_name} ---")
self._request("POST", f"repos/{owner}/{repo}/branches", json={
"new_branch_name": new_branch_name,
"old_ref": base_commit_sha
})
# Apply the diff using diffpatch in the branch (wherever it is)
print(f"--- Applying diff to {head_owner}/{head_repo} branch {new_branch_name} ---")
self._request("POST", f"repos/{head_owner}/{head_repo}/diffpatch", json={
"branch": new_branch_name,
"content": diff_content,
"message": title
})
# Now create the PR in the ORIGINAL repo
data = {
"head": f"{head_owner}:{new_branch_name}" if head_owner != owner else new_branch_name,
"base": base_branch, "base": base_branch,
"title": title, "title": title,
"body": "Test Pull Request" "body": body,
"allow_maintainer_edit": True
} }
response = self._request("POST", url, json=data) print(f"--- Creating PR in {repo_full_name} from {data['head']} ---")
response = self._request("POST", f"repos/{owner}/{repo}/pulls", json=data)
return response.json() return response.json()
def create_branch(self, owner: str, repo: str, new_branch_name: str, old_ref: str):
print(f"--- Checking branch '{new_branch_name}' in {owner}/{repo} ---")
try:
self._request("GET", f"repos/{owner}/{repo}/branches/{new_branch_name}")
print(f"Branch '{new_branch_name}' already exists.")
return
except requests.exceptions.HTTPError as e:
if e.response.status_code != 404:
raise # Re-raise other HTTP errors
print(f"--- Creating branch '{new_branch_name}' in {owner}/{repo} from {old_ref} ---")
url = f"repos/{owner}/{repo}/branches"
data = {
"new_branch_name": new_branch_name,
"old_ref": old_ref
}
self._request("POST", url, json=data)
print(f"Branch '{new_branch_name}' created in {owner}/{repo}.")
def ensure_branch_exists(self, owner: str, repo: str, branch: str = "main", timeout: int = 10):
print(f"--- Ensuring branch '{branch}' exists in {owner}/{repo} ---")
start_time = time.time()
while time.time() - start_time < timeout:
try:
self._request("GET", f"repos/{owner}/{repo}/branches/{branch}")
print(f"Branch '{branch}' confirmed in {owner}/{repo}.")
return
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
print(f"Branch '{branch}' not found yet in {owner}/{repo}. Retrying...")
time.sleep(1)
continue
raise
raise Exception(f"Timeout waiting for branch {branch} in {owner}/{repo}")
def modify_gitea_pr(self, repo_full_name: str, pr_number: int, diff_content: str, message: str): def modify_gitea_pr(self, repo_full_name: str, pr_number: int, diff_content: str, message: str):
owner, repo = repo_full_name.split("/") owner, repo = repo_full_name.split("/")
# Get PR details to find the head branch # Get PR details to find the head branch AND head repo
pr_details = self._request("GET", f"repos/{owner}/{repo}/pulls/{pr_number}").json() pr_details = self._request("GET", f"repos/{owner}/{repo}/pulls/{pr_number}").json()
head_branch = pr_details["head"]["ref"] head_branch = pr_details["head"]["ref"]
head_repo_owner = pr_details["head"]["repo"]["owner"]["login"]
head_repo_name = pr_details["head"]["repo"]["name"]
file_path = f"modified-file-{int(time.time())}.txt" # Apply the diff using diffpatch
file_content = "This is a modified test file for the PR." print(f"--- Modifying PR #{pr_number} in {head_repo_owner}/{head_repo_name} branch {head_branch} ---")
self._request("POST", f"repos/{head_repo_owner}/{head_repo_name}/diffpatch", json={
self._request("POST", f"repos/{owner}/{repo}/contents/{file_path}", json={ "branch": head_branch,
"content": base64.b64encode(file_content.encode('utf-8')).decode('ascii'), "content": diff_content,
"message": message, "message": message
"branch": head_branch
}) })
def update_gitea_pr_properties(self, repo_full_name: str, pr_number: int, **kwargs): def update_gitea_pr_properties(self, repo_full_name: str, pr_number: int, **kwargs):
@@ -261,12 +385,12 @@ new file mode 100644
timeline_events = response.json() timeline_events = response.json()
if timeline_events: # Check if timeline_events list is not empty if timeline_events: # Check if timeline_events list is not empty
return timeline_events return timeline_events
print(f"Attempt {i+1}: Timeline for PR {pr_number} is empty. Retrying in 3 seconds...") print(f"Attempt {i+1}: Timeline for PR {pr_number} is empty. Retrying in 1 seconds...")
time.sleep(3) time.sleep(1)
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
if e.response.status_code == 404: if e.response.status_code == 404:
print(f"Attempt {i+1}: Timeline for PR {pr_number} not found yet. Retrying in 3 seconds...") print(f"Attempt {i+1}: Timeline for PR {pr_number} not found yet. Retrying in 1 seconds...")
time.sleep(3) time.sleep(1)
else: else:
raise # Re-raise other HTTP errors raise # Re-raise other HTTP errors
raise Exception(f"Failed to retrieve timeline for PR {pr_number} after multiple retries.") raise Exception(f"Failed to retrieve timeline for PR {pr_number} after multiple retries.")
@@ -283,12 +407,12 @@ new file mode 100644
print(f"Attempt {i+1}: Comments for PR {pr_number} received: {comments}") # Added debug print print(f"Attempt {i+1}: Comments for PR {pr_number} received: {comments}") # Added debug print
if comments: # Check if comments list is not empty if comments: # Check if comments list is not empty
return comments return comments
print(f"Attempt {i+1}: Comments for PR {pr_number} are empty. Retrying in 3 seconds...") print(f"Attempt {i+1}: Comments for PR {pr_number} are empty. Retrying in 1 seconds...")
time.sleep(3) time.sleep(1)
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
if e.response.status_code == 404: if e.response.status_code == 404:
print(f"Attempt {i+1}: Comments for PR {pr_number} not found yet. Retrying in 3 seconds...") print(f"Attempt {i+1}: Comments for PR {pr_number} not found yet. Retrying in 1 seconds...")
time.sleep(3) time.sleep(1)
else: else:
raise # Re-raise other HTTP errors raise # Re-raise other HTTP errors
raise Exception(f"Failed to retrieve comments for PR {pr_number} after multiple retries.") raise Exception(f"Failed to retrieve comments for PR {pr_number} after multiple retries.")
@@ -299,3 +423,87 @@ new file mode 100644
response = self._request("GET", url) response = self._request("GET", url)
return response.json() return response.json()
def create_review(self, repo_full_name: str, pr_number: int, event: str = "APPROVED", body: str = "LGTM"):
owner, repo = repo_full_name.split("/")
# Check if this user already has an APPROVED review to avoid 422
current_user = self.headers.get("Sudo") or "admin" # simplified
existing_reviews = self.list_reviews(repo_full_name, pr_number)
for r in existing_reviews:
if r["user"]["login"] == current_user and r["state"] == "APPROVED" and event == "APPROVED":
print(f"User {current_user} already has an APPROVED review for {repo_full_name} PR #{pr_number}")
return r
url = f"repos/{owner}/{repo}/pulls/{pr_number}/reviews"
data = {
"event": event,
"body": body
}
print(f"--- Creating and submitting review ({event}) for {repo_full_name} PR #{pr_number} as {current_user} ---")
try:
response = self._request("POST", url, json=data)
review = response.json()
except requests.exceptions.HTTPError as e:
# If it fails with 422, it might be because a review is already pending or something else
print(f"Failed to create review: {e.response.text}")
# Try to find a pending review to submit
existing_reviews = self.list_reviews(repo_full_name, pr_number)
pending_review = next((r for r in existing_reviews if r["user"]["login"] == current_user and r["state"] == "PENDING"), None)
if pending_review:
review = pending_review
else:
raise
# If the state is PENDING, we submit it.
if review.get("state") == "PENDING":
review_id = review["id"]
submit_url = f"repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}"
submit_data = {
"event": event,
"body": body
}
try:
self._request("POST", submit_url, json=submit_data)
print(f"--- Review {review_id} submitted ---")
except requests.exceptions.HTTPError as e:
if "already" in e.response.text.lower() or "stay pending" in e.response.text.lower():
print(f"Review {review_id} could not be submitted further: {e.response.text}")
else:
raise
return review
def list_reviews(self, repo_full_name: str, pr_number: int):
owner, repo = repo_full_name.split("/")
url = f"repos/{owner}/{repo}/pulls/{pr_number}/reviews"
response = self._request("GET", url)
return response.json()
def approve_requested_reviews(self, repo_full_name: str, pr_number: int):
print(f"--- Checking for REQUEST_REVIEW state in {repo_full_name} PR #{pr_number} ---")
reviews = self.list_reviews(repo_full_name, pr_number)
requested_reviews = [r for r in reviews if r["state"] == "REQUEST_REVIEW"]
if not requested_reviews:
print(f"No reviews in REQUEST_REVIEW state found for {repo_full_name} PR #{pr_number}")
return
admin_token = self.headers["Authorization"].split(" ")[1]
for r in requested_reviews:
reviewer_username = r["user"]["login"]
print(f"Reacting on REQUEST_REVIEW for user {reviewer_username} by approving...")
reviewer_client = GiteaAPIClient(base_url=self.base_url, token=admin_token, sudo=reviewer_username)
time.sleep(1) # give a chance to avoid possible concurrency issues with reviews request/approval
reviewer_client.create_review(repo_full_name, pr_number, event="APPROVED", body="Approving requested review")
def restart_service(self, service_name: str):
print(f"--- Restarting service: {service_name} ---")
try:
# Assumes podman-compose.yml is in the parent directory of tests/lib
subprocess.run(["podman-compose", "restart", service_name], check=True, cwd=os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)))
print(f"Service {service_name} restarted successfully.")
except subprocess.CalledProcessError as e:
print(f"Error restarting service {service_name}: {e}")
raise

View File

@@ -17,7 +17,7 @@ from tests.lib.common_test_utils import (
def test_pr_workflow_succeeded(gitea_env, mock_build_result): def test_pr_workflow_succeeded(gitea_env, mock_build_result):
"""End-to-end test for a successful PR workflow.""" """End-to-end test for a successful PR workflow."""
diff = "diff --git a/test.txt b/test.txt\nnew file mode 100644\nindex 0000000..e69de29\n" diff = "diff --git a/test.txt b/test.txt\nnew file mode 100644\nindex 0000000..e69de29\n"
pr = gitea_env.create_gitea_pr("pool/pkgA", diff, "Test PR - should succeed") pr = gitea_env.create_gitea_pr("pool/pkgA", diff, "Test PR - should succeed", False)
initial_pr_number = pr["number"] initial_pr_number = pr["number"]
compose_dir = Path(__file__).parent.parent compose_dir = Path(__file__).parent.parent
@@ -87,7 +87,7 @@ def test_pr_workflow_succeeded(gitea_env, mock_build_result):
def test_pr_workflow_failed(gitea_env, mock_build_result): def test_pr_workflow_failed(gitea_env, mock_build_result):
"""End-to-end test for a failed PR workflow.""" """End-to-end test for a failed PR workflow."""
diff = "diff --git a/another_test.txt b/another_test.txt\nnew file mode 100644\nindex 0000000..e69de29\n" diff = "diff --git a/another_test.txt b/another_test.txt\nnew file mode 100644\nindex 0000000..e69de29\n"
pr = gitea_env.create_gitea_pr("pool/pkgA", diff, "Test PR - should fail") pr = gitea_env.create_gitea_pr("pool/pkgA", diff, "Test PR - should fail", False)
initial_pr_number = pr["number"] initial_pr_number = pr["number"]
compose_dir = Path(__file__).parent.parent compose_dir = Path(__file__).parent.parent

View File

@@ -0,0 +1,82 @@
import pytest
import re
import time
from pathlib import Path
from tests.lib.common_test_utils import GiteaAPIClient
@pytest.mark.t001
@pytest.mark.xfail(reason="The bot sometimes re-request reviews despite having all the approvals")
def test_001_automerge(automerge_env, test_user_client):
"""
Test scenario:
1. Setup custom workflow.config with mandatory reviewers (+usera, +userb).
2. Create a package PR in 'merge' branch.
3. Make sure the workflow-pr service created related project PR in 'merge' branch.
4. React on 'requested' reviews by approving them.
5. Make sure both PRs are merged automatically by the workflow-pr service.
"""
gitea_env, test_full_repo_name, merge_branch_name = automerge_env
# 1. Create a package PR
diff = """diff --git a/merge_test_fixture.txt b/merge_test_fixture.txt
new file mode 100644
index 0000000..e69de29
"""
print(f"--- Creating package PR in pool/pkgA on branch {merge_branch_name} ---")
package_pr = test_user_client.create_gitea_pr("pool/pkgA", diff, "Test Automerge Fixture", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
print(f"Created package PR pool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = None
print(f"Polling pool/pkgA PR #{package_pr_number} timeline for forwarded PR event...")
for _ in range(40):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("pool/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"products/SLFO/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."
print(f"Found project PR: products/SLFO#{project_pr_number}")
# 4. Make sure both PRs are merged automatically by the workflow-pr service
print("Polling for PR merge status and reacting on REQUEST_REVIEW...")
package_merged = False
project_merged = False
for i in range(15): # Poll for up to 15 seconds
# Package PR
if not package_merged:
pkg_details = gitea_env.get_pr_details("pool/pkgA", package_pr_number)
if pkg_details.get("merged"):
package_merged = True
print(f"Package PR pool/pkgA#{package_pr_number} merged.")
else:
gitea_env.approve_requested_reviews("pool/pkgA", package_pr_number)
# Project PR
if not project_merged:
prj_details = gitea_env.get_pr_details("products/SLFO", project_pr_number)
if prj_details.get("merged"):
project_merged = True
print(f"Project PR products/SLFO#{project_pr_number} merged.")
else:
gitea_env.approve_requested_reviews("products/SLFO", project_pr_number)
if package_merged and project_merged:
break
time.sleep(1)
assert package_merged, f"Package PR pool/pkgA#{package_pr_number} was not merged automatically."
assert project_merged, f"Project PR products/SLFO#{project_pr_number} was not merged automatically."
print("Both PRs merged successfully.")

View File

@@ -0,0 +1,346 @@
import pytest
import re
import time
import base64
from pathlib import Path
from tests.lib.common_test_utils import GiteaAPIClient
@pytest.mark.t004
@pytest.mark.xfail(reason="the bot sometimes re-requests review from autogits_obs_staging_bot despite having the approval")
def test_004_maintainer(maintainer_env, ownerA_client):
"""
Test scenario:
1. workflow.config will not have users with '+' sign.
2. The package PR is opened by the package maintainer (ownerA for pkgA).
3. Do not submit any review approval.
4. Check that both PRs are automatically merged anyway.
"""
gitea_env, test_full_repo_name, branch_name = maintainer_env
# 0. Smoke test ownerA_client
print(f"--- Smoke testing ownerA_client ---")
ownerA_client._request("GET", "users/admin")
print(f"ownerA_client smoke test passed")
# 0.1 Verify all users from config exist
print("--- Verifying all users from config exist ---")
import json
wf_file = gitea_env.get_file_info("products", "SLFO", "workflow.config", branch=branch_name)
wf = json.loads(base64.b64decode(wf_file["content"]).decode("utf-8"))
mt_file = gitea_env.get_file_info("products", "SLFO", "_maintainership.json", branch=branch_name)
mt = json.loads(base64.b64decode(mt_file["content"]).decode("utf-8"))
expected_users = set()
for r in wf.get("Reviewers", []):
username = r.lstrip("+-")
if username and username not in ["autogits_obs_staging_bot", "workflow-pr"]:
expected_users.add(username)
for pkg_users in mt.values():
for username in pkg_users:
expected_users.add(username)
for username in expected_users:
gitea_env._request("GET", f"users/{username}")
print(f"Verified user exists: {username}")
# 1. Create a package PR as ownerA
diff = """diff --git a/maintainer_test_fixture.txt b/maintainer_test_fixture.txt
new file mode 100644
index 0000000..e69de29
"""
print(f"--- Creating package PR in pool/pkgA on branch {branch_name} as ownerA ---")
package_pr = ownerA_client.create_gitea_pr("pool/pkgA", diff, "Test Maintainer Merge", True, base_branch=branch_name)
package_pr_number = package_pr["number"]
print(f"Created package PR pool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = None
print(f"Polling pool/pkgA PR #{package_pr_number} timeline for forwarded PR event...")
for _ in range(40):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("pool/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"products/SLFO/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."
print(f"Found project PR: products/SLFO#{project_pr_number}")
# 3. Make sure both PRs are merged automatically WITHOUT manual approvals
print("Polling for PR merge status (only bot approval allowed)...")
package_merged = False
project_merged = False
for i in range(15): # Poll for up to 15 seconds
# Package PR
if not package_merged:
pkg_details = gitea_env.get_pr_details("pool/pkgA", package_pr_number)
if pkg_details.get("merged"):
package_merged = True
print(f"Package PR pool/pkgA#{package_pr_number} merged.")
else:
# Approve ONLY bot if requested
reviews = gitea_env.list_reviews("pool/pkgA", package_pr_number)
if any(r["state"] == "REQUEST_REVIEW" and r["user"]["login"] == "autogits_obs_staging_bot" for r in reviews):
gitea_env.approve_requested_reviews("pool/pkgA", package_pr_number)
# Project PR
if not project_merged:
prj_details = gitea_env.get_pr_details("products/SLFO", project_pr_number)
if prj_details.get("merged"):
project_merged = True
print(f"Project PR products/SLFO#{project_pr_number} merged.")
else:
# Approve ONLY bot if requested
reviews = gitea_env.list_reviews("products/SLFO", project_pr_number)
if any(r["state"] == "REQUEST_REVIEW" and r["user"]["login"] == "autogits_obs_staging_bot" for r in reviews):
gitea_env.approve_requested_reviews("products/SLFO", project_pr_number)
if package_merged and project_merged:
break
time.sleep(1)
assert package_merged, f"Package PR pool/pkgA#{package_pr_number} was not merged automatically."
assert project_merged, f"Project PR products/SLFO#{project_pr_number} was not merged automatically."
print("Both PRs merged successfully by maintainer rule.")
@pytest.mark.t005
# @pytest.mark.xfail(reason="TBD troubleshoot")
def test_005_any_maintainer_approval_sufficient(maintainer_env, ownerA_client, ownerBB_client):
"""
Test scenario:
1. The package PR for pkgB is opened by ownerA (who is not a maintainer of pkgB).
2. Check that review request comes to both ownerB and ownerBB.
3. ownerB doesn't leave review.
4. check that review from ownerBB was enough to get both PRs merged.
"""
gitea_env, test_full_repo_name, branch_name = maintainer_env
# 1. Create a package PR for pool/pkgB as ownerA
diff = """diff --git a/pkgB_test_fixture.txt b/pkgB_test_fixture.txt
new file mode 100644
index 0000000..e69de29
"""
print(f"--- Creating package PR in pool/pkgB on branch {branch_name} as ownerA ---")
package_pr = ownerA_client.create_gitea_pr("pool/pkgB", diff, "Test Single Maintainer Merge", True, base_branch=branch_name)
package_pr_number = package_pr["number"]
print(f"Created package PR pool/pkgB#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = None
print(f"Polling pool/pkgB PR #{package_pr_number} timeline for forwarded PR event...")
for _ in range(40):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("pool/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"products/SLFO/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."
print(f"Found project PR: products/SLFO#{project_pr_number}")
# 3. Check that review requests came to ownerB and ownerBB
print("Checking for review requests from ownerB and ownerBB...")
reviewers_requested = set()
for _ in range(20):
reviews = gitea_env.list_reviews("pool/pkgB", package_pr_number)
reviewers_requested = {r["user"]["login"] for r in reviews if r["state"] == "REQUEST_REVIEW"}
if "ownerB" in reviewers_requested and "ownerBB" in reviewers_requested:
break
time.sleep(1)
assert "ownerB" in reviewers_requested, f"ownerB was not requested for review. Requested: {reviewers_requested}"
assert "ownerBB" in reviewers_requested, f"ownerBB was not requested for review. Requested: {reviewers_requested}"
print(f"Confirmed: ownerB and ownerBB were requested for review.")
# 4. ownerBB leaves review, ownerB does not.
print("ownerBB approving the PR...")
ownerBB_client.create_review("pool/pkgB", package_pr_number, event="APPROVED", body="Approval from ownerBB")
# 5. Check that both PRs are merged automatically
print("Polling for PR merge status (only bot approval allowed for project PR)...")
package_merged = False
project_merged = False
for i in range(15): # Poll for up to 15 seconds
# Package PR
if not package_merged:
pkg_details = gitea_env.get_pr_details("pool/pkgB", package_pr_number)
if pkg_details.get("merged"):
package_merged = True
print(f"Package PR pool/pkgB#{package_pr_number} merged.")
# Project PR
if not project_merged:
prj_details = gitea_env.get_pr_details("products/SLFO", project_pr_number)
if prj_details.get("merged"):
project_merged = True
print(f"Project PR products/SLFO#{project_pr_number} merged.")
else:
# Approve ONLY bot if requested
reviews = gitea_env.list_reviews("products/SLFO", project_pr_number)
if any(r["state"] == "REQUEST_REVIEW" and r["user"]["login"] == "autogits_obs_staging_bot" for r in reviews):
gitea_env.approve_requested_reviews("products/SLFO", project_pr_number)
if package_merged and project_merged:
break
time.sleep(1)
assert package_merged, f"Package PR pool/pkgB#{package_pr_number} was not merged automatically."
assert project_merged, f"Project PR products/SLFO#{project_pr_number} was not merged automatically."
print("Both PRs merged successfully with only one maintainer approval.")
@pytest.mark.t006
def test_006_maintainer_rejection_removes_other_requests(maintainer_env, ownerA_client, ownerBB_client):
"""
Test scenario:
1. The package PR for pkgB is opened by ownerA (who is not a maintainer of pkgB).
2. Check that review request comes to both ownerB and ownerBB.
3. ownerBB rejects the PR (REQUEST_CHANGES).
4. Check that review request for ownerB is removed.
"""
gitea_env, test_full_repo_name, branch_name = maintainer_env
# 1. Create a package PR for pool/pkgB as ownerA
diff = """diff --git a/pkgB_rejection_test.txt b/pkgB_rejection_test.txt
new file mode 100644
index 0000000..e69de29
"""
print(f"--- Creating package PR in pool/pkgB on branch {branch_name} as ownerA ---")
package_pr = ownerA_client.create_gitea_pr("pool/pkgB", diff, "Test Maintainer Rejection", True, base_branch=branch_name)
package_pr_number = package_pr["number"]
print(f"Created package PR pool/pkgB#{package_pr_number}")
# 2. Check that review requests came to ownerB and ownerBB
print("Checking for review requests from ownerB and ownerBB...")
for _ in range(20):
reviews = gitea_env.list_reviews("pool/pkgB", package_pr_number)
reviewers_requested = {r["user"]["login"] for r in reviews if r["state"] == "REQUEST_REVIEW"}
if "ownerB" in reviewers_requested and "ownerBB" in reviewers_requested:
break
time.sleep(1)
else:
reviews = gitea_env.list_reviews("pool/pkgB", package_pr_number)
reviewers_requested = {r["user"]["login"] for r in reviews if r["state"] == "REQUEST_REVIEW"}
pytest.fail(f"ownerB and ownerBB were not both requested. Got: {reviewers_requested}")
# 3. ownerBB rejects the PR
print("ownerBB rejecting the PR...")
ownerBB_client.create_review("pool/pkgB", package_pr_number, event="REQUEST_CHANGES", body="Rejecting from ownerBB")
# 4. Check that review request for ownerB is removed
print("Checking if ownerB's review request is removed...")
for _ in range(20):
reviews = gitea_env.list_reviews("pool/pkgB", package_pr_number)
reviewers_requested = {r["user"]["login"] for r in reviews if r["state"] == "REQUEST_REVIEW"}
if "ownerB" not in reviewers_requested:
print("Confirmed: ownerB's review request was removed.")
break
time.sleep(1)
else:
pytest.fail("ownerB's review request was not removed after ownerBB rejection.")
@pytest.mark.t007
@pytest.mark.xfail(reason="TBD troubleshoot")
def test_007_review_required_needs_all_approvals(review_required_env, ownerA_client, ownerBB_client):
"""
Test scenario:
1. it uses new fixture with "ReviewRequired = true" in the workflow.config.
2. Package PR for pkgB opened by ownerA.
3. Check review request comes to both ownerB and ownerBB.
4. ownerBB approves.
5. make sure that review is not merged automatically and the request for ownerB is not removed.
"""
gitea_env, test_full_repo_name, branch_name = review_required_env
# 0. Smoke test ownerA_client
print(f"--- Smoke testing ownerA_client ---")
ownerA_client._request("GET", "users/admin")
print(f"ownerA_client smoke test passed")
# 1. Create a package PR for pool/pkgB as ownerA
diff = """diff --git a/pkgB_review_required_test.txt b/pkgB_review_required_test.txt
new file mode 100644
index 0000000..e69de29
"""
print(f"--- Creating package PR in pool/pkgB on branch {branch_name} as ownerA ---")
package_pr = ownerA_client.create_gitea_pr("pool/pkgB", diff, "Test Review Required", True, base_branch=branch_name)
package_pr_number = package_pr["number"]
print(f"Created package PR pool/pkgB#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = None
print(f"Polling pool/pkgB PR #{package_pr_number} timeline for forwarded PR event...")
for _ in range(40):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("pool/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"products/SLFO/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."
print(f"Found project PR: products/SLFO#{project_pr_number}")
# 3. Check that review requests came to ownerB and ownerBB
print("Checking for review requests from ownerB and ownerBB...")
for _ in range(20):
reviews = gitea_env.list_reviews("pool/pkgB", package_pr_number)
reviewers_requested = {r["user"]["login"] for r in reviews if r["state"] == "REQUEST_REVIEW"}
if "ownerB" in reviewers_requested and "ownerBB" in reviewers_requested:
break
time.sleep(1)
else:
reviews = gitea_env.list_reviews("pool/pkgB", package_pr_number)
reviewers_requested = {r["user"]["login"] for r in reviews if r["state"] == "REQUEST_REVIEW"}
pytest.fail(f"ownerB and ownerBB were not both requested. Got: {reviewers_requested}")
# 4. ownerBB leaves review, ownerB does not.
print("ownerBB approving the PR...")
ownerBB_client.create_review("pool/pkgB", package_pr_number, event="APPROVED", body="Approval from ownerBB")
# 5. Check that the PR is NOT merged automatically and ownerB request remains
print("Waiting to ensure PR is NOT merged and ownerB request remains...")
for i in range(10):
pkg_details = gitea_env.get_pr_details("pool/pkgB", package_pr_number)
reviews = gitea_env.list_reviews("pool/pkgB", package_pr_number)
review_states = [(r["user"]["login"], r["state"]) for r in reviews]
print(f"Attempt {i+1}: Merged={pkg_details.get('merged')}, Reviews={review_states}")
time.sleep(2)
pkg_details = gitea_env.get_pr_details("pool/pkgB", package_pr_number)
assert not pkg_details.get("merged"), "Package PR was merged automatically but it should NOT have been (ReviewRequired=true)."
reviews = gitea_env.list_reviews("pool/pkgB", package_pr_number)
reviewers_requested = {r["user"]["login"] for r in reviews if r["state"] == "REQUEST_REVIEW"}
assert "ownerB" in reviewers_requested, f"ownerB's review request was removed, but it should have remained. All reviews: {[(r['user']['login'], r['state']) for r in reviews]}"
print("Confirmed: PR not merged and ownerB review request remains as expected.")

View File

@@ -18,11 +18,12 @@ pytest.initial_pr_number = None
pytest.forwarded_pr_number = None pytest.forwarded_pr_number = None
@pytest.mark.t001
@pytest.mark.dependency() @pytest.mark.dependency()
def test_001_project_pr(gitea_env): def test_001_project_pr(gitea_env):
"""Forwarded PR correct title""" """Forwarded PR correct title"""
diff = "diff --git a/another_test.txt b/another_test.txt\nnew file mode 100644\nindex 0000000..e69de29\n" diff = "diff --git a/another_test.txt b/another_test.txt\nnew file mode 100644\nindex 0000000..e69de29\n"
pytest.pr = gitea_env.create_gitea_pr("pool/pkgA", diff, "Test PR") pytest.pr = gitea_env.create_gitea_pr("pool/pkgA", diff, "Test PR", False)
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
@@ -56,6 +57,7 @@ def test_001_project_pr(gitea_env):
), "Forwarded PR correct title" ), "Forwarded PR correct title"
@pytest.mark.t002
@pytest.mark.dependency(depends=["test_001_project_pr"]) @pytest.mark.dependency(depends=["test_001_project_pr"])
def test_002_updated_project_pr(gitea_env): def test_002_updated_project_pr(gitea_env):
"""Forwarded PR head is updated""" """Forwarded PR head is updated"""
@@ -76,6 +78,7 @@ def test_002_updated_project_pr(gitea_env):
assert sha_changed, "Forwarded PR has sha updated" assert sha_changed, "Forwarded PR has sha updated"
@pytest.mark.t003
@pytest.mark.dependency(depends=["test_001_project_pr"]) @pytest.mark.dependency(depends=["test_001_project_pr"])
def test_003_wip(gitea_env): def test_003_wip(gitea_env):
"""WIP flag set for PR""" """WIP flag set for PR"""
@@ -115,3 +118,209 @@ def test_003_wip(gitea_env):
wip_flag_removed = True wip_flag_removed = True
break break
assert wip_flag_removed, "WIP flag was not removed from the forwarded PR." assert wip_flag_removed, "WIP flag was not removed from the forwarded PR."
@pytest.mark.t005
@pytest.mark.xfail(reason="works only in ibs_state branch?")
@pytest.mark.dependency()
def test_005_NoProjectGitPR_edits_disabled(no_project_git_pr_env, test_user_client):
"""
Reworked test: Sets workflow.config with NoProjectGitPR: true and creates a Package PR.
Verifies that no Project PR is created, then manually creates one and checks for bot warning.
"""
gitea_env, test_full_repo_name, dev_branch_name = no_project_git_pr_env
# 1. Create a Package PR (without "Allow edits from maintainers" enabled)
initial_diff = """diff --git a/first_file.txt b/first_file.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/first_file.txt
@@ -0,0 +1 @@
+Initial content
"""
package_pr = test_user_client.create_gitea_pr("pool/pkgA", initial_diff, "Test PR for No Project PR, No Edits", False, base_branch=dev_branch_name)
package_pr_number = package_pr["number"]
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("pool/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"products/SLFO/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 products/SLFO."
print("Verification complete: No Project PR was created by the bot.")
# 3. Manually create the Project PR
pkgA_main_sha = gitea_env._request("GET", f"repos/pool/pkgA/branches/{dev_branch_name}").json()["commit"]["id"]
package_pr_details = gitea_env.get_pr_details("pool/pkgA", package_pr_number)
pkgA_pr_head_sha = package_pr_details["head"]["sha"]
project_pr_title = "Forwarded PRs: pkgA (Manual)"
project_pr_body = f"Manual Project PR for NoProjectGitPR. \nPR: pool/pkgA!{package_pr_number}"
project_pr_diff = f"""diff --git a/pkgA b/pkgA
index {pkgA_main_sha[:7]}..{pkgA_pr_head_sha[:7]} 160000
--- a/pkgA
+++ b/pkgA
@@ -1 +1 @@
-Subproject commit {pkgA_main_sha}
+Subproject commit {pkgA_pr_head_sha}
"""
manual_project_pr = test_user_client.create_gitea_pr(test_full_repo_name, project_pr_diff, project_pr_title, True, base_branch=dev_branch_name, body=project_pr_body)
manual_project_pr_number = manual_project_pr["number"]
# Verify and set allow_maintainer_edit to False
test_user_client.update_gitea_pr_properties(test_full_repo_name, manual_project_pr_number, allow_maintainer_edit=False)
# Verify that allow_maintainer_edit is now disabled
updated_pr = gitea_env.get_pr_details(test_full_repo_name, manual_project_pr_number)
assert updated_pr.get("allow_maintainer_edit") is False, "Expected allow_maintainer_edit to be False after update"
print(f"Manually created Project PR #{manual_project_pr_number} in {test_full_repo_name}")
# 4. Trigger an update on the Package PR to prompt the bot to react to the manual Project PR
new_diff_content = """diff --git a/trigger_bot.txt b/trigger_bot.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/trigger_bot.txt
@@ -0,0 +1 @@
+Trigger content
"""
test_user_client.modify_gitea_pr("pool/pkgA", package_pr_number, new_diff_content, "Trigger bot update")
# 5. Verify that the bot adds a warning comment because it cannot update the manual PR (edits disabled)
warning_found = False
print(f"Polling Package PR #{package_pr_number} for warning comment...")
for _ in range(20):
time.sleep(3)
comments = gitea_env.get_comments("pool/pkgA", package_pr_number)
for comment in comments:
# According to test-plan.md, the warning explains that it cannot update the PR.
if "cannot update" in comment.get("body", "").lower():
warning_found = True
print(f"Warning comment found: {comment.get('body')}")
break
if warning_found:
break
# assert warning_found, "Bot did not post the expected warning comment on the Package PR."
# print("Verification complete: Bot posted a warning comment as expected.")
@pytest.mark.t006
@pytest.mark.xfail(reason="works only in ibs_state branch?")
@pytest.mark.dependency()
def test_006_NoProjectGitPR_edits_enabled(no_project_git_pr_env, test_user_client):
"""
Verify that no project PR is created when "NoProjectGitPR" is true
and "Allow edits from maintainers" is enabled, using a dev branch.
"""
gitea_env, test_full_repo_name, dev_branch_name = no_project_git_pr_env
# 2. Create a Package PR with "Allow edits from maintainers" enabled
diff = """diff --git a/new_feature.txt b/new_feature.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/new_feature.txt
@@ -0,0 +1 @@
+New feature content
"""
package_pr = test_user_client.create_gitea_pr("pool/pkgA", diff, "Test PR for NoProjectGitPR", False, base_branch=dev_branch_name)
package_pr_number = package_pr["number"]
# Enable "Allow edits from maintainers"
test_user_client.update_gitea_pr_properties("pool/pkgA", package_pr_number, allow_maintainer_edit=True)
print(f"Created Package PR #{package_pr_number} and enabled 'Allow edits from maintainers'.")
# Get SHAs needed for the manual Project PR diff
pkgA_main_sha = gitea_env._request("GET", f"repos/pool/pkgA/branches/{dev_branch_name}").json()["commit"]["id"]
package_pr_details = gitea_env.get_pr_details("pool/pkgA", package_pr_number)
pkgA_pr_head_sha = package_pr_details["head"]["sha"]
# 3. Assert that the workflow-pr bot did not create a Project PR in the products/SLFO 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("pool/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 products/SLFO/pulls/(\d+)
match = re.search(r"products/SLFO/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 products/SLFO."
print("Verification complete: No Project PR was created in products/SLFO as expected.")
# 1. Create that Project PR from the test code.
project_pr_title = "Forwarded PRs: pkgA"
project_pr_body = f"Test Project PR for NoProjectGitPR. \nPR: pool/pkgA!{package_pr_number}"
project_pr_diff = f"""diff --git a/pkgA b/pkgA
index {pkgA_main_sha[:7]}..{pkgA_pr_head_sha[:7]} 160000
--- a/pkgA
+++ b/pkgA
@@ -1 +1 @@
-Subproject commit {pkgA_main_sha}
+Subproject commit {pkgA_pr_head_sha}
"""
manual_project_pr = test_user_client.create_gitea_pr(test_full_repo_name, project_pr_diff, project_pr_title, True, base_branch=dev_branch_name, body=project_pr_body)
manual_project_pr_number = manual_project_pr["number"]
# Explicitly ensure allow_maintainer_edit is True (it should be by default now, but just in case)
test_user_client.update_gitea_pr_properties(test_full_repo_name, manual_project_pr_number, allow_maintainer_edit=True)
print(f"Manually created Project PR #{manual_project_pr_number} in {test_full_repo_name}")
time.sleep(5) # Give the bot time to potentially react or for the PR to settle
# Get initial SHA of the manually created Project PR
initial_project_pr_details = gitea_env.get_pr_details(test_full_repo_name, manual_project_pr_number)
initial_head_sha = initial_project_pr_details["head"]["sha"]
print(f"Manually created Project PR initial head SHA: {initial_head_sha}")
# 2. Add new commit to the package PR.
new_diff_content = """diff --git a/another_file.txt b/another_file.txt
new file mode 100644
index 0000000..f587a12
--- /dev/null
+++ b/another_file.txt
@@ -0,0 +1 @@
+Another file content
"""
test_user_client.modify_gitea_pr("pool/pkgA", package_pr_number, new_diff_content, "Add another file to Package PR")
print(f"Added new commit to Package PR #{package_pr_number}.")
time.sleep(5) # Give the bot time to react
# 3. Make sure the project PR is properly updated by the bot
project_pr_updated = False
print(f"Polling manually created Project PR #{manual_project_pr_number} for update...")
for _ in range(20): # Poll for a reasonable time
time.sleep(2) # Wait a bit longer to be sure
current_project_pr_details = gitea_env.get_pr_details(test_full_repo_name, manual_project_pr_number)
current_head_sha = current_project_pr_details["head"]["sha"]
if current_head_sha != initial_head_sha:
project_pr_updated = True
print(f"Manually created Project PR updated. New head SHA: {current_head_sha}")
break
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,7 +6,7 @@ COPY integration/rabbitmq-config/certs/cert.pem /usr/share/pki/trust/anchors/git
RUN update-ca-certificates RUN update-ca-certificates
# Install git and ssh # Install git and ssh
RUN zypper -n in git-core openssh-clients binutils RUN zypper -n in git-core openssh-clients binutils git-lfs
# Copy the pre-built binary into the container # Copy the pre-built binary into the container
COPY workflow-pr/workflow-pr /usr/local/bin/workflow-pr COPY workflow-pr/workflow-pr /usr/local/bin/workflow-pr

View File

@@ -2,14 +2,14 @@
FROM registry.suse.com/bci/bci-base:15.7 FROM registry.suse.com/bci/bci-base:15.7
# Add the custom CA to the trust store # Add the custom CA to the trust store
COPY rabbitmq-config/certs/cert.pem /usr/share/pki/trust/anchors/gitea-rabbitmq-ca.crt COPY integration/rabbitmq-config/certs/cert.pem /usr/share/pki/trust/anchors/gitea-rabbitmq-ca.crt
RUN update-ca-certificates RUN update-ca-certificates
RUN zypper ar -f http://download.opensuse.org/repositories/devel:/Factory:/git-workflow/15.7/devel:Factory:git-workflow.repo RUN zypper ar -f http://download.opensuse.org/repositories/devel:/Factory:/git-workflow/15.7/devel:Factory:git-workflow.repo
RUN zypper --gpg-auto-import-keys ref RUN zypper --gpg-auto-import-keys ref
# Install git and ssh # Install git and ssh
RUN zypper -n in git-core openssh-clients autogits-workflow-pr binutils RUN zypper -n in git-core openssh-clients autogits-workflow-pr binutils git-lfs
COPY integration/workflow-pr/entrypoint.sh /usr/local/bin/entrypoint.sh COPY integration/workflow-pr/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +4755 /usr/local/bin/entrypoint.sh RUN chmod +4755 /usr/local/bin/entrypoint.sh

View File

@@ -1,3 +1,7 @@
[ [
"products/SLFO#main" "products/SLFO#main",
"products/SLFO#dev",
"products/SLFO#merge",
"products/SLFO#maintainer-merge",
"products/SLFO#review-required"
] ]

View File

@@ -0,0 +1,4 @@
[Unit]
Description=Autogits Workflow Direct instances
Documentation=https://src.opensuse.org/git-workflow/autogits

View File

@@ -20,4 +20,5 @@ PrivateTmp=yes
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
WantedBy=workflow-direct.target

View File

@@ -0,0 +1,5 @@
[Unit]
Description=Autogits Workflow PR instances
Documentation=https://src.opensuse.org/git-workflow/autogits

View File

@@ -20,4 +20,5 @@ PrivateTmp=yes
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
WantedBy=workflow-pr.target

View File

@@ -94,19 +94,6 @@ 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

@@ -406,12 +406,6 @@ 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 {