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) }