2 Commits

Author SHA256 Message Date
5abb1773db reparent: move reparent to common interface 2026-02-01 05:08:17 +01:00
2fb18c4641 reparent: first version 2026-02-01 04:51:49 +01:00
4 changed files with 480 additions and 0 deletions

View File

@@ -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(

View File

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

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

@@ -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/(?<org>[_\.a-zA-Z0-9-]+)/(?<project>[_\.a-zA-Z0-9-]+)/issues/(?<num>[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)))
}
}

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

@@ -0,0 +1,179 @@
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 {
_, err := bot.gitea.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")
}