Compare commits
2 Commits
gitea-api-
...
reparent
| Author | SHA256 | Date | |
|---|---|---|---|
| 5abb1773db | |||
| 2fb18c4641 |
@@ -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(
|
||||
|
||||
@@ -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
177
reparent-bot/main.go
Normal 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
179
reparent-bot/rabbit.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user