Files
autogits/reparent-bot/bot.go
Adam Majer f5ec5944db
All checks were successful
go-generate-check / go-generate-check (pull_request) Successful in 25s
reparent: fix typo
2026-02-03 22:31:59 +01:00

277 lines
8.1 KiB
Go

package main
import (
"fmt"
"regexp"
"runtime/debug"
"slices"
"strings"
"sync"
"time"
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
)
type RepoInfo struct {
Owner string
Name string
Branch string
}
type ReparentBot struct {
configs common.AutogitConfigs
gitea common.Gitea
giteaUrl string
botUser string
maintainershipFetcher MaintainershipFetcher
}
func (bot *ReparentBot) ParseSourceReposFromIssue(issue *models.Issue) []RepoInfo {
var sourceRepos []RepoInfo
rx := regexp.MustCompile(`([_a-zA-Z0-9\.-]+)/([_a-zA-Z0-9\.-]+)(?:#([_a-zA-Z0-9\.\-/]+))?$`)
for _, line := range strings.Split(issue.Body, "\n") {
matches := rx.FindStringSubmatch(strings.TrimSpace(line))
if len(matches) == 4 {
sourceRepos = append(sourceRepos, RepoInfo{Owner: matches[1], Name: matches[2], Branch: matches[3]})
}
}
return sourceRepos
}
type MaintainershipFetcher interface {
FetchProjectMaintainershipData(gitea common.GiteaMaintainershipReader, config *common.AutogitConfig) (common.MaintainershipData, error)
}
type RealMaintainershipFetcher struct{}
func (f *RealMaintainershipFetcher) FetchProjectMaintainershipData(gitea common.GiteaMaintainershipReader, config *common.AutogitConfig) (common.MaintainershipData, error) {
return common.FetchProjectMaintainershipData(gitea, config)
}
func (bot *ReparentBot) GetMaintainers(config *common.AutogitConfig) ([]string, error) {
m, err := bot.maintainershipFetcher.FetchProjectMaintainershipData(bot.gitea, config)
if err != nil {
return nil, err
}
return m.ListProjectMaintainers(config.ReviewGroups), nil
}
var serializeMutex sync.Mutex
func (bot *ReparentBot) ProcessIssue(org, repo string, issue *models.Issue) error {
defer func() {
if r := recover(); r != nil {
common.LogInfo("panic caught --- recovered")
common.LogError(string(debug.Stack()))
}
}()
serializeMutex.Lock()
defer serializeMutex.Unlock()
common.LogInfo(">>> Starting processing issue:", common.IssueToString(issue))
defer common.LogInfo("<<< End processing issue:", common.IssueToString(issue))
if issue.State == "closed" {
return nil
}
if !strings.HasPrefix(strings.ToUpper(issue.Title), "[ADD]") {
return nil
}
newRepoLabel := false
for _, l := range issue.Labels {
if l.Name == common.Label_NewRepository {
newRepoLabel = true
break
}
}
if !newRepoLabel {
common.LogDebug("Not a new repository. Nothing to do here.")
return nil
}
bot.gitea.ResetTimelineCache(org, repo, issue.Index)
timeline, err := bot.gitea.GetTimeline(org, repo, issue.Index)
if err != nil {
common.LogError("Failed to fetch issue timeline:", err)
return err
}
sourceRepos := bot.ParseSourceReposFromIssue(issue)
if len(sourceRepos) == 0 {
common.LogDebug("Could not parse any source repos from issue body")
return nil
}
targetBranch := strings.TrimPrefix(issue.Ref, "refs/heads/")
config := bot.configs.GetPrjGitConfig(org, repo, targetBranch)
if config == nil {
for _, c := range bot.configs {
if c.Organization == org && c.Branch == targetBranch {
config = c
break
}
}
}
if config == nil {
return fmt.Errorf("no config found for %s/%s#%s", org, repo, targetBranch)
}
maintainers, err := bot.GetMaintainers(config)
if err != nil {
return err
}
if len(maintainers) == 0 {
return fmt.Errorf("no maintainers found for %s/%s#%s", org, repo, targetBranch)
}
repos := make([]struct {
repo *models.Repository
name string
}, len(sourceRepos))
for idx, sourceInfo := range sourceRepos {
source, err := bot.gitea.GetRepository(sourceInfo.Owner, sourceInfo.Name)
branch := ""
if sourceInfo.Branch != "" {
branch = sourceInfo.Branch
} else if source != nil {
branch = source.DefaultBranch
}
repos[idx].name = sourceInfo.Owner + "/" + sourceInfo.Name + "#" + branch
if err != nil {
common.LogError("failed to fetch source repo", repos[idx].name, ":", err)
return nil
}
if source == nil {
msg := fmt.Sprintf("Source repository not found: %s", repos[idx].name)
bot.AddCommentOnce(org, repo, issue.Index, timeline, msg)
common.LogError(msg)
return nil
}
if source.Parent != nil && source.Parent.Owner.UserName == config.Organization {
common.LogDebug("Already reparented repo. Nothing to do here.")
return nil
}
// README: issue creator *must be* owner of the repo, OR repository must not be a fork
if issue.User == nil || issue.User.UserName != sourceInfo.Owner && source.Fork {
user := "(nil)"
if issue.User != nil {
user = issue.User.UserName
}
msg := fmt.Sprintf("@%s: You are not the owner of %s and it is already a fork. Skipping.", user, repos[idx].name)
bot.AddCommentOnce(org, repo, issue.Index, timeline, msg)
return nil
}
// Check if already exists in target org
existing, err := bot.gitea.GetRepository(org, sourceInfo.Name)
if err == nil && existing != nil {
bot.AddCommentOnce(org, repo, issue.Index, timeline, fmt.Sprintf("Repository %s already exists in organization.", sourceInfo.Name))
return nil
}
repos[idx].repo = source
}
// Check for approval in comments
approved := false
for _, e := range timeline {
if e.Type == common.TimelineCommentType_Comment &&
e.User != nil &&
bot.IsMaintainer(e.User.UserName, maintainers) &&
bot.IsApproval(e.Body) &&
e.Updated == e.Created {
// Approval must be newer than the last issue update (with some tolerance for latency)
// If the comment is significantly older than the issue update, it applies to an old version.
// We use a 1-minute tolerance to avoid race conditions
if time.Time(e.Created).Before(time.Time(issue.Updated).Add(-1*time.Minute)) && issue.Updated != issue.Created {
common.LogDebug("Ignoring stale approval from %s (created: %v, issue updated: %v)", e.User.UserName, e.Created, issue.Updated)
continue
}
approved = true
break
}
}
if approved {
common.LogInfo("Issue approved, processing source repos...")
if !common.IsDryRun {
for _, source := range repos {
r := source.repo
_, err := bot.gitea.ReparentRepository(r.Owner.UserName, r.Name, org)
if err != nil {
common.LogError("Reparent failed for", source.name, ":", err)
continue
}
bot.AddCommentOnce(org, repo, issue.Index, timeline, fmt.Sprintf("Repository %s forked successfully.", source.name))
}
// bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{State: "closed"})
} else {
common.LogInfo("Dry run: would process %d source repos for issue %d", len(sourceRepos), issue.Index)
}
} else {
// Request review/assignment if not already done
found := false
for _, a := range issue.Assignees {
if bot.IsMaintainer(a.UserName, maintainers) {
found = true
break
}
}
if !found {
common.LogInfo("Requesting review from maintainers:", maintainers)
if !common.IsDryRun {
_, err := bot.gitea.UpdateIssue(org, repo, issue.Index, &models.EditIssueOption{
Assignees: maintainers,
})
if err != nil {
common.LogError("Failed to assign maintainers:", err)
}
bot.AddCommentOnce(org, repo, issue.Index, timeline,
"Review requested from maintainers: "+strings.Join(maintainers, ", "))
}
}
}
return nil
}
func (bot *ReparentBot) IsMaintainer(user string, maintainers []string) bool {
return slices.Contains(maintainers, user)
}
func (bot *ReparentBot) IsApproval(body string) bool {
body = strings.ToLower(strings.TrimSpace(body))
return strings.Contains(body, "approved") || strings.Contains(body, "lgtm")
}
func (bot *ReparentBot) HasComment(timeline []*models.TimelineComment, message string) bool {
for _, e := range timeline {
if e.Type == common.TimelineCommentType_Comment && e.User != nil && e.User.UserName == bot.botUser && strings.TrimSpace(e.Body) == strings.TrimSpace(message) {
return true
}
}
return false
}
func (bot *ReparentBot) AddCommentOnce(org, repo string, index int64, timeline []*models.TimelineComment, msg string) {
if bot.HasComment(timeline, msg) {
return
}
bot.gitea.AddComment(&models.PullRequest{Base: &models.PRBranchInfo{Repo: &models.Repository{Owner: &models.User{UserName: org}, Name: repo}}, Index: index}, msg)
}