Compare commits

..

1 Commits

Author SHA256 Message Date
a7784977f9 wip 2026-03-01 22:50:00 +01:00
47 changed files with 1551 additions and 2025 deletions

View File

@@ -40,12 +40,10 @@ jobs:
run: make down
working-directory: ./autogits/integration
- name: Start images
run: |
make up
make wait_healthy
run: make up
working-directory: ./autogits/integration
- name: Run tests
run: make pytest
run: py.test-3.11 -v tests
working-directory: ./autogits/integration
- name: Make sure the pod is down
if: always()

View File

@@ -23,6 +23,7 @@ import (
"errors"
"fmt"
"io"
"log"
"os"
"slices"
"strings"
@@ -38,10 +39,6 @@ const (
Permission_ForceMerge = "force-merge"
Permission_Group = "release-engineering"
MergeModeFF = "ff-only"
MergeModeReplace = "replace"
MergeModeDevel = "devel"
)
type ConfigFile struct {
@@ -55,9 +52,9 @@ type ReviewGroup struct {
}
type QAConfig struct {
Name string
Origin string
Label string // requires this gitea lable to be set or skipped
Name string
Origin string
Label string // requires this gitea lable to be set or skipped
BuildDisableRepos []string // which repos to build disable in the new project
}
@@ -92,8 +89,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
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
MergeMode string // project merge mode
Labels map[string]string // list of tags, if not default, to apply
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
@@ -188,37 +184,21 @@ func ReadWorkflowConfig(gitea GiteaFileContentAndRepoFetcher, git_project string
}
}
config.GitProjectName = config.GitProjectName + "#" + branch
// verify merge modes
switch config.MergeMode {
case MergeModeFF, MergeModeDevel, MergeModeReplace:
break // good results
case "":
config.MergeMode = MergeModeFF
default:
return nil, fmt.Errorf("Unsupported merge mode in %s: %s", git_project, config.MergeMode)
}
return config, nil
}
func ResolveWorkflowConfigs(gitea GiteaFileContentAndRepoFetcher, config *ConfigFile) (AutogitConfigs, error) {
configs := make([]*AutogitConfig, 0, len(config.GitProjectNames))
var errs []error
for _, git_project := range config.GitProjectNames {
c, err := ReadWorkflowConfig(gitea, git_project)
if err != nil {
// can't sync, so ignore for now
errs = append(errs, err)
log.Println(err)
} else {
configs = append(configs, c)
}
}
if len(errs) > 0 {
return configs, errors.Join(errs...)
}
return configs, nil
}

View File

@@ -4,7 +4,6 @@ import (
"slices"
"testing"
"go.uber.org/mock/gomock"
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
mock_common "src.opensuse.org/autogits/common/mock"
@@ -342,67 +341,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

@@ -396,17 +396,12 @@ func (e *GitHandlerImpl) GitExecQuietOrPanic(cwd string, params ...string) {
}
type ChanIO struct {
ch chan byte
done chan struct{}
ch chan byte
}
func (c *ChanIO) Write(p []byte) (int, error) {
for _, b := range p {
select {
case c.ch <- b:
case <-c.done:
return 0, io.EOF
}
c.ch <- b
}
return len(p), nil
}
@@ -415,32 +410,21 @@ func (c *ChanIO) Write(p []byte) (int, error) {
func (c *ChanIO) Read(data []byte) (idx int, err error) {
var ok bool
select {
case data[idx], ok = <-c.ch:
data[idx], ok = <-c.ch
if !ok {
err = io.EOF
return
}
idx++
for len(c.ch) > 0 && idx < len(data) {
data[idx], ok = <-c.ch
if !ok {
err = io.EOF
return
}
idx++
case <-c.done:
err = io.EOF
return
}
for len(c.ch) > 0 && idx < len(data) {
select {
case data[idx], ok = <-c.ch:
if !ok {
err = io.EOF
return
}
idx++
case <-c.done:
err = io.EOF
return
default:
return
}
idx++
}
return
@@ -487,14 +471,7 @@ func parseGitMsg(data <-chan byte) (GitMsg, error) {
var size int
pos := 0
for {
c, ok := <-data
if !ok {
return GitMsg{}, io.EOF
}
if c == ' ' {
break
}
for c := <-data; c != ' '; c = <-data {
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
id[pos] = c
pos++
@@ -506,15 +483,7 @@ func parseGitMsg(data <-chan byte) (GitMsg, error) {
pos = 0
var c byte
for {
var ok bool
c, ok = <-data
if !ok {
return GitMsg{}, io.EOF
}
if c == ' ' || c == '\x00' {
break
}
for c = <-data; c != ' ' && c != '\x00'; c = <-data {
if c >= 'a' && c <= 'z' {
msgType[pos] = c
pos++
@@ -540,14 +509,7 @@ func parseGitMsg(data <-chan byte) (GitMsg, error) {
return GitMsg{}, fmt.Errorf("Invalid object type: '%s'", string(msgType))
}
for {
c, ok := <-data
if !ok {
return GitMsg{}, io.EOF
}
if c == '\x00' {
break
}
for c = <-data; c != '\000'; c = <-data {
if c >= '0' && c <= '9' {
size = size*10 + (int(c) - '0')
} else {
@@ -566,37 +528,18 @@ func parseGitCommitHdr(oldHdr [2]string, data <-chan byte) ([2]string, int, erro
hdr := make([]byte, 0, 60)
val := make([]byte, 0, 1000)
c, ok := <-data
if !ok {
return [2]string{}, 0, io.EOF
}
c := <-data
size := 1
if c != '\n' { // end of header marker
for {
if c == ' ' {
break
}
for ; c != ' '; c = <-data {
hdr = append(hdr, c)
size++
var ok bool
c, ok = <-data
if !ok {
return [2]string{}, size, io.EOF
}
}
if size == 1 { // continuation header here
hdr = []byte(oldHdr[0])
val = append([]byte(oldHdr[1]), '\n')
}
for {
var ok bool
c, ok = <-data
if !ok {
return [2]string{}, size, io.EOF
}
if c == '\n' {
break
}
for c := <-data; c != '\n'; c = <-data {
val = append(val, c)
size++
}
@@ -609,14 +552,7 @@ func parseGitCommitHdr(oldHdr [2]string, data <-chan byte) ([2]string, int, erro
func parseGitCommitMsg(data <-chan byte, l int) (string, error) {
msg := make([]byte, 0, l)
for {
c, ok := <-data
if !ok {
return string(msg), io.EOF
}
if c == '\x00' {
break
}
for c := <-data; c != '\x00'; c = <-data {
msg = append(msg, c)
l--
}
@@ -642,7 +578,7 @@ func parseGitCommit(data <-chan byte) (GitCommit, error) {
var hdr [2]string
hdr, size, err := parseGitCommitHdr(hdr, data)
if err != nil {
return GitCommit{}, err
return GitCommit{}, nil
}
l -= size
@@ -663,28 +599,14 @@ func parseGitCommit(data <-chan byte) (GitCommit, error) {
func parseTreeEntry(data <-chan byte, hashLen int) (GitTreeEntry, error) {
var e GitTreeEntry
for {
c, ok := <-data
if !ok {
return e, io.EOF
}
if c == ' ' {
break
}
for c := <-data; c != ' '; c = <-data {
e.mode = e.mode*8 + int(c-'0')
e.size++
}
e.size++
name := make([]byte, 0, 128)
for {
c, ok := <-data
if !ok {
return e, io.EOF
}
if c == '\x00' {
break
}
for c := <-data; c != '\x00'; c = <-data {
name = append(name, c)
e.size++
}
@@ -695,10 +617,7 @@ func parseTreeEntry(data <-chan byte, hashLen int) (GitTreeEntry, error) {
hash := make([]byte, 0, hashLen*2)
for range hashLen {
c, ok := <-data
if !ok {
return e, io.EOF
}
c := <-data
hash = append(hash, hexBinToAscii[((c&0xF0)>>4)], hexBinToAscii[c&0xF])
}
e.hash = string(hash)
@@ -719,16 +638,13 @@ func parseGitTree(data <-chan byte) (GitTree, error) {
for parsedLen < hdr.size {
entry, err := parseTreeEntry(data, len(hdr.hash)/2)
if err != nil {
return GitTree{}, err
return GitTree{}, nil
}
t.items = append(t.items, entry)
parsedLen += entry.size
}
c, ok := <-data // \0 read
if !ok {
return t, io.EOF
}
c := <-data // \0 read
if c != '\x00' {
return t, fmt.Errorf("Unexpected character during git tree data read")
@@ -749,16 +665,9 @@ func parseGitBlob(data <-chan byte) ([]byte, error) {
d := make([]byte, hdr.size)
for l := 0; l < hdr.size; l++ {
var ok bool
d[l], ok = <-data
if !ok {
return d, io.EOF
}
}
eob, ok := <-data
if !ok {
return d, io.EOF
d[l] = <-data
}
eob := <-data
if eob != '\x00' {
return d, fmt.Errorf("invalid byte read in parseGitBlob")
}
@@ -770,25 +679,16 @@ func (e *GitHandlerImpl) GitParseCommits(cwd string, commitIDs []string) (parsed
var done sync.Mutex
done.Lock()
done_signal := make(chan struct{})
var once sync.Once
close_done := func() {
once.Do(func() {
close(done_signal)
})
}
data_in, data_out := ChanIO{make(chan byte), done_signal}, ChanIO{make(chan byte), done_signal}
data_in, data_out := ChanIO{make(chan byte)}, ChanIO{make(chan byte)}
parsedCommits = make([]GitCommit, 0, len(commitIDs))
go func() {
defer done.Unlock()
defer close_done()
defer close(data_out.ch)
for _, id := range commitIDs {
data_out.Write([]byte(id))
data_out.Write([]byte{0})
data_out.ch <- '\x00'
c, e := parseGitCommit(data_in.ch)
if e != nil {
err = fmt.Errorf("Error parsing git commit: %w", e)
@@ -815,14 +715,12 @@ func (e *GitHandlerImpl) GitParseCommits(cwd string, commitIDs []string) (parsed
LogDebug("command run:", cmd.Args)
if e := cmd.Run(); e != nil {
LogError(e)
close_done()
close(data_in.ch)
close(data_out.ch)
return nil, e
}
done.Lock()
close_done()
close(data_in.ch)
return
}
@@ -831,21 +729,15 @@ func (e *GitHandlerImpl) GitCatFile(cwd, commitId, filename string) (data []byte
var done sync.Mutex
done.Lock()
done_signal := make(chan struct{})
var once sync.Once
close_done := func() {
once.Do(func() {
close(done_signal)
})
}
data_in, data_out := ChanIO{make(chan byte), done_signal}, ChanIO{make(chan byte), done_signal}
data_in, data_out := ChanIO{make(chan byte)}, ChanIO{make(chan byte)}
go func() {
defer done.Unlock()
defer close_done()
defer close(data_out.ch)
data_out.Write([]byte(commitId))
data_out.Write([]byte{0})
data_out.ch <- '\x00'
var c GitCommit
c, err = parseGitCommit(data_in.ch)
if err != nil {
@@ -853,9 +745,11 @@ func (e *GitHandlerImpl) GitCatFile(cwd, commitId, filename string) (data []byte
return
}
data_out.Write([]byte(c.Tree))
data_out.Write([]byte{0})
data_out.ch <- '\x00'
var tree GitTree
tree, err = parseGitTree(data_in.ch)
if err != nil {
LogError("Error parsing git tree:", err)
return
@@ -865,7 +759,7 @@ func (e *GitHandlerImpl) GitCatFile(cwd, commitId, filename string) (data []byte
if te.isBlob() && te.name == filename {
LogInfo("blob", te.hash)
data_out.Write([]byte(te.hash))
data_out.Write([]byte{0})
data_out.ch <- '\x00'
data, err = parseGitBlob(data_in.ch)
return
}
@@ -890,13 +784,11 @@ func (e *GitHandlerImpl) GitCatFile(cwd, commitId, filename string) (data []byte
LogDebug("command run:", cmd.Args)
if e := cmd.Run(); e != nil {
LogError(e)
close_done()
close(data_in.ch)
close(data_out.ch)
return nil, e
}
done.Lock()
close_done()
close(data_in.ch)
return
}
@@ -906,24 +798,16 @@ func (e *GitHandlerImpl) GitDirectoryList(gitPath, commitId string) (directoryLi
directoryList = make(map[string]string)
done.Lock()
done_signal := make(chan struct{})
var once sync.Once
close_done := func() {
once.Do(func() {
close(done_signal)
})
}
data_in, data_out := ChanIO{make(chan byte), done_signal}, ChanIO{make(chan byte), done_signal}
data_in, data_out := ChanIO{make(chan byte)}, ChanIO{make(chan byte)}
LogDebug("Getting directory for:", commitId)
go func() {
defer done.Unlock()
defer close_done()
defer close(data_out.ch)
data_out.Write([]byte(commitId))
data_out.Write([]byte{0})
data_out.ch <- '\x00'
var c GitCommit
c, err = parseGitCommit(data_in.ch)
if err != nil {
@@ -939,7 +823,7 @@ func (e *GitHandlerImpl) GitDirectoryList(gitPath, commitId string) (directoryLi
delete(trees, p)
data_out.Write([]byte(tree))
data_out.Write([]byte{0})
data_out.ch <- '\x00'
var tree GitTree
tree, err = parseGitTree(data_in.ch)
@@ -973,14 +857,12 @@ func (e *GitHandlerImpl) GitDirectoryList(gitPath, commitId string) (directoryLi
LogDebug("command run:", cmd.Args)
if e := cmd.Run(); e != nil {
LogError(e)
close_done()
close(data_in.ch)
close(data_out.ch)
return directoryList, e
}
done.Lock()
close_done()
close(data_in.ch)
return directoryList, err
}
@@ -990,14 +872,7 @@ func (e *GitHandlerImpl) GitDirectoryContentList(gitPath, commitId string) (dire
directoryList = make(map[string]string)
done.Lock()
done_signal := make(chan struct{})
var once sync.Once
close_done := func() {
once.Do(func() {
close(done_signal)
})
}
data_in, data_out := ChanIO{make(chan byte), done_signal}, ChanIO{make(chan byte), done_signal}
data_in, data_out := ChanIO{make(chan byte)}, ChanIO{make(chan byte)}
LogDebug("Getting directory content for:", commitId)
@@ -1006,7 +881,7 @@ func (e *GitHandlerImpl) GitDirectoryContentList(gitPath, commitId string) (dire
defer close(data_out.ch)
data_out.Write([]byte(commitId))
data_out.Write([]byte{0})
data_out.ch <- '\x00'
var c GitCommit
c, err = parseGitCommit(data_in.ch)
if err != nil {
@@ -1022,7 +897,7 @@ func (e *GitHandlerImpl) GitDirectoryContentList(gitPath, commitId string) (dire
delete(trees, p)
data_out.Write([]byte(tree))
data_out.Write([]byte{0})
data_out.ch <- '\x00'
var tree GitTree
tree, err = parseGitTree(data_in.ch)
@@ -1058,14 +933,12 @@ func (e *GitHandlerImpl) GitDirectoryContentList(gitPath, commitId string) (dire
LogDebug("command run:", cmd.Args)
if e := cmd.Run(); e != nil {
LogError(e)
close_done()
close(data_in.ch)
close(data_out.ch)
return directoryList, e
}
done.Lock()
close_done()
close(data_in.ch)
return directoryList, err
}
@@ -1075,24 +948,16 @@ func (e *GitHandlerImpl) GitSubmoduleList(gitPath, commitId string) (submoduleLi
submoduleList = make(map[string]string)
done.Lock()
done_signal := make(chan struct{})
var once sync.Once
close_done := func() {
once.Do(func() {
close(done_signal)
})
}
data_in, data_out := ChanIO{make(chan byte), done_signal}, ChanIO{make(chan byte), done_signal}
data_in, data_out := ChanIO{make(chan byte)}, ChanIO{make(chan byte)}
LogDebug("Getting submodules for:", commitId)
go func() {
defer done.Unlock()
defer close_done()
defer close(data_out.ch)
data_out.Write([]byte(commitId))
data_out.Write([]byte{0})
data_out.ch <- '\x00'
var c GitCommit
c, err = parseGitCommit(data_in.ch)
if err != nil {
@@ -1108,7 +973,7 @@ func (e *GitHandlerImpl) GitSubmoduleList(gitPath, commitId string) (submoduleLi
delete(trees, p)
data_out.Write([]byte(tree))
data_out.Write([]byte{0})
data_out.ch <- '\x00'
var tree GitTree
tree, err = parseGitTree(data_in.ch)
@@ -1145,26 +1010,17 @@ func (e *GitHandlerImpl) GitSubmoduleList(gitPath, commitId string) (submoduleLi
LogDebug("command run:", cmd.Args)
if e := cmd.Run(); e != nil {
LogError(e)
close_done()
close(data_in.ch)
close(data_out.ch)
return submoduleList, e
}
done.Lock()
close_done()
close(data_in.ch)
return submoduleList, err
}
func (e *GitHandlerImpl) GitSubmoduleCommitId(cwd, packageName, commitId string) (subCommitId string, valid bool) {
done_signal := make(chan struct{})
var once sync.Once
close_done := func() {
once.Do(func() {
close(done_signal)
})
}
data_in, data_out := ChanIO{make(chan byte), done_signal}, ChanIO{make(chan byte), done_signal}
data_in, data_out := ChanIO{make(chan byte)}, ChanIO{make(chan byte)}
var wg sync.WaitGroup
wg.Add(1)
@@ -1180,18 +1036,17 @@ func (e *GitHandlerImpl) GitSubmoduleCommitId(cwd, packageName, commitId string)
}()
defer wg.Done()
defer close_done()
defer close(data_out.ch)
data_out.Write([]byte(commitId))
data_out.Write([]byte{0})
data_out.ch <- '\x00'
c, err := parseGitCommit(data_in.ch)
if err != nil {
LogError("Error parsing git commit:", err)
panic(err)
}
data_out.Write([]byte(c.Tree))
data_out.Write([]byte{0})
data_out.ch <- '\x00'
tree, err := parseGitTree(data_in.ch)
if err != nil {
@@ -1223,14 +1078,12 @@ func (e *GitHandlerImpl) GitSubmoduleCommitId(cwd, packageName, commitId string)
LogDebug("command run:", cmd.Args)
if e := cmd.Run(); e != nil {
LogError(e)
close_done()
close(data_in.ch)
close(data_out.ch)
return subCommitId, false
}
wg.Wait()
close_done()
close(data_in.ch)
return subCommitId, len(subCommitId) > 0
}

View File

@@ -28,7 +28,6 @@ import (
"slices"
"strings"
"testing"
"time"
)
func TestGitClone(t *testing.T) {
@@ -718,44 +717,3 @@ func TestGitDirectoryListRepro(t *testing.T) {
t.Errorf("Expected 'subdir' in directory list, got %v", dirs)
}
}
func TestGitDeadlockFix(t *testing.T) {
gitDir := t.TempDir()
testDir, _ := os.Getwd()
cmd := exec.Command("/usr/bin/bash", path.Join(testDir, "tsetup.sh"))
cmd.Dir = gitDir
_, err := cmd.CombinedOutput()
gh, err := AllocateGitWorkTree(gitDir, "Test", "test@example.com")
if err != nil {
t.Fatal(err)
}
h, err := gh.ReadExistingPath(".")
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Use a blob ID to trigger error in GitParseCommits
// This ensures that the function returns error immediately and doesn't deadlock
blobId := "81aba862107f1e2f5312e165453955485f424612f313d6c2fb1b31fef9f82a14"
done := make(chan error)
go func() {
_, err := h.GitParseCommits("", []string{blobId})
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Error("Expected error from GitParseCommits with blob ID, got nil")
} else {
// This is expected
t.Logf("Got expected error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("GitParseCommits deadlocked! Fix is NOT working.")
}
}

View File

@@ -537,7 +537,7 @@ func ObsSafeProjectName(prjname string) string {
}
var ValidBlockModes []string = []string{"all", "local", "never"}
var ValidPrjLinkModes []string = []string{"off", "localdep", "alldirect", "alldirect_or_localdep", "all"}
var ValidPrjLinkModes []string = []string{"off", "localdep", "alldirect", "all"}
var ValidTriggerModes []string = []string{"transitive", "direct", "local"}
func (c *ObsClient) SetProjectMeta(meta *ProjectMeta) error {

View File

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

View File

@@ -73,7 +73,6 @@ func TestPRSet_Merge_Special(t *testing.T) {
// Clone and fetch for new-pkg
mockGit.EXPECT().GitClone("new-pkg", "main", "pkg-ssh-url").Return("origin", nil)
mockGit.EXPECT().GitExecOrPanic("new-pkg", "fetch", "origin", "pkg-head-sha")
mockGit.EXPECT().GitExecOrPanic("new-pkg", "checkout", "-B", "main", "pkg-head-sha")
// Pushing changes
mockGit.EXPECT().GitExecOrPanic("_ObsPrj", "push", "origin")

View File

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

View File

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

View File

@@ -327,7 +327,6 @@ func main() {
interval := flag.Int64("interval", 10, "Notification polling interval in minutes (min 1 min)")
configFile := flag.String("config", "", "PrjGit listing config file")
logging := flag.String("logging", "info", "Logging level: [none, error, info, debug]")
exitOnConfigError := flag.Bool("exit-on-config-error", false, "Exit if any repository in configuration cannot be resolved")
flag.BoolVar(&common.IsDryRun, "dry", false, "Dry run, no effect. For debugging")
flag.Parse()
@@ -383,10 +382,8 @@ func main() {
giteaTransport := common.AllocateGiteaTransport(*giteaUrl)
configs, err := common.ResolveWorkflowConfigs(giteaTransport, configData)
if err != nil {
common.LogError("Failed to resolve some configuration repositories:", err)
if *exitOnConfigError {
return
}
common.LogError("Cannot parse workflow configs:", err)
return
}
reviewer, err := giteaTransport.GetCurrentUser()

View File

@@ -4,7 +4,7 @@ ENV container=podman
ENV LANG=en_US.UTF-8
RUN zypper -vvvn install podman podman-compose vim make python3-pytest python3-requests python3-pytest-dependency python3-pytest-httpserver
RUN zypper -vvvn install podman podman-compose vim make python3-pytest python3-requests python3-pytest-dependency
COPY . /opt/project/

View File

@@ -1,19 +1,51 @@
# We want to be able to test in two **modes**:
# A. bots are used from official packages as defined in */Dockerfile.package
# B. bots are just picked up from binaries that are placed in corresponding parent directory.
# The topology is defined in podman-compose file and can be spawned in two ways:
# 1. podman-compose on a local machine (needs dependencies as defined in the Dockerfile)
# 2. pytest in a dedicated container (recommended)
# 1. Privileged container (needs no additional dependancies)
# 2. podman-compose on a local machine (needs dependencies as defined in the Dockerfile)
# Typical workflow:
# 1. 'make build' - prepares images
# 2. 'make up' - spawns podman-compose
# 3. 'make pytest' - run tests inside the tester container
# 4. 'make down' - once the containers are not needed
#
# OR just run 'make test' to do it all at once.
# A1: - run 'make test_package'
# B1: - run 'make test_local' (make sure that the go binaries in parent folder are built)
# A2:
# 1. 'make build_package' - prepares images (recommended, otherwise there might be surprises if image fails to build during `make up`)
# 2. 'make up' - spawns podman-compose
# 3. 'pytest -v tests/*' - run tests
# 4. 'make down' - once the containers are not needed
# B2: (make sure the go binaries in the parent folder are built)
# 1. 'make build_local' - prepared images (recommended, otherwise there might be surprises if image fails to build during `make up`)
# 2. 'make up' - spawns podman-compose
# 3. 'pytest -v tests/*' - run tests
# 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)
# Default test target
test: test_b
# try to detect mode B1, otherwise mode A1
test: GIWTF_IMAGE_SUFFIX=$(AUTO_DETECT_MODE)
test: build_container test_container
# mode A1
test_package: GIWTF_IMAGE_SUFFIX=.package
test_package: build_container test_container
# mode B1
test_local: GIWTF_IMAGE_SUFFIX=.local
test_local: build_container test_container
MODULES := gitea-events-rabbitmq-publisher obs-staging-bot workflow-pr
# Prepare topology 1
build_container:
podman build ../ -f integration/Dockerfile -t autogits_integration
# Run tests in topology 1
test_container:
podman run --rm --privileged -t -e GIWTF_IMAGE_SUFFIX=$(GIWTF_IMAGE_SUFFIX) autogits_integration /usr/bin/bash -c "make build && make up && sleep 25 && pytest -v tests/*"
build_local: AUTO_DETECT_MODE=.local
build_local: build
@@ -21,66 +53,16 @@ build_local: build
build_package: AUTO_DETECT_MODE=.package
build_package: build
# parse all service images from podman-compose and build them
# mode B with pytest in container
test_b: AUTO_DETECT_MODE=.local
test_b: build up wait_healthy pytest
# Complete cycle for CI
test-ci: test_b down
wait_healthy:
@echo "Waiting for services to be healthy..."
@echo "Waiting for gitea (max 2m)..."
@start_time=$$(date +%s); \
until podman exec gitea-test curl -f -s http://localhost:3000/api/v1/version >/dev/null 2>&1; do \
current_time=$$(date +%s); \
elapsed=$$((current_time - start_time)); \
if [ $$elapsed -gt 120 ]; then \
echo "ERROR: Gitea failed to start within 2 minutes."; \
echo "--- Troubleshooting Info ---"; \
echo "Diagnostics output (curl):"; \
podman exec gitea-test curl -v http://localhost:3000/api/v1/version || true; \
echo "--- Container Logs ---"; \
podman logs gitea-test --tail 20; \
echo "--- Container Status ---"; \
podman inspect gitea-test --format '{{.State.Status}}'; \
exit 1; \
fi; \
sleep 2; \
done
@echo "Waiting for rabbitmq (max 2m)..."
@start_time=$$(date +%s); \
until podman exec rabbitmq-test rabbitmq-diagnostics check_running -q >/dev/null 2>&1; do \
current_time=$$(date +%s); \
elapsed=$$((current_time - start_time)); \
if [ $$elapsed -gt 120 ]; then \
echo "ERROR: RabbitMQ failed to start within 2 minutes."; \
echo "--- Troubleshooting Info ---"; \
echo "Diagnostics output:"; \
podman exec rabbitmq-test rabbitmq-diagnostics check_running || true; \
echo "--- Container Logs ---"; \
podman logs rabbitmq-test --tail 20; \
echo "--- Container Status ---"; \
podman inspect rabbitmq-test --format '{{.State.Status}}'; \
exit 1; \
fi; \
sleep 2; \
done
@echo "All services are healthy!"
pytest:
podman-compose exec tester pytest -v tests/*
# parse all service images from podman-compose and build them (topology 2)
build:
podman pull docker.io/library/rabbitmq:3.13.7-management
for i in $$(grep -A 1000 services: podman-compose.yml | grep -oE '^ [^: ]+'); do GIWTF_IMAGE_SUFFIX=$(AUTO_DETECT_MODE) podman-compose build $$i || exit 1; done
# this will spawn prebuilt containers
# this will spawn prebuilt containers (topology 2)
up:
podman-compose up -d
# tear down
# tear down (topology 2)
down:
podman-compose down
@@ -91,3 +73,4 @@ up-bots-package:
# mode B
up-bots-local:
GIWTF_IMAGE_SUFFIX=.local podman-compose up -d

View File

@@ -1,52 +0,0 @@
# Makefile Targets
This document describes the targets available in the `integration/Makefile`.
## Primary Workflow
### `test` (or `test_b`)
- **Action**: Performs a complete build-and-test cycle.
- **Steps**:
1. `build`: Prepares all container images.
2. `up`: Starts all services via `podman-compose`.
3. `wait_healthy`: Polls Gitea and RabbitMQ until they are ready.
4. `pytest`: Executes the test suite inside the `tester` container.
- **Outcome**: The environment remains active for fast iteration.
### `test-ci`
- **Action**: Performs the full `test` cycle followed by teardown.
- **Steps**: `test_b` -> `down`
- **Purpose**: Ideal for CI environments where a clean state is required after testing.
---
## Individual Targets
### `build`
- **Action**: Pulls external images (RabbitMQ) and builds all local service images defined in `podman-compose.yml`.
- **Note**: Use `build_local` or `build_package` to specify bot source mode.
### `up`
- **Action**: Starts the container topology in detached mode.
### `wait_healthy`
- **Action**: Polls the health status of `gitea-test` and `rabbitmq-test` containers.
- **Purpose**: Ensures infrastructure is stable before test execution.
### `pytest`
- **Action**: Runs `pytest -v tests/*` inside the running `tester` container.
- **Requirement**: The environment must already be started via `up`.
### `down`
- **Action**: Stops and removes all containers and networks defined in the compose file.
---
## Configuration Modes
The Makefile supports two deployment modes via `GIWTF_IMAGE_SUFFIX`:
- **.local** (Default): Uses binaries built from the local source (requires `make build` in project root).
- **.package**: Uses official pre-built packages for the bots.
Targets like `build_local`, `build_package`, `up-bots-local`, and `up-bots-package` allow for explicit mode selection.

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,14 @@
# Use a base Python image
FROM registry.suse.com/bci/python:3.11
# Set the working directory
WORKDIR /app
# Copy the server script
COPY server.py .
# Expose the port the server will run on
EXPOSE 8080
# Command to run the server
CMD ["python3", "-u", "server.py"]

View File

@@ -0,0 +1,18 @@
<project name="openSUSE:Leap:16.0:PullRequest">
<title>Leap 16.0 PullRequest area</title>
<description>Base project to define the pull request builds</description>
<person userid="autogits_obs_staging_bot" role="maintainer"/>
<person userid="maxlin_factory" role="maintainer"/>
<group groupid="maintenance-opensuse.org" role="maintainer"/>
<debuginfo>
<enable/>
</debuginfo>
<repository name="standard">
<path project="openSUSE:Leap:16.0" repository="standard"/>
<arch>x86_64</arch>
<arch>i586</arch>
<arch>aarch64</arch>
<arch>ppc64le</arch>
<arch>s390x</arch>
</repository>
</project>

View File

@@ -0,0 +1,59 @@
<project name="openSUSE:Leap:16.0">
<title>openSUSE Leap 16.0 based on SLFO</title>
<description>Leap 16.0 based on SLES 16.0 (specifically SLFO:1.2)</description>
<link project="openSUSE:Backports:SLE-16.0"/>
<scmsync>http://gitea-test:3000/myproducts/mySLFO#staging-main</scmsync>
<person userid="dimstar_suse" role="maintainer"/>
<person userid="lkocman-factory" role="maintainer"/>
<person userid="maxlin_factory" role="maintainer"/>
<person userid="factory-auto" role="reviewer"/>
<person userid="licensedigger" role="reviewer"/>
<group groupid="autobuild-team" role="maintainer"/>
<group groupid="factory-maintainers" role="maintainer"/>
<group groupid="maintenance-opensuse.org" role="maintainer"/>
<group groupid="factory-staging" role="reviewer"/>
<build>
<disable repository="ports"/>
</build>
<debuginfo>
<enable/>
</debuginfo>
<repository name="standard" rebuild="local">
<path project="openSUSE:Backports:SLE-16.0" repository="standard"/>
<path project="SUSE:SLFO:1.2" repository="standard"/>
<arch>local</arch>
<arch>i586</arch>
<arch>x86_64</arch>
<arch>aarch64</arch>
<arch>ppc64le</arch>
<arch>s390x</arch>
</repository>
<repository name="product">
<releasetarget project="openSUSE:Leap:16.0:ToTest" repository="product" trigger="manual"/>
<path project="openSUSE:Leap:16.0:NonFree" repository="standard"/>
<path project="openSUSE:Leap:16.0" repository="images"/>
<path project="openSUSE:Leap:16.0" repository="standard"/>
<path project="openSUSE:Backports:SLE-16.0" repository="standard"/>
<path project="SUSE:SLFO:1.2" repository="standard"/>
<arch>local</arch>
<arch>i586</arch>
<arch>x86_64</arch>
<arch>aarch64</arch>
<arch>ppc64le</arch>
<arch>s390x</arch>
</repository>
<repository name="ports">
<arch>armv7l</arch>
</repository>
<repository name="images">
<releasetarget project="openSUSE:Leap:16.0:ToTest" repository="images" trigger="manual"/>
<path project="openSUSE:Leap:16.0" repository="standard"/>
<path project="openSUSE:Backports:SLE-16.0" repository="standard"/>
<path project="SUSE:SLFO:1.2" repository="standard"/>
<arch>i586</arch>
<arch>x86_64</arch>
<arch>aarch64</arch>
<arch>ppc64le</arch>
<arch>s390x</arch>
</repository>
</project>

View File

@@ -0,0 +1,140 @@
import http.server
import socketserver
import os
import logging
import signal
import sys
import threading
import fnmatch
PORT = 8080
RESPONSE_DIR = "/app/responses"
STATE_DIR = "/tmp/mock_obs_state"
class MockOBSHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
logging.info(f"GET request for: {self.path}")
path_without_query = self.path.split('?')[0]
# Check for state stored by a PUT request first
sanitized_put_path = 'PUT' + path_without_query.replace('/', '_')
state_file_path = os.path.join(STATE_DIR, sanitized_put_path)
if os.path.exists(state_file_path):
logging.info(f"Found stored PUT state for {self.path} at {state_file_path}")
self.send_response(200)
self.send_header("Content-type", "application/xml")
file_size = os.path.getsize(state_file_path)
self.send_header("Content-Length", str(file_size))
self.end_headers()
with open(state_file_path, 'rb') as f:
self.wfile.write(f.read())
return
# If no PUT state file, fall back to the glob/exact match logic
self.handle_request('GET')
def do_PUT(self):
logging.info(f"PUT request for: {self.path}")
logging.info(f"Headers: {self.headers}")
path_without_query = self.path.split('?')[0]
body = b''
if self.headers.get('Transfer-Encoding', '').lower() == 'chunked':
logging.info("Chunked transfer encoding detected")
while True:
line = self.rfile.readline().strip()
if not line:
break
chunk_length = int(line, 16)
if chunk_length == 0:
self.rfile.readline()
break
body += self.rfile.read(chunk_length)
self.rfile.read(2) # Read the trailing CRLF
else:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length)
logging.info(f"Body: {body.decode('utf-8')}")
sanitized_path = 'PUT' + path_without_query.replace('/', '_')
state_file_path = os.path.join(STATE_DIR, sanitized_path)
logging.info(f"Saving state for {self.path} to {state_file_path}")
os.makedirs(os.path.dirname(state_file_path), exist_ok=True)
with open(state_file_path, 'wb') as f:
f.write(body)
self.send_response(200)
self.send_header("Content-type", "text/plain")
response_body = b"OK"
self.send_header("Content-Length", str(len(response_body)))
self.end_headers()
self.wfile.write(response_body)
def do_POST(self):
logging.info(f"POST request for: {self.path}")
self.handle_request('POST')
def do_DELETE(self):
logging.info(f"DELETE request for: {self.path}")
self.handle_request('DELETE')
def handle_request(self, method):
path_without_query = self.path.split('?')[0]
sanitized_request_path = method + path_without_query.replace('/', '_')
logging.info(f"Handling request, looking for match for: {sanitized_request_path}")
response_file = None
# Check for glob match first
if os.path.exists(RESPONSE_DIR):
for filename in os.listdir(RESPONSE_DIR):
if fnmatch.fnmatch(sanitized_request_path, filename):
response_file = os.path.join(RESPONSE_DIR, filename)
logging.info(f"Found matching response file (glob): {response_file}")
break
# Fallback to exact match if no glob match
if response_file is None:
exact_file = os.path.join(RESPONSE_DIR, sanitized_request_path)
if os.path.exists(exact_file):
response_file = exact_file
logging.info(f"Found matching response file (exact): {response_file}")
if response_file:
logging.info(f"Serving content from {response_file}")
self.send_response(200)
self.send_header("Content-type", "application/xml")
file_size = os.path.getsize(response_file)
self.send_header("Content-Length", str(file_size))
self.end_headers()
with open(response_file, 'rb') as f:
self.wfile.write(f.read())
else:
logging.info(f"Response file not found for {sanitized_request_path}. Sending 404.")
self.send_response(404)
self.send_header("Content-type", "text/plain")
body = f"Mock response not found for {sanitized_request_path}".encode('utf-8')
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
if not os.path.exists(STATE_DIR):
logging.info(f"Creating state directory: {STATE_DIR}")
os.makedirs(STATE_DIR)
if not os.path.exists(RESPONSE_DIR):
os.makedirs(RESPONSE_DIR)
with socketserver.TCPServer(("", PORT), MockOBSHandler) as httpd:
logging.info(f"Serving mock OBS API on port {PORT}")
def graceful_shutdown(sig, frame):
logging.info("Received SIGTERM, shutting down gracefully...")
threading.Thread(target=httpd.shutdown).start()
signal.signal(signal.SIGTERM, graceful_shutdown)
httpd.serve_forever()
logging.info("Server has shut down.")

View File

@@ -1,64 +0,0 @@
# Podman-Compose Services Architecture
This document describes the services defined in `podman-compose.yml` used for integration testing.
## Network
- **gitea-network**: A bridge network that enables communication between all services.
## Services
### gitea
- **Description**: Self-hosted Git service, serving as the central hub for repositories.
- **Container Name**: `gitea-test`
- **Image**: Built from `./gitea/Dockerfile`
- **Ports**: `3000` (HTTP), `3022` (SSH)
- **Volumes**: `./gitea-data` (persistent data), `./gitea-logs` (logs)
- **Healthcheck**: Monitors the Gitea API version endpoint.
### rabbitmq
- **Description**: Message broker for asynchronous communication between services.
- **Container Name**: `rabbitmq-test`
- **Image**: `rabbitmq:3.13.7-management`
- **Ports**: `5671` (AMQP with TLS), `15672` (Management UI)
- **Volumes**: `./rabbitmq-data`, `./rabbitmq-config/certs`, `./rabbitmq-config/rabbitmq.conf`, `./rabbitmq-config/definitions.json`
- **Healthcheck**: Ensures the broker is running and ready to accept connections.
### gitea-publisher
- **Description**: Publishes events from Gitea webhooks to the RabbitMQ message queue.
- **Container Name**: `gitea-publisher`
- **Dependencies**: `gitea` (started), `rabbitmq` (healthy)
- **Topic Domain**: `suse`
### workflow-pr
- **Description**: Manages pull request workflows, synchronizing between ProjectGit and PackageGit.
- **Container Name**: `workflow-pr`
- **Dependencies**: `gitea` (started), `rabbitmq` (healthy)
- **Environment**: Configured via `AUTOGITS_*` variables.
- **Volumes**: `./gitea-data` (read-only), `./workflow-pr/workflow-pr.json` (config), `./workflow-pr-repos` (working directories)
### tester
- **Description**: The dedicated test runner container. It hosts the `pytest` suite and provides a mock OBS API using `pytest-httpserver`.
- **Container Name**: `tester`
- **Image**: Built from `./Dockerfile.tester`
- **Mock API**: Listens on port `8080` within the container network to simulate OBS.
- **Volumes**: Project root mounted at `/opt/project` for source access.
### obs-staging-bot
- **Description**: Interacts with Gitea and the OBS API (mocked by `tester`) to manage staging projects.
- **Container Name**: `obs-staging-bot`
- **Dependencies**: `gitea` (started), `tester` (started)
- **Environment**:
- `AUTOGITS_STAGING_BOT_POLL_INTERVAL`: Set to `2s` for fast integration testing.
- **Mock Integration**: Points to `http://tester:8080` for both OBS API and Web hosts.
---
## Testing Workflow
1. **Build**: `make build` (root) then `make build` (integration).
2. **Up**: `make up` starts all services.
3. **Wait**: `make wait_healthy` ensures infrastructure is ready.
4. **Test**: `make pytest` runs the suite inside the `tester` container.
5. **Down**: `make down` stops and removes containers.
Use `make test` to perform steps 1-4 automatically.

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 |
| |
+-------------------------------------------------------------------------------------------------+

View File

@@ -95,8 +95,7 @@ services:
- ./workflow-pr/workflow-pr.json:/etc/workflow-pr.json:ro,z
- ./workflow-pr-repos:/var/lib/workflow-pr/repos:Z
command: [
"-check-on-start",
"-exit-on-config-error",
"-check-on-start",
"-debug",
"-gitea-url", "http://gitea-test:3000",
"-url", "amqps://rabbitmq-test:5671",
@@ -105,21 +104,17 @@ services:
]
restart: unless-stopped
tester:
build:
context: .
dockerfile: Dockerfile.tester
container_name: tester
mock-obs:
build: ./mock-obs
container_name: mock-obs
init: true
dns_search: .
networks:
- gitea-network
environment:
- PYTEST_HTTPSERVER_HOST=0.0.0.0
- PYTEST_HTTPSERVER_PORT=8080
ports:
- "8080:8080"
volumes:
- ..:/opt/project:z
command: sleep infinity
- ./mock-obs/responses:/app/responses:z # Use :z for shared SELinux label
restart: unless-stopped
obs-staging-bot:
build:
@@ -132,17 +127,16 @@ services:
depends_on:
gitea:
condition: service_started
tester:
mock-obs:
condition: service_started
environment:
- OBS_USER=mock
- OBS_PASSWORD=mock-long-password
- AUTOGITS_STAGING_BOT_POLL_INTERVAL=2s
volumes:
- ./gitea-data:/gitea-data:ro,z
command:
- "-debug"
- "-gitea-url=http://gitea-test:3000"
- "-obs=http://tester:8080"
- "-obs-web=http://tester:8080"
- "-obs=http://mock-obs:8080"
- "-obs-web=http://mock-obs:8080"
restart: unless-stopped

View File

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

View File

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

View File

@@ -8,83 +8,7 @@ import time
import os
import json
import base64
import re
from tests.lib.common_test_utils import GiteaAPIClient, vprint
import tests.lib.common_test_utils as common_utils
@pytest.fixture(autouse=True)
def is_test_run():
common_utils.IS_TEST_RUN = True
yield
common_utils.IS_TEST_RUN = False
if os.environ.get("AUTOGITS_PRINT_FIXTURES") is None:
print("--- Fixture messages are suppressed. Set AUTOGITS_PRINT_FIXTURES=1 to enable them. ---")
class ObsMockState:
def __init__(self):
self.build_results = {} # project -> (package, code)
self.project_metas = {} # project -> scmsync
self.default_build_result = None
@pytest.fixture
def obs_mock_state():
return ObsMockState()
@pytest.fixture(autouse=True)
def default_obs_handlers(httpserver, obs_mock_state):
"""
Sets up default handlers for OBS API to avoid 404s.
"""
def project_meta_handler(request):
project = request.path.split("/")[2]
scmsync = obs_mock_state.project_metas.get(project, "http://gitea-test:3000/myproducts/mySLFO.git")
return f'<project name="{project}"><scmsync>{scmsync}</scmsync></project>'
def build_result_handler(request):
project = request.path.split("/")[2]
res = obs_mock_state.build_results.get(project) or obs_mock_state.default_build_result
if not res:
return '<resultlist></resultlist>'
package_name, code = res
# We'll use a simple hardcoded XML here to avoid re-parsing template every time
# or we can use the template. For simplicity, let's use a basic one.
xml_template = f"""<resultlist state="mock">
<result project="{project}" repository="standard" arch="x86_64" code="unpublished" state="unpublished">
<scmsync>http://gitea-test:3000/myproducts/mySLFO.git?onlybuild={package_name}#sha</scmsync>
<status package="{package_name}" code="{code}"/>
</result>
</resultlist>"""
return xml_template
# Register handlers
httpserver.expect_request(re.compile(r"/source/[^/]+/_meta$"), method="GET").respond_with_handler(project_meta_handler)
httpserver.expect_request(re.compile(r"/build/[^/]+/_result"), method="GET").respond_with_handler(build_result_handler)
httpserver.expect_request(re.compile(r"/source/[^/]+/_meta$"), method="PUT").respond_with_data("OK")
httpserver.expect_request(re.compile(r"/source/[^/]+$"), method="DELETE").respond_with_data("OK")
@pytest.fixture
def mock_build_result(obs_mock_state):
"""
Fixture to set up mock build results.
"""
def _setup_mock(package_name: str, code: str, project: str = None):
if project:
obs_mock_state.build_results[project] = (package_name, code)
else:
# If no project specified, we can't easily know which one to set
# but usually it's the one the bot will request.
# We'll use a special key to signify "all" or we can just wait for the request.
# For now, let's assume we want to match openSUSE:Leap:16.0:PullRequest:*
# The test will call it with specific project if needed.
# In test_pr_workflow, it doesn't know the PR number yet.
# So we'll make the handler fallback to this if project not found.
obs_mock_state.default_build_result = (package_name, code)
return _setup_mock
from tests.lib.common_test_utils import GiteaAPIClient
BRANCH_CONFIG_COMMON = {
"workflow.config": {
@@ -147,21 +71,6 @@ BRANCH_CONFIG_CUSTOM = {
"ReviewPending": "review/Pending"
}
}
},
"merge-ff": {
"workflow.config": {
"MergeMode": "ff-only"
}
},
"merge-replace": {
"workflow.config": {
"MergeMode": "replace"
}
},
"merge-devel": {
"workflow.config": {
"MergeMode": "devel"
}
}
}
@@ -172,7 +81,7 @@ _CREATED_USERS = set()
_CREATED_LABELS = set()
_ADDED_COLLABORATORS = set() # format: (org_repo, username)
def setup_users_from_config(client: GiteaAPIClient, wf: dict, mt: dict, stats: dict = None, handled: dict = None):
def setup_users_from_config(client: GiteaAPIClient, wf: dict, mt: dict):
"""
Parses workflow.config and _maintainership.json, creates users, and adds them as collaborators.
"""
@@ -192,19 +101,13 @@ def setup_users_from_config(client: GiteaAPIClient, wf: dict, mt: dict, stats: d
# Create all users
for username in all_users:
new_user = client.create_user(username, "password123", f"{username}@example.com")
_CREATED_USERS.add(username)
if stats and handled and username not in handled["users"]:
handled["users"].add(username)
if new_user: stats["users"]["new"] += 1
else: stats["users"]["reused"] += 1
if username not in _CREATED_USERS:
client.create_user(username, "password123", f"{username}@example.com")
_CREATED_USERS.add(username)
new_coll = client.add_collaborator("myproducts", "mySLFO", username, "write")
_ADDED_COLLABORATORS.add(("myproducts/mySLFO", username))
if stats and handled and ("myproducts/mySLFO", username) not in handled["collaborators"]:
handled["collaborators"].add(("myproducts/mySLFO", username))
if new_coll: stats["collaborators"]["new"] += 1
else: stats["collaborators"]["reused"] += 1
if ("myproducts/mySLFO", username) not in _ADDED_COLLABORATORS:
client.add_collaborator("myproducts", "mySLFO", username, "write")
_ADDED_COLLABORATORS.add(("myproducts/mySLFO", username))
# Set specific repository permissions based on maintainership
for pkg, users in mt.items():
@@ -212,34 +115,20 @@ def setup_users_from_config(client: GiteaAPIClient, wf: dict, mt: dict, stats: d
for username in users:
if not repo_name:
for r in ["pkgA", "pkgB"]:
new_coll = client.add_collaborator("mypool", r, username, "write")
_ADDED_COLLABORATORS.add((f"mypool/{r}", username))
if stats and handled and (f"mypool/{r}", username) not in handled["collaborators"]:
handled["collaborators"].add((f"mypool/{r}", username))
if new_coll: stats["collaborators"]["new"] += 1
else: stats["collaborators"]["reused"] += 1
if (f"mypool/{r}", username) not in _ADDED_COLLABORATORS:
client.add_collaborator("mypool", r, username, "write")
_ADDED_COLLABORATORS.add((f"mypool/{r}", username))
else:
new_coll = client.add_collaborator("mypool", repo_name, username, "write")
_ADDED_COLLABORATORS.add((f"mypool/{repo_name}", username))
if stats and handled and (f"mypool/{repo_name}", username) not in handled["collaborators"]:
handled["collaborators"].add((f"mypool/{repo_name}", username))
if new_coll: stats["collaborators"]["new"] += 1
else: stats["collaborators"]["reused"] += 1
if (f"mypool/{repo_name}", username) not in _ADDED_COLLABORATORS:
client.add_collaborator("mypool", repo_name, username, "write")
_ADDED_COLLABORATORS.add((f"mypool/{repo_name}", username))
def ensure_config_file(client: GiteaAPIClient, owner: str, repo: str, branch: str, file_name: str, expected_content_dict: dict, existing_files: list = None):
def ensure_config_file(client: GiteaAPIClient, owner: str, repo: str, branch: str, file_name: str, expected_content_dict: dict):
"""
Checks if a config file exists and has the correct content.
Returns True if a change was made, False otherwise.
"""
file_info = None
if existing_files is not None:
if file_name not in [f["path"] for f in existing_files]:
pass # File definitely doesn't exist
else:
file_info = client.get_file_info(owner, repo, file_name, branch=branch)
else:
file_info = client.get_file_info(owner, repo, file_name, branch=branch)
file_info = client.get_file_info(owner, repo, file_name, branch=branch)
expected_content = json.dumps(expected_content_dict, indent=4)
if file_info:
@@ -259,28 +148,8 @@ def gitea_env():
"""
Global fixture to set up the Gitea environment for all tests.
"""
setup_start_time = time.time()
stats = {
"orgs": {"new": 0, "reused": 0},
"repos": {"new": 0, "reused": 0},
"users": {"new": 0, "reused": 0},
"labels": {"new": 0, "reused": 0},
"collaborators": {"new": 0, "reused": 0},
"branches": {"new": 0, "reused": 0},
"webhooks": {"new": 0, "reused": 0},
}
handled_in_session = {
"orgs": set(),
"repos": set(),
"users": set(),
"labels": set(),
"collaborators": set(),
"branches": set(),
"webhooks": set(),
}
gitea_url = "http://gitea-test:3000"
admin_token_path = os.path.join(os.path.dirname(__file__), "..", "gitea-data", "admin.token")
gitea_url = "http://127.0.0.1:3000"
admin_token_path = "./gitea-data/admin.token"
admin_token = None
try:
@@ -290,55 +159,35 @@ def gitea_env():
raise Exception(f"Admin token file not found at {admin_token_path}.")
client = GiteaAPIClient(base_url=gitea_url, token=admin_token)
client.use_cache = True
# Wait for Gitea
for i in range(10):
try:
resp, dur = client._request("GET", "version")
if resp.status_code == 200:
vprint(f"DEBUG: Gitea connection successful (duration: {dur:.3f}s)")
if client._request("GET", "version").status_code == 200:
break
except Exception as e:
vprint(f"DEBUG: Gitea connection attempt {i+1} failed: {e}")
except:
pass
time.sleep(1)
else: raise Exception("Gitea not available.")
else:
raise Exception("Gitea not available.")
vprint("--- Starting Gitea Global Setup ---")
print("--- Starting Gitea Global Setup ---")
for org in ["myproducts", "mypool"]:
new_org = client.create_org(org)
_CREATED_ORGS.add(org)
if org not in handled_in_session["orgs"]:
handled_in_session["orgs"].add(org)
if new_org: stats["orgs"]["new"] += 1
else: stats["orgs"]["reused"] += 1
if org not in _CREATED_ORGS:
client.create_org(org)
_CREATED_ORGS.add(org)
for org, repo in [("myproducts", "mySLFO"), ("mypool", "pkgA"), ("mypool", "pkgB")]:
new_repo = client.create_repo(org, repo)
client.update_repo_settings(org, repo)
repo_full = f"{org}/{repo}"
_CREATED_REPOS.add(repo_full)
if repo_full not in handled_in_session["repos"]:
handled_in_session["repos"].add(repo_full)
if new_repo: stats["repos"]["new"] += 1
else: stats["repos"]["reused"] += 1
# Create webhook for publisher
new_hook = client.create_webhook(org, repo, "http://gitea-publisher:8002/rabbitmq-forwarder")
if repo_full not in handled_in_session["webhooks"]:
handled_in_session["webhooks"].add(repo_full)
if new_hook: stats["webhooks"]["new"] += 1
else: stats["webhooks"]["reused"] += 1
if f"{org}/{repo}" not in _CREATED_REPOS:
client.create_repo(org, repo)
client.update_repo_settings(org, repo)
_CREATED_REPOS.add(f"{org}/{repo}")
# Create labels
for name, color in [("staging/Backlog", "#0000ff"), ("review/Pending", "#ffff00")]:
new_label = client.create_label("myproducts", "mySLFO", name, color=color)
_CREATED_LABELS.add(("myproducts/mySLFO", name))
if ("myproducts/mySLFO", name) not in handled_in_session["labels"]:
handled_in_session["labels"].add(("myproducts/mySLFO", name))
if new_label: stats["labels"]["new"] += 1
else: stats["labels"]["reused"] += 1
if ("myproducts/mySLFO", name) not in _CREATED_LABELS:
client.create_label("myproducts", "mySLFO", name, color=color)
_CREATED_LABELS.add(("myproducts/mySLFO", name))
# Submodules in mySLFO
client.add_submodules("myproducts", "mySLFO")
@@ -347,51 +196,24 @@ def gitea_env():
("myproducts/mySLFO", "workflow-pr"),
("mypool/pkgA", "workflow-pr"),
("mypool/pkgB", "workflow-pr")]:
org_part, repo_part = repo_full.split("/")
new_coll = client.add_collaborator(org_part, repo_part, bot, "write")
_ADDED_COLLABORATORS.add((repo_full, bot))
if (repo_full, bot) not in handled_in_session["collaborators"]:
handled_in_session["collaborators"].add((repo_full, bot))
if new_coll: stats["collaborators"]["new"] += 1
else: stats["collaborators"]["reused"] += 1
# Collect all users from all configurations first to do setup once
all_setup_users_wf = {}
all_setup_users_mt = {}
if (repo_full, bot) not in _ADDED_COLLABORATORS:
org_part, repo_part = repo_full.split("/")
client.add_collaborator(org_part, repo_part, bot, "write")
_ADDED_COLLABORATORS.add((repo_full, bot))
restart_needed = False
# Setup all branches and configs
repo_list = [("mypool", "pkgA"), ("mypool", "pkgB"), ("myproducts", "mySLFO")]
repo_branches = {}
for owner, repo in repo_list:
resp, _ = client._request("GET", f"repos/{owner}/{repo}/branches")
repo_branches[(owner, repo)] = {b["name"] for b in resp.json()}
for branch_name, custom_configs in BRANCH_CONFIG_CUSTOM.items():
# Ensure branch exists in all 3 repos
for owner, repo in repo_list:
for owner, repo in [("myproducts", "mySLFO"), ("mypool", "pkgA"), ("mypool", "pkgB")]:
if branch_name != "main":
if branch_name not in repo_branches[(owner, repo)]:
try:
resp, _ = client._request("GET", f"repos/{owner}/{repo}/branches/main")
main_sha = resp.json()["commit"]["id"]
new_branch = client.create_branch(owner, repo, branch_name, main_sha)
repo_branches[(owner, repo)].add(branch_name)
if (f"{owner}/{repo}", branch_name) not in handled_in_session["branches"]:
handled_in_session["branches"].add((f"{owner}/{repo}", branch_name))
if new_branch: stats["branches"]["new"] += 1
else: stats["branches"]["reused"] += 1
except Exception as e:
if "already exists" not in str(e).lower():
raise
else:
if (f"{owner}/{repo}", branch_name) not in handled_in_session["branches"]:
handled_in_session["branches"].add((f"{owner}/{repo}", branch_name))
stats["branches"]["reused"] += 1
else:
# main branch always exists, but let's track it as reused if not handled
if (f"{owner}/{repo}", "main") not in handled_in_session["branches"]:
handled_in_session["branches"].add((f"{owner}/{repo}", "main"))
stats["branches"]["reused"] += 1
try:
main_sha = client._request("GET", f"repos/{owner}/{repo}/branches/main").json()["commit"]["id"]
client.create_branch(owner, repo, branch_name, main_sha)
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Merge configs
merged_configs = {}
@@ -410,40 +232,19 @@ def gitea_env():
else:
merged_configs[file_name] = custom_content
# Pre-fetch existing files in this branch to avoid 404s in ensure_config_file
try:
resp, _ = client._request("GET", f"repos/myproducts/mySLFO/contents?ref={branch_name}")
existing_files = resp.json()
except:
existing_files = []
# Ensure config files in myproducts/mySLFO
for file_name, content_dict in merged_configs.items():
ensure_config_file(client, "myproducts", "mySLFO", branch_name, file_name, content_dict, existing_files=existing_files)
if ensure_config_file(client, "myproducts", "mySLFO", branch_name, file_name, content_dict):
restart_needed = True
# Collect configs for user setup
wf_cfg = merged_configs.get("workflow.config", {})
mt_cfg = merged_configs.get("_maintainership.json", {})
# Simple merge for user collection
if "Reviewers" in wf_cfg:
all_setup_users_wf.setdefault("Reviewers", []).extend(wf_cfg["Reviewers"])
for k, v in mt_cfg.items():
all_setup_users_mt.setdefault(k, []).extend(v)
# Setup users (using configs from this branch)
setup_users_from_config(client, merged_configs.get("workflow.config", {}), merged_configs.get("_maintainership.json", {}))
# Dedup and setup users once
if "Reviewers" in all_setup_users_wf:
all_setup_users_wf["Reviewers"] = list(set(all_setup_users_wf["Reviewers"]))
for k in all_setup_users_mt:
all_setup_users_mt[k] = list(set(all_setup_users_mt[k]))
setup_users_from_config(client, all_setup_users_wf, all_setup_users_mt, stats=stats, handled=handled_in_session)
if restart_needed:
client.restart_service("workflow-pr")
time.sleep(2) # Give it time to pick up changes
setup_duration = time.time() - setup_start_time
print(f"--- Gitea Global Setup Complete (took {setup_duration:.2f}s) ---\n"
f"Objects created: {stats['orgs']['new']} orgs, {stats['repos']['new']} repos, {stats['branches']['new']} branches, {stats['webhooks']['new']} webhooks, {stats['users']['new']} users, {stats['labels']['new']} labels, {stats['collaborators']['new']} collaborators\n"
f"Objects reused: {stats['orgs']['reused']} orgs, {stats['repos']['reused']} repos, {stats['branches']['reused']} branches, {stats['webhooks']['reused']} webhooks, {stats['users']['reused']} users, {stats['labels']['reused']} labels, {stats['collaborators']['reused']} collaborators")
client.use_cache = False
print("--- Gitea Global Setup Complete ---")
yield client
@pytest.fixture(scope="session")
@@ -474,18 +275,6 @@ def no_project_git_pr_env(gitea_env):
def label_env(gitea_env):
return gitea_env, "myproducts/mySLFO", "label-test"
@pytest.fixture(scope="session")
def merge_ff_env(gitea_env):
return gitea_env, "myproducts/mySLFO", "merge-ff"
@pytest.fixture(scope="session")
def merge_replace_env(gitea_env):
return gitea_env, "myproducts/mySLFO", "merge-replace"
@pytest.fixture(scope="session")
def merge_devel_env(gitea_env):
return gitea_env, "myproducts/mySLFO", "merge-devel"
@pytest.fixture(scope="session")
def usera_client(gitea_env):
return GiteaAPIClient(base_url=gitea_env.base_url, token=gitea_env.headers["Authorization"].split(" ")[1], sudo="usera")

View File

@@ -3,16 +3,45 @@ import time
import pytest
import requests
import json
import re
import xml.etree.ElementTree as ET
from pathlib import Path
import base64
import subprocess
IS_TEST_RUN = False
TEST_DATA_DIR = Path(__file__).parent.parent / "data"
BUILD_RESULT_TEMPLATE = TEST_DATA_DIR / "build_result.xml.template"
MOCK_RESPONSES_DIR = Path(__file__).parent.parent.parent / "mock-obs" / "responses"
MOCK_BUILD_RESULT_FILE = (
MOCK_RESPONSES_DIR / "GET_build_openSUSE:Leap:16.0:PullRequest:*__result"
)
MOCK_BUILD_RESULT_FILE1 = MOCK_RESPONSES_DIR / "GET_build_openSUSE:Leap:16.0__result"
@pytest.fixture
def mock_build_result():
"""
Fixture to create a mock build result file from the template.
Returns a factory function that the test can call with parameters.
"""
def _create_result_file(package_name: str, code: str):
tree = ET.parse(BUILD_RESULT_TEMPLATE)
root = tree.getroot()
for status_tag in root.findall(".//status"):
status_tag.set("package", package_name)
status_tag.set("code", code)
MOCK_RESPONSES_DIR.mkdir(exist_ok=True)
tree.write(MOCK_BUILD_RESULT_FILE)
tree.write(MOCK_BUILD_RESULT_FILE1)
return str(MOCK_BUILD_RESULT_FILE)
yield _create_result_file
if MOCK_BUILD_RESULT_FILE.exists():
MOCK_BUILD_RESULT_FILE.unlink()
MOCK_BUILD_RESULT_FILE1.unlink()
def vprint(*args, **kwargs):
if IS_TEST_RUN or os.environ.get("AUTOGITS_PRINT_FIXTURES") == "1":
print(*args, **kwargs)
class GiteaAPIClient:
def __init__(self, base_url, token, sudo=None):
@@ -20,45 +49,24 @@ class GiteaAPIClient:
self.headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
if sudo:
self.headers["Sudo"] = sudo
self._cache = {}
self.use_cache = False
def _request(self, method, path, **kwargs):
# Very basic cache for GET requests to speed up setup
cache_key = (method, path, json.dumps(kwargs, sort_keys=True))
if self.use_cache and method == "GET" and cache_key in self._cache:
return self._cache[cache_key], 0.0
url = f"{self.base_url}/api/v1/{path}"
start_time = time.time()
response = requests.request(method, url, headers=self.headers, **kwargs)
try:
response = requests.request(method, url, headers=self.headers, **kwargs)
duration = time.time() - start_time
response.raise_for_status()
if self.use_cache:
if method == "GET":
self._cache[cache_key] = response
else:
self._cache.clear()
return response, duration
except requests.exceptions.HTTPError as e:
duration = time.time() - start_time
vprint(f"[{duration:.3f}s] HTTPError in _request: {e}")
vprint(f"Response Content: {e.response.text}")
raise
except requests.exceptions.RequestException as e:
duration = time.time() - start_time
vprint(f"[{duration:.3f}s] Request failed: {e}")
print(f"HTTPError in _request: {e}")
print(f"Response Content: {e.response.text}")
raise
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, duration = self._request("GET", url)
response = self._request("GET", url)
return response.json()
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
@@ -66,7 +74,7 @@ class GiteaAPIClient:
raise
def create_user(self, username, password, email):
vprint(f"--- Creating user: {username} ---")
print(f"--- Creating user: {username} ---")
data = {
"username": username,
"password": password,
@@ -75,20 +83,18 @@ class GiteaAPIClient:
"send_notify": False
}
try:
response, duration = self._request("POST", "admin/users", json=data)
vprint(f"[{duration:.3f}s] User '{username}' created.")
return True
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
vprint(f"User '{username}' already exists. Updating password...")
print(f"User '{username}' already exists. Updating password...")
# Update password to be sure it matches our expectation
response, duration = self._request("PATCH", f"admin/users/{username}", json={"password": password, "login_name": username})
return False
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"):
vprint(f"--- Getting token for user: {username} ---")
print(f"--- Getting token for user: {username} ---")
url = f"{self.base_url}/api/v1/users/{username}/tokens"
# Create new token using Basic Auth
@@ -98,30 +104,39 @@ class GiteaAPIClient:
response.raise_for_status()
def create_org(self, org_name):
vprint(f"--- Checking organization: {org_name} ---")
print(f"--- Checking organization: {org_name} ---")
try:
response, duration = self._request("GET", f"orgs/{org_name}")
vprint(f"[{duration:.3f}s] Organization '{org_name}' already exists.")
return False
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:
vprint(f"Creating organization '{org_name}'...")
print(f"Creating organization '{org_name}'...")
data = {"username": org_name, "full_name": org_name}
response, duration = self._request("POST", "orgs", json=data)
vprint(f"[{duration:.3f}s] Organization '{org_name}' created.")
return True
self._request("POST", "orgs", json=data)
print(f"Organization '{org_name}' created.")
else:
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):
vprint(f"--- Checking repository: {org_name}/{repo_name} ---")
print(f"--- Checking repository: {org_name}/{repo_name} ---")
try:
response, duration = self._request("GET", f"repos/{org_name}/{repo_name}")
vprint(f"[{duration:.3f}s] Repository '{org_name}/{repo_name}' already exists.")
return False
self._request("GET", f"repos/{org_name}/{repo_name}")
print(f"Repository '{org_name}/{repo_name}' already exists.")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
vprint(f"Creating repository '{org_name}/{repo_name}'...")
print(f"Creating repository '{org_name}/{repo_name}'...")
data = {
"name": repo_name,
"auto_init": True,
@@ -131,48 +146,34 @@ class GiteaAPIClient:
"private": False,
"readme": "Default"
}
response, duration = self._request("POST", f"orgs/{org_name}/repos", json=data)
vprint(f"[{duration:.3f}s] Repository '{org_name}/{repo_name}' created with a README.")
self._request("POST", f"orgs/{org_name}/repos", json=data)
print(f"Repository '{org_name}/{repo_name}' created with a README.")
time.sleep(0.1) # Added delay to allow Git operations to become available
return True
else:
raise
def add_collaborator(self, org_name, repo_name, collaborator_name, permission="write"):
vprint(f"--- Adding {collaborator_name} as a collaborator to {org_name}/{repo_name} with '{permission}' permission ---")
# Check if already a collaborator to provide accurate stats
try:
self._request("GET", f"repos/{org_name}/{repo_name}/collaborators/{collaborator_name}")
vprint(f"{collaborator_name} is already a collaborator of {org_name}/{repo_name}.")
return False
except requests.exceptions.HTTPError as e:
if e.response.status_code != 404:
raise
print(f"--- Adding {collaborator_name} as a collaborator to {org_name}/{repo_name} with '{permission}' permission ---")
data = {"permission": permission}
# Gitea API returns 204 No Content on success and doesn't fail if already present.
response, duration = self._request("PUT", f"repos/{org_name}/{repo_name}/collaborators/{collaborator_name}", json=data)
vprint(f"[{duration:.3f}s] Added {collaborator_name} to {org_name}/{repo_name}.")
return True
self._request("PUT", f"repos/{org_name}/{repo_name}/collaborators/{collaborator_name}", json=data)
print(f"Attempted to add {collaborator_name} to {org_name}/{repo_name}.")
def add_submodules(self, org_name, repo_name):
vprint(f"--- Adding submodules to {org_name}/{repo_name} using diffpatch ---")
print(f"--- Adding submodules to {org_name}/{repo_name} using diffpatch ---")
parent_repo_path = f"repos/{org_name}/{repo_name}"
try:
response, duration = self._request("GET", f"{parent_repo_path}/contents/.gitmodules")
vprint(f"[{duration:.3f}s] Submodules appear to be already added. Skipping.")
self._request("GET", f"{parent_repo_path}/contents/.gitmodules")
print("Submodules appear to be already added. Skipping.")
return
except requests.exceptions.HTTPError as e:
if e.response.status_code != 404:
raise
# Get latest commit SHAs for the submodules
response_a, duration_a = self._request("GET", "repos/mypool/pkgA/branches/main")
pkg_a_sha = response_a.json()["commit"]["id"]
response_b, duration_b = self._request("GET", "repos/mypool/pkgB/branches/main")
pkg_b_sha = response_b.json()["commit"]["id"]
pkg_a_sha = self._request("GET", "repos/mypool/pkgA/branches/main").json()["commit"]["id"]
pkg_b_sha = self._request("GET", "repos/mypool/pkgB/branches/main").json()["commit"]["id"]
if not pkg_a_sha or not pkg_b_sha:
raise Exception("Error: Could not get submodule commit SHAs. Cannot apply patch.")
@@ -210,81 +211,34 @@ index 0000000..{pkg_b_sha}
"content": diff_content,
"message": message
}
vprint(f"Applying submodule patch to {org_name}/{repo_name}...")
response, duration = self._request("POST", f"{parent_repo_path}/diffpatch", json=data)
vprint(f"[{duration:.3f}s] Submodule patch applied.")
print(f"Applying submodule patch to {org_name}/{repo_name}...")
self._request("POST", f"{parent_repo_path}/diffpatch", json=data)
print("Submodule patch applied.")
def update_repo_settings(self, org_name, repo_name):
vprint(f"--- Updating repository settings for: {org_name}/{repo_name} ---")
response, duration = self._request("GET", f"repos/{org_name}/{repo_name}")
repo_data = response.json()
print(f"--- Updating repository settings for: {org_name}/{repo_name} ---")
repo_data = self._request("GET", f"repos/{org_name}/{repo_name}").json()
# Ensure these are boolean values, not string
repo_data["allow_manual_merge"] = True
repo_data["autodetect_manual_merge"] = True
response, duration = self._request("PATCH", f"repos/{org_name}/{repo_name}", json=repo_data)
vprint(f"[{duration:.3f}s] Repository settings for '{org_name}/{repo_name}' updated.")
def create_webhook(self, owner: str, repo: str, target_url: str):
vprint(f"--- Checking webhook for {owner}/{repo} -> {target_url} ---")
url = f"repos/{owner}/{repo}/hooks"
try:
response, duration = self._request("GET", url)
hooks = response.json()
for hook in hooks:
if hook["config"]["url"] == target_url:
vprint(f"Webhook for {owner}/{repo} already exists with correct URL.")
return False
elif "gitea-publisher" in hook["config"]["url"] or "10.89.0." in hook["config"]["url"]:
vprint(f"Found old webhook {hook['id']} with URL {hook['config']['url']}. Deleting...")
self._request("DELETE", f"{url}/{hook['id']}")
except requests.exceptions.HTTPError:
pass
vprint(f"--- Creating webhook for {owner}/{repo} -> {target_url} ---")
data = {
"type": "gitea",
"config": {
"url": target_url,
"content_type": "json"
},
"events": ["push", "pull_request", "pull_request_review", "issue_comment"],
"active": True
}
response, duration = self._request("POST", url, json=data)
vprint(f"[{duration:.3f}s] Webhook created for {owner}/{repo}.")
return True
self._request("PATCH", f"repos/{org_name}/{repo_name}", json=repo_data)
print(f"Repository settings for '{org_name}/{repo_name}' updated.")
def create_label(self, owner: str, repo: str, name: str, color: str = "#abcdef"):
vprint(f"--- Checking label '{name}' in {owner}/{repo} ---")
print(f"--- Creating label '{name}' in {owner}/{repo} ---")
url = f"repos/{owner}/{repo}/labels"
# Check if label exists first
try:
response, duration = self._request("GET", url)
labels = response.json()
for label in labels:
if label["name"] == name:
vprint(f"Label '{name}' already exists in {owner}/{repo}.")
return False
except requests.exceptions.HTTPError:
pass
vprint(f"--- Creating label '{name}' in {owner}/{repo} ---")
data = {
"name": name,
"color": color
}
try:
response, duration = self._request("POST", url, json=data)
vprint(f"[{duration:.3f}s] Label '{name}' created.")
return True
self._request("POST", url, json=data)
print(f"Label '{name}' created.")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 422: # Already exists (race condition or other reason)
vprint(f"Label '{name}' already exists.")
return False
if e.response.status_code == 422: # Already exists
print(f"Label '{name}' already exists.")
else:
raise
@@ -298,7 +252,7 @@ index 0000000..{pkg_b_sha}
}
if file_info:
vprint(f"--- Updating file {file_path} in {owner}/{repo} ---")
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:
@@ -307,40 +261,58 @@ index 0000000..{pkg_b_sha}
data["message"] = f"Update {file_path}"
method = "PUT"
else:
vprint(f"--- Creating file {file_path} in {owner}/{repo} ---")
print(f"--- Creating file {file_path} in {owner}/{repo} ---")
method = "POST"
url = f"repos/{owner}/{repo}/contents/{file_path}"
response, duration = self._request(method, url, json=data)
vprint(f"[{duration:.3f}s] File {file_path} {'updated' if file_info else 'created'} in {owner}/{repo}.")
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
new_branch_name = f"pr-branch-{int(time.time()*1000)}"
if use_fork:
sudo_user = self.headers.get("Sudo")
head_owner = sudo_user
head_repo = repo
new_branch_name = f"pr-branch-{int(time.time()*1000)}"
vprint(f"--- Forking {repo_full_name} ---")
print(f"--- Forking {repo_full_name} ---")
try:
response, duration = self._request("POST", f"repos/{owner}/{repo}/forks", json={})
vprint(f"[{duration:.3f}s] --- Forked to {head_owner}/{head_repo} ---")
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
vprint(f"--- Already forked to {head_owner}/{head_repo} ---")
print(f"--- Already forked to {head_owner}/{head_repo} ---")
else:
raise
# Apply the diff using diffpatch and create the new branch automatically
vprint(f"--- Applying diff to {head_owner}/{head_repo} from {base_branch} to new branch {new_branch_name} ---")
response, duration = self._request("POST", f"repos/{head_owner}/{head_repo}/diffpatch", json={
"branch": base_branch,
"new_branch": new_branch_name,
# 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
})
@@ -353,59 +325,59 @@ index 0000000..{pkg_b_sha}
"body": body,
"allow_maintainer_edit": True
}
vprint(f"--- Creating PR in {repo_full_name} from {data['head']} ---")
response, duration = self._request("POST", f"repos/{owner}/{repo}/pulls", 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()
def create_branch(self, owner: str, repo: str, new_branch_name: str, old_ref: str):
vprint(f"--- Checking branch '{new_branch_name}' in {owner}/{repo} ---")
print(f"--- Checking branch '{new_branch_name}' in {owner}/{repo} ---")
try:
response, duration = self._request("GET", f"repos/{owner}/{repo}/branches/{new_branch_name}")
vprint(f"[{duration:.3f}s] Branch '{new_branch_name}' already exists.")
return False
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
vprint(f"--- Creating branch '{new_branch_name}' in {owner}/{repo} from {old_ref} ---")
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
}
response, duration = self._request("POST", url, json=data)
vprint(f"[{duration:.3f}s] Branch '{new_branch_name}' created in {owner}/{repo}.")
return True
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):
vprint(f"--- Ensuring branch '{branch}' exists in {owner}/{repo} ---")
print(f"--- Ensuring branch '{branch}' exists in {owner}/{repo} ---")
start_time = time.time()
while time.time() - start_time < timeout:
try:
response, duration = self._request("GET", f"repos/{owner}/{repo}/branches/{branch}")
vprint(f"[{duration:.3f}s] Branch '{branch}' confirmed in {owner}/{repo}.")
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:
vprint(f"Branch '{branch}' not found yet in {owner}/{repo}. Retrying...")
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):
owner, repo = repo_full_name.split("/")
# Get PR details to find the head branch AND head repo
response, duration = self._request("GET", f"repos/{owner}/{repo}/pulls/{pr_number}")
pr_details = response.json()
pr_details = self._request("GET", f"repos/{owner}/{repo}/pulls/{pr_number}").json()
head_branch = pr_details["head"]["ref"]
head_repo_owner = pr_details["head"]["repo"]["owner"]["login"]
head_repo_name = pr_details["head"]["repo"]["name"]
# Apply the diff using diffpatch
vprint(f"--- Modifying PR #{pr_number} in {head_repo_owner}/{head_repo_name} branch {head_branch} ---")
response, duration = self._request("POST", f"repos/{head_repo_owner}/{head_repo_name}/diffpatch", json={
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={
"branch": head_branch,
"content": diff_content,
"message": message
@@ -414,15 +386,15 @@ index 0000000..{pkg_b_sha}
def update_gitea_pr_properties(self, repo_full_name: str, pr_number: int, **kwargs):
owner, repo = repo_full_name.split("/")
url = f"repos/{owner}/{repo}/pulls/{pr_number}"
response, duration = self._request("PATCH", url, json=kwargs)
response = self._request("PATCH", url, json=kwargs)
return response.json()
def create_issue_comment(self, repo_full_name: str, issue_number: int, body: str):
owner, repo = repo_full_name.split("/")
url = f"repos/{owner}/{repo}/issues/{issue_number}/comments"
data = {"body": body}
vprint(f"--- Creating comment on {repo_full_name} issue #{issue_number} ---")
response, duration = self._request("POST", url, json=data)
print(f"--- Creating comment on {repo_full_name} issue #{issue_number} ---")
response = self._request("POST", url, json=data)
return response.json()
def get_timeline_events(self, repo_full_name: str, pr_number: int):
@@ -432,15 +404,15 @@ index 0000000..{pkg_b_sha}
# Retry logic for timeline events
for i in range(10): # Try up to 10 times
try:
response, duration = self._request("GET", url)
response = self._request("GET", url)
timeline_events = response.json()
if timeline_events: # Check if timeline_events list is not empty
return timeline_events
vprint(f"Attempt {i+1}: Timeline for PR {pr_number} is empty. Retrying in 1 seconds...")
print(f"Attempt {i+1}: Timeline for PR {pr_number} is empty. Retrying in 1 seconds...")
time.sleep(1)
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
vprint(f"Attempt {i+1}: Timeline for PR {pr_number} not found yet. Retrying in 1 seconds...")
print(f"Attempt {i+1}: Timeline for PR {pr_number} not found yet. Retrying in 1 seconds...")
time.sleep(1)
else:
raise # Re-raise other HTTP errors
@@ -453,16 +425,16 @@ index 0000000..{pkg_b_sha}
# Retry logic for comments
for i in range(10): # Try up to 10 times
try:
response, duration = self._request("GET", url)
response = self._request("GET", url)
comments = response.json()
vprint(f"[{duration:.3f}s] 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
return comments
vprint(f"Attempt {i+1}: Comments for PR {pr_number} are empty. Retrying in 1 seconds...")
print(f"Attempt {i+1}: Comments for PR {pr_number} are empty. Retrying in 1 seconds...")
time.sleep(1)
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
vprint(f"Attempt {i+1}: Comments for PR {pr_number} not found yet. Retrying in 1 seconds...")
print(f"Attempt {i+1}: Comments for PR {pr_number} not found yet. Retrying in 1 seconds...")
time.sleep(1)
else:
raise # Re-raise other HTTP errors
@@ -471,7 +443,7 @@ index 0000000..{pkg_b_sha}
def get_pr_details(self, repo_full_name: str, pr_number: int):
owner, repo = repo_full_name.split("/")
url = f"repos/{owner}/{repo}/pulls/{pr_number}"
response, duration = self._request("GET", url)
response = self._request("GET", url)
return response.json()
def create_review(self, repo_full_name: str, pr_number: int, event: str = "APPROVED", body: str = "LGTM"):
@@ -482,7 +454,7 @@ index 0000000..{pkg_b_sha}
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":
vprint(f"User {current_user} already has an APPROVED review for {repo_full_name} PR #{pr_number}")
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"
@@ -490,13 +462,13 @@ index 0000000..{pkg_b_sha}
"event": event,
"body": body
}
vprint(f"--- Creating and submitting review ({event}) for {repo_full_name} PR #{pr_number} as {current_user} ---")
print(f"--- Creating and submitting review ({event}) for {repo_full_name} PR #{pr_number} as {current_user} ---")
try:
response, duration = self._request("POST", url, json=data)
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
vprint(f"Failed to create review: {e.response.text}")
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)
@@ -514,11 +486,11 @@ index 0000000..{pkg_b_sha}
"body": body
}
try:
response, duration = self._request("POST", submit_url, json=submit_data)
vprint(f"[{duration:.3f}s] --- Review {review_id} submitted ---")
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():
vprint(f"Review {review_id} could not be submitted further: {e.response.text}")
print(f"Review {review_id} could not be submitted further: {e.response.text}")
else:
raise
@@ -527,65 +499,34 @@ index 0000000..{pkg_b_sha}
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, duration = self._request("GET", url)
response = self._request("GET", url)
return response.json()
def approve_requested_reviews(self, repo_full_name: str, pr_number: int):
vprint(f"--- Checking for REQUEST_REVIEW state in {repo_full_name} PR #{pr_number} ---")
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:
vprint(f"No reviews in REQUEST_REVIEW state found for {repo_full_name} PR #{pr_number}")
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"]
vprint(f"Reacting on REQUEST_REVIEW for user {reviewer_username} by approving...")
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 wait_for_project_pr(self, package_pr_repo, package_pr_number, project_pr_repo="myproducts/mySLFO", timeout=60):
vprint(f"Polling {package_pr_repo} PR #{package_pr_number} timeline for forwarded PR event in {project_pr_repo}...")
for _ in range(timeout):
time.sleep(1)
timeline_events = self.get_timeline_events(package_pr_repo, package_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(fr"{project_pr_repo}/pulls/(\d+)", url_to_check)
if match:
return int(match.group(1))
return None
def 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
def approve_and_wait_merge(self, package_pr_repo, package_pr_number, project_pr_number, project_pr_repo="myproducts/mySLFO", timeout=30):
vprint(f"Approving reviews and verifying both PRs are merged ({package_pr_repo}#{package_pr_number} and {project_pr_repo}#{project_pr_number})...")
package_merged = False
project_merged = False
for i in range(timeout):
self.approve_requested_reviews(package_pr_repo, package_pr_number)
self.approve_requested_reviews(project_pr_repo, project_pr_number)
if not package_merged:
pkg_details = self.get_pr_details(package_pr_repo, package_pr_number)
if pkg_details.get("merged"):
package_merged = True
vprint(f"Package PR {package_pr_repo}#{package_pr_number} merged.")
if not project_merged:
prj_details = self.get_pr_details(project_pr_repo, project_pr_number)
if prj_details.get("merged"):
project_merged = True
vprint(f"Project PR {project_pr_repo}#{project_pr_number} merged.")
if package_merged and project_merged:
return True, True
time.sleep(1)
return package_merged, project_merged

View File

@@ -1,9 +1,12 @@
import pytest
import re
import time
import subprocess
import requests
from pathlib import Path
from tests.lib.common_test_utils import (
GiteaAPIClient,
mock_build_result,
)
# =============================================================================
@@ -18,10 +21,29 @@ def test_pr_workflow_succeeded(staging_main_env, mock_build_result):
pr = gitea_env.create_gitea_pr("mypool/pkgA", diff, "Test PR - should succeed", False, base_branch=merge_branch_name)
initial_pr_number = pr["number"]
forwarded_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", initial_pr_number)
compose_dir = Path(__file__).parent.parent
forwarded_pr_number = None
print(
f"Polling mypool/pkgA PR #{initial_pr_number} timeline for forwarded PR event..."
)
for _ in range(20):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", initial_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
forwarded_pr_number = match.group(1)
break
if forwarded_pr_number:
break
assert (
forwarded_pr_number is not None
), "Workflow bot did not create a project PR."
), "Workflow bot did not create a pull_ref event on the timeline."
print(f"Found forwarded PR: myproducts/mySLFO #{forwarded_pr_number}")
print(f"Polling myproducts/mySLFO PR #{forwarded_pr_number} for reviewer assignment...")
@@ -38,10 +60,17 @@ def test_pr_workflow_succeeded(staging_main_env, mock_build_result):
assert reviewer_added, "Staging bot was not added as a reviewer."
print("Staging bot has been added as a reviewer.")
mock_build_result(package_name="pkgA", code="succeeded")
mock_build_result(package_name="pkgA", code="succeeded")
print("Restarting obs-staging-bot...")
subprocess.run(
["podman-compose", "restart", "obs-staging-bot"],
cwd=compose_dir,
check=True,
capture_output=True,
)
print(f"Polling myproducts/mySLFO PR #{forwarded_pr_number} for final status...")
status_comment_found = False
for _ in range(20):
time.sleep(1)
@@ -63,10 +92,29 @@ def test_pr_workflow_failed(staging_main_env, mock_build_result):
pr = gitea_env.create_gitea_pr("mypool/pkgA", diff, "Test PR - should fail", False, base_branch=merge_branch_name)
initial_pr_number = pr["number"]
forwarded_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", initial_pr_number)
compose_dir = Path(__file__).parent.parent
forwarded_pr_number = None
print(
f"Polling mypool/pkgA PR #{initial_pr_number} timeline for forwarded PR event..."
)
for _ in range(20):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", initial_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
forwarded_pr_number = match.group(1)
break
if forwarded_pr_number:
break
assert (
forwarded_pr_number is not None
), "Workflow bot did not create a project PR."
), "Workflow bot did not create a pull_ref event on the timeline."
print(f"Found forwarded PR: myproducts/mySLFO #{forwarded_pr_number}")
print(f"Polling myproducts/mySLFO PR #{forwarded_pr_number} for reviewer assignment...")
@@ -85,6 +133,14 @@ def test_pr_workflow_failed(staging_main_env, mock_build_result):
mock_build_result(package_name="pkgA", code="failed")
print("Restarting obs-staging-bot...")
subprocess.run(
["podman-compose", "restart", "obs-staging-bot"],
cwd=compose_dir,
check=True,
capture_output=True,
)
print(f"Polling myproducts/mySLFO PR #{forwarded_pr_number} for final status...")
status_comment_found = False
for _ in range(20):

View File

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

View File

@@ -25,18 +25,59 @@ index 0000000..e69de29
print(f"Created package PR mypool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
project_pr_number = None
print(f"Polling mypool/pkgA PR #{package_pr_number} timeline for forwarded PR event...")
for _ in range(40):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", package_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
project_pr_number = int(match.group(1))
break
if project_pr_number:
break
assert project_pr_number is not None, "Workflow bot did not create a project PR."
print(f"Found project PR: myproducts/mySLFO#{project_pr_number}")
# 3. Approve reviews and verify merged
pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number)
assert pkg_merged, f"Package PR mypool/pkgA#{package_pr_number} was not merged automatically."
assert prj_merged, f"Project PR myproducts/mySLFO#{project_pr_number} was not merged automatically."
print("Approving reviews and verifying both PRs are merged...")
package_merged = False
project_merged = False
for i in range(20): # Poll for up to 20 seconds
gitea_env.approve_requested_reviews("mypool/pkgA", package_pr_number)
gitea_env.approve_requested_reviews("myproducts/mySLFO", project_pr_number)
if not package_merged:
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
if pkg_details.get("merged"):
package_merged = True
print(f"Package PR mypool/pkgA#{package_pr_number} merged.")
# Project PR
if not project_merged:
prj_details = gitea_env.get_pr_details("myproducts/mySLFO", project_pr_number)
if prj_details.get("merged"):
project_merged = True
print(f"Project PR myproducts/mySLFO#{project_pr_number} merged.")
if package_merged and project_merged:
break
time.sleep(1)
assert package_merged, f"Package PR mypool/pkgA#{package_pr_number} was not merged automatically."
assert project_merged, f"Project PR myproducts/mySLFO#{project_pr_number} was not merged automatically."
print("Both PRs merged successfully.")
@pytest.mark.t002
def test_002_manual_merge(manual_merge_env, test_user_client, usera_client, staging_bot_client, ownerA_client):
def test_002_manual_merge(manual_merge_env, test_user_client, usera_client, staging_bot_client):
"""
Test scenario TC-MERGE-002:
1. Create a PackageGit PR with ManualMergeOnly set to true.
@@ -52,24 +93,39 @@ new file mode 100644
index 0000000..e69de29
"""
print(f"--- Creating package PR in mypool/pkgA on branch {merge_branch_name} ---")
package_pr = gitea_env.create_gitea_pr("mypool/pkgA", diff, "Test Manual Merge Fixture", False, base_branch=merge_branch_name)
package_pr = test_user_client.create_gitea_pr("mypool/pkgA", diff, "Test Manual Merge Fixture", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
print(f"Created package PR mypool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
project_pr_number = None
print(f"Polling mypool/pkgA PR #{package_pr_number} timeline for forwarded PR event...")
for _ in range(40):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("mypool/pkgA", package_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"myproducts/mySLFO/pulls/(\d+)", url_to_check)
if match:
project_pr_number = int(match.group(1))
break
if project_pr_number:
break
assert project_pr_number is not None, "Workflow bot did not create a project PR."
print(f"Found project PR: myproducts/mySLFO#{project_pr_number}")
# 3. Approve reviews and verify NOT merged
print("Waiting for required review requests and approving them...")
print("Waiting for all expected review requests and approving them...")
# Expected reviewers based on manual-merge branch config and pkgA maintainership
mandatory_reviewers = {"usera", "userb"}
maintainers = {"ownerA", "ownerX", "ownerY"}
expected_reviewers = {"usera", "userb", "ownerA", "ownerX", "ownerY"}
# ManualMergeOnly still requires regular reviews to be satisfied.
# We poll until required reviewers have approved.
all_approved = False
# We poll until all expected reviewers are requested, then approve them.
all_requested = False
for _ in range(30):
# Trigger approvals for whatever is already requested
gitea_env.approve_requested_reviews("mypool/pkgA", package_pr_number)
@@ -81,17 +137,20 @@ index 0000000..e69de29
print("Staging bot has a pending/requested review. Approving...")
staging_bot_client.create_review("myproducts/mySLFO", project_pr_number, event="APPROVED", body="Staging bot approves")
# Check if mandatory reviewers and at least one maintainer have approved
# Check if all expected reviewers have at least one review record (any state)
pkg_reviews = gitea_env.list_reviews("mypool/pkgA", package_pr_number)
approved_reviewers = {r["user"]["login"] for r in pkg_reviews if r["state"] == "APPROVED"}
current_reviewers = {r["user"]["login"] for r in pkg_reviews}
if mandatory_reviewers.issubset(approved_reviewers) and any(m in approved_reviewers for m in maintainers):
# And check project PR for bot approval
prj_approved = any(r["user"]["login"] == "autogits_obs_staging_bot" and r["state"] == "APPROVED" for r in prj_reviews)
if prj_approved:
all_approved = True
print(f"Required reviewers approved: mandatory={mandatory_reviewers}, maintainer={[m for m in maintainers if m in approved_reviewers]}, staging_bot=True")
break
if expected_reviewers.issubset(current_reviewers):
# Also ensure they are all approved (not just requested)
approved_reviewers = {r["user"]["login"] for r in pkg_reviews if r["state"] == "APPROVED"}
if expected_reviewers.issubset(approved_reviewers):
# And check project PR for bot approval
prj_approved = any(r["user"]["login"] == "autogits_obs_staging_bot" and r["state"] == "APPROVED" for r in prj_reviews)
if prj_approved:
all_requested = True
print(f"All expected reviewers {expected_reviewers} and staging bot have approved.")
break
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
prj_details = gitea_env.get_pr_details("myproducts/mySLFO", project_pr_number)
@@ -101,12 +160,12 @@ index 0000000..e69de29
time.sleep(2)
assert all_approved, f"Timed out waiting for required approvals. Mandatory: {mandatory_reviewers}, Maintainers: {maintainers}. Current approved: {approved_reviewers}"
assert all_requested, f"Timed out waiting for all expected reviewers {expected_reviewers} to approve. Current: {current_reviewers}"
print("Both PRs have all required approvals but are not merged (as expected with ManualMergeOnly).")
# 4. Comment "merge ok" from a requested reviewer (ownerA)
print("Commenting 'merge ok' on package PR from a maintainer...")
ownerA_client.create_issue_comment("mypool/pkgA", package_pr_number, "merge ok")
# 4. Comment "merge ok" from a requested reviewer (usera)
print("Commenting 'merge ok' on package PR...")
usera_client.create_issue_comment("mypool/pkgA", package_pr_number, "merge ok")
# 5. Verify both PRs are merged
print("Polling for PR merge status...")
@@ -134,359 +193,3 @@ index 0000000..e69de29
assert package_merged, f"Package PR mypool/pkgA#{package_pr_number} was not merged after 'merge ok'."
assert project_merged, f"Project PR myproducts/mySLFO#{project_pr_number} was not merged after 'merge ok'."
print("Both PRs merged successfully after 'merge ok'.")
@pytest.mark.t003
def test_003_refuse_manual_merge(manual_merge_env, test_user_client, ownerB_client, staging_bot_client):
"""
Test scenario TC-MERGE-003:
1. Create a PackageGit PR with ManualMergeOnly set to true.
2. Ensure all mandatory reviews are completed on both project and package PRs.
3. Comment "merge ok" on the package PR from the account of a not requested reviewer.
4. Verify the PR is not merged.
"""
gitea_env, test_full_repo_name, merge_branch_name = manual_merge_env
# 1. Create a package PR
diff = """diff --git a/manual_merge_test.txt b/manual_merge_test.txt
new file mode 100644
index 0000000..e69de29
"""
print(f"--- Creating package PR in mypool/pkgA on branch {merge_branch_name} ---")
package_pr = test_user_client.create_gitea_pr("mypool/pkgA", diff, "Test Manual Merge Fixture", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
print(f"Created package PR mypool/pkgA#{package_pr_number}")
# 2. Make sure the workflow-pr service created related project PR
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None, "Workflow bot did not create a project PR."
print(f"Found project PR: myproducts/mySLFO#{project_pr_number}")
# 3. Approve reviews and verify NOT merged
print("Waiting for required review requests and approving them...")
# Expected reviewers based on manual-merge branch config and pkgA maintainership
mandatory_reviewers = {"usera", "userb"}
maintainers = {"ownerA", "ownerX", "ownerY"}
# ManualMergeOnly still requires regular reviews to be satisfied.
# We poll until required reviewers have approved.
all_approved = False
for _ in range(30):
# Trigger approvals for whatever is already requested
gitea_env.approve_requested_reviews("mypool/pkgA", package_pr_number)
gitea_env.approve_requested_reviews("myproducts/mySLFO", project_pr_number)
# Explicitly handle staging bot if it is requested or pending
prj_reviews = gitea_env.list_reviews("myproducts/mySLFO", project_pr_number)
if any(r["user"]["login"] == "autogits_obs_staging_bot" and r["state"] in ["REQUEST_REVIEW", "PENDING"] for r in prj_reviews):
print("Staging bot has a pending/requested review. Approving...")
staging_bot_client.create_review("myproducts/mySLFO", project_pr_number, event="APPROVED", body="Staging bot approves")
# Check if mandatory reviewers and at least one maintainer have approved
pkg_reviews = gitea_env.list_reviews("mypool/pkgA", package_pr_number)
approved_reviewers = {r["user"]["login"] for r in pkg_reviews if r["state"] == "APPROVED"}
if mandatory_reviewers.issubset(approved_reviewers) and any(m in approved_reviewers for m in maintainers):
# And check project PR for bot approval
prj_approved = any(r["user"]["login"] == "autogits_obs_staging_bot" and r["state"] == "APPROVED" for r in prj_reviews)
if prj_approved:
all_approved = True
print(f"Required reviewers approved: mandatory={mandatory_reviewers}, maintainer={[m for m in maintainers if m in approved_reviewers]}, staging_bot=True")
break
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
prj_details = gitea_env.get_pr_details("myproducts/mySLFO", project_pr_number)
assert not pkg_details.get("merged"), "Package PR merged prematurely (ManualMergeOnly ignored?)"
assert not prj_details.get("merged"), "Project PR merged prematurely (ManualMergeOnly ignored?)"
time.sleep(2)
assert all_approved, f"Timed out waiting for required approvals. Mandatory: {mandatory_reviewers}, Maintainers: {maintainers}. Current approved: {approved_reviewers}"
print("Both PRs have all required approvals but are not merged (as expected with ManualMergeOnly).")
# 4. Comment "merge ok" from a requested reviewer (ownerB)
print("Commenting 'merge ok' on package PR as user ownerB ...")
ownerB_client.create_issue_comment("mypool/pkgA", package_pr_number, "merge ok")
# 5. Verify both PRs are merged
print("Polling for PR merge status...")
package_merged = False
project_merged = False
for i in range(20): # Poll for up to 20 seconds
if not package_merged:
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
if pkg_details.get("merged"):
package_merged = True
print(f"Package PR mypool/pkgA#{package_pr_number} merged.")
if not project_merged:
prj_details = gitea_env.get_pr_details("myproducts/mySLFO", project_pr_number)
if prj_details.get("merged"):
project_merged = True
print(f"Project PR myproducts/mySLFO#{project_pr_number} merged.")
if package_merged and project_merged:
break
time.sleep(1)
assert not package_merged, f"Package PR mypool/pkgA#{package_pr_number} was merged after 'merge ok'."
assert not project_merged, f"Project PR myproducts/mySLFO#{project_pr_number} was merged after 'merge ok'."
print("Both PRs merged not after 'merge ok'.")
@pytest.mark.t008
def test_008_merge_mode_ff_only_success(merge_ff_env, test_user_client):
"""
Test MergeMode "ff-only" - Success case (FF-mergeable)
"""
gitea_env, test_full_repo_name, merge_branch_name = merge_ff_env
# 1. Create a package PR (this will be FF-mergeable by default)
diff = """diff --git a/ff_test.txt b/ff_test.txt
new file mode 100644
index 0000000..e69de29
"""
package_pr = test_user_client.create_gitea_pr("mypool/pkgA", diff, "Test FF Merge", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None
pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number)
assert pkg_merged and prj_merged
@pytest.mark.t009
def test_009_merge_mode_ff_only_failure(merge_ff_env, ownerA_client):
"""
Test MergeMode "ff-only" - Failure case (Content Conflict, should NOT merge)
"""
gitea_env, test_full_repo_name, merge_branch_name = merge_ff_env
ts = time.strftime("%H%M%S")
filename = f"ff_fail_test_{ts}.txt"
# 1. Create a package PR that adds a file
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
+PR content
"""
package_pr = ownerA_client.create_gitea_pr("mypool/pkgA", diff, "Test FF Merge Failure (Conflict)", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
# 2. Wait for project PR to be created
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None
print("Making PR non-FF by creating a content conflict in the base branch...")
gitea_env.create_file("mypool", "pkgA", filename, "Conflicting base content\n", branch=merge_branch_name)
print("Approving reviews initially...")
gitea_env.approve_requested_reviews("mypool/pkgA", package_pr_number)
gitea_env.approve_requested_reviews("myproducts/mySLFO", project_pr_number)
print("Pushing another change to PR branch to trigger sync...")
gitea_env.modify_gitea_pr("mypool/pkgA", package_pr_number,
"diff --git a/sync_test.txt b/sync_test.txt\nnew file mode 100644\nindex 0000000..e69de29\n",
"Trigger Sync")
# The bot should detect it's not FF and NOT merge, and re-request reviews because of the new commit
print("Waiting for reviews to be re-requested and approving again...")
time.sleep(10) # Wait for bot to process sync
# Approve again and verify it is NOT merged
print("Approving again and verifying PR is NOT merged (because it's not FF)...")
for i in range(15):
gitea_env.approve_requested_reviews("mypool/pkgA", package_pr_number)
gitea_env.approve_requested_reviews("myproducts/mySLFO", project_pr_number)
time.sleep(1)
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
assert not pkg_details.get("merged"), "Package PR merged despite NOT being FF-mergeable!"
print("FF-only failure (not merged after sync) verified.")
@pytest.mark.t010
def test_010_merge_mode_devel_success(merge_devel_env, ownerA_client):
"""
Test MergeMode "devel" - Success case (Content Conflict, should still merge via force-push)
"""
gitea_env, test_full_repo_name, merge_branch_name = merge_devel_env
ts = time.strftime("%H%M%S")
filename = f"devel_test_{ts}.txt"
# 1. Create a package PR that adds a file
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
+PR content
"""
package_pr = ownerA_client.create_gitea_pr("mypool/pkgA", diff, "Test Devel Merge (Conflict)", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
# 2. Create a content conflict by committing the same file to the base branch
gitea_env.create_file("mypool", "pkgA", filename, "Conflicting base content\n", branch=merge_branch_name)
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None
# Before merge, get the head sha of the package pr and project pr
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
pkg_head_sha = pkg_details["head"]["sha"]
prj_details = gitea_env.get_pr_details("myproducts/mySLFO", project_pr_number)
prj_head_sha = prj_details["head"]["sha"]
pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number)
assert pkg_merged and prj_merged
print("Devel merge (force-push) successful.")
# Verify that pkgA submodule points to the correct SHA
pkgA_submodule_info = gitea_env.get_file_info("myproducts", "mySLFO", "pkgA", branch=merge_branch_name)
assert pkgA_submodule_info["sha"] == pkg_head_sha, f"Submodule pkgA should point to {pkg_head_sha} but points to {pkgA_submodule_info['sha']}"
@pytest.mark.t011
def test_011_merge_mode_replace_success(merge_replace_env, ownerA_client):
"""
Test MergeMode "replace" - Success case (Content Conflict, bot should add merge commit)
"""
gitea_env, test_full_repo_name, merge_branch_name = merge_replace_env
ts = time.strftime("%H%M%S")
filename = f"replace_test_{ts}.txt"
# 1. Create a package PR that adds a file
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
+PR content
"""
package_pr = ownerA_client.create_gitea_pr("mypool/pkgA", diff, "Test Replace Merge (Conflict)", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
# Enable "Allow edits from maintainers"
ownerA_client.update_gitea_pr_properties("mypool/pkgA", package_pr_number, allow_maintainer_edit=True)
# 2. Create a content conflict by committing the same file to the base branch
gitea_env.create_file("mypool", "pkgA", filename, "Conflicting base content\n", branch=merge_branch_name)
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None
# Before merge, get the head sha of the package pr and project pr
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
pkg_head_sha = pkg_details["head"]["sha"]
prj_details = gitea_env.get_pr_details("myproducts/mySLFO", project_pr_number)
prj_head_sha = prj_details["head"]["sha"]
pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number, timeout=60)
assert pkg_merged and prj_merged
print("Replace merge successful.")
# Verify that the project branch HEAD is a merge commit
resp, _ = gitea_env._request("GET", f"repos/myproducts/mySLFO/branches/{merge_branch_name}")
branch_info = resp.json()
new_head_sha = branch_info["commit"]["id"]
resp, _ = gitea_env._request("GET", f"repos/myproducts/mySLFO/git/commits/{new_head_sha}")
commit_details = resp.json()
assert len(commit_details["parents"]) > 1, f"Project branch {merge_branch_name} HEAD should be a merge commit but has {len(commit_details['parents'])} parents"
# Verify that pkgA submodule points to the correct SHA
pkgA_submodule_info = gitea_env.get_file_info("myproducts", "mySLFO", "pkgA", branch=merge_branch_name)
assert pkgA_submodule_info["sha"] == pkg_head_sha, f"Submodule pkgA should point to {pkg_head_sha} but points to {pkgA_submodule_info['sha']}"
@pytest.mark.t012
def test_012_merge_mode_devel_ff_success(merge_devel_env, ownerA_client):
"""
Test MergeMode "devel" - Success case (No Conflict, should fast-forward)
"""
gitea_env, test_full_repo_name, merge_branch_name = merge_devel_env
ts = time.strftime("%H%M%S")
filename = f"devel_ff_test_{ts}.txt"
# 1. Create a package PR (this will be FF-mergeable by default)
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
+PR content
"""
package_pr = ownerA_client.create_gitea_pr("mypool/pkgA", diff, "Test Devel FF Merge", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
pkg_head_sha = pkg_details["head"]["sha"]
pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number)
assert pkg_merged and prj_merged
print("Devel FF merge successful.")
# Verify that the package base branch HEAD is the same as the PR head (FF)
resp, _ = gitea_env._request("GET", f"repos/mypool/pkgA/branches/{merge_branch_name}")
branch_info = resp.json()
new_head_sha = branch_info["commit"]["id"]
assert new_head_sha == pkg_head_sha, f"Package branch {merge_branch_name} HEAD should be {pkg_head_sha} but is {new_head_sha}"
resp, _ = gitea_env._request("GET", f"repos/mypool/pkgA/git/commits/{new_head_sha}")
commit_details = resp.json()
assert len(commit_details["parents"]) == 1, f"Package branch {merge_branch_name} HEAD should have 1 parent but has {len(commit_details['parents'])}"
@pytest.mark.t013
def test_013_merge_mode_replace_ff_success(merge_replace_env, ownerA_client):
"""
Test MergeMode "replace" - Success case (No Conflict, should fast-forward)
"""
gitea_env, test_full_repo_name, merge_branch_name = merge_replace_env
ts = time.strftime("%H%M%S")
filename = f"replace_ff_test_{ts}.txt"
# 1. Create a package PR (this will be FF-mergeable by default)
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
+PR content
"""
package_pr = ownerA_client.create_gitea_pr("mypool/pkgA", diff, "Test Replace FF Merge", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
project_pr_number = gitea_env.wait_for_project_pr("mypool/pkgA", package_pr_number)
assert project_pr_number is not None
pkg_details = gitea_env.get_pr_details("mypool/pkgA", package_pr_number)
pkg_head_sha = pkg_details["head"]["sha"]
pkg_merged, prj_merged = gitea_env.approve_and_wait_merge("mypool/pkgA", package_pr_number, project_pr_number)
assert pkg_merged and prj_merged
print("Replace FF merge successful.")
# Verify that the package base branch HEAD is the same as the PR head (FF)
resp, _ = gitea_env._request("GET", f"repos/mypool/pkgA/branches/{merge_branch_name}")
branch_info = resp.json()
new_head_sha = branch_info["commit"]["id"]
assert new_head_sha == pkg_head_sha, f"Package branch {merge_branch_name} HEAD should be {pkg_head_sha} but is {new_head_sha}"
resp, _ = gitea_env._request("GET", f"repos/mypool/pkgA/git/commits/{new_head_sha}")
commit_details = resp.json()
assert len(commit_details["parents"]) == 1, f"Package branch {merge_branch_name} HEAD should have 1 parent but has {len(commit_details['parents'])}"

View File

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

View File

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

View File

@@ -23,19 +23,12 @@ echo "Waiting for workflow.config in myproducts/mySLFO..."
API_URL="http://gitea-test:3000/api/v1/repos/myproducts/mySLFO/contents/workflow.config"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $GITEA_TOKEN" "$API_URL")
WAITED=false
while [ "$HTTP_STATUS" != "200" ]; do
WAITED=true
echo "workflow.config not found yet (HTTP Status: $HTTP_STATUS). Retrying in 5s..."
sleep 5
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $GITEA_TOKEN" "$API_URL")
done
if [ "$WAITED" = true ]; then
echo "workflow.config found. Sleeping 15s to let other configurations settle..."
sleep 15
fi
# Wait for the shared SSH key to be generated by the gitea setup script
echo "Waiting for /var/lib/gitea/ssh-keys/id_ed25519..."
while [ ! -f /var/lib/gitea/ssh-keys/id_ed25519 ]; do
@@ -70,5 +63,4 @@ package=$(rpm -qa | grep autogits-workflow-pr) || :
echo "!!!!!!!!!!!!!!!! using binary $exe; installed package: $package"
which strings > /dev/null 2>&1 && strings "$exe" | grep -A 2 vcs.revision= | head -4 || :
set -x
exec "$exe" "$@"

View File

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

View File

@@ -131,10 +131,6 @@ func ProcessBuildStatus(project *common.BuildResultList) BuildStatusSummary {
found:
for j := 0; j < len(project.Result); j++ {
common.LogDebug(" found match for @ idx:", j)
if project.Result[i].Dirty {
// ignore possible temporary failures and wait for settling
return BuildStatusSummaryBuilding
}
res := ProcessRepoBuildStatus(project.Result[i].Status)
switch res {
case BuildStatusSummarySuccess:
@@ -877,11 +873,8 @@ func ProcessPullRequest(obs common.ObsClientInterface, gitea common.Gitea, org,
// NOTE: this is user input, so we need some limits here
l := len(stagingConfig.ObsProject)
if l >= len(stagingConfig.StagingProject) || stagingConfig.ObsProject != stagingConfig.StagingProject[0:l] {
// TEMPORARY HACK: We remove this when Factory has switched to git
if ( stagingConfig.ObsProject != "openSUSE:Factory:git" && stagingConfig.StagingProject != "openSUSE:Factory:PullRequest" ) {
common.LogError("StagingProject (", stagingConfig.StagingProject, ") is not child of target project", stagingConfig.ObsProject)
return true, nil
}
common.LogError("StagingProject (", stagingConfig.StagingProject, ") is not child of target project", stagingConfig.ObsProject)
return true, nil
}
}
@@ -1046,7 +1039,6 @@ func ProcessPullRequest(obs common.ObsClientInterface, gitea common.Gitea, org,
}
}
}
switch overallBuildStatus {
case BuildStatusSummarySuccess:
status.Status = common.CommitStatus_Success
@@ -1171,7 +1163,6 @@ var IsDryRun bool
var ProcessPROnly string
var ObsClient common.ObsClientInterface
var BotUser string
var PollInterval = 5 * time.Minute
func ObsWebHostFromApiHost(apihost string) string {
u, err := url.Parse(apihost)
@@ -1194,18 +1185,9 @@ func main() {
flag.StringVar(&ObsApiHost, "obs", "", "API for OBS instance")
flag.StringVar(&ObsWebHost, "obs-web", "", "Web OBS instance, if not derived from the obs config")
flag.BoolVar(&IsDryRun, "dry", false, "Dry-run, don't actually create any build projects or review changes")
pollIntervalStr := flag.String("poll-interval", common.GetEnvOverrideString(os.Getenv("AUTOGITS_STAGING_BOT_POLL_INTERVAL"), ""), "Polling interval for notifications (e.g. 5m, 10s)")
debug := flag.Bool("debug", false, "Turns on debug logging")
flag.Parse()
if len(*pollIntervalStr) > 0 {
if d, err := time.ParseDuration(*pollIntervalStr); err == nil {
PollInterval = d
} else {
common.LogError("Invalid poll interval:", err)
}
}
if *debug {
common.SetLoggingLevel(common.LogLevelDebug)
} else {
@@ -1274,6 +1256,6 @@ func main() {
for {
PollWorkNotifications(ObsClient, gitea)
common.LogInfo("Poll cycle finished")
time.Sleep(PollInterval)
time.Sleep(5 * time.Minute)
}
}

View File

@@ -0,0 +1,45 @@
OBS Status Service
==================
Caches and reports status of a PR as SVG or JSON
Requests for individual PRs statuses:
GET /${PR_HASH}
Update requests for individual PRs statuses:
POST /${PR_HASH}
POST requires cert auth to function.
Areas of Responsibility
-----------------------
* Listens for PR status reports from workflow-pr bot (or other interface)
* Produces SVG output based on GET request
* Produces JSON output based on GET request
Target Usage
------------
* comment section of a PR
* 3rd party tooling
PR Encoding
-----------
PRs are encoded as SHA256 hashes with a salt. This allows the existence of
individual PRs to remain secret while the hash is known to tools that have
read access to the PRs
Encoding data is input into the sha256 hash as follows:
* Salt string, min 100 bytes.
* Organization name of the PR
* Repository name of the PR
* PR number (decimal string)
Encoded PR is then passed to process as mime64 encoded without trailing =.

113
pr-status-service/main.go Normal file
View File

@@ -0,0 +1,113 @@
package main
/*
* This file is part of Autogits.
*
* Copyright © 2024 SUSE LLC
*
* Autogits is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation, either version 2 of the License, or (at your option) any later
* version.
*
* Autogits is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* Foobar. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"encoding/json"
"flag"
"log"
"net/http"
"os"
"strings"
"src.opensuse.org/autogits/common"
)
const (
AppName = "obs-status-service"
)
var obs *common.ObsClient
var debug bool
var salt string
var RabbitMQHost, Topic, orgs *string
func LogDebug(v ...any) {
if debug {
log.Println(v...)
}
}
func main() {
cert := flag.String("cert-file", "", "TLS certificates file")
key := flag.String("key-file", "", "Private key for the TLS certificate")
listen := flag.String("listen", "[::1]:8080", "Listening string")
disableTls := flag.Bool("no-tls", false, "Disable TLS")
obsHost := flag.String("obs-host", "https://api.opensuse.org", "OBS API endpoint for package status information")
flag.BoolVar(&debug, "debug", false, "Enable debug logging")
RabbitMQHost = flag.String("rabbit-mq", "amqps://rabbit.opensuse.org", "RabbitMQ message bus server")
Topic = flag.String("topic", "opensuse.obs", "RabbitMQ topic prefix")
orgs = flag.String("orgs", "opensuse", "Comma separated list of orgs to watch")
flag.Parse()
salt = os.Getenv("PR_STATUS_SALT")
if len(salt) < 100 {
log.Fatal("PR_STATUS_SALT must be at least 100 bytes")
}
common.PanicOnError(common.RequireObsSecretToken())
var err error
if obs, err = common.NewObsClient(*obsHost); err != nil {
log.Fatal(err)
}
http.HandleFunc("GET /{PR_HASH}", func(res http.ResponseWriter, req *http.Request) {
hash := req.PathValue("PR_HASH")
isJSON := strings.HasSuffix(hash, ".json") || req.Header.Get("Accept") == "application/json"
hash = strings.TrimSuffix(hash, ".json")
hash = strings.TrimSuffix(hash, ".svg")
status := GetPRStatus(hash)
if status == nil {
res.WriteHeader(http.StatusNotFound)
return
}
if isJSON {
res.Header().Set("Content-Type", "application/json")
json.NewEncoder(res).Encode(status)
return
}
// default SVG
res.Header().Set("Content-Type", "image/svg+xml")
res.Write([]byte(status.ToSVG()))
})
http.HandleFunc("POST /{PR_HASH}", func(res http.ResponseWriter, req *http.Request) {
hash := req.PathValue("PR_HASH")
var status PRStatus
if err := json.NewDecoder(req.Body).Decode(&status); err != nil {
res.WriteHeader(http.StatusBadRequest)
return
}
UpdatePRStatus(hash, &status)
res.WriteHeader(http.StatusNoContent)
})
go ProcessUpdates()
if *disableTls {
log.Fatal(http.ListenAndServe(*listen, nil))
} else {
log.Fatal(http.ListenAndServeTLS(*listen, *cert, *key, nil))
}
}

110
pr-status-service/rabbit.go Normal file
View File

@@ -0,0 +1,110 @@
package main
import (
"fmt"
"log"
"net/url"
"strings"
"src.opensuse.org/autogits/common"
)
type PRHandler struct{}
func (h *PRHandler) ProcessFunc(request *common.Request) error {
event, ok := request.Data.(*common.PullRequestWebhookEvent)
if !ok {
return nil
}
org := event.Repository.Owner.Username
repo := event.Repository.Name
prNum := fmt.Sprint(event.Number)
hash := CalculatePRHash(salt, org, repo, prNum)
status := &PRStatus{
PR: fmt.Sprintf("%s/%s#%s", org, repo, prNum),
IsReviewed: false,
IsMergeable: event.Pull_Request.State == "open",
MergeStatus: event.Pull_Request.State,
}
UpdatePRStatus(hash, status)
return nil
}
type ReviewHandler struct {
Approved bool
}
func (h *ReviewHandler) ProcessFunc(request *common.Request) error {
event, ok := request.Data.(*common.PullRequestWebhookEvent)
if !ok {
return nil
}
org := event.Repository.Owner.Username
repo := event.Repository.Name
prNum := fmt.Sprint(event.Number)
hash := CalculatePRHash(salt, org, repo, prNum)
status := GetPRStatus(hash)
if status == nil {
status = &PRStatus{
PR: fmt.Sprintf("%s/%s#%s", org, repo, prNum),
}
}
status.IsReviewed = true
isApproved := IsApproved_No
if h.Approved {
isApproved = IsApproved_Yes
status.MergeStatus = "approved"
} else {
status.MergeStatus = "rejected"
}
found := false
for i, r := range status.Reviews {
if r.Reviewer == event.Sender.Username {
status.Reviews[i].IsApproved = isApproved
found = true
break
}
}
if !found {
status.Reviews = append(status.Reviews, ReviewStatus{
Reviewer: event.Sender.Username,
IsApproved: isApproved,
})
}
UpdatePRStatus(hash, status)
return nil
}
func ProcessUpdates() {
if RabbitMQHost == nil || *RabbitMQHost == "" {
return
}
u, err := url.Parse(*RabbitMQHost)
if err != nil {
log.Fatal(err)
}
processor := &common.RabbitMQGiteaEventsProcessor{
Handlers: map[string]common.RequestProcessor{
common.RequestType_PR: &PRHandler{},
common.RequestType_PRReviewAccepted: &ReviewHandler{Approved: true},
common.RequestType_PRReviewRejected: &ReviewHandler{Approved: false},
},
Orgs: strings.Split(*orgs, ","),
}
processor.Connection().RabbitURL = u
err = common.ProcessRabbitMQEvents(processor)
if err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,91 @@
package main
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
"sync"
)
const (
IsApproved_Pending = 0
IsApproved_Yes = 1
IsApproved_No = 2
)
type ReviewStatus struct {
Reviewer string
IsApproved int
}
type PRStatus struct {
PR string
IsReviewed bool
IsMergeable bool
MergeStatus string
Reviews []ReviewStatus
}
func (status *PRStatus) ToSVG() string {
mergeableText := "NO"
mergeableColor := "red"
if status.IsMergeable {
mergeableText = "YES"
mergeableColor = "green"
}
reviewedText := "NO"
reviewedColor := "red"
if status.IsReviewed {
reviewedText = "YES"
reviewedColor = "green"
}
var reviewsBuilder strings.Builder
for i, r := range status.Reviews {
color := "orange"
if r.IsApproved == IsApproved_Yes {
color = "green"
} else if r.IsApproved == IsApproved_No {
color = "red"
}
if i > 0 {
reviewsBuilder.WriteString(", ")
}
fmt.Fprintf(&reviewsBuilder, `<tspan fill="%s">%s</tspan>`, color, r.Reviewer)
}
return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="100">
<rect width="400" height="100" fill="#f8f9fa" stroke="#dee2e6"/>
<text x="10" y="25" font-family="sans-serif" font-size="14" fill="#333">Is Mergeable: <tspan fill="%s" font-weight="bold">%s</tspan></text>
<text x="10" y="45" font-family="sans-serif" font-size="14" fill="#333">Is Reviewed?: <tspan fill="%s" font-weight="bold">%s</tspan></text>
<text x="10" y="65" font-family="sans-serif" font-size="14" fill="#333">Merge Status: %s</text>
<text x="10" y="85" font-family="sans-serif" font-size="14" fill="#333">Reviews: %s</text>
</svg>`, mergeableColor, mergeableText, reviewedColor, reviewedText, status.MergeStatus, reviewsBuilder.String())
}
var prStatuses = make(map[string]*PRStatus)
var prStatusesLock sync.RWMutex
func GetPRStatus(hash string) *PRStatus {
prStatusesLock.RLock()
defer prStatusesLock.RUnlock()
return prStatuses[hash]
}
func UpdatePRStatus(hash string, status *PRStatus) {
prStatusesLock.Lock()
defer prStatusesLock.Unlock()
prStatuses[hash] = status
}
func CalculatePRHash(salt, org, repo, prNum string) string {
h := sha256.New()
h.Write([]byte(salt))
h.Write([]byte(org))
h.Write([]byte(repo))
h.Write([]byte(prNum))
return base64.RawStdEncoding.EncodeToString(h.Sum(nil))
}

View File

@@ -0,0 +1,185 @@
package main
import (
"src.opensuse.org/autogits/common"
"testing"
)
func TestCalculatePRHash(t *testing.T) {
salt = "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890" // 100 bytes
org := "opensuse"
repo := "pr-status-service"
prNum := "1"
hash := CalculatePRHash(salt, org, repo, prNum)
if hash == "" {
t.Errorf("Hash is empty")
}
hash2 := CalculatePRHash(salt, org, repo, prNum)
if hash != hash2 {
t.Errorf("Hash is not consistent")
}
// Different inputs should give different hashes
hash3 := CalculatePRHash(salt, org, repo, "2")
if hash == hash3 {
t.Errorf("Hashes for different PR numbers are same")
}
}
func TestPRStatusToSVG(t *testing.T) {
status := &PRStatus{
PR: "org/repo#1",
IsMergeable: true,
IsReviewed: true,
MergeStatus: "can be merged",
Reviews: []ReviewStatus{
{Reviewer: "alice", IsApproved: IsApproved_Yes},
{Reviewer: "bob", IsApproved: IsApproved_No},
{Reviewer: "charlie", IsApproved: IsApproved_Pending},
},
}
svg := status.ToSVG()
// Check for key elements in the SVG
expectedStrings := []string{
`Is Mergeable: <tspan fill="green" font-weight="bold">YES</tspan>`,
`Is Reviewed?: <tspan fill="green" font-weight="bold">YES</tspan>`,
`Merge Status: can be merged`,
`<tspan fill="green">alice</tspan>`,
`<tspan fill="red">bob</tspan>`,
`<tspan fill="orange">charlie</tspan>`,
}
for _, expected := range expectedStrings {
if !contains(svg, expected) {
t.Errorf("SVG missing expected string: %s", expected)
}
}
}
func TestPRStatusStore(t *testing.T) {
hash := "test-hash"
status := &PRStatus{PR: "org/repo#123"}
UpdatePRStatus(hash, status)
ret := GetPRStatus(hash)
if ret == nil || ret.PR != status.PR {
t.Errorf("Retrieved status does not match stored status")
}
retNil := GetPRStatus("non-existent")
if retNil != nil {
t.Errorf("Expected nil for non-existent hash")
}
}
func TestHandlers(t *testing.T) {
salt = "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"
repo := &common.Repository{
Name: "test-repo",
Owner: &common.Organization{
Username: "test-org",
},
}
prHandler := &PRHandler{}
prEvent := &common.PullRequestWebhookEvent{
Number: 1,
Repository: repo,
Pull_Request: &common.PullRequest{
State: "open",
},
}
err := prHandler.ProcessFunc(&common.Request{Data: prEvent})
if err != nil {
t.Errorf("PRHandler error: %v", err)
}
hash := CalculatePRHash(salt, "test-org", "test-repo", "1")
status := GetPRStatus(hash)
if status == nil {
t.Fatalf("Status not found after PRHandler")
}
if status.IsMergeable != true {
t.Errorf("Expected mergeable true")
}
// Test ReviewHandler
reviewHandler := &ReviewHandler{Approved: true}
reviewEvent := &common.PullRequestWebhookEvent{
Number: 1,
Repository: repo,
Sender: common.User{
Username: "reviewer1",
},
}
err = reviewHandler.ProcessFunc(&common.Request{Data: reviewEvent})
if err != nil {
t.Errorf("ReviewHandler error: %v", err)
}
status = GetPRStatus(hash)
if !status.IsReviewed {
t.Errorf("Expected IsReviewed true")
}
if len(status.Reviews) != 1 || status.Reviews[0].Reviewer != "reviewer1" || status.Reviews[0].IsApproved != IsApproved_Yes {
t.Errorf("Review not correctly recorded")
}
}
func TestReviewUpdate(t *testing.T) {
repo := &common.Repository{
Name: "test-repo",
Owner: &common.Organization{
Username: "test-org",
},
}
hash := CalculatePRHash(salt, "test-org", "test-repo", "1")
UpdatePRStatus(hash, &PRStatus{PR: "test-org/test-repo#1"})
reviewHandler := &ReviewHandler{Approved: true}
reviewEvent := &common.PullRequestWebhookEvent{
Number: 1,
Repository: repo,
Sender: common.User{
Username: "reviewer1",
},
}
reviewHandler.ProcessFunc(&common.Request{Data: reviewEvent})
// Update same review
reviewHandler.Approved = false
reviewHandler.ProcessFunc(&common.Request{Data: reviewEvent})
status := GetPRStatus(hash)
if len(status.Reviews) != 1 || status.Reviews[0].IsApproved != IsApproved_No {
t.Errorf("Review not correctly updated")
}
}
func TestHandlerErrors(t *testing.T) {
prHandler := &PRHandler{}
err := prHandler.ProcessFunc(&common.Request{Data: nil})
if err != nil {
t.Errorf("Expected nil error for nil data in PRHandler")
}
reviewHandler := &ReviewHandler{}
err = reviewHandler.ProcessFunc(&common.Request{Data: nil})
if err != nil {
t.Errorf("Expected nil error for nil data in ReviewHandler")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || (len(substr) > 0 && (s[:len(substr)] == substr || contains(s[1:], substr))))
}

View File

@@ -503,10 +503,7 @@ func updateConfiguration(configFilename string, orgs *[]string) {
os.Exit(4)
}
configs, err := common.ResolveWorkflowConfigs(gitea, configFile)
if err != nil {
common.LogError("Failed to resolve some configuration repositories:", err)
}
configs, _ := common.ResolveWorkflowConfigs(gitea, configFile)
configuredRepos = make(map[string][]*common.AutogitConfig)
*orgs = make([]string, 0, 1)
for _, c := range configs {

View File

@@ -54,7 +54,6 @@ This is the ProjectGit config file. For runtime config file, see bottom.
| *GitProjectName* | Repository and branch where the ProjectGit lives. | no | string | **Format**: `org/project_repo#branch` | By default assumes `_ObsPrj` with default branch in the *Organization* |
| *ManualMergeOnly* | Merges are permitted only upon receiving a "merge ok" comment from designated maintainers in the PkgGit PR. | no | bool | true, false | false |
| *ManualMergeProject* | Merges are permitted only upon receiving a "merge ok" comment in the ProjectGit PR from project maintainers. | no | bool | true, false | false |
| *MergeMode* | Type of package merge accepted. See below for details. | no | string | ff-only, replace, devel | ff-only |
| *ReviewRequired* | If submitter is a maintainer, require review from another maintainer if available. | no | bool | true, false | false |
| *NoProjectGitPR* | Do not create PrjGit PR, but still perform other tasks. | no | bool | true, false | false |
| *Reviewers* | PrjGit reviewers. Additional review requests are triggered for associated PkgGit PRs. PrjGit PR is merged only when all reviews are complete. | no | array of strings | | `[]` |
@@ -97,19 +96,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.
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
------
@@ -118,6 +104,8 @@ The following labels are used, when defined in Repo/Org.
| Label Config Entry | Default label | Description
|--------------------|----------------|----------------------------------------
| StagingAuto | staging/Auto | Assigned to Project Git PRs when first staged
| ReviewPending | review/Pending | Assigned to Project Git PR when package reviews are still pending
| ReviewDone | review/Done | Assigned to Project Git PR when reviews are complete on all package PRs
Maintainership

View File

@@ -58,7 +58,6 @@ func main() {
checkOnStart := flag.Bool("check-on-start", common.GetEnvOverrideBool(os.Getenv("AUTOGITS_CHECK_ON_START"), false), "Check all repositories for consistency on start, without delays")
checkIntervalHours := flag.Float64("check-interval", 5, "Check interval (+-random delay) for repositories for consitency, in hours")
flag.BoolVar(&ListPROnly, "list-prs-only", false, "Only lists PRs without acting on them")
exitOnConfigError := flag.Bool("exit-on-config-error", false, "Exit if any repository in configuration cannot be resolved")
flag.Int64Var(&PRID, "id", -1, "Process only the specific ID and ignore the rest. Use for debugging")
basePath := flag.String("repo-path", common.GetEnvOverrideString(os.Getenv("AUTOGITS_REPO_PATH"), ""), "Repository path. Default is temporary directory")
pr := flag.String("only-pr", "", "Only specific PR to process. For debugging")
@@ -98,10 +97,8 @@ func main() {
configs, err := common.ResolveWorkflowConfigs(Gitea, config)
if err != nil {
common.LogError("Failed to resolve some configuration repositories:", err)
if *exitOnConfigError {
return
}
common.LogError("Cannot resolve config files:", err)
return
}
for _, c := range configs {

View File

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

View File

@@ -128,7 +128,6 @@ func TestOpenPR(t *testing.T) {
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{"testRepo": "testing"}, nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mockGit.EXPECT().Close().Return(nil).AnyTimes()
@@ -188,7 +187,6 @@ func TestOpenPR(t *testing.T) {
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{"testRepo": "testing"}, nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mockGit.EXPECT().Close().Return(nil).AnyTimes()
@@ -238,7 +236,6 @@ func TestOpenPR(t *testing.T) {
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{"testRepo": "testing"}, nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
gitea.EXPECT().RequestReviews(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
@@ -292,7 +289,6 @@ func TestOpenPR(t *testing.T) {
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{"testRepo": "testing"}, nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mockGit.EXPECT().Close().Return(nil).AnyTimes()
@@ -347,7 +343,6 @@ func TestOpenPR(t *testing.T) {
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{"testRepo": "testing"}, nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mockGit.EXPECT().Close().Return(nil).AnyTimes()

View File

@@ -782,10 +782,7 @@ func TestPRProcessor_Process_EdgeCases(t *testing.T) {
gitea.EXPECT().FetchMaintainershipFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
// Mock expectations for the merged path
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil)
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{"pkg-a": "old-sha"}, nil).AnyTimes()
gitea.EXPECT().GetRecentCommits(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.Commit{{SHA: "pkg-sha"}}, nil).AnyTimes()
@@ -953,10 +950,6 @@ func TestProcessFunc(t *testing.T) {
mockGit.EXPECT().GetPath().Return("/tmp").AnyTimes()
mockGit.EXPECT().Close().Return(nil)
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
// Expect Process calls (mocked via mockGit mostly)
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()

View File

@@ -185,9 +185,6 @@ func TestDefaultStateChecker_ProcessPR(t *testing.T) {
mockGit.EXPECT().GetPath().Return("/tmp").AnyTimes()
mockGit.EXPECT().Close().Return(nil)
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
// Expectations for ProcesPullRequest
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(pr, nil).AnyTimes()
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()