diff --git a/README.md b/README.md index 4182960..5f1af4d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/common/config_test.go b/common/config_test.go index 2398cda..810e793 100644 --- a/common/config_test.go +++ b/common/config_test.go @@ -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) diff --git a/common/gitea_utils.go b/common/gitea_utils.go index ef78015..38c0677 100644 --- a/common/gitea_utils.go +++ b/common/gitea_utils.go @@ -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 } } diff --git a/common/maintainership_test.go b/common/maintainership_test.go index 90b90fd..ada8b52 100644 --- a/common/maintainership_test.go +++ b/common/maintainership_test.go @@ -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 diff --git a/common/mock/gitea_utils.go b/common/mock/gitea_utils.go index ad37c8c..4f70c73 100644 --- a/common/mock/gitea_utils.go +++ b/common/mock/gitea_utils.go @@ -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() diff --git a/common/pr_linkage_test.go b/common/pr_linkage_test.go index c9f50a3..ae1bc61 100644 --- a/common/pr_linkage_test.go +++ b/common/pr_linkage_test.go @@ -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() diff --git a/common/pr_merge_special_test.go b/common/pr_merge_special_test.go index 94c3f5c..30bd0e0 100644 --- a/common/pr_merge_special_test.go +++ b/common/pr_merge_special_test.go @@ -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) diff --git a/common/pr_test.go b/common/pr_test.go index 4b9fe63..22fa327 100644 --- a/common/pr_test.go +++ b/common/pr_test.go @@ -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() diff --git a/common/reviews_test.go b/common/reviews_test.go index e7685c6..48e1445 100644 --- a/common/reviews_test.go +++ b/common/reviews_test.go @@ -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 { diff --git a/common/test_utils/common.go b/common/test_utils/common.go new file mode 100644 index 0000000..9a42ad8 --- /dev/null +++ b/common/test_utils/common.go @@ -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) +} + diff --git a/common/utils_test.go b/common/utils_test.go index 8a33cc8..d2536ff 100644 --- a/common/utils_test.go +++ b/common/utils_test.go @@ -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}) + }) +} + diff --git a/reparent-bot/bot.go b/reparent-bot/bot.go new file mode 100644 index 0000000..2389b25 --- /dev/null +++ b/reparent-bot/bot.go @@ -0,0 +1,276 @@ +package main + +import ( + "fmt" + "regexp" + "runtime/debug" + "slices" + "strings" + "sync" + "time" + + "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, repo, 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 == nil || issue.User.UserName != sourceInfo.Owner && source.Fork { + user := "(nil)" + if issue.User != nil { + user = issue.User.UserName + } + msg := fmt.Sprintf("@%s: You are not the owner of %s and it is already a fork. Skipping.", user, 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 { + + // Approval must be newer than the last issue update (with some tolerance for latency) + // If the comment is significantly older than the issue update, it applies to an old version. + // We use a 1-minute tolerance to avoid race conditions + if time.Time(e.Created).Before(time.Time(issue.Updated).Add(-1*time.Minute)) && issue.Updated != issue.Created { + common.LogDebug("Ignoring stale approval from %s (created: %v, issue updated: %v)", e.User.UserName, e.Created, issue.Updated) + continue + } + + 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) +} diff --git a/reparent-bot/bot_test.go b/reparent-bot/bot_test.go new file mode 100644 index 0000000..136206f --- /dev/null +++ b/reparent-bot/bot_test.go @@ -0,0 +1,638 @@ +package main + +import ( + "errors" + "testing" + "time" + + "github.com/go-openapi/strfmt" + "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().AddComment(gomock.Any(), gomock.Any()).Return(nil) + }, + wantErr: false, + }, + } + + 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") + }) +} + +func TestProcessIssue_ApprovalReuse(t *testing.T) { + // This test verifies that an approval for a previous version of the issue body (e.g. repo1) + // is IGNORED for a new version (e.g. repo2). + // We simulate an issue that was updated AFTER the approval comment. + + ctrl := test_utils.NewController(t) + defer ctrl.Finish() + mockGitea := mock.NewMockGitea(ctrl) + + mockFetcher := &MockMaintainershipFetcher{} + bot := &ReparentBot{ + botUser: "bot", + maintainershipFetcher: mockFetcher, + configs: common.AutogitConfigs{ + &common.AutogitConfig{Organization: "org", Branch: "master"}, + }, + gitea: mockGitea, + } + + // Timestamps + approvalTime := time.Now().Add(-2 * time.Hour) + issueUpdateTime := time.Now().Add(-1 * time.Hour) + + issue := &models.Issue{ + Index: 100, + State: "open", + Title: "[ADD] My Repo", + Labels: []*models.Label{{Name: common.Label_NewRepository}}, + Ref: "refs/heads/master", + Body: "https://src.opensuse.org/owner/repo2", // Changed to repo2 + User: &models.User{UserName: "owner"}, + Updated: strfmt.DateTime(issueUpdateTime), + Repository: &models.RepositoryMeta{Owner: "org", Name: "repo"}, + } + + // Setup expectations + mockGitea.EXPECT().ResetTimelineCache("org", "repo", int64(100)).AnyTimes() + + // Timeline has an OLD approval + timeline := []*models.TimelineComment{ + { + Type: common.TimelineCommentType_Comment, + User: &models.User{UserName: "m1"}, + Body: "approved", + Created: strfmt.DateTime(approvalTime), + Updated: strfmt.DateTime(approvalTime), + }, + } + mockGitea.EXPECT().GetTimeline("org", "repo", int64(100)).Return(timeline, nil) + + // Maintainers + mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} + mockFetcher.err = nil + + // Repo info fetching + repo2 := &models.Repository{Name: "repo2", Owner: &models.User{UserName: "owner"}, DefaultBranch: "master"} + mockGitea.EXPECT().GetRepository("owner", "repo2").Return(repo2, nil) + + // Check for existing repo in target org + mockGitea.EXPECT().GetRepository("org", "repo2").Return(nil, nil) + + // CRITICAL: We expect ReparentRepository to NOT be called because the approval is stale. + mockGitea.EXPECT().ReparentRepository(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + // It should request review instead + mockGitea.EXPECT().UpdateIssue(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).AnyTimes() + + // Execute + common.IsDryRun = false + err := bot.ProcessIssue("org", "repo", issue) + if err != nil { + t.Errorf("ProcessIssue() unexpected error: %v", err) + } +} diff --git a/reparent-bot/main.go b/reparent-bot/main.go new file mode 100644 index 0000000..c6ec173 --- /dev/null +++ b/reparent-bot/main.go @@ -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))) + } +} diff --git a/reparent-bot/main_test.go b/reparent-bot/main_test.go new file mode 100644 index 0000000..9d30db9 --- /dev/null +++ b/reparent-bot/main_test.go @@ -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() +} + diff --git a/reparent-bot/rabbit.go b/reparent-bot/rabbit.go new file mode 100644 index 0000000..2680162 --- /dev/null +++ b/reparent-bot/rabbit.go @@ -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) +} + + diff --git a/reparent-bot/rabbit_test.go b/reparent-bot/rabbit_test.go new file mode 100644 index 0000000..27fa1ab --- /dev/null +++ b/reparent-bot/rabbit_test.go @@ -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) + } + }) + } +}