All checks were successful
go-generate-check / go-generate-check (pull_request) Successful in 25s
277 lines
8.1 KiB
Go
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)
|
|
}
|