From 2fb18c4641d87c10cbb4e9e4a0866cb12e364d8a37aa6ce769726030ecb97da0 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Sun, 1 Feb 2026 04:51:49 +0100 Subject: [PATCH 01/16] reparent: first version --- reparent-bot/gitea.go | 47 +++++++++++ reparent-bot/main.go | 177 ++++++++++++++++++++++++++++++++++++++++ reparent-bot/rabbit.go | 180 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 404 insertions(+) create mode 100644 reparent-bot/gitea.go create mode 100644 reparent-bot/main.go create mode 100644 reparent-bot/rabbit.go diff --git a/reparent-bot/gitea.go b/reparent-bot/gitea.go new file mode 100644 index 0000000..1aa9810 --- /dev/null +++ b/reparent-bot/gitea.go @@ -0,0 +1,47 @@ +package main + +import ( + "net/url" + + transport "github.com/go-openapi/runtime/client" + apiclient "src.opensuse.org/autogits/common/gitea-generated/client" + "src.opensuse.org/autogits/common/gitea-generated/client/repository" + "src.opensuse.org/autogits/common/gitea-generated/models" + "src.opensuse.org/autogits/common" +) + +type GiteaReparenter struct { + transport *transport.Runtime + client *apiclient.GiteaAPI +} + +func NewGiteaReparenter(giteaUrl string) *GiteaReparenter { + u, err := url.Parse(giteaUrl) + if err != nil { + return nil + } + + r := &GiteaReparenter{} + r.transport = transport.New(u.Host, apiclient.DefaultBasePath, []string{u.Scheme}) + r.transport.DefaultAuthentication = transport.BearerToken(common.GetGiteaToken()) + r.client = apiclient.New(r.transport, nil) + + return r +} + +func (g *GiteaReparenter) ReparentRepository(owner, repo, org string) (*models.Repository, error) { + params := repository.NewCreateForkParams(). + WithOwner(owner). + WithRepo(repo). + WithBody(&models.CreateForkOption{ + Organization: org, + Reparent: true, + }) + + res, err := g.client.Repository.CreateFork(params, g.transport.DefaultAuthentication) + if err != nil { + return nil, err + } + + return res.Payload, nil +} diff --git a/reparent-bot/main.go b/reparent-bot/main.go new file mode 100644 index 0000000..058f942 --- /dev/null +++ b/reparent-bot/main.go @@ -0,0 +1,177 @@ +package main + +import ( + "flag" + "fmt" + "net/url" + "os" + "regexp" + "runtime/debug" + "slices" + "strconv" + "time" + + "src.opensuse.org/autogits/common" + "src.opensuse.org/autogits/common/gitea-generated/models" +) + +type ReparentBot struct { + configs common.AutogitConfigs + gitea common.Gitea + giteaUrl string +} + +func (bot *ReparentBot) ProcessNotifications(notification *models.NotificationThread) { + defer func() { + if r := recover(); r != nil { + common.LogInfo("panic caught --- recovered") + common.LogError(string(debug.Stack())) + } + }() + + // We only care about Issues for now as per README + if notification.Subject.Type != "Issue" { + return + } + + rx := regexp.MustCompile(`^/?api/v\d+/repos/(?[_\.a-zA-Z0-9-]+)/(?[_\.a-zA-Z0-9-]+)/issues/(?[0-9]+)$`) + u, err := url.Parse(notification.Subject.URL) + if err != nil { + common.LogError("Invalid format of notification:", notification.Subject.URL, err) + return + } + + match := rx.FindStringSubmatch(u.Path) + if match == nil { + common.LogError("** Unexpected format of notification:", notification.Subject.URL) + return + } + + org := match[1] + repo := match[2] + id, _ := strconv.ParseInt(match[3], 10, 64) + + common.LogInfo("processing issue:", fmt.Sprintf("%s/%s#%d", org, repo, id)) + issue, err := bot.gitea.GetIssue(org, repo, id) + if err != nil { + common.LogError(" ** Cannot fetch issue associated with notification:", notification.Subject.URL, "Error:", err) + return + } + + if err := bot.ProcessIssue(org, repo, issue); err == nil && !common.IsDryRun { + if err := bot.gitea.SetNotificationRead(notification.ID); err != nil { + common.LogDebug(" Cannot set notification as read", err) + } + } else if err != nil { + common.LogError(err) + } +} + +func (bot *ReparentBot) PeriodCheck() { + notifications, err := bot.gitea.GetNotifications("Issue", nil) + if err != nil { + common.LogError(" Error fetching unread notifications: %w", err) + return + } + + for _, notification := range notifications { + bot.ProcessNotifications(notification) + } +} + +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") + flag.Parse() + + 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, + } + + 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 + } + + 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))) + } +} \ No newline at end of file diff --git a/reparent-bot/rabbit.go b/reparent-bot/rabbit.go new file mode 100644 index 0000000..da5f481 --- /dev/null +++ b/reparent-bot/rabbit.go @@ -0,0 +1,180 @@ +package main + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "src.opensuse.org/autogits/common" + "src.opensuse.org/autogits/common/gitea-generated/models" +) + +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) +} + +func (bot *ReparentBot) ParseRepoFromIssue(issue *models.Issue) (owner, repo string, err error) { + // Look for URL in body + rx := regexp.MustCompile(`https?://[a-zA-Z0-9\.-]+/([_a-zA-Z0-9-]+)/([_a-zA-Z0-9-]+)`) + matches := rx.FindStringSubmatch(issue.Body) + if len(matches) == 3 { + return matches[1], matches[2], nil + } + return "", "", fmt.Errorf("could not find repo URL in issue body") +} + +func (bot *ReparentBot) GetMaintainers(config *common.AutogitConfig) ([]string, error) { + m, err := common.FetchProjectMaintainershipData(bot.gitea, config) + if err != nil { + return nil, err + } + return m.ListProjectMaintainers(config.ReviewGroups), nil +} + +func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) error { + if issue.State == "closed" { + return nil + } + + if !strings.HasPrefix(strings.ToUpper(issue.Title), "[ADD]") { + return nil + } + + targetOwner, targetRepo, err := bot.ParseRepoFromIssue(issue) + if err != nil { + common.LogDebug("Could not parse repo from issue:", err) + return nil + } + + target, err := bot.gitea.GetRepository(targetOwner, targetRepo) + if err != nil { + return fmt.Errorf("failed to fetch target repo %s/%s: %w", targetOwner, targetRepo, err) + } + if target == nil { + return fmt.Errorf("target repo %s/%s not found", targetOwner, targetRepo) + } + + // README: issue creator *must be* owner of the repo, OR repository must not be a fork + if issue.User.UserName != targetOwner && target.Fork { + msg := fmt.Sprintf("@%s: You are not the owner of %s/%s and it is a fork. Only owners can add their forks, or non-forks can be added by anyone.", issue.User.UserName, targetOwner, targetRepo) + bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, msg) + return nil + } + + config := bot.configs.GetPrjGitConfig(org, repo, "") + if config == nil { + // Try to find any config for this org + for _, c := range bot.configs { + if c.Organization == org { + config = c + break + } + } + } + if config == nil { + return fmt.Errorf("no config found for %s/%s", org, repo) + } + + maintainers, err := bot.GetMaintainers(config) + if err != nil { + return err + } + + if len(maintainers) == 0 { + return fmt.Errorf("no maintainers found for %s/%s", org, repo) + } + + // Check for approval in comments + comments, err := bot.gitea.GetIssueComments(org, repo, issue.Index) + if err != nil { + return err + } + + approved := false + for _, c := range comments { + if bot.IsMaintainer(c.User.UserName, maintainers) && bot.IsApproval(c.Body) { + approved = true + break + } + } + + if approved { + common.LogInfo("Issue approved, forking...") + if !common.IsDryRun { + // Check if already exists + existing, err := bot.gitea.GetRepository(org, targetRepo) + if err == nil && existing != nil { + bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, "Repository already exists in organization.") + } else { + forker := NewGiteaReparenter(bot.giteaUrl) + _, err := forker.ReparentRepository(targetOwner, targetRepo, org) + if err != nil { + return fmt.Errorf("fork failed: %w", err) + } + + bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, "Repository forked successfully.") + } + + bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{State: "closed"}) + } else { + common.LogInfo("Dry run: would fork %s/%s into %s", targetOwner, targetRepo, org) + } + } 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 { + bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{ + Assignees: maintainers, + }) + bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, + "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") +} -- 2.51.1 From 5abb1773dbac4f8a391d67b9a155906395001b7366232c4ad948002ce089d65e Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Sun, 1 Feb 2026 05:08:17 +0100 Subject: [PATCH 02/16] reparent: move reparent to common interface --- common/gitea_utils.go | 22 ++++++++ common/mock/gitea_utils.go | 102 +++++++++++++++++++++++++++++++++++++ reparent-bot/gitea.go | 47 ----------------- reparent-bot/rabbit.go | 3 +- 4 files changed, 125 insertions(+), 49 deletions(-) delete mode 100644 reparent-bot/gitea.go diff --git a/common/gitea_utils.go b/common/gitea_utils.go index ef78015..20e96e1 100644 --- a/common/gitea_utils.go +++ b/common/gitea_utils.go @@ -179,6 +179,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 +192,7 @@ type Gitea interface { GiteaPRFetcher GiteaPRUpdater GiteaMerger + GiteaReparenter GiteaCommitFetcher GiteaReviewFetcher GiteaCommentFetcher @@ -281,6 +286,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( diff --git a/common/mock/gitea_utils.go b/common/mock/gitea_utils.go index ad37c8c..149b935 100644 --- a/common/mock/gitea_utils.go +++ b/common/mock/gitea_utils.go @@ -2458,6 +2458,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 @@ -3499,6 +3562,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/reparent-bot/gitea.go b/reparent-bot/gitea.go deleted file mode 100644 index 1aa9810..0000000 --- a/reparent-bot/gitea.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "net/url" - - transport "github.com/go-openapi/runtime/client" - apiclient "src.opensuse.org/autogits/common/gitea-generated/client" - "src.opensuse.org/autogits/common/gitea-generated/client/repository" - "src.opensuse.org/autogits/common/gitea-generated/models" - "src.opensuse.org/autogits/common" -) - -type GiteaReparenter struct { - transport *transport.Runtime - client *apiclient.GiteaAPI -} - -func NewGiteaReparenter(giteaUrl string) *GiteaReparenter { - u, err := url.Parse(giteaUrl) - if err != nil { - return nil - } - - r := &GiteaReparenter{} - r.transport = transport.New(u.Host, apiclient.DefaultBasePath, []string{u.Scheme}) - r.transport.DefaultAuthentication = transport.BearerToken(common.GetGiteaToken()) - r.client = apiclient.New(r.transport, nil) - - return r -} - -func (g *GiteaReparenter) ReparentRepository(owner, repo, org string) (*models.Repository, error) { - params := repository.NewCreateForkParams(). - WithOwner(owner). - WithRepo(repo). - WithBody(&models.CreateForkOption{ - Organization: org, - Reparent: true, - }) - - res, err := g.client.Repository.CreateFork(params, g.transport.DefaultAuthentication) - if err != nil { - return nil, err - } - - return res.Payload, nil -} diff --git a/reparent-bot/rabbit.go b/reparent-bot/rabbit.go index da5f481..1d6ba13 100644 --- a/reparent-bot/rabbit.go +++ b/reparent-bot/rabbit.go @@ -132,8 +132,7 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro if err == nil && existing != nil { bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, "Repository already exists in organization.") } else { - forker := NewGiteaReparenter(bot.giteaUrl) - _, err := forker.ReparentRepository(targetOwner, targetRepo, org) + _, err := bot.gitea.ReparentRepository(targetOwner, targetRepo, org) if err != nil { return fmt.Errorf("fork failed: %w", err) } -- 2.51.1 From 67d57c4eae90195256abbb3eafc714873a8fbd724da0752aac65b22c492475ef Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 2 Feb 2026 12:29:08 +0100 Subject: [PATCH 03/16] reparent: handle multipe repositories in request --- reparent-bot/rabbit.go | 95 ++++++++++++++++++++++++------------------ 1 file changed, 54 insertions(+), 41 deletions(-) diff --git a/reparent-bot/rabbit.go b/reparent-bot/rabbit.go index 1d6ba13..4ed71fa 100644 --- a/reparent-bot/rabbit.go +++ b/reparent-bot/rabbit.go @@ -39,14 +39,22 @@ func (s *IssueProcessor) ProcessFunc(req *common.Request) error { return s.bot.ProcessIssue(org, repo, issue) } -func (bot *ReparentBot) ParseRepoFromIssue(issue *models.Issue) (owner, repo string, err error) { - // Look for URL in body +type RepoInfo struct { + Owner string + Name string +} + +func (bot *ReparentBot) ParseSourceReposFromIssue(issue *models.Issue) []RepoInfo { + var sourceRepos []RepoInfo rx := regexp.MustCompile(`https?://[a-zA-Z0-9\.-]+/([_a-zA-Z0-9-]+)/([_a-zA-Z0-9-]+)`) - matches := rx.FindStringSubmatch(issue.Body) - if len(matches) == 3 { - return matches[1], matches[2], nil + + for _, line := range strings.Split(issue.Body, "\n") { + matches := rx.FindStringSubmatch(strings.TrimSpace(line)) + if len(matches) == 3 { + sourceRepos = append(sourceRepos, RepoInfo{Owner: matches[1], Name: matches[2]}) + } } - return "", "", fmt.Errorf("could not find repo URL in issue body") + return sourceRepos } func (bot *ReparentBot) GetMaintainers(config *common.AutogitConfig) ([]string, error) { @@ -66,39 +74,25 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro return nil } - targetOwner, targetRepo, err := bot.ParseRepoFromIssue(issue) - if err != nil { - common.LogDebug("Could not parse repo from issue:", err) + sourceRepos := bot.ParseSourceReposFromIssue(issue) + if len(sourceRepos) == 0 { + common.LogDebug("Could not parse any source repos from issue body") return nil } - target, err := bot.gitea.GetRepository(targetOwner, targetRepo) - if err != nil { - return fmt.Errorf("failed to fetch target repo %s/%s: %w", targetOwner, targetRepo, err) - } - if target == nil { - return fmt.Errorf("target repo %s/%s not found", targetOwner, targetRepo) - } + targetBranch := strings.TrimPrefix(issue.Ref, "refs/heads/") - // README: issue creator *must be* owner of the repo, OR repository must not be a fork - if issue.User.UserName != targetOwner && target.Fork { - msg := fmt.Sprintf("@%s: You are not the owner of %s/%s and it is a fork. Only owners can add their forks, or non-forks can be added by anyone.", issue.User.UserName, targetOwner, targetRepo) - bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, msg) - return nil - } - - config := bot.configs.GetPrjGitConfig(org, repo, "") + config := bot.configs.GetPrjGitConfig(org, repo, targetBranch) if config == nil { - // Try to find any config for this org for _, c := range bot.configs { - if c.Organization == org { + if c.Organization == org && c.Branch == targetBranch { config = c break } } } if config == nil { - return fmt.Errorf("no config found for %s/%s", org, repo) + return fmt.Errorf("no config found for %s/%s#%s", org, org, targetBranch) } maintainers, err := bot.GetMaintainers(config) @@ -107,7 +101,7 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro } if len(maintainers) == 0 { - return fmt.Errorf("no maintainers found for %s/%s", org, repo) + return fmt.Errorf("no maintainers found for %s/%s#%s", org, repo, targetBranch) } // Check for approval in comments @@ -118,31 +112,49 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro approved := false for _, c := range comments { - if bot.IsMaintainer(c.User.UserName, maintainers) && bot.IsApproval(c.Body) { + if bot.IsMaintainer(c.User.UserName, maintainers) && bot.IsApproval(c.Body) && c.Updated == c.Created { approved = true break } } if approved { - common.LogInfo("Issue approved, forking...") + common.LogInfo("Issue approved, processing source repos...") if !common.IsDryRun { - // Check if already exists - existing, err := bot.gitea.GetRepository(org, targetRepo) - if err == nil && existing != nil { - bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, "Repository already exists in organization.") - } else { - _, err := bot.gitea.ReparentRepository(targetOwner, targetRepo, org) + for _, sourceInfo := range sourceRepos { + source, err := bot.gitea.GetRepository(sourceInfo.Owner, sourceInfo.Name) if err != nil { - return fmt.Errorf("fork failed: %w", err) + common.LogError("failed to fetch source repo", sourceInfo.Owner, sourceInfo.Name, ":", err) + continue + } + if source == nil { + common.LogError("source repo not found:", sourceInfo.Owner, sourceInfo.Name) + continue } - bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, "Repository forked successfully.") - } + // README: issue creator *must be* owner of the repo, OR repository must not be a fork + if issue.User.UserName != sourceInfo.Owner && source.Fork { + msg := fmt.Sprintf("@%s: You are not the owner of %s/%s and it is already a fork. Skipping.", issue.User.UserName, sourceInfo.Owner, sourceInfo.Name) + bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, msg) + continue + } - bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{State: "closed"}) + // Check if already exists in target org + existing, err := bot.gitea.GetRepository(org, sourceInfo.Name) + if err == nil && existing != nil { + bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, fmt.Sprintf("Repository %s already exists in organization.", sourceInfo.Name)) + } else { + _, err := bot.gitea.ReparentRepository(sourceInfo.Owner, sourceInfo.Name, org) + if err != nil { + common.LogError("Reparent failed for", sourceInfo.Name, ":", err) + continue + } + bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, fmt.Sprintf("Repository %s/%s forked successfully.", sourceInfo.Owner, sourceInfo.Name)) + } + } + // bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{State: "closed"}) } else { - common.LogInfo("Dry run: would fork %s/%s into %s", targetOwner, targetRepo, org) + common.LogInfo("Dry run: would process %d source repos for issue %d", len(sourceRepos), issue.Index) } } else { // Request review/assignment if not already done @@ -177,3 +189,4 @@ func (bot *ReparentBot) IsApproval(body string) bool { body = strings.ToLower(strings.TrimSpace(body)) return strings.Contains(body, "approved") || strings.Contains(body, "lgtm") } + -- 2.51.1 From 2bd6321fb6e312db71d7c606a31a78bbf22a85030786d866890448b4bbe8d0e1 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 2 Feb 2026 13:46:16 +0100 Subject: [PATCH 04/16] reparent: parse branch information for source repository --- reparent-bot/rabbit.go | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/reparent-bot/rabbit.go b/reparent-bot/rabbit.go index 4ed71fa..d332467 100644 --- a/reparent-bot/rabbit.go +++ b/reparent-bot/rabbit.go @@ -40,18 +40,19 @@ func (s *IssueProcessor) ProcessFunc(req *common.Request) error { } type RepoInfo struct { - Owner string - Name string + Owner string + Name string + Branch string } func (bot *ReparentBot) ParseSourceReposFromIssue(issue *models.Issue) []RepoInfo { var sourceRepos []RepoInfo - rx := regexp.MustCompile(`https?://[a-zA-Z0-9\.-]+/([_a-zA-Z0-9-]+)/([_a-zA-Z0-9-]+)`) + rx := regexp.MustCompile(`https?://[a-zA-Z0-9\.-]+/([_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) == 3 { - sourceRepos = append(sourceRepos, RepoInfo{Owner: matches[1], Name: matches[2]}) + if len(matches) == 4 { + sourceRepos = append(sourceRepos, RepoInfo{Owner: matches[1], Name: matches[2], Branch: matches[3]}) } } return sourceRepos @@ -122,19 +123,24 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro common.LogInfo("Issue approved, processing source repos...") if !common.IsDryRun { for _, sourceInfo := range sourceRepos { + repoFull := sourceInfo.Owner + "/" + sourceInfo.Name + if sourceInfo.Branch != "" { + repoFull += "#" + sourceInfo.Branch + } + source, err := bot.gitea.GetRepository(sourceInfo.Owner, sourceInfo.Name) if err != nil { - common.LogError("failed to fetch source repo", sourceInfo.Owner, sourceInfo.Name, ":", err) + common.LogError("failed to fetch source repo", repoFull, ":", err) continue } if source == nil { - common.LogError("source repo not found:", sourceInfo.Owner, sourceInfo.Name) + common.LogError("source repo not found:", repoFull) continue } // README: issue creator *must be* owner of the repo, OR repository must not be a fork if issue.User.UserName != sourceInfo.Owner && source.Fork { - msg := fmt.Sprintf("@%s: You are not the owner of %s/%s and it is already a fork. Skipping.", issue.User.UserName, sourceInfo.Owner, sourceInfo.Name) + msg := fmt.Sprintf("@%s: You are not the owner of %s and it is already a fork. Skipping.", issue.User.UserName, repoFull) bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, msg) continue } @@ -146,10 +152,10 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro } else { _, err := bot.gitea.ReparentRepository(sourceInfo.Owner, sourceInfo.Name, org) if err != nil { - common.LogError("Reparent failed for", sourceInfo.Name, ":", err) + common.LogError("Reparent failed for", repoFull, ":", err) continue } - bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, fmt.Sprintf("Repository %s/%s forked successfully.", sourceInfo.Owner, sourceInfo.Name)) + bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, fmt.Sprintf("Repository %s forked successfully.", repoFull)) } } // bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{State: "closed"}) @@ -189,4 +195,3 @@ func (bot *ReparentBot) IsApproval(body string) bool { body = strings.ToLower(strings.TrimSpace(body)) return strings.Contains(body, "approved") || strings.Contains(body, "lgtm") } - -- 2.51.1 From c355624194ec16216e85a8e01e8f0d964393607bfef0692556d206af4d2a7166 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 2 Feb 2026 13:53:13 +0100 Subject: [PATCH 05/16] rebase: branch is optional --- reparent-bot/main.go | 3 ++- reparent-bot/rabbit.go | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/reparent-bot/main.go b/reparent-bot/main.go index 058f942..b538e16 100644 --- a/reparent-bot/main.go +++ b/reparent-bot/main.go @@ -174,4 +174,5 @@ func main() { bot.PeriodCheck() time.Sleep(time.Duration(*interval * int64(time.Minute))) } -} \ No newline at end of file +} + diff --git a/reparent-bot/rabbit.go b/reparent-bot/rabbit.go index d332467..b298989 100644 --- a/reparent-bot/rabbit.go +++ b/reparent-bot/rabbit.go @@ -47,7 +47,7 @@ type RepoInfo struct { func (bot *ReparentBot) ParseSourceReposFromIssue(issue *models.Issue) []RepoInfo { var sourceRepos []RepoInfo - rx := regexp.MustCompile(`https?://[a-zA-Z0-9\.-]+/([_a-zA-Z0-9-]+)/([_a-zA-Z0-9-]+)(?:#([_a-zA-Z0-9\.-]+))?`) + rx := regexp.MustCompile(`https?://[a-zA-Z0-9\.-]+/([_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)) @@ -123,12 +123,14 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro common.LogInfo("Issue approved, processing source repos...") if !common.IsDryRun { for _, sourceInfo := range sourceRepos { + source, err := bot.gitea.GetRepository(sourceInfo.Owner, sourceInfo.Name) repoFull := sourceInfo.Owner + "/" + sourceInfo.Name if sourceInfo.Branch != "" { repoFull += "#" + sourceInfo.Branch + } else if source != nil { + repoFull += "#" + source.DefaultBranch } - source, err := bot.gitea.GetRepository(sourceInfo.Owner, sourceInfo.Name) if err != nil { common.LogError("failed to fetch source repo", repoFull, ":", err) continue -- 2.51.1 From 443b7eca24dd91fdae990742ae3275f3865e1f1a2778d66a446d893b0b42d85a Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 2 Feb 2026 20:08:31 +0100 Subject: [PATCH 06/16] reparent: use timeline --- reparent-bot/main.go | 10 +++++++++- reparent-bot/rabbit.go | 28 +++++++++++++++++++++------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/reparent-bot/main.go b/reparent-bot/main.go index b538e16..3bc343e 100644 --- a/reparent-bot/main.go +++ b/reparent-bot/main.go @@ -19,6 +19,8 @@ type ReparentBot struct { configs common.AutogitConfigs gitea common.Gitea giteaUrl string + + botUser string } func (bot *ReparentBot) ProcessNotifications(notification *models.NotificationThread) { @@ -151,6 +153,13 @@ func main() { 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, } @@ -175,4 +184,3 @@ func main() { time.Sleep(time.Duration(*interval * int64(time.Minute))) } } - diff --git a/reparent-bot/rabbit.go b/reparent-bot/rabbit.go index b298989..19370ed 100644 --- a/reparent-bot/rabbit.go +++ b/reparent-bot/rabbit.go @@ -75,6 +75,25 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro 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 + } + + 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") @@ -106,14 +125,9 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro } // Check for approval in comments - comments, err := bot.gitea.GetIssueComments(org, repo, issue.Index) - if err != nil { - return err - } - approved := false - for _, c := range comments { - if bot.IsMaintainer(c.User.UserName, maintainers) && bot.IsApproval(c.Body) && c.Updated == c.Created { + for _, e := range timeline { + if e.Type == common.TimelineCommentType_Comment && e.User != nil && bot.IsMaintainer(e.User.UserName, maintainers) && bot.IsApproval(e.Body) && e.Updated == e.Created { approved = true break } -- 2.51.1 From 6e9f1c80736abfe1f76b8fb548253837d46b406e339fed64b565daa8acfd6d7b Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 2 Feb 2026 20:58:12 +0100 Subject: [PATCH 07/16] reparent: refactor --- reparent-bot/bot.go | 184 +++++++++++++++++++++++++++++++++++++++++ reparent-bot/rabbit.go | 176 --------------------------------------- 2 files changed, 184 insertions(+), 176 deletions(-) create mode 100644 reparent-bot/bot.go diff --git a/reparent-bot/bot.go b/reparent-bot/bot.go new file mode 100644 index 0000000..cdd389f --- /dev/null +++ b/reparent-bot/bot.go @@ -0,0 +1,184 @@ +package main + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "src.opensuse.org/autogits/common" + "src.opensuse.org/autogits/common/gitea-generated/models" +) + +type RepoInfo struct { + Owner string + Name string + Branch string +} + +func (bot *ReparentBot) ParseSourceReposFromIssue(issue *models.Issue) []RepoInfo { + var sourceRepos []RepoInfo + rx := regexp.MustCompile(`https?://[a-zA-Z0-9\.-]+/([_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 +} + +func (bot *ReparentBot) GetMaintainers(config *common.AutogitConfig) ([]string, error) { + m, err := common.FetchProjectMaintainershipData(bot.gitea, config) + if err != nil { + return nil, err + } + return m.ListProjectMaintainers(config.ReviewGroups), nil +} + +func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) error { + 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 + } + + timeline, err := bot.gitea.GetTimeline(org, repo, issue.Index) + if err != nil { + common.LogError("Failed to fetch issue timeline:", err) + return err + } + + sourceRepos := bot.ParseSourceReposFromIssue(issue) + if len(sourceRepos) == 0 { + common.LogDebug("Could not parse any source repos from issue body") + return nil + } + + targetBranch := strings.TrimPrefix(issue.Ref, "refs/heads/") + + config := bot.configs.GetPrjGitConfig(org, repo, targetBranch) + if config == nil { + for _, c := range bot.configs { + if c.Organization == org && c.Branch == targetBranch { + config = c + break + } + } + } + if config == nil { + return fmt.Errorf("no config found for %s/%s#%s", org, org, targetBranch) + } + + maintainers, err := bot.GetMaintainers(config) + if err != nil { + return err + } + + if len(maintainers) == 0 { + return fmt.Errorf("no maintainers found for %s/%s#%s", org, repo, targetBranch) + } + + // Check for approval in comments + approved := false + for _, e := range timeline { + if e.Type == common.TimelineCommentType_Comment && e.User != nil && bot.IsMaintainer(e.User.UserName, maintainers) && bot.IsApproval(e.Body) && e.Updated == e.Created { + approved = true + break + } + } + + if approved { + common.LogInfo("Issue approved, processing source repos...") + if !common.IsDryRun { + for _, sourceInfo := range sourceRepos { + source, err := bot.gitea.GetRepository(sourceInfo.Owner, sourceInfo.Name) + repoFull := sourceInfo.Owner + "/" + sourceInfo.Name + if sourceInfo.Branch != "" { + repoFull += "#" + sourceInfo.Branch + } else if source != nil { + repoFull += "#" + source.DefaultBranch + } + + if err != nil { + common.LogError("failed to fetch source repo", repoFull, ":", err) + continue + } + if source == nil { + common.LogError("source repo not found:", repoFull) + continue + } + + // README: issue creator *must be* owner of the repo, OR repository must not be a fork + if issue.User.UserName != sourceInfo.Owner && source.Fork { + msg := fmt.Sprintf("@%s: You are not the owner of %s and it is already a fork. Skipping.", issue.User.UserName, repoFull) + bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, msg) + continue + } + + // Check if already exists in target org + existing, err := bot.gitea.GetRepository(org, sourceInfo.Name) + if err == nil && existing != nil { + bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, fmt.Sprintf("Repository %s already exists in organization.", sourceInfo.Name)) + } else { + _, err := bot.gitea.ReparentRepository(sourceInfo.Owner, sourceInfo.Name, org) + if err != nil { + common.LogError("Reparent failed for", repoFull, ":", err) + continue + } + bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, fmt.Sprintf("Repository %s forked successfully.", repoFull)) + } + } + // 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 { + bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{ + Assignees: maintainers, + }) + bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, + "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") +} diff --git a/reparent-bot/rabbit.go b/reparent-bot/rabbit.go index 19370ed..2680162 100644 --- a/reparent-bot/rabbit.go +++ b/reparent-bot/rabbit.go @@ -2,12 +2,7 @@ package main import ( "fmt" - "regexp" - "slices" - "strings" - "src.opensuse.org/autogits/common" - "src.opensuse.org/autogits/common/gitea-generated/models" ) type IssueProcessor struct { @@ -39,175 +34,4 @@ func (s *IssueProcessor) ProcessFunc(req *common.Request) error { return s.bot.ProcessIssue(org, repo, issue) } -type RepoInfo struct { - Owner string - Name string - Branch string -} -func (bot *ReparentBot) ParseSourceReposFromIssue(issue *models.Issue) []RepoInfo { - var sourceRepos []RepoInfo - rx := regexp.MustCompile(`https?://[a-zA-Z0-9\.-]+/([_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 -} - -func (bot *ReparentBot) GetMaintainers(config *common.AutogitConfig) ([]string, error) { - m, err := common.FetchProjectMaintainershipData(bot.gitea, config) - if err != nil { - return nil, err - } - return m.ListProjectMaintainers(config.ReviewGroups), nil -} - -func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) error { - 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 - } - - timeline, err := bot.gitea.GetTimeline(org, repo, issue.Index) - if err != nil { - common.LogError("Failed to fetch issue timeline:", err) - return err - } - - sourceRepos := bot.ParseSourceReposFromIssue(issue) - if len(sourceRepos) == 0 { - common.LogDebug("Could not parse any source repos from issue body") - return nil - } - - targetBranch := strings.TrimPrefix(issue.Ref, "refs/heads/") - - config := bot.configs.GetPrjGitConfig(org, repo, targetBranch) - if config == nil { - for _, c := range bot.configs { - if c.Organization == org && c.Branch == targetBranch { - config = c - break - } - } - } - if config == nil { - return fmt.Errorf("no config found for %s/%s#%s", org, org, targetBranch) - } - - maintainers, err := bot.GetMaintainers(config) - if err != nil { - return err - } - - if len(maintainers) == 0 { - return fmt.Errorf("no maintainers found for %s/%s#%s", org, repo, targetBranch) - } - - // Check for approval in comments - approved := false - for _, e := range timeline { - if e.Type == common.TimelineCommentType_Comment && e.User != nil && bot.IsMaintainer(e.User.UserName, maintainers) && bot.IsApproval(e.Body) && e.Updated == e.Created { - approved = true - break - } - } - - if approved { - common.LogInfo("Issue approved, processing source repos...") - if !common.IsDryRun { - for _, sourceInfo := range sourceRepos { - source, err := bot.gitea.GetRepository(sourceInfo.Owner, sourceInfo.Name) - repoFull := sourceInfo.Owner + "/" + sourceInfo.Name - if sourceInfo.Branch != "" { - repoFull += "#" + sourceInfo.Branch - } else if source != nil { - repoFull += "#" + source.DefaultBranch - } - - if err != nil { - common.LogError("failed to fetch source repo", repoFull, ":", err) - continue - } - if source == nil { - common.LogError("source repo not found:", repoFull) - continue - } - - // README: issue creator *must be* owner of the repo, OR repository must not be a fork - if issue.User.UserName != sourceInfo.Owner && source.Fork { - msg := fmt.Sprintf("@%s: You are not the owner of %s and it is already a fork. Skipping.", issue.User.UserName, repoFull) - bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, msg) - continue - } - - // Check if already exists in target org - existing, err := bot.gitea.GetRepository(org, sourceInfo.Name) - if err == nil && existing != nil { - bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, fmt.Sprintf("Repository %s already exists in organization.", sourceInfo.Name)) - } else { - _, err := bot.gitea.ReparentRepository(sourceInfo.Owner, sourceInfo.Name, org) - if err != nil { - common.LogError("Reparent failed for", repoFull, ":", err) - continue - } - bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, fmt.Sprintf("Repository %s forked successfully.", repoFull)) - } - } - // 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 { - bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{ - Assignees: maintainers, - }) - bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, - "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") -} -- 2.51.1 From 40f3cfe238ee2fcbfe25fbaed52ee5231472a2c437d0dccf1389c854e68afb7c Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 2 Feb 2026 21:08:55 +0100 Subject: [PATCH 08/16] reparent: add comments once --- reparent-bot/bot.go | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/reparent-bot/bot.go b/reparent-bot/bot.go index cdd389f..dfe57d3 100644 --- a/reparent-bot/bot.go +++ b/reparent-bot/bot.go @@ -128,21 +128,21 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro // README: issue creator *must be* owner of the repo, OR repository must not be a fork if issue.User.UserName != sourceInfo.Owner && source.Fork { msg := fmt.Sprintf("@%s: You are not the owner of %s and it is already a fork. Skipping.", issue.User.UserName, repoFull) - bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, msg) + bot.AddCommentOnce(org, repo, issue.Index, timeline, msg) continue } // Check if already exists in target org existing, err := bot.gitea.GetRepository(org, sourceInfo.Name) if err == nil && existing != nil { - bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, fmt.Sprintf("Repository %s already exists in organization.", sourceInfo.Name)) + bot.AddCommentOnce(org, repo, issue.Index, timeline, fmt.Sprintf("Repository %s already exists in organization.", sourceInfo.Name)) } else { _, err := bot.gitea.ReparentRepository(sourceInfo.Owner, sourceInfo.Name, org) if err != nil { common.LogError("Reparent failed for", repoFull, ":", err) continue } - bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, fmt.Sprintf("Repository %s forked successfully.", repoFull)) + bot.AddCommentOnce(org, repo, issue.Index, timeline, fmt.Sprintf("Repository %s forked successfully.", repoFull)) } } // bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{State: "closed"}) @@ -165,7 +165,7 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{ Assignees: maintainers, }) - bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: issue.Index}, + bot.AddCommentOnce(org, repo, issue.Index, timeline, "Review requested from maintainers: "+strings.Join(maintainers, ", ")) } } @@ -182,3 +182,19 @@ 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) +} -- 2.51.1 From cc1f178872a7c131d070efd81859928995f8f1f3670aaa0143990323eba8a8cc Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 2 Feb 2026 21:30:56 +0100 Subject: [PATCH 09/16] reparent: unit tests --- reparent-bot/bot.go | 22 +- reparent-bot/bot_test.go | 522 ++++++++++++++++++++++++++++++++++++ reparent-bot/main.go | 20 +- reparent-bot/main_test.go | 171 ++++++++++++ reparent-bot/rabbit_test.go | 103 +++++++ 5 files changed, 826 insertions(+), 12 deletions(-) create mode 100644 reparent-bot/bot_test.go create mode 100644 reparent-bot/main_test.go create mode 100644 reparent-bot/rabbit_test.go diff --git a/reparent-bot/bot.go b/reparent-bot/bot.go index dfe57d3..9003ab4 100644 --- a/reparent-bot/bot.go +++ b/reparent-bot/bot.go @@ -29,8 +29,28 @@ func (bot *ReparentBot) ParseSourceReposFromIssue(issue *models.Issue) []RepoInf 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) +} + +type ReparentBot struct { + configs common.AutogitConfigs + gitea common.Gitea + giteaUrl string + + botUser string + + maintainershipFetcher MaintainershipFetcher +} + func (bot *ReparentBot) GetMaintainers(config *common.AutogitConfig) ([]string, error) { - m, err := common.FetchProjectMaintainershipData(bot.gitea, config) + m, err := bot.maintainershipFetcher.FetchProjectMaintainershipData(bot.gitea, config) if err != nil { return nil, err } diff --git a/reparent-bot/bot_test.go b/reparent-bot/bot_test.go new file mode 100644 index 0000000..f5cd9ea --- /dev/null +++ b/reparent-bot/bot_test.go @@ -0,0 +1,522 @@ +package main + +import ( + "errors" + "testing" + + "go.uber.org/mock/gomock" + "src.opensuse.org/autogits/common" + "src.opensuse.org/autogits/common/gitea-generated/models" + mock "src.opensuse.org/autogits/common/mock" +) + +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) { + ctrl := common.NewController(t) + defer ctrl.Finish() + + mockGitea := mock.NewMockGitea(ctrl) + mockFetcher := &MockMaintainershipFetcher{} + bot := &ReparentBot{ + gitea: mockGitea, + 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().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().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 + }, + 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) + }, + 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, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + common.IsDryRun = tc.dryRun + 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 := common.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") + }) +} \ No newline at end of file diff --git a/reparent-bot/main.go b/reparent-bot/main.go index 3bc343e..00a385b 100644 --- a/reparent-bot/main.go +++ b/reparent-bot/main.go @@ -15,14 +15,6 @@ import ( "src.opensuse.org/autogits/common/gitea-generated/models" ) -type ReparentBot struct { - configs common.AutogitConfigs - gitea common.Gitea - giteaUrl string - - botUser string -} - func (bot *ReparentBot) ProcessNotifications(notification *models.NotificationThread) { defer func() { if r := recover(); r != nil { @@ -88,8 +80,13 @@ func main() { 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 @@ -138,9 +135,10 @@ func main() { } bot := &ReparentBot{ - gitea: giteaTransport, - configs: configs, - giteaUrl: *giteaUrl, + gitea: giteaTransport, + configs: configs, + giteaUrl: *giteaUrl, + maintainershipFetcher: &RealMaintainershipFetcher{}, } common.LogInfo(" ** reparent-bot starting") diff --git a/reparent-bot/main_test.go b/reparent-bot/main_test.go new file mode 100644 index 0000000..a023ade --- /dev/null +++ b/reparent-bot/main_test.go @@ -0,0 +1,171 @@ +package main + +import ( + "errors" + "os" + "testing" + "flag" + + "go.uber.org/mock/gomock" + "src.opensuse.org/autogits/common/gitea-generated/models" + mock "src.opensuse.org/autogits/common/mock" + "src.opensuse.org/autogits/common" +) + +func TestProcessNotifications(t *testing.T) { + ctrl := common.NewController(t) + defer ctrl.Finish() + + mockGitea := mock.NewMockGitea(ctrl) + bot := &ReparentBot{gitea: mockGitea} + + tests := []struct { + name string + notification *models.NotificationThread + dryRun bool + setupMock func() + }{ + { + name: "not an issue", + notification: &models.NotificationThread{ + Subject: &models.NotificationSubject{Type: "Pull"}, + }, + setupMock: func() {}, + }, + { + name: "invalid URL", + notification: &models.NotificationThread{ + Subject: &models.NotificationSubject{ + Type: "Issue", + URL: ":invalid", + }, + }, + setupMock: func() {}, + }, + { + name: "unexpected URL format", + notification: &models.NotificationThread{ + Subject: &models.NotificationSubject{ + Type: "Issue", + URL: "https://gitea.com/api/v1/repos/org/repo/something/1", + }, + }, + setupMock: func() {}, + }, + { + name: "fetch issue error", + notification: &models.NotificationThread{ + Subject: &models.NotificationSubject{ + Type: "Issue", + URL: "https://gitea.com/api/v1/repos/org/repo/issues/1", + }, + }, + setupMock: func() { + mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(nil, errors.New("error")) + }, + }, + { + name: "success", + notification: &models.NotificationThread{ + ID: 123, + Subject: &models.NotificationSubject{ + Type: "Issue", + URL: "https://gitea.com/api/v1/repos/org/repo/issues/1", + }, + }, + setupMock: func() { + mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(&models.Issue{State: "closed"}, nil) + mockGitea.EXPECT().SetNotificationRead(int64(123)).Return(nil) + }, + }, + { + name: "success but set read error", + notification: &models.NotificationThread{ + ID: 124, + Subject: &models.NotificationSubject{ + Type: "Issue", + URL: "https://gitea.com/api/v1/repos/org/repo/issues/1", + }, + }, + setupMock: func() { + mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(&models.Issue{State: "closed"}, nil) + mockGitea.EXPECT().SetNotificationRead(int64(124)).Return(errors.New("error")) + }, + }, + { + name: "dry run", + dryRun: true, + notification: &models.NotificationThread{ + ID: 125, + Subject: &models.NotificationSubject{ + Type: "Issue", + URL: "https://gitea.com/api/v1/repos/org/repo/issues/1", + }, + }, + setupMock: func() { + mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(&models.Issue{State: "closed"}, nil) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + common.IsDryRun = tc.dryRun + tc.setupMock() + bot.ProcessNotifications(tc.notification) + }) + } + common.IsDryRun = false +} + +func TestPeriodCheck(t *testing.T) { + ctrl := common.NewController(t) + defer ctrl.Finish() + + mockGitea := mock.NewMockGitea(ctrl) + bot := &ReparentBot{gitea: mockGitea} + + t.Run("success", func(t *testing.T) { + notifications := []*models.NotificationThread{ + { + Subject: &models.NotificationSubject{ + Type: "Issue", + URL: "https://gitea.com/api/v1/repos/org/repo/issues/1", + }, + }, + } + mockGitea.EXPECT().GetNotifications("Issue", nil).Return(notifications, nil) + mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(&models.Issue{State: "closed"}, nil) + mockGitea.EXPECT().SetNotificationRead(gomock.Any()).Return(nil) + bot.PeriodCheck() + }) + + t.Run("error", func(t *testing.T) { + mockGitea.EXPECT().GetNotifications("Issue", nil).Return(nil, errors.New("error")) + bot.PeriodCheck() + }) +} + +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() +} \ No newline at end of file diff --git a/reparent-bot/rabbit_test.go b/reparent-bot/rabbit_test.go new file mode 100644 index 0000000..62da58d --- /dev/null +++ b/reparent-bot/rabbit_test.go @@ -0,0 +1,103 @@ +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" +) + +func TestIssueProcessor_ProcessFunc(t *testing.T) { + ctrl := common.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"}, 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"}, 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) + } + }) + } +} -- 2.51.1 From e1af02efd93bfb755f7220de96fd9a1da99db3865b0b5ac954efb24e24249400 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Tue, 3 Feb 2026 14:24:07 +0100 Subject: [PATCH 10/16] reparent: doc --- README.md | 1 + 1 file changed, 1 insertion(+) 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) -- 2.51.1 From 2d526592235273bbce771da241e44fcaa28cf3364155a74dc911819db1138a2e Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Tue, 3 Feb 2026 14:24:42 +0100 Subject: [PATCH 11/16] test: move NewController to common/test_util subpackage We want to share the NewController logging setup with other tests across utilities --- common/config_test.go | 5 +++-- common/maintainership_test.go | 5 +++-- common/pr_linkage_test.go | 5 +++-- common/pr_merge_special_test.go | 3 ++- common/pr_test.go | 7 ++++--- common/reviews_test.go | 3 ++- common/test_utils/common.go | 14 ++++++++++++++ common/utils_test.go | 5 ----- reparent-bot/bot.go | 20 ++++++++++---------- reparent-bot/bot_test.go | 7 ++++--- reparent-bot/main_test.go | 24 +++++++++++++----------- reparent-bot/rabbit_test.go | 3 ++- 12 files changed, 60 insertions(+), 41 deletions(-) create mode 100644 common/test_utils/common.go 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/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/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..46edb31 100644 --- a/common/utils_test.go +++ b/common/utils_test.go @@ -3,7 +3,6 @@ package common_test import ( "reflect" "testing" - "go.uber.org/mock/gomock" "src.opensuse.org/autogits/common" ) @@ -307,7 +306,3 @@ func TestNewPackageIssueParsing(t *testing.T) { } } -func NewController(t *testing.T) *gomock.Controller { - common.SetTestLogger(t) - return gomock.NewController(t) -} diff --git a/reparent-bot/bot.go b/reparent-bot/bot.go index 9003ab4..7828610 100644 --- a/reparent-bot/bot.go +++ b/reparent-bot/bot.go @@ -16,6 +16,16 @@ type RepoInfo struct { 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(`https?://[a-zA-Z0-9\.-]+/([_a-zA-Z0-9\.-]+)/([_a-zA-Z0-9\.-]+)(?:#([_a-zA-Z0-9\.\-/]+))?`) @@ -39,16 +49,6 @@ func (f *RealMaintainershipFetcher) FetchProjectMaintainershipData(gitea common. return common.FetchProjectMaintainershipData(gitea, config) } -type ReparentBot struct { - configs common.AutogitConfigs - gitea common.Gitea - giteaUrl string - - botUser string - - maintainershipFetcher MaintainershipFetcher -} - func (bot *ReparentBot) GetMaintainers(config *common.AutogitConfig) ([]string, error) { m, err := bot.maintainershipFetcher.FetchProjectMaintainershipData(bot.gitea, config) if err != nil { diff --git a/reparent-bot/bot_test.go b/reparent-bot/bot_test.go index f5cd9ea..1148f4c 100644 --- a/reparent-bot/bot_test.go +++ b/reparent-bot/bot_test.go @@ -8,6 +8,7 @@ import ( "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 { @@ -160,7 +161,7 @@ func TestHasComment(t *testing.T) { } func TestProcessIssue(t *testing.T) { - ctrl := common.NewController(t) + ctrl := test_utils.NewController(t) defer ctrl.Finish() mockGitea := mock.NewMockGitea(ctrl) @@ -501,7 +502,7 @@ func TestGetMaintainers(t *testing.T) { } func TestAddCommentOnce(t *testing.T) { - ctrl := common.NewController(t) + ctrl := test_utils.NewController(t) defer ctrl.Finish() mockGitea := mock.NewMockGitea(ctrl) bot := &ReparentBot{gitea: mockGitea, botUser: "bot"} @@ -519,4 +520,4 @@ func TestAddCommentOnce(t *testing.T) { mockGitea.EXPECT().AddComment(gomock.Any(), "new").Return(nil) bot.AddCommentOnce("org", "repo", 1, timeline, "new") }) -} \ No newline at end of file +} diff --git a/reparent-bot/main_test.go b/reparent-bot/main_test.go index a023ade..dbac4d4 100644 --- a/reparent-bot/main_test.go +++ b/reparent-bot/main_test.go @@ -2,18 +2,19 @@ package main import ( "errors" + "flag" "os" "testing" - "flag" "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" + "src.opensuse.org/autogits/common/test_utils" ) func TestProcessNotifications(t *testing.T) { - ctrl := common.NewController(t) + ctrl := test_utils.NewController(t) defer ctrl.Finish() mockGitea := mock.NewMockGitea(ctrl) @@ -93,7 +94,7 @@ func TestProcessNotifications(t *testing.T) { }, }, { - name: "dry run", + name: "dry run", dryRun: true, notification: &models.NotificationThread{ ID: 125, @@ -119,7 +120,7 @@ func TestProcessNotifications(t *testing.T) { } func TestPeriodCheck(t *testing.T) { - ctrl := common.NewController(t) + ctrl := test_utils.NewController(t) defer ctrl.Finish() mockGitea := mock.NewMockGitea(ctrl) @@ -151,21 +152,22 @@ func TestMainFunction(t *testing.T) { 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() -} \ No newline at end of file +} + diff --git a/reparent-bot/rabbit_test.go b/reparent-bot/rabbit_test.go index 62da58d..345caac 100644 --- a/reparent-bot/rabbit_test.go +++ b/reparent-bot/rabbit_test.go @@ -7,10 +7,11 @@ import ( "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 := common.NewController(t) + ctrl := test_utils.NewController(t) defer ctrl.Finish() mockGitea := mock.NewMockGitea(ctrl) -- 2.51.1 From 842284505b803d503b6fa55b0f97488bc65ea4f552ddf2cde0f55536ff4e9ac8 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Tue, 3 Feb 2026 15:13:35 +0100 Subject: [PATCH 12/16] reparent: work on issues, not notifications --- common/gitea_utils.go | 33 +++++++++ common/mock/gitea_utils.go | 102 +++++++++++++++++++++++++++ reparent-bot/bot.go | 8 +++ reparent-bot/bot_test.go | 27 +++---- reparent-bot/main.go | 69 +++--------------- reparent-bot/main_test.go | 141 ------------------------------------- 6 files changed, 168 insertions(+), 212 deletions(-) diff --git a/common/gitea_utils.go b/common/gitea_utils.go index 20e96e1..c480031 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) ([]*models.Issue, error) +} + type GiteaTimelineFetcher interface { ResetTimelineCache(org, repo string, idx int64) GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) @@ -205,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) @@ -550,6 +561,28 @@ 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) ([]*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) + } + ret, err := gitea.client.Issue.IssueListIssues(params, gitea.transport.DefaultAuthentication) + if err != nil { + return nil, err + } + + return ret.Payload, nil +} + const ( GiteaNotificationType_Pull = "Pull" ) diff --git a/common/mock/gitea_utils.go b/common/mock/gitea_utils.go index 149b935..7b9f2ff 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) ([]*models.Issue, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOpenIssues", org, repo, labels, issue_type) + 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 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) + 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) ([]*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) ([]*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 @@ -3093,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) ([]*models.Issue, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOpenIssues", org, repo, labels, issue_type) + 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 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) + 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) ([]*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) ([]*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() diff --git a/reparent-bot/bot.go b/reparent-bot/bot.go index 7828610..b7f8261 100644 --- a/reparent-bot/bot.go +++ b/reparent-bot/bot.go @@ -3,6 +3,7 @@ package main import ( "fmt" "regexp" + "runtime/debug" "slices" "strings" @@ -58,6 +59,13 @@ func (bot *ReparentBot) GetMaintainers(config *common.AutogitConfig) ([]string, } 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())) + } + }() + if issue.State == "closed" { return nil } diff --git a/reparent-bot/bot_test.go b/reparent-bot/bot_test.go index 1148f4c..3395e92 100644 --- a/reparent-bot/bot_test.go +++ b/reparent-bot/bot_test.go @@ -64,13 +64,13 @@ func TestParseSourceReposFromIssue(t *testing.T) { }, }, { - name: "no repos", - body: "Nothing here", + name: "no repos", + body: "Nothing here", expected: nil, }, { - name: "not matching url", - body: "invalid link", + name: "not matching url", + body: "invalid link", expected: nil, }, } @@ -179,7 +179,7 @@ func TestProcessIssue(t *testing.T) { name string issue *models.Issue dryRun bool - setupMock func() + setupMock func() wantErr bool }{ { @@ -187,7 +187,7 @@ func TestProcessIssue(t *testing.T) { issue: &models.Issue{ State: "closed", }, - setupMock: func() {}, + setupMock: func() {}, wantErr: false, }, { @@ -196,7 +196,7 @@ func TestProcessIssue(t *testing.T) { State: "open", Title: "Just a comment", }, - setupMock: func() {}, + setupMock: func() {}, wantErr: false, }, { @@ -205,7 +205,7 @@ func TestProcessIssue(t *testing.T) { State: "open", Title: "[ADD] My Repo", }, - setupMock: func() {}, + setupMock: func() {}, wantErr: false, }, { @@ -343,7 +343,7 @@ func TestProcessIssue(t *testing.T) { 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) @@ -353,7 +353,7 @@ func TestProcessIssue(t *testing.T) { wantErr: false, }, { - name: "approved - dry run", + name: "approved - dry run", dryRun: true, issue: &models.Issue{ Index: 3, @@ -452,7 +452,7 @@ func TestProcessIssue(t *testing.T) { 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) @@ -464,8 +464,8 @@ func TestProcessIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - common.IsDryRun = tc.dryRun - tc.setupMock() + common.IsDryRun = tc.dryRun + tc.setupMock() err := bot.ProcessIssue("org", "repo", tc.issue) if (err != nil) != tc.wantErr { t.Errorf("ProcessIssue() error = %v, wantErr %v", err, tc.wantErr) @@ -521,3 +521,4 @@ func TestAddCommentOnce(t *testing.T) { bot.AddCommentOnce("org", "repo", 1, timeline, "new") }) } + diff --git a/reparent-bot/main.go b/reparent-bot/main.go index 00a385b..1f84a8c 100644 --- a/reparent-bot/main.go +++ b/reparent-bot/main.go @@ -2,74 +2,27 @@ package main import ( "flag" - "fmt" "net/url" "os" - "regexp" - "runtime/debug" "slices" - "strconv" "time" "src.opensuse.org/autogits/common" - "src.opensuse.org/autogits/common/gitea-generated/models" ) -func (bot *ReparentBot) ProcessNotifications(notification *models.NotificationThread) { - defer func() { - if r := recover(); r != nil { - common.LogInfo("panic caught --- recovered") - common.LogError(string(debug.Stack())) - } - }() - - // We only care about Issues for now as per README - if notification.Subject.Type != "Issue" { - return - } - - rx := regexp.MustCompile(`^/?api/v\d+/repos/(?[_\.a-zA-Z0-9-]+)/(?[_\.a-zA-Z0-9-]+)/issues/(?[0-9]+)$`) - u, err := url.Parse(notification.Subject.URL) - if err != nil { - common.LogError("Invalid format of notification:", notification.Subject.URL, err) - return - } - - match := rx.FindStringSubmatch(u.Path) - if match == nil { - common.LogError("** Unexpected format of notification:", notification.Subject.URL) - return - } - - org := match[1] - repo := match[2] - id, _ := strconv.ParseInt(match[3], 10, 64) - - common.LogInfo("processing issue:", fmt.Sprintf("%s/%s#%d", org, repo, id)) - issue, err := bot.gitea.GetIssue(org, repo, id) - if err != nil { - common.LogError(" ** Cannot fetch issue associated with notification:", notification.Subject.URL, "Error:", err) - return - } - - if err := bot.ProcessIssue(org, repo, issue); err == nil && !common.IsDryRun { - if err := bot.gitea.SetNotificationRead(notification.ID); err != nil { - common.LogDebug(" Cannot set notification as read", err) - } - } else if err != nil { - common.LogError(err) - } -} - func (bot *ReparentBot) PeriodCheck() { - notifications, err := bot.gitea.GetNotifications("Issue", nil) - if err != nil { - common.LogError(" Error fetching unread notifications: %w", err) - return - } + for _, c := range bot.configs { + org, repo, _ := c.GetPrjGit() - for _, notification := range notifications { - bot.ProcessNotifications(notification) + issues, err := bot.gitea.GetOpenIssues(org, repo, common.Label_NewRepository, common.IssueType_Issue) + if err != nil { + common.LogError("Error fetching issues for processing:", err) + return + } + for _, issue := range issues { + err := bot.ProcessIssue(org, repo, issue) + common.LogError("issue processing error:", err) + } } } diff --git a/reparent-bot/main_test.go b/reparent-bot/main_test.go index dbac4d4..9d30db9 100644 --- a/reparent-bot/main_test.go +++ b/reparent-bot/main_test.go @@ -1,152 +1,11 @@ package main import ( - "errors" "flag" "os" "testing" - - "go.uber.org/mock/gomock" - "src.opensuse.org/autogits/common" - "src.opensuse.org/autogits/common/gitea-generated/models" - mock "src.opensuse.org/autogits/common/mock" - "src.opensuse.org/autogits/common/test_utils" ) -func TestProcessNotifications(t *testing.T) { - ctrl := test_utils.NewController(t) - defer ctrl.Finish() - - mockGitea := mock.NewMockGitea(ctrl) - bot := &ReparentBot{gitea: mockGitea} - - tests := []struct { - name string - notification *models.NotificationThread - dryRun bool - setupMock func() - }{ - { - name: "not an issue", - notification: &models.NotificationThread{ - Subject: &models.NotificationSubject{Type: "Pull"}, - }, - setupMock: func() {}, - }, - { - name: "invalid URL", - notification: &models.NotificationThread{ - Subject: &models.NotificationSubject{ - Type: "Issue", - URL: ":invalid", - }, - }, - setupMock: func() {}, - }, - { - name: "unexpected URL format", - notification: &models.NotificationThread{ - Subject: &models.NotificationSubject{ - Type: "Issue", - URL: "https://gitea.com/api/v1/repos/org/repo/something/1", - }, - }, - setupMock: func() {}, - }, - { - name: "fetch issue error", - notification: &models.NotificationThread{ - Subject: &models.NotificationSubject{ - Type: "Issue", - URL: "https://gitea.com/api/v1/repos/org/repo/issues/1", - }, - }, - setupMock: func() { - mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(nil, errors.New("error")) - }, - }, - { - name: "success", - notification: &models.NotificationThread{ - ID: 123, - Subject: &models.NotificationSubject{ - Type: "Issue", - URL: "https://gitea.com/api/v1/repos/org/repo/issues/1", - }, - }, - setupMock: func() { - mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(&models.Issue{State: "closed"}, nil) - mockGitea.EXPECT().SetNotificationRead(int64(123)).Return(nil) - }, - }, - { - name: "success but set read error", - notification: &models.NotificationThread{ - ID: 124, - Subject: &models.NotificationSubject{ - Type: "Issue", - URL: "https://gitea.com/api/v1/repos/org/repo/issues/1", - }, - }, - setupMock: func() { - mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(&models.Issue{State: "closed"}, nil) - mockGitea.EXPECT().SetNotificationRead(int64(124)).Return(errors.New("error")) - }, - }, - { - name: "dry run", - dryRun: true, - notification: &models.NotificationThread{ - ID: 125, - Subject: &models.NotificationSubject{ - Type: "Issue", - URL: "https://gitea.com/api/v1/repos/org/repo/issues/1", - }, - }, - setupMock: func() { - mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(&models.Issue{State: "closed"}, nil) - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - common.IsDryRun = tc.dryRun - tc.setupMock() - bot.ProcessNotifications(tc.notification) - }) - } - common.IsDryRun = false -} - -func TestPeriodCheck(t *testing.T) { - ctrl := test_utils.NewController(t) - defer ctrl.Finish() - - mockGitea := mock.NewMockGitea(ctrl) - bot := &ReparentBot{gitea: mockGitea} - - t.Run("success", func(t *testing.T) { - notifications := []*models.NotificationThread{ - { - Subject: &models.NotificationSubject{ - Type: "Issue", - URL: "https://gitea.com/api/v1/repos/org/repo/issues/1", - }, - }, - } - mockGitea.EXPECT().GetNotifications("Issue", nil).Return(notifications, nil) - mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(&models.Issue{State: "closed"}, nil) - mockGitea.EXPECT().SetNotificationRead(gomock.Any()).Return(nil) - bot.PeriodCheck() - }) - - t.Run("error", func(t *testing.T) { - mockGitea.EXPECT().GetNotifications("Issue", nil).Return(nil, errors.New("error")) - bot.PeriodCheck() - }) -} - func TestMainFunction(t *testing.T) { os.Setenv("AUTOGITS_GITEA_TOKEN", "dummy") os.Setenv("AUTOGITS_RABBIT_USER", "dummy") -- 2.51.1 From 8678167498f00ffa32388b52e7f840c6a429b9b4741c75a0c93d19d31802116d Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Tue, 3 Feb 2026 17:18:46 +0100 Subject: [PATCH 13/16] reparent: reorganize logic --- common/gitea_utils.go | 9 ++- common/mock/gitea_utils.go | 24 ++++---- reparent-bot/bot.go | 109 ++++++++++++++++++++++++------------- reparent-bot/main.go | 9 ++- 4 files changed, 98 insertions(+), 53 deletions(-) diff --git a/common/gitea_utils.go b/common/gitea_utils.go index c480031..38c0677 100644 --- a/common/gitea_utils.go +++ b/common/gitea_utils.go @@ -86,7 +86,7 @@ const ( ) type GiteaIssueLister interface { - GetOpenIssues(org, repo, labels string, issue_type int) ([]*models.Issue, error) + GetOpenIssues(org, repo, labels string, issue_type int, q string) ([]*models.Issue, error) } type GiteaTimelineFetcher interface { @@ -561,7 +561,7 @@ 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) ([]*models.Issue, error) { +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" @@ -575,6 +575,10 @@ func (gitea *GiteaTransport) GetOpenIssues(org, repo, labels string, filter_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 @@ -897,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/mock/gitea_utils.go b/common/mock/gitea_utils.go index 7b9f2ff..4f70c73 100644 --- a/common/mock/gitea_utils.go +++ b/common/mock/gitea_utils.go @@ -232,18 +232,18 @@ func (m *MockGiteaIssueLister) EXPECT() *MockGiteaIssueListerMockRecorder { } // GetOpenIssues mocks base method. -func (m *MockGiteaIssueLister) GetOpenIssues(org, repo, labels string, issue_type int) ([]*models.Issue, error) { +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) + 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 any) *MockGiteaIssueListerGetOpenIssuesCall { +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) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOpenIssues", reflect.TypeOf((*MockGiteaIssueLister)(nil).GetOpenIssues), org, repo, labels, issue_type, q) return &MockGiteaIssueListerGetOpenIssuesCall{Call: call} } @@ -259,13 +259,13 @@ func (c *MockGiteaIssueListerGetOpenIssuesCall) Return(arg0 []*models.Issue, arg } // Do rewrite *gomock.Call.Do -func (c *MockGiteaIssueListerGetOpenIssuesCall) Do(f func(string, string, string, int) ([]*models.Issue, error)) *MockGiteaIssueListerGetOpenIssuesCall { +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) ([]*models.Issue, error)) *MockGiteaIssueListerGetOpenIssuesCall { +func (c *MockGiteaIssueListerGetOpenIssuesCall) DoAndReturn(f func(string, string, string, int, string) ([]*models.Issue, error)) *MockGiteaIssueListerGetOpenIssuesCall { c.Call = c.Call.DoAndReturn(f) return c } @@ -3157,18 +3157,18 @@ func (c *MockGiteaGetNotificationsCall) DoAndReturn(f func(string, *time.Time) ( } // GetOpenIssues mocks base method. -func (m *MockGitea) GetOpenIssues(org, repo, labels string, issue_type int) ([]*models.Issue, error) { +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) + 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 any) *MockGiteaGetOpenIssuesCall { +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) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOpenIssues", reflect.TypeOf((*MockGitea)(nil).GetOpenIssues), org, repo, labels, issue_type, q) return &MockGiteaGetOpenIssuesCall{Call: call} } @@ -3184,13 +3184,13 @@ func (c *MockGiteaGetOpenIssuesCall) Return(arg0 []*models.Issue, arg1 error) *M } // Do rewrite *gomock.Call.Do -func (c *MockGiteaGetOpenIssuesCall) Do(f func(string, string, string, int) ([]*models.Issue, error)) *MockGiteaGetOpenIssuesCall { +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) ([]*models.Issue, error)) *MockGiteaGetOpenIssuesCall { +func (c *MockGiteaGetOpenIssuesCall) DoAndReturn(f func(string, string, string, int, string) ([]*models.Issue, error)) *MockGiteaGetOpenIssuesCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/reparent-bot/bot.go b/reparent-bot/bot.go index b7f8261..a111e64 100644 --- a/reparent-bot/bot.go +++ b/reparent-bot/bot.go @@ -6,6 +6,7 @@ import ( "runtime/debug" "slices" "strings" + "sync" "src.opensuse.org/autogits/common" "src.opensuse.org/autogits/common/gitea-generated/models" @@ -29,7 +30,7 @@ type ReparentBot struct { func (bot *ReparentBot) ParseSourceReposFromIssue(issue *models.Issue) []RepoInfo { var sourceRepos []RepoInfo - rx := regexp.MustCompile(`https?://[a-zA-Z0-9\.-]+/([_a-zA-Z0-9\.-]+)/([_a-zA-Z0-9\.-]+)(?:#([_a-zA-Z0-9\.\-/]+))?`) + 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)) @@ -58,6 +59,8 @@ func (bot *ReparentBot) GetMaintainers(config *common.AutogitConfig) ([]string, 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 { @@ -66,6 +69,12 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro } }() + 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 } @@ -87,6 +96,7 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro 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) @@ -123,10 +133,61 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro return fmt.Errorf("no maintainers found for %s/%s#%s", org, repo, targetBranch) } + repos := make([]struct { + repo *models.Repository + name string + }, len(sourceRepos)) + for idx, sourceInfo := range sourceRepos { + source, err := bot.gitea.GetRepository(sourceInfo.Owner, sourceInfo.Name) + branch := "" + if sourceInfo.Branch != "" { + branch = sourceInfo.Branch + } else if source != nil { + branch = source.DefaultBranch + } + repos[idx].name = sourceInfo.Owner + "/" + sourceInfo.Name + "#" + branch + + if err != nil { + common.LogError("failed to fetch source repo", repos[idx].name, ":", err) + return nil + } + if source == nil { + msg := fmt.Sprintf("Source repository not found: %s", repos[idx].name) + bot.AddCommentOnce(org, repo, issue.Index, timeline, msg) + common.LogError(msg) + return nil + } + + if source.Parent != nil && source.Parent.Owner.UserName == config.Organization { + common.LogDebug("Already reparented repo. Nothing to do here.") + return nil + } + + // README: issue creator *must be* owner of the repo, OR repository must not be a fork + if issue.User.UserName != sourceInfo.Owner && source.Fork { + msg := fmt.Sprintf("@%s: You are not the owner of %s and it is already a fork. Skipping.", issue.User.UserName, repos[idx].name) + bot.AddCommentOnce(org, repo, issue.Index, timeline, msg) + return nil + } + + // Check if already exists in target org + existing, err := bot.gitea.GetRepository(org, sourceInfo.Name) + if err == nil && existing != nil { + bot.AddCommentOnce(org, repo, issue.Index, timeline, fmt.Sprintf("Repository %s already exists in organization.", sourceInfo.Name)) + return nil + } + + repos[idx].repo = source + } // Check for approval in comments approved := false for _, e := range timeline { - if e.Type == common.TimelineCommentType_Comment && e.User != nil && bot.IsMaintainer(e.User.UserName, maintainers) && bot.IsApproval(e.Body) && e.Updated == e.Created { + if e.Type == common.TimelineCommentType_Comment && + e.User != nil && + bot.IsMaintainer(e.User.UserName, maintainers) && + bot.IsApproval(e.Body) && + e.Updated == e.Created { + approved = true break } @@ -135,43 +196,14 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro if approved { common.LogInfo("Issue approved, processing source repos...") if !common.IsDryRun { - for _, sourceInfo := range sourceRepos { - source, err := bot.gitea.GetRepository(sourceInfo.Owner, sourceInfo.Name) - repoFull := sourceInfo.Owner + "/" + sourceInfo.Name - if sourceInfo.Branch != "" { - repoFull += "#" + sourceInfo.Branch - } else if source != nil { - repoFull += "#" + source.DefaultBranch - } - + for _, source := range repos { + r := source.repo + _, err := bot.gitea.ReparentRepository(r.Owner.UserName, r.Name, org) if err != nil { - common.LogError("failed to fetch source repo", repoFull, ":", err) + common.LogError("Reparent failed for", source.name, ":", err) continue } - if source == nil { - common.LogError("source repo not found:", repoFull) - continue - } - - // README: issue creator *must be* owner of the repo, OR repository must not be a fork - if issue.User.UserName != sourceInfo.Owner && source.Fork { - msg := fmt.Sprintf("@%s: You are not the owner of %s and it is already a fork. Skipping.", issue.User.UserName, repoFull) - bot.AddCommentOnce(org, repo, issue.Index, timeline, msg) - continue - } - - // 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)) - } else { - _, err := bot.gitea.ReparentRepository(sourceInfo.Owner, sourceInfo.Name, org) - if err != nil { - common.LogError("Reparent failed for", repoFull, ":", err) - continue - } - bot.AddCommentOnce(org, repo, issue.Index, timeline, fmt.Sprintf("Repository %s forked successfully.", repoFull)) - } + 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 { @@ -190,9 +222,12 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro if !found { common.LogInfo("Requesting review from maintainers:", maintainers) if !common.IsDryRun { - bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{ + _, 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, ", ")) } diff --git a/reparent-bot/main.go b/reparent-bot/main.go index 1f84a8c..c6ec173 100644 --- a/reparent-bot/main.go +++ b/reparent-bot/main.go @@ -11,19 +11,24 @@ import ( ) 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) + 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) - common.LogError("issue processing error:", err) + if err != nil { + common.LogError("issue processing error:", err) + } } } + common.LogDebug("--- ending periodic check ---") } func main() { -- 2.51.1 From e78db48ba2958bd564d15d391db80aa7bc483980bd5af5b31c8ace8a1f73ca97 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Tue, 3 Feb 2026 17:39:57 +0100 Subject: [PATCH 14/16] reparent: unit tests --- common/utils_test.go | 25 ++++++++++++++++++++ reparent-bot/bot_test.go | 47 +++++++++++++++++++++++++++++++++---- reparent-bot/rabbit_test.go | 10 ++++++-- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/common/utils_test.go b/common/utils_test.go index 46edb31..d2536ff 100644 --- a/common/utils_test.go +++ b/common/utils_test.go @@ -5,6 +5,7 @@ import ( "testing" "src.opensuse.org/autogits/common" + "src.opensuse.org/autogits/common/gitea-generated/models" ) func TestGitUrlParse(t *testing.T) { @@ -306,3 +307,27 @@ func TestNewPackageIssueParsing(t *testing.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_test.go b/reparent-bot/bot_test.go index 3395e92..556fc3c 100644 --- a/reparent-bot/bot_test.go +++ b/reparent-bot/bot_test.go @@ -161,13 +161,9 @@ func TestHasComment(t *testing.T) { } func TestProcessIssue(t *testing.T) { - ctrl := test_utils.NewController(t) - defer ctrl.Finish() - - mockGitea := mock.NewMockGitea(ctrl) + var mockGitea *mock.MockGitea mockFetcher := &MockMaintainershipFetcher{} bot := &ReparentBot{ - gitea: mockGitea, botUser: "bot", maintainershipFetcher: mockFetcher, configs: common.AutogitConfigs{ @@ -264,6 +260,8 @@ func TestProcessIssue(t *testing.T) { 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) }, @@ -316,6 +314,8 @@ func TestProcessIssue(t *testing.T) { 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) }, @@ -375,6 +375,8 @@ func TestProcessIssue(t *testing.T) { 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, }, @@ -427,6 +429,7 @@ func TestProcessIssue(t *testing.T) { 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, }, @@ -460,11 +463,45 @@ func TestProcessIssue(t *testing.T) { }, wantErr: false, }, + { + name: "user nil panic", + issue: &models.Issue{ + Index: 99, + State: "open", + Title: "[ADD] My Repo", + Labels: []*models.Label{{Name: common.Label_NewRepository}}, + Ref: "refs/heads/master", + Body: "https://src.opensuse.org/owner/repo", + User: nil, + }, + setupMock: func() { + mockGitea.EXPECT().GetTimeline("org", "repo", int64(99)).Return(nil, nil) + mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} + mockFetcher.err = nil + mockGitea.EXPECT().GetRepository("owner", "repo").Return(&models.Repository{DefaultBranch: "master", Fork: true}, nil) + mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil) + }, + wantErr: true, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + ctrl := test_utils.NewController(t) + defer ctrl.Finish() + mockGitea = mock.NewMockGitea(ctrl) + mockGitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + bot.gitea = mockGitea + common.IsDryRun = tc.dryRun + if tc.issue != nil { + if tc.issue.Repository == nil { + tc.issue.Repository = &models.RepositoryMeta{Owner: "org", Name: "repo"} + } + if tc.issue.User == nil && tc.name != "user nil panic" { + tc.issue.User = &models.User{UserName: "owner"} + } + } tc.setupMock() err := bot.ProcessIssue("org", "repo", tc.issue) if (err != nil) != tc.wantErr { diff --git a/reparent-bot/rabbit_test.go b/reparent-bot/rabbit_test.go index 345caac..27fa1ab 100644 --- a/reparent-bot/rabbit_test.go +++ b/reparent-bot/rabbit_test.go @@ -39,7 +39,10 @@ func TestIssueProcessor_ProcessFunc(t *testing.T) { }, }, setupMock: func() { - mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(&models.Issue{State: "closed"}, nil) + mockGitea.EXPECT().GetIssue("org", "repo", int64(1)).Return(&models.Issue{ + State: "closed", + Repository: &models.RepositoryMeta{Owner: "org", Name: "repo"}, + }, nil) }, wantErr: false, }, @@ -58,7 +61,10 @@ func TestIssueProcessor_ProcessFunc(t *testing.T) { }, }, setupMock: func() { - mockGitea.EXPECT().GetIssue("org", "repo", int64(2)).Return(&models.Issue{State: "closed"}, nil) + mockGitea.EXPECT().GetIssue("org", "repo", int64(2)).Return(&models.Issue{ + State: "closed", + Repository: &models.RepositoryMeta{Owner: "org", Name: "repo"}, + }, nil) }, wantErr: false, }, -- 2.51.1 From 60af65025bc3fe391dcbc1b4966b6e17a9d3ef10fafe50b2e5c3761bdbbffc62 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Tue, 3 Feb 2026 20:47:15 +0100 Subject: [PATCH 15/16] reparent: fix race condition and unit tests --- reparent-bot/bot.go | 17 ++++++++- reparent-bot/bot_test.go | 81 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/reparent-bot/bot.go b/reparent-bot/bot.go index a111e64..f4f1564 100644 --- a/reparent-bot/bot.go +++ b/reparent-bot/bot.go @@ -7,6 +7,7 @@ import ( "slices" "strings" "sync" + "time" "src.opensuse.org/autogits/common" "src.opensuse.org/autogits/common/gitea-generated/models" @@ -164,8 +165,12 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro } // README: issue creator *must be* owner of the repo, OR repository must not be a fork - if issue.User.UserName != sourceInfo.Owner && source.Fork { - msg := fmt.Sprintf("@%s: You are not the owner of %s and it is already a fork. Skipping.", issue.User.UserName, repos[idx].name) + 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 } @@ -188,6 +193,14 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro 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 } diff --git a/reparent-bot/bot_test.go b/reparent-bot/bot_test.go index 556fc3c..136206f 100644 --- a/reparent-bot/bot_test.go +++ b/reparent-bot/bot_test.go @@ -3,7 +3,9 @@ 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" @@ -479,9 +481,9 @@ func TestProcessIssue(t *testing.T) { mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} mockFetcher.err = nil mockGitea.EXPECT().GetRepository("owner", "repo").Return(&models.Repository{DefaultBranch: "master", Fork: true}, nil) - mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil) + mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil) }, - wantErr: true, + wantErr: false, }, } @@ -559,3 +561,78 @@ func TestAddCommentOnce(t *testing.T) { }) } +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) + } +} -- 2.51.1 From f5ec5944dba5dfe9fd8a5f874a3fbcf897d98ccd43bff19a4ad40ad2a5524d26 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Tue, 3 Feb 2026 22:31:59 +0100 Subject: [PATCH 16/16] reparent: fix typo --- reparent-bot/bot.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reparent-bot/bot.go b/reparent-bot/bot.go index f4f1564..2389b25 100644 --- a/reparent-bot/bot.go +++ b/reparent-bot/bot.go @@ -122,7 +122,7 @@ func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) erro } } if config == nil { - return fmt.Errorf("no config found for %s/%s#%s", org, org, targetBranch) + return fmt.Errorf("no config found for %s/%s#%s", org, repo, targetBranch) } maintainers, err := bot.GetMaintainers(config) -- 2.51.1