18 Commits

Author SHA256 Message Date
0e05ea2595 pr: Process issue comments in addition to PR comments
All checks were successful
go-generate-check / go-generate-check (pull_request) Successful in 8s
IssueComment webhook is called whenever an issue comment is edited,
added, etc. But IssueComments belong to both Issues and PRs. Gitea
views PRs as a superset of Issues. We must differentiate
betweeen PR and Issue comments at runtime.
2026-02-16 14:20:47 +01:00
9915aa5e1c pr: subscribe to issue
When processing PRs, we need to also handle cases when comments
are added to PRs, namely the "merge ok" comments.

Additionally, New Package Process requires issue processing
2026-02-16 14:00:01 +01:00
f5ec5944db reparent: fix typo
All checks were successful
go-generate-check / go-generate-check (pull_request) Successful in 25s
2026-02-03 22:31:59 +01:00
60af65025b reparent: fix race condition and unit tests 2026-02-03 20:47:15 +01:00
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
20 changed files with 1596 additions and 17 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})
})
}

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

@@ -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)
}

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

@@ -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)
}
}

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)
}
})
}
}

View File

@@ -178,6 +178,8 @@ func main() {
common.RequestType_PRReviewAccepted: req,
common.RequestType_PRReviewRejected: req,
common.RequestType_PRComment: req,
common.RequestType_Issue: req,
common.RequestType_IssueComment: req,
},
}
listenDefs.Connection().RabbitURL, _ = url.Parse(*rabbitUrl)

View File

@@ -666,10 +666,22 @@ func (w *RequestProcessor) ProcessFunc(request *common.Request) (err error) {
}
} else if req, ok := request.Data.(*common.IssueCommentWebhookEvent); ok {
pr, err = Gitea.GetPullRequest(req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
if err != nil {
common.LogError("Cannot find PR for issue:", req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
issue, issueErr := Gitea.GetIssue(req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
if err != nil && issueErr != nil {
common.LogError("Cannot find PR or Issue:", req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
return err
}
if pr == nil && issue != nil {
configs, ok := w.configuredRepos[req.Repository.Owner.Username]
if !ok {
common.LogError("*** Cannot find config for org:", req.Repository.Owner.Username)
return nil
}
processor := &IssueProcessor{
issue: issue,
}
return processor.ProcessIssue(configs)
}
} else if req, ok := request.Data.(*common.IssueWebhookEvent); ok {
issue, err := Gitea.GetIssue(req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
if err != nil {

View File

@@ -915,6 +915,7 @@ func TestProcessFunc(t *testing.T) {
}
gitea.EXPECT().GetPullRequest("test-org", "test-repo", int64(1)).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().GetIssue("test-org", "test-repo", int64(1)).Return(&models.Issue{}, nil).AnyTimes()
mockGitGen.EXPECT().CreateGitHandler(gomock.Any()).Return(mockGit, nil)
mockGit.EXPECT().Close().Return(nil)
@@ -924,6 +925,30 @@ func TestProcessFunc(t *testing.T) {
}
})
t.Run("IssueCommentWebhookEvent - Regular Issue", func(t *testing.T) {
event := &common.IssueCommentWebhookEvent{
Issue: &common.IssueDetail{Number: 2},
Repository: &common.Repository{
Name: "test-repo",
Owner: &common.Organization{Username: "test-org"},
},
}
gitea.EXPECT().GetPullRequest("test-org", "test-repo", int64(2)).Return(nil, errors.New("not a PR"))
gitea.EXPECT().GetIssue("test-org", "test-repo", int64(2)).Return(&models.Issue{
Index: 2,
Repository: &models.RepositoryMeta{
Owner: "test-org",
Name: "test-repo",
},
}, nil)
err := reqProc.ProcessFunc(&common.Request{Data: event})
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
t.Run("Recursion limit", func(t *testing.T) {
reqProc.recursive = 3
err := reqProc.ProcessFunc(&common.Request{})