14 Commits

Author SHA256 Message Date
e78db48ba2 reparent: unit tests 2026-02-03 17:39:57 +01:00
8678167498 reparent: reorganize logic 2026-02-03 17:18:46 +01:00
842284505b reparent: work on issues, not notifications 2026-02-03 15:13:35 +01:00
2d52659223 test: move NewController to common/test_util subpackage
We want to share the NewController logging setup with other tests
across utilities
2026-02-03 14:25:07 +01:00
e1af02efd9 reparent: doc 2026-02-03 14:24:07 +01:00
cc1f178872 reparent: unit tests 2026-02-02 22:58:30 +01:00
40f3cfe238 reparent: add comments once 2026-02-02 21:08:55 +01:00
6e9f1c8073 reparent: refactor 2026-02-02 20:58:31 +01:00
443b7eca24 reparent: use timeline 2026-02-02 20:12:28 +01:00
c355624194 rebase: branch is optional 2026-02-02 14:37:56 +01:00
2bd6321fb6 reparent: parse branch information for source repository 2026-02-02 13:46:16 +01:00
67d57c4eae reparent: handle multipe repositories in request 2026-02-02 12:29:08 +01:00
5abb1773db reparent: move reparent to common interface 2026-02-01 05:08:17 +01:00
2fb18c4641 reparent: first version 2026-02-01 04:51:49 +01:00
17 changed files with 1465 additions and 15 deletions

View File

@@ -11,6 +11,7 @@ The bots that drive Git Workflow for package management
* obs-forward-bot -- forwards PR as OBS sr (TODO)
* obs-staging-bot -- build bot for a PR
* obs-status-service -- report build status of an OBS project as an SVG
* reparent-bot -- creates new repositories as reverse-forks of source repositories. Used to add new packages to projects.
* workflow-pr -- keeps PR to _ObsPrj consistent with a PR to a package update
* workflow-direct -- update _ObsPrj based on direct pushes and repo creations/removals from organization
* staging-utils -- review tooling for PR (TODO)

View File

@@ -7,6 +7,7 @@ import (
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
mock_common "src.opensuse.org/autogits/common/mock"
"src.opensuse.org/autogits/common/test_utils"
)
func TestLabelKey(t *testing.T) {
@@ -53,7 +54,7 @@ func TestConfigLabelParser(t *testing.T) {
DefaultBranch: "master",
}
ctl := NewController(t)
ctl := test_utils.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)
@@ -175,7 +176,7 @@ func TestConfigWorkflowParser(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctl := NewController(t)
ctl := test_utils.NewController(t)
gitea := mock_common.NewMockGiteaFileContentAndRepoFetcher(ctl)
gitea.EXPECT().GetRepositoryFileContent("foo", "bar", "", "workflow.config").Return([]byte(test.config_json), "abc", nil)
gitea.EXPECT().GetRepository("foo", "bar").Return(&test.repo, nil)

View File

@@ -79,6 +79,16 @@ type GiteaIssueFetcher interface {
GetIssue(org, repo string, idx int64) (*models.Issue, error)
}
const (
IssueType_All = 0
IssueType_Issue = 1
IssueType_PR = 2
)
type GiteaIssueLister interface {
GetOpenIssues(org, repo, labels string, issue_type int, q string) ([]*models.Issue, error)
}
type GiteaTimelineFetcher interface {
ResetTimelineCache(org, repo string, idx int64)
GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error)
@@ -179,6 +189,10 @@ type GiteaMerger interface {
ManualMergePR(org, repo string, id int64, commitid string, delBranch bool) error
}
type GiteaReparenter interface {
ReparentRepository(owner, repo, org string) (*models.Repository, error)
}
type Gitea interface {
GiteaComment
GiteaRepoFetcher
@@ -188,6 +202,7 @@ type Gitea interface {
GiteaPRFetcher
GiteaPRUpdater
GiteaMerger
GiteaReparenter
GiteaCommitFetcher
GiteaReviewFetcher
GiteaCommentFetcher
@@ -200,6 +215,7 @@ type Gitea interface {
GiteaLabelGetter
GiteaLabelSettter
GiteaIssueFetcher
GiteaIssueLister
GetNotifications(Type string, since *time.Time) ([]*models.NotificationThread, error)
GetDoneNotifications(Type string, page int64) ([]*models.NotificationThread, error)
@@ -281,6 +297,23 @@ func (gitea *GiteaTransport) UpdatePullRequest(org, repo string, num int64, opti
return pr.Payload, err
}
func (gitea *GiteaTransport) ReparentRepository(owner, repo, org string) (*models.Repository, error) {
params := repository.NewCreateForkParams().
WithOwner(owner).
WithRepo(repo).
WithBody(&models.CreateForkOption{
Organization: org,
Reparent: true,
})
res, err := gitea.client.Repository.CreateFork(params, gitea.transport.DefaultAuthentication)
if err != nil {
return nil, err
}
return res.Payload, nil
}
func (gitea *GiteaTransport) ManualMergePR(org, repo string, num int64, commitid string, delBranch bool) error {
manual_merge := "manually-merged"
_, err := gitea.client.Repository.RepoMergePullRequest(
@@ -528,6 +561,32 @@ func (gitea *GiteaTransport) UpdateIssue(owner, repo string, idx int64, options
return ret.Payload, nil
}
func (gitea *GiteaTransport) GetOpenIssues(org, repo, labels string, filter_type int, q string) ([]*models.Issue, error) {
open := "open"
issue_type := "issue"
pr_type := "pull_request"
params := issue.NewIssueListIssuesParams().WithOwner(org).WithRepo(repo).WithState(&open)
if len(labels) > 0 {
params = params.WithLabels(&labels)
}
switch filter_type {
case IssueType_Issue:
params = params.WithType(&issue_type)
case IssueType_PR:
params = params.WithType(&pr_type)
}
if len(q) != 0 {
params = params.WithQ(&q)
}
ret, err := gitea.client.Issue.IssueListIssues(params, gitea.transport.DefaultAuthentication)
if err != nil {
return nil, err
}
return ret.Payload, nil
}
const (
GiteaNotificationType_Pull = "Pull"
)
@@ -842,6 +901,7 @@ func (gitea *GiteaTransport) ResetTimelineCache(org, repo string, idx int64) {
Cache, IsCached := giteaTimelineCache[prID]
if IsCached {
Cache.lastCheck = Cache.lastCheck.Add(-time.Hour)
giteaTimelineCache[prID] = Cache
}
}

View File

@@ -10,6 +10,7 @@ import (
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/client/repository"
mock_common "src.opensuse.org/autogits/common/mock"
"src.opensuse.org/autogits/common/test_utils"
)
func TestMaintainership(t *testing.T) {
@@ -172,7 +173,7 @@ func TestMaintainership(t *testing.T) {
}
t.Run(test.name+"_File", func(t *testing.T) {
ctl := NewController(t)
ctl := test_utils.NewController(t)
mi := mock_common.NewMockGiteaMaintainershipReader(ctl)
// tests with maintainership file
@@ -185,7 +186,7 @@ func TestMaintainership(t *testing.T) {
})
t.Run(test.name+"_Dir", func(t *testing.T) {
ctl := NewController(t)
ctl := test_utils.NewController(t)
mi := mock_common.NewMockGiteaMaintainershipReader(ctl)
// run same tests with directory maintainership data

View File

@@ -207,6 +207,69 @@ func (c *MockGiteaIssueFetcherGetIssueCall) DoAndReturn(f func(string, string, i
return c
}
// MockGiteaIssueLister is a mock of GiteaIssueLister interface.
type MockGiteaIssueLister struct {
ctrl *gomock.Controller
recorder *MockGiteaIssueListerMockRecorder
isgomock struct{}
}
// MockGiteaIssueListerMockRecorder is the mock recorder for MockGiteaIssueLister.
type MockGiteaIssueListerMockRecorder struct {
mock *MockGiteaIssueLister
}
// NewMockGiteaIssueLister creates a new mock instance.
func NewMockGiteaIssueLister(ctrl *gomock.Controller) *MockGiteaIssueLister {
mock := &MockGiteaIssueLister{ctrl: ctrl}
mock.recorder = &MockGiteaIssueListerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockGiteaIssueLister) EXPECT() *MockGiteaIssueListerMockRecorder {
return m.recorder
}
// GetOpenIssues mocks base method.
func (m *MockGiteaIssueLister) GetOpenIssues(org, repo, labels string, issue_type int, q string) ([]*models.Issue, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOpenIssues", org, repo, labels, issue_type, q)
ret0, _ := ret[0].([]*models.Issue)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOpenIssues indicates an expected call of GetOpenIssues.
func (mr *MockGiteaIssueListerMockRecorder) GetOpenIssues(org, repo, labels, issue_type, q any) *MockGiteaIssueListerGetOpenIssuesCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOpenIssues", reflect.TypeOf((*MockGiteaIssueLister)(nil).GetOpenIssues), org, repo, labels, issue_type, q)
return &MockGiteaIssueListerGetOpenIssuesCall{Call: call}
}
// MockGiteaIssueListerGetOpenIssuesCall wrap *gomock.Call
type MockGiteaIssueListerGetOpenIssuesCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaIssueListerGetOpenIssuesCall) Return(arg0 []*models.Issue, arg1 error) *MockGiteaIssueListerGetOpenIssuesCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaIssueListerGetOpenIssuesCall) Do(f func(string, string, string, int, string) ([]*models.Issue, error)) *MockGiteaIssueListerGetOpenIssuesCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaIssueListerGetOpenIssuesCall) DoAndReturn(f func(string, string, string, int, string) ([]*models.Issue, error)) *MockGiteaIssueListerGetOpenIssuesCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockGiteaTimelineFetcher is a mock of GiteaTimelineFetcher interface.
type MockGiteaTimelineFetcher struct {
ctrl *gomock.Controller
@@ -2458,6 +2521,69 @@ func (c *MockGiteaMergerManualMergePRCall) DoAndReturn(f func(string, string, in
return c
}
// MockGiteaReparenter is a mock of GiteaReparenter interface.
type MockGiteaReparenter struct {
ctrl *gomock.Controller
recorder *MockGiteaReparenterMockRecorder
isgomock struct{}
}
// MockGiteaReparenterMockRecorder is the mock recorder for MockGiteaReparenter.
type MockGiteaReparenterMockRecorder struct {
mock *MockGiteaReparenter
}
// NewMockGiteaReparenter creates a new mock instance.
func NewMockGiteaReparenter(ctrl *gomock.Controller) *MockGiteaReparenter {
mock := &MockGiteaReparenter{ctrl: ctrl}
mock.recorder = &MockGiteaReparenterMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockGiteaReparenter) EXPECT() *MockGiteaReparenterMockRecorder {
return m.recorder
}
// ReparentRepository mocks base method.
func (m *MockGiteaReparenter) ReparentRepository(owner, repo, org string) (*models.Repository, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ReparentRepository", owner, repo, org)
ret0, _ := ret[0].(*models.Repository)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ReparentRepository indicates an expected call of ReparentRepository.
func (mr *MockGiteaReparenterMockRecorder) ReparentRepository(owner, repo, org any) *MockGiteaReparenterReparentRepositoryCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReparentRepository", reflect.TypeOf((*MockGiteaReparenter)(nil).ReparentRepository), owner, repo, org)
return &MockGiteaReparenterReparentRepositoryCall{Call: call}
}
// MockGiteaReparenterReparentRepositoryCall wrap *gomock.Call
type MockGiteaReparenterReparentRepositoryCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaReparenterReparentRepositoryCall) Return(arg0 *models.Repository, arg1 error) *MockGiteaReparenterReparentRepositoryCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaReparenterReparentRepositoryCall) Do(f func(string, string, string) (*models.Repository, error)) *MockGiteaReparenterReparentRepositoryCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaReparenterReparentRepositoryCall) DoAndReturn(f func(string, string, string) (*models.Repository, error)) *MockGiteaReparenterReparentRepositoryCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockGitea is a mock of Gitea interface.
type MockGitea struct {
ctrl *gomock.Controller
@@ -3030,6 +3156,45 @@ func (c *MockGiteaGetNotificationsCall) DoAndReturn(f func(string, *time.Time) (
return c
}
// GetOpenIssues mocks base method.
func (m *MockGitea) GetOpenIssues(org, repo, labels string, issue_type int, q string) ([]*models.Issue, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOpenIssues", org, repo, labels, issue_type, q)
ret0, _ := ret[0].([]*models.Issue)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOpenIssues indicates an expected call of GetOpenIssues.
func (mr *MockGiteaMockRecorder) GetOpenIssues(org, repo, labels, issue_type, q any) *MockGiteaGetOpenIssuesCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOpenIssues", reflect.TypeOf((*MockGitea)(nil).GetOpenIssues), org, repo, labels, issue_type, q)
return &MockGiteaGetOpenIssuesCall{Call: call}
}
// MockGiteaGetOpenIssuesCall wrap *gomock.Call
type MockGiteaGetOpenIssuesCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaGetOpenIssuesCall) Return(arg0 []*models.Issue, arg1 error) *MockGiteaGetOpenIssuesCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaGetOpenIssuesCall) Do(f func(string, string, string, int, string) ([]*models.Issue, error)) *MockGiteaGetOpenIssuesCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaGetOpenIssuesCall) DoAndReturn(f func(string, string, string, int, string) ([]*models.Issue, error)) *MockGiteaGetOpenIssuesCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// GetOrganization mocks base method.
func (m *MockGitea) GetOrganization(orgName string) (*models.Organization, error) {
m.ctrl.T.Helper()
@@ -3499,6 +3664,45 @@ func (c *MockGiteaManualMergePRCall) DoAndReturn(f func(string, string, int64, s
return c
}
// ReparentRepository mocks base method.
func (m *MockGitea) ReparentRepository(owner, repo, org string) (*models.Repository, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ReparentRepository", owner, repo, org)
ret0, _ := ret[0].(*models.Repository)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ReparentRepository indicates an expected call of ReparentRepository.
func (mr *MockGiteaMockRecorder) ReparentRepository(owner, repo, org any) *MockGiteaReparentRepositoryCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReparentRepository", reflect.TypeOf((*MockGitea)(nil).ReparentRepository), owner, repo, org)
return &MockGiteaReparentRepositoryCall{Call: call}
}
// MockGiteaReparentRepositoryCall wrap *gomock.Call
type MockGiteaReparentRepositoryCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaReparentRepositoryCall) Return(arg0 *models.Repository, arg1 error) *MockGiteaReparentRepositoryCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaReparentRepositoryCall) Do(f func(string, string, string) (*models.Repository, error)) *MockGiteaReparentRepositoryCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaReparentRepositoryCall) DoAndReturn(f func(string, string, string) (*models.Repository, error)) *MockGiteaReparentRepositoryCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// RequestReviews mocks base method.
func (m *MockGitea) RequestReviews(pr *models.PullRequest, reviewer ...string) ([]*models.PullReview, error) {
m.ctrl.T.Helper()

View File

@@ -7,6 +7,7 @@ import (
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
mock_common "src.opensuse.org/autogits/common/mock"
"src.opensuse.org/autogits/common/test_utils"
)
func TestFetchPRSet_Linkage(t *testing.T) {
@@ -45,7 +46,7 @@ func TestFetchPRSet_Linkage(t *testing.T) {
}
t.Run("Fetch from ProjectGit PR", func(t *testing.T) {
ctl := NewController(t)
ctl := test_utils.NewController(t)
defer ctl.Finish()
mockGitea := mock_common.NewMockGiteaPRTimelineReviewFetcher(ctl)
mockGitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
@@ -74,7 +75,7 @@ func TestFetchPRSet_Linkage(t *testing.T) {
})
t.Run("Fetch from Package PR via Timeline", func(t *testing.T) {
ctl := NewController(t)
ctl := test_utils.NewController(t)
defer ctl.Finish()
mockGitea := mock_common.NewMockGiteaPRTimelineReviewFetcher(ctl)
mockGitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()

View File

@@ -7,10 +7,11 @@ import (
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
mock_common "src.opensuse.org/autogits/common/mock"
"src.opensuse.org/autogits/common/test_utils"
)
func TestPRSet_Merge_Special(t *testing.T) {
ctl := NewController(t)
ctl := test_utils.NewController(t)
defer ctl.Finish()
mockGitea := mock_common.NewMockGiteaReviewUnrequester(ctl)

View File

@@ -13,6 +13,7 @@ import (
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
mock_common "src.opensuse.org/autogits/common/mock"
"src.opensuse.org/autogits/common/test_utils"
)
/*
@@ -561,7 +562,7 @@ func TestPR(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctl := NewController(t)
ctl := test_utils.NewController(t)
pr_mock := mock_common.NewMockGiteaPRTimelineReviewFetcher(ctl)
pr_mock.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
review_mock := mock_common.NewMockGiteaPRChecker(ctl)
@@ -1307,7 +1308,7 @@ func TestPRMerge(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctl := NewController(t)
ctl := test_utils.NewController(t)
mock := mock_common.NewMockGiteaPRTimelineReviewFetcher(ctl)
mock.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
reviewUnrequestMock := mock_common.NewMockGiteaReviewUnrequester(ctl)
@@ -1381,7 +1382,7 @@ func TestPRChanges(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctl := NewController(t)
ctl := test_utils.NewController(t)
mock_fetcher := mock_common.NewMockGiteaPRTimelineReviewFetcher(ctl)
mock_fetcher.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mock_fetcher.EXPECT().GetPullRequest("org", "prjgit", int64(42)).Return(test.PrjPRs, nil).AnyTimes()

View File

@@ -7,6 +7,7 @@ import (
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
mock_common "src.opensuse.org/autogits/common/mock"
"src.opensuse.org/autogits/common/test_utils"
)
func TestReviews(t *testing.T) {
@@ -141,7 +142,7 @@ func TestReviews(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctl := NewController(t)
ctl := test_utils.NewController(t)
rf := mock_common.NewMockGiteaReviewTimelineFetcher(ctl)
if test.timeline == nil {

View File

@@ -0,0 +1,14 @@
package test_utils
import (
"testing"
"go.uber.org/mock/gomock"
"src.opensuse.org/autogits/common"
)
func NewController(t *testing.T) *gomock.Controller {
common.SetTestLogger(t)
return gomock.NewController(t)
}

View File

@@ -3,9 +3,9 @@ package common_test
import (
"reflect"
"testing"
"go.uber.org/mock/gomock"
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
)
func TestGitUrlParse(t *testing.T) {
@@ -307,7 +307,27 @@ func TestNewPackageIssueParsing(t *testing.T) {
}
}
func NewController(t *testing.T) *gomock.Controller {
common.SetTestLogger(t)
return gomock.NewController(t)
func TestIssueToString(t *testing.T) {
t.Run("nil issue", func(t *testing.T) {
if got := common.IssueToString(nil); got != "(nil)" {
t.Errorf("expected (nil), got %s", got)
}
})
t.Run("nil repository", func(t *testing.T) {
// This test currently panics, which is a bug.
common.IssueToString(&models.Issue{Index: 1})
})
}
func TestPRtoString(t *testing.T) {
t.Run("nil pr", func(t *testing.T) {
if got := common.PRtoString(nil); got != "(null)" {
t.Errorf("expected (null), got %s", got)
}
})
t.Run("nil Base", func(t *testing.T) {
// This test currently panics, which is a bug.
common.PRtoString(&models.PullRequest{Index: 1})
})
}

263
reparent-bot/bot.go Normal file
View File

@@ -0,0 +1,263 @@
package main
import (
"fmt"
"regexp"
"runtime/debug"
"slices"
"strings"
"sync"
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
)
type RepoInfo struct {
Owner string
Name string
Branch string
}
type ReparentBot struct {
configs common.AutogitConfigs
gitea common.Gitea
giteaUrl string
botUser string
maintainershipFetcher MaintainershipFetcher
}
func (bot *ReparentBot) ParseSourceReposFromIssue(issue *models.Issue) []RepoInfo {
var sourceRepos []RepoInfo
rx := regexp.MustCompile(`([_a-zA-Z0-9\.-]+)/([_a-zA-Z0-9\.-]+)(?:#([_a-zA-Z0-9\.\-/]+))?$`)
for _, line := range strings.Split(issue.Body, "\n") {
matches := rx.FindStringSubmatch(strings.TrimSpace(line))
if len(matches) == 4 {
sourceRepos = append(sourceRepos, RepoInfo{Owner: matches[1], Name: matches[2], Branch: matches[3]})
}
}
return sourceRepos
}
type MaintainershipFetcher interface {
FetchProjectMaintainershipData(gitea common.GiteaMaintainershipReader, config *common.AutogitConfig) (common.MaintainershipData, error)
}
type RealMaintainershipFetcher struct{}
func (f *RealMaintainershipFetcher) FetchProjectMaintainershipData(gitea common.GiteaMaintainershipReader, config *common.AutogitConfig) (common.MaintainershipData, error) {
return common.FetchProjectMaintainershipData(gitea, config)
}
func (bot *ReparentBot) GetMaintainers(config *common.AutogitConfig) ([]string, error) {
m, err := bot.maintainershipFetcher.FetchProjectMaintainershipData(bot.gitea, config)
if err != nil {
return nil, err
}
return m.ListProjectMaintainers(config.ReviewGroups), nil
}
var serializeMutex sync.Mutex
func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) error {
defer func() {
if r := recover(); r != nil {
common.LogInfo("panic caught --- recovered")
common.LogError(string(debug.Stack()))
}
}()
serializeMutex.Lock()
defer serializeMutex.Unlock()
common.LogInfo(">>> Starting processing issue:", common.IssueToString(issue))
defer common.LogInfo("<<< End processing issue:", common.IssueToString(issue))
if issue.State == "closed" {
return nil
}
if !strings.HasPrefix(strings.ToUpper(issue.Title), "[ADD]") {
return nil
}
newRepoLabel := false
for _, l := range issue.Labels {
if l.Name == common.Label_NewRepository {
newRepoLabel = true
break
}
}
if !newRepoLabel {
common.LogDebug("Not a new repository. Nothing to do here.")
return nil
}
bot.gitea.ResetTimelineCache(org, repo, issue.Index)
timeline, err := bot.gitea.GetTimeline(org, repo, issue.Index)
if err != nil {
common.LogError("Failed to fetch issue timeline:", err)
return err
}
sourceRepos := bot.ParseSourceReposFromIssue(issue)
if len(sourceRepos) == 0 {
common.LogDebug("Could not parse any source repos from issue body")
return nil
}
targetBranch := strings.TrimPrefix(issue.Ref, "refs/heads/")
config := bot.configs.GetPrjGitConfig(org, repo, targetBranch)
if config == nil {
for _, c := range bot.configs {
if c.Organization == org && c.Branch == targetBranch {
config = c
break
}
}
}
if config == nil {
return fmt.Errorf("no config found for %s/%s#%s", org, org, targetBranch)
}
maintainers, err := bot.GetMaintainers(config)
if err != nil {
return err
}
if len(maintainers) == 0 {
return fmt.Errorf("no maintainers found for %s/%s#%s", org, repo, targetBranch)
}
repos := make([]struct {
repo *models.Repository
name string
}, len(sourceRepos))
for idx, sourceInfo := range sourceRepos {
source, err := bot.gitea.GetRepository(sourceInfo.Owner, sourceInfo.Name)
branch := ""
if sourceInfo.Branch != "" {
branch = sourceInfo.Branch
} else if source != nil {
branch = source.DefaultBranch
}
repos[idx].name = sourceInfo.Owner + "/" + sourceInfo.Name + "#" + branch
if err != nil {
common.LogError("failed to fetch source repo", repos[idx].name, ":", err)
return nil
}
if source == nil {
msg := fmt.Sprintf("Source repository not found: %s", repos[idx].name)
bot.AddCommentOnce(org, repo, issue.Index, timeline, msg)
common.LogError(msg)
return nil
}
if source.Parent != nil && source.Parent.Owner.UserName == config.Organization {
common.LogDebug("Already reparented repo. Nothing to do here.")
return nil
}
// README: issue creator *must be* owner of the repo, OR repository must not be a fork
if issue.User.UserName != sourceInfo.Owner && source.Fork {
msg := fmt.Sprintf("@%s: You are not the owner of %s and it is already a fork. Skipping.", issue.User.UserName, repos[idx].name)
bot.AddCommentOnce(org, repo, issue.Index, timeline, msg)
return nil
}
// Check if already exists in target org
existing, err := bot.gitea.GetRepository(org, sourceInfo.Name)
if err == nil && existing != nil {
bot.AddCommentOnce(org, repo, issue.Index, timeline, fmt.Sprintf("Repository %s already exists in organization.", sourceInfo.Name))
return nil
}
repos[idx].repo = source
}
// Check for approval in comments
approved := false
for _, e := range timeline {
if e.Type == common.TimelineCommentType_Comment &&
e.User != nil &&
bot.IsMaintainer(e.User.UserName, maintainers) &&
bot.IsApproval(e.Body) &&
e.Updated == e.Created {
approved = true
break
}
}
if approved {
common.LogInfo("Issue approved, processing source repos...")
if !common.IsDryRun {
for _, source := range repos {
r := source.repo
_, err := bot.gitea.ReparentRepository(r.Owner.UserName, r.Name, org)
if err != nil {
common.LogError("Reparent failed for", source.name, ":", err)
continue
}
bot.AddCommentOnce(org, repo, issue.Index, timeline, fmt.Sprintf("Repository %s forked successfully.", source.name))
}
// bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{State: "closed"})
} else {
common.LogInfo("Dry run: would process %d source repos for issue %d", len(sourceRepos), issue.Index)
}
} else {
// Request review/assignment if not already done
found := false
for _, a := range issue.Assignees {
if bot.IsMaintainer(a.UserName, maintainers) {
found = true
break
}
}
if !found {
common.LogInfo("Requesting review from maintainers:", maintainers)
if !common.IsDryRun {
_, err := bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{
Assignees: maintainers,
})
if err != nil {
common.LogError("Failed to assign maintainers:", err)
}
bot.AddCommentOnce(org, repo, issue.Index, timeline,
"Review requested from maintainers: "+strings.Join(maintainers, ", "))
}
}
}
return nil
}
func (bot *ReparentBot) IsMaintainer(user string, maintainers []string) bool {
return slices.Contains(maintainers, user)
}
func (bot *ReparentBot) IsApproval(body string) bool {
body = strings.ToLower(strings.TrimSpace(body))
return strings.Contains(body, "approved") || strings.Contains(body, "lgtm")
}
func (bot *ReparentBot) HasComment(timeline []*models.TimelineComment, message string) bool {
for _, e := range timeline {
if e.Type == common.TimelineCommentType_Comment && e.User != nil && e.User.UserName == bot.botUser && strings.TrimSpace(e.Body) == strings.TrimSpace(message) {
return true
}
}
return false
}
func (bot *ReparentBot) AddCommentOnce(org, repo string, index int64, timeline []*models.TimelineComment, msg string) {
if bot.HasComment(timeline, msg) {
return
}
bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: index}, msg)
}

561
reparent-bot/bot_test.go Normal file
View File

@@ -0,0 +1,561 @@
package main
import (
"errors"
"testing"
"go.uber.org/mock/gomock"
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
mock "src.opensuse.org/autogits/common/mock"
"src.opensuse.org/autogits/common/test_utils"
)
type MockMaintainershipFetcher struct {
data common.MaintainershipData
err error
}
func (m *MockMaintainershipFetcher) FetchProjectMaintainershipData(gitea common.GiteaMaintainershipReader, config *common.AutogitConfig) (common.MaintainershipData, error) {
return m.data, m.err
}
type MockMaintainershipData struct {
maintainers []string
}
func (m *MockMaintainershipData) ListProjectMaintainers(groups []*common.ReviewGroup) []string {
return m.maintainers
}
func (m *MockMaintainershipData) ListPackageMaintainers(pkg string, groups []*common.ReviewGroup) []string {
return m.maintainers
}
func (m *MockMaintainershipData) IsApproved(pkg string, reviews []*models.PullReview, submitter string, groups []*common.ReviewGroup) bool {
return true
}
func TestParseSourceReposFromIssue(t *testing.T) {
bot := &ReparentBot{}
tests := []struct {
name string
body string
expected []RepoInfo
}{
{
name: "single repo",
body: "https://src.opensuse.org/owner/repo",
expected: []RepoInfo{
{Owner: "owner", Name: "repo", Branch: ""},
},
},
{
name: "repo with branch",
body: "https://src.opensuse.org/owner/repo#branch",
expected: []RepoInfo{
{Owner: "owner", Name: "repo", Branch: "branch"},
},
},
{
name: "multiple repos",
body: "Check these out:\nhttps://src.opensuse.org/o1/r1\nhttps://src.opensuse.org/o2/r2#b2",
expected: []RepoInfo{
{Owner: "o1", Name: "r1", Branch: ""},
{Owner: "o2", Name: "r2", Branch: "b2"},
},
},
{
name: "no repos",
body: "Nothing here",
expected: nil,
},
{
name: "not matching url",
body: "invalid link",
expected: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := bot.ParseSourceReposFromIssue(&models.Issue{Body: tc.body})
if len(got) != len(tc.expected) {
t.Fatalf("expected %d repos, got %d", len(tc.expected), len(got))
}
for i := range got {
if got[i] != tc.expected[i] {
t.Errorf("at index %d: expected %+v, got %+v", i, tc.expected[i], got[i])
}
}
})
}
}
func TestIsMaintainer(t *testing.T) {
bot := &ReparentBot{}
maintainers := []string{"alice", "bob"}
tests := []struct {
user string
expected bool
}{
{"alice", true},
{"bob", true},
{"charlie", false},
}
for _, tc := range tests {
t.Run(tc.user, func(t *testing.T) {
if got := bot.IsMaintainer(tc.user, maintainers); got != tc.expected {
t.Errorf("expected %v for %s, got %v", tc.expected, tc.user, got)
}
})
}
}
func TestIsApproval(t *testing.T) {
bot := &ReparentBot{}
tests := []struct {
body string
expected bool
}{
{"approved", true},
{"LGTM", true},
{"Looks good to me, approved!", true},
{"not yet", false},
{"", false},
}
for _, tc := range tests {
t.Run(tc.body, func(t *testing.T) {
if got := bot.IsApproval(tc.body); got != tc.expected {
t.Errorf("expected %v for %s, got %v", tc.expected, tc.body, got)
}
})
}
}
func TestHasComment(t *testing.T) {
bot := &ReparentBot{botUser: "bot"}
timeline := []*models.TimelineComment{
{Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "user"}, Body: "hello"},
{Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "bot"}, Body: "ping"},
}
tests := []struct {
name string
msg string
expected bool
}{
{"exists", "ping", true},
{"exists with spaces", " ping ", true},
{"not exists", "pong", false},
{"other user", "hello", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := bot.HasComment(timeline, tc.msg); got != tc.expected {
t.Errorf("expected %v for %s, got %v", tc.expected, tc.msg, got)
}
})
}
}
func TestProcessIssue(t *testing.T) {
var mockGitea *mock.MockGitea
mockFetcher := &MockMaintainershipFetcher{}
bot := &ReparentBot{
botUser: "bot",
maintainershipFetcher: mockFetcher,
configs: common.AutogitConfigs{
&common.AutogitConfig{Organization: "org", Branch: "master"},
},
}
tests := []struct {
name string
issue *models.Issue
dryRun bool
setupMock func()
wantErr bool
}{
{
name: "closed issue",
issue: &models.Issue{
State: "closed",
},
setupMock: func() {},
wantErr: false,
},
{
name: "no [ADD] prefix",
issue: &models.Issue{
State: "open",
Title: "Just a comment",
},
setupMock: func() {},
wantErr: false,
},
{
name: "no NewRepository label",
issue: &models.Issue{
State: "open",
Title: "[ADD] My Repo",
},
setupMock: func() {},
wantErr: false,
},
{
name: "timeline fetch error",
issue: &models.Issue{
Index: 10,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
},
setupMock: func() {
mockGitea.EXPECT().GetTimeline("org", "repo", int64(10)).Return(nil, errors.New("error"))
},
wantErr: true,
},
{
name: "no source repos parsed",
issue: &models.Issue{
Index: 11,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
Body: "No links here",
},
setupMock: func() {
mockGitea.EXPECT().GetTimeline("org", "repo", int64(11)).Return(nil, nil)
},
wantErr: false,
},
{
name: "missing config",
issue: &models.Issue{
Index: 1,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
Ref: "refs/heads/wrong-branch",
Body: "https://src.opensuse.org/owner/repo",
},
setupMock: func() {
mockGitea.EXPECT().GetTimeline("org", "repo", int64(1)).Return(nil, nil)
},
wantErr: true,
},
{
name: "config found in loop",
issue: &models.Issue{
Index: 6,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
Ref: "refs/heads/master",
Body: "https://src.opensuse.org/owner/repo",
},
setupMock: func() {
mockGitea.EXPECT().GetTimeline("org", "repo", int64(6)).Return(nil, nil)
mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}}
mockFetcher.err = nil
mockGitea.EXPECT().GetRepository("owner", "repo").Return(&models.Repository{DefaultBranch: "master"}, nil)
mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil)
mockGitea.EXPECT().UpdateIssue("org", "repo", int64(6), gomock.Any()).Return(nil, nil)
mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil)
},
wantErr: false,
},
{
name: "maintainer fetch error",
issue: &models.Issue{
Index: 12,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
Ref: "refs/heads/master",
Body: "https://src.opensuse.org/owner/repo",
},
setupMock: func() {
mockGitea.EXPECT().GetTimeline("org", "repo", int64(12)).Return(nil, nil)
mockFetcher.err = errors.New("error")
},
wantErr: true,
},
{
name: "no maintainers found",
issue: &models.Issue{
Index: 13,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
Ref: "refs/heads/master",
Body: "https://src.opensuse.org/owner/repo",
},
setupMock: func() {
mockGitea.EXPECT().GetTimeline("org", "repo", int64(13)).Return(nil, nil)
mockFetcher.data = &MockMaintainershipData{maintainers: nil}
mockFetcher.err = nil
},
wantErr: true,
},
{
name: "not approved - requests review",
issue: &models.Issue{
Index: 2,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
Ref: "refs/heads/master",
Body: "https://src.opensuse.org/owner/repo",
},
setupMock: func() {
mockGitea.EXPECT().GetTimeline("org", "repo", int64(2)).Return(nil, nil)
mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}}
mockFetcher.err = nil
mockGitea.EXPECT().GetRepository("owner", "repo").Return(&models.Repository{DefaultBranch: "master"}, nil)
mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil)
mockGitea.EXPECT().UpdateIssue("org", "repo", int64(2), gomock.Any()).Return(nil, nil)
mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil)
},
wantErr: false,
},
{
name: "approved - processes repos",
issue: &models.Issue{
Index: 3,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
Ref: "refs/heads/master",
Body: "https://src.opensuse.org/owner/repo",
User: &models.User{UserName: "owner"},
},
setupMock: func() {
timeline := []*models.TimelineComment{
{
Type: common.TimelineCommentType_Comment,
User: &models.User{UserName: "m1"},
Body: "approved",
},
}
mockGitea.EXPECT().GetTimeline("org", "repo", int64(3)).Return(timeline, nil)
mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}}
mockFetcher.err = nil
repo := &models.Repository{Name: "repo", Owner: &models.User{UserName: "owner"}, DefaultBranch: "master"}
mockGitea.EXPECT().GetRepository("owner", "repo").Return(repo, nil)
mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil)
mockGitea.EXPECT().ReparentRepository("owner", "repo", "org").Return(nil, nil)
mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil)
},
wantErr: false,
},
{
name: "approved - dry run",
dryRun: true,
issue: &models.Issue{
Index: 3,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
Ref: "refs/heads/master",
Body: "https://src.opensuse.org/owner/repo",
User: &models.User{UserName: "owner"},
},
setupMock: func() {
timeline := []*models.TimelineComment{
{
Type: common.TimelineCommentType_Comment,
User: &models.User{UserName: "m1"},
Body: "approved",
},
}
mockGitea.EXPECT().GetTimeline("org", "repo", int64(3)).Return(timeline, nil)
mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}}
mockFetcher.err = nil
mockGitea.EXPECT().GetRepository("owner", "repo").Return(&models.Repository{DefaultBranch: "master"}, nil)
mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil)
},
wantErr: false,
},
{
name: "approved - source repo fetch error",
issue: &models.Issue{
Index: 3,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
Ref: "refs/heads/master",
Body: "https://src.opensuse.org/owner/repo",
User: &models.User{UserName: "owner"},
},
setupMock: func() {
timeline := []*models.TimelineComment{
{
Type: common.TimelineCommentType_Comment,
User: &models.User{UserName: "m1"},
Body: "approved",
},
}
mockGitea.EXPECT().GetTimeline("org", "repo", int64(3)).Return(timeline, nil)
mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}}
mockFetcher.err = nil
mockGitea.EXPECT().GetRepository("owner", "repo").Return(nil, errors.New("error"))
},
wantErr: false,
},
{
name: "approved - source repo not found",
issue: &models.Issue{
Index: 3,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
Ref: "refs/heads/master",
Body: "https://src.opensuse.org/owner/repo",
User: &models.User{UserName: "owner"},
},
setupMock: func() {
timeline := []*models.TimelineComment{
{
Type: common.TimelineCommentType_Comment,
User: &models.User{UserName: "m1"},
Body: "approved",
},
}
mockGitea.EXPECT().GetTimeline("org", "repo", int64(3)).Return(timeline, nil)
mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}}
mockFetcher.err = nil
mockGitea.EXPECT().GetRepository("owner", "repo").Return(nil, nil)
mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil)
},
wantErr: false,
},
{
name: "approved - reparent error",
issue: &models.Issue{
Index: 3,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
Ref: "refs/heads/master",
Body: "https://src.opensuse.org/owner/repo",
User: &models.User{UserName: "owner"},
},
setupMock: func() {
timeline := []*models.TimelineComment{
{
Type: common.TimelineCommentType_Comment,
User: &models.User{UserName: "m1"},
Body: "approved",
},
}
mockGitea.EXPECT().GetTimeline("org", "repo", int64(3)).Return(timeline, nil)
mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}}
mockFetcher.err = nil
repo := &models.Repository{Name: "repo", Owner: &models.User{UserName: "owner"}, DefaultBranch: "master"}
mockGitea.EXPECT().GetRepository("owner", "repo").Return(repo, nil)
mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil)
mockGitea.EXPECT().ReparentRepository("owner", "repo", "org").Return(nil, errors.New("error"))
},
wantErr: false,
},
{
name: "user nil panic",
issue: &models.Issue{
Index: 99,
State: "open",
Title: "[ADD] My Repo",
Labels: []*models.Label{{Name: common.Label_NewRepository}},
Ref: "refs/heads/master",
Body: "https://src.opensuse.org/owner/repo",
User: nil,
},
setupMock: func() {
mockGitea.EXPECT().GetTimeline("org", "repo", int64(99)).Return(nil, nil)
mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}}
mockFetcher.err = nil
mockGitea.EXPECT().GetRepository("owner", "repo").Return(&models.Repository{DefaultBranch: "master", Fork: true}, nil)
mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil)
},
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctrl := test_utils.NewController(t)
defer ctrl.Finish()
mockGitea = mock.NewMockGitea(ctrl)
mockGitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
bot.gitea = mockGitea
common.IsDryRun = tc.dryRun
if tc.issue != nil {
if tc.issue.Repository == nil {
tc.issue.Repository = &models.RepositoryMeta{Owner: "org", Name: "repo"}
}
if tc.issue.User == nil && tc.name != "user nil panic" {
tc.issue.User = &models.User{UserName: "owner"}
}
}
tc.setupMock()
err := bot.ProcessIssue("org", "repo", tc.issue)
if (err != nil) != tc.wantErr {
t.Errorf("ProcessIssue() error = %v, wantErr %v", err, tc.wantErr)
}
})
}
common.IsDryRun = false
}
func TestGetMaintainers(t *testing.T) {
mockFetcher := &MockMaintainershipFetcher{}
bot := &ReparentBot{maintainershipFetcher: mockFetcher}
config := &common.AutogitConfig{}
t.Run("success", func(t *testing.T) {
mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}}
mockFetcher.err = nil
got, err := bot.GetMaintainers(config)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(got) != 1 || got[0] != "m1" {
t.Errorf("expected [m1], got %v", got)
}
})
t.Run("error", func(t *testing.T) {
mockFetcher.err = errors.New("error")
_, err := bot.GetMaintainers(config)
if err == nil {
t.Error("expected error, got nil")
}
})
}
func TestAddCommentOnce(t *testing.T) {
ctrl := test_utils.NewController(t)
defer ctrl.Finish()
mockGitea := mock.NewMockGitea(ctrl)
bot := &ReparentBot{gitea: mockGitea, botUser: "bot"}
timeline := []*models.TimelineComment{
{Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "bot"}, Body: "already there"},
}
t.Run("already exists", func(t *testing.T) {
bot.AddCommentOnce("org", "repo", 1, timeline, "already there")
// No expectation means it should NOT call AddComment
})
t.Run("new comment", func(t *testing.T) {
mockGitea.EXPECT().AddComment(gomock.Any(), "new").Return(nil)
bot.AddCommentOnce("org", "repo", 1, timeline, "new")
})
}

142
reparent-bot/main.go Normal file
View File

@@ -0,0 +1,142 @@
package main
import (
"flag"
"net/url"
"os"
"slices"
"time"
"src.opensuse.org/autogits/common"
)
func (bot *ReparentBot) PeriodCheck() {
common.LogDebug("--- starting periodic check ---")
for _, c := range bot.configs {
org, repo, _ := c.GetPrjGit()
issues, err := bot.gitea.GetOpenIssues(org, repo, common.Label_NewRepository, common.IssueType_Issue, "[ADD]")
if err != nil {
common.LogError("Error fetching issues for processing:", err)
return
}
common.LogDebug("Processing potential new issues for", org+"/"+repo, len(issues))
for _, issue := range issues {
err := bot.ProcessIssue(org, repo, issue)
if err != nil {
common.LogError("issue processing error:", err)
}
}
}
common.LogDebug("--- ending periodic check ---")
}
func main() {
giteaUrl := flag.String("gitea-url", "https://src.opensuse.org", "Gitea instance used")
rabbitMqHost := flag.String("rabbit-url", "amqps://rabbit.opensuse.org", "RabbitMQ instance where Gitea webhook notifications are sent")
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]")
flag.BoolVar(&common.IsDryRun, "dry", false, "Dry run, no effect. For debugging")
testMain := flag.Bool("test.main", false, "Internal use for testing main")
flag.Parse()
if *testMain {
return
}
if err := common.SetLoggingLevelFromString(*logging); err != nil {
common.LogError(err.Error())
return
}
if cf := os.Getenv("AUTOGITS_CONFIG"); len(cf) > 0 {
*configFile = cf
}
if url := os.Getenv("AUTOGITS_URL"); len(url) > 0 {
*giteaUrl = url
}
if url := os.Getenv("AUTOGITS_RABBITURL"); len(url) > 0 {
*rabbitMqHost = url
}
if *configFile == "" {
common.LogError("Missing config file")
return
}
configData, err := common.ReadConfigFile(*configFile)
if err != nil {
common.LogError("Failed to read config file", err)
return
}
if err := common.RequireGiteaSecretToken(); err != nil {
common.LogError(err)
return
}
if err := common.RequireRabbitSecrets(); err != nil {
common.LogError(err)
return
}
giteaTransport := common.AllocateGiteaTransport(*giteaUrl)
configs, err := common.ResolveWorkflowConfigs(giteaTransport, configData)
if err != nil {
common.LogError("Cannot parse workflow configs:", err)
return
}
if *interval < 1 {
*interval = 1
}
bot := &ReparentBot{
gitea: giteaTransport,
configs: configs,
giteaUrl: *giteaUrl,
maintainershipFetcher: &RealMaintainershipFetcher{},
}
common.LogInfo(" ** reparent-bot starting")
common.LogInfo(" ** polling interval:", *interval, "min")
common.LogInfo(" ** connecting to RabbitMQ:", *rabbitMqHost)
u, err := url.Parse(*rabbitMqHost)
if err != nil {
common.LogError("Cannot parse RabbitMQ host:", err)
return
}
botUser, err := bot.gitea.GetCurrentUser()
if err != nil {
common.LogError("Failed to fetch current user:", err)
return
}
bot.botUser = botUser.UserName
process_issue := IssueProcessor{
bot: bot,
}
eventsProcessor := &common.RabbitMQGiteaEventsProcessor{
Orgs: []string{},
Handlers: map[string]common.RequestProcessor{
common.RequestType_Issue: &process_issue,
common.RequestType_IssueComment: &process_issue,
},
}
eventsProcessor.Connection().RabbitURL = u
for _, c := range bot.configs {
if org, _, _ := c.GetPrjGit(); !slices.Contains(eventsProcessor.Orgs, org) {
eventsProcessor.Orgs = append(eventsProcessor.Orgs, org)
}
}
go common.ProcessRabbitMQEvents(eventsProcessor)
for {
bot.PeriodCheck()
time.Sleep(time.Duration(*interval * int64(time.Minute)))
}
}

32
reparent-bot/main_test.go Normal file
View File

@@ -0,0 +1,32 @@
package main
import (
"flag"
"os"
"testing"
)
func TestMainFunction(t *testing.T) {
os.Setenv("AUTOGITS_GITEA_TOKEN", "dummy")
os.Setenv("AUTOGITS_RABBIT_USER", "dummy")
os.Setenv("AUTOGITS_RABBIT_PASSWORD", "dummy")
os.Setenv("AUTOGITS_CONFIG", "test_config.json")
// Backup and restore os.Args and flag.CommandLine
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
os.Args = []string{"cmd", "-test.main"}
// Reset flags
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
defer func() {
if r := recover(); r != nil {
t.Log("Recovered from main panic:", r)
}
}()
main()
}

37
reparent-bot/rabbit.go Normal file
View File

@@ -0,0 +1,37 @@
package main
import (
"fmt"
"src.opensuse.org/autogits/common"
)
type IssueProcessor struct {
bot *ReparentBot
}
func (s *IssueProcessor) ProcessFunc(req *common.Request) error {
var org, repo string
var index int64
switch data := req.Data.(type) {
case *common.IssueWebhookEvent:
org = data.Repository.Owner.Username
repo = data.Repository.Name
index = int64(data.Issue.Number)
case *common.IssueCommentWebhookEvent:
org = data.Repository.Owner.Username
repo = data.Repository.Name
index = int64(data.Issue.Number)
default:
return fmt.Errorf("Unhandled request type: %s", req.Type)
}
issue, err := s.bot.gitea.GetIssue(org, repo, index)
if err != nil {
return err
}
return s.bot.ProcessIssue(org, repo, issue)
}

110
reparent-bot/rabbit_test.go Normal file
View File

@@ -0,0 +1,110 @@
package main
import (
"errors"
"testing"
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
mock "src.opensuse.org/autogits/common/mock"
"src.opensuse.org/autogits/common/test_utils"
)
func TestIssueProcessor_ProcessFunc(t *testing.T) {
ctrl := test_utils.NewController(t)
defer ctrl.Finish()
mockGitea := mock.NewMockGitea(ctrl)
bot := &ReparentBot{gitea: mockGitea}
processor := &IssueProcessor{bot: bot}
tests := []struct {
name string
req *common.Request
setupMock func()
wantErr bool
}{
{
name: "issue event",
req: &common.Request{
Type: common.RequestType_Issue,
Data: &common.IssueWebhookEvent{
Repository: &common.Repository{
Name: "repo",
Owner: &common.Organization{Username: "org"},
},
Issue: &common.IssueDetail{
Number: 1,
},
},
},
setupMock: func() {
mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(&models.Issue{
State: "closed",
Repository: &models.RepositoryMeta{Owner: "org", Name: "repo"},
}, nil)
},
wantErr: false,
},
{
name: "issue comment event",
req: &common.Request{
Type: common.RequestType_IssueComment,
Data: &common.IssueCommentWebhookEvent{
Repository: &common.Repository{
Name: "repo",
Owner: &common.Organization{Username: "org"},
},
Issue: &common.IssueDetail{
Number: 2,
},
},
},
setupMock: func() {
mockGitea.EXPECT().GetIssue("org", "repo", int64(2)).Return(&models.Issue{
State: "closed",
Repository: &models.RepositoryMeta{Owner: "org", Name: "repo"},
}, nil)
},
wantErr: false,
},
{
name: "unhandled type",
req: &common.Request{
Type: "unhandled",
Data: nil,
},
setupMock: func() {},
wantErr: true,
},
{
name: "get issue error",
req: &common.Request{
Type: common.RequestType_Issue,
Data: &common.IssueWebhookEvent{
Repository: &common.Repository{
Name: "repo",
Owner: &common.Organization{Username: "org"},
},
Issue: &common.IssueDetail{
Number: 3,
},
},
},
setupMock: func() {
mockGitea.EXPECT().GetIssue("org", "repo", int64(3)).Return(nil, errors.New("error"))
},
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tc.setupMock()
err := processor.ProcessFunc(tc.req)
if (err != nil) != tc.wantErr {
t.Errorf("ProcessFunc() error = %v, wantErr %v", err, tc.wantErr)
}
})
}
}