package common import ( "bufio" "errors" "fmt" "os" "path" "slices" "strings" "src.opensuse.org/autogits/common/gitea-generated/client/repository" "src.opensuse.org/autogits/common/gitea-generated/models" ) type PRInfo struct { PR *models.PullRequest Reviews *PRReviews RemoteName string } type PRSet struct { PRs []*PRInfo Config *AutogitConfig BotUser string HasAutoStaging bool } func (prinfo *PRInfo) PRComponents() (org string, repo string, idx int64) { org = prinfo.PR.Base.Repo.Owner.UserName repo = prinfo.PR.Base.Repo.Name idx = prinfo.PR.Index return } func (prinfo *PRInfo) RemoveReviewers(gitea GiteaUnreviewTimelineFetcher, Reviewers []string, BotUser string) { org, repo, idx := prinfo.PRComponents() tl, err := gitea.GetTimeline(org, repo, idx) if err != nil { LogError("Failed to fetch timeline for", PRtoString(prinfo.PR), err) } // find review request for each reviewer ReviewersToUnrequest := Reviewers ReviewersAlreadyChecked := []string{} for _, tlc := range tl { if tlc.Type == TimelineCommentType_ReviewRequested && tlc.Assignee != nil { user := tlc.Assignee.UserName if idx := slices.Index(ReviewersToUnrequest, user); idx >= 0 && !slices.Contains(ReviewersAlreadyChecked, user) { if tlc.User != nil && tlc.User.UserName == BotUser { ReviewersAlreadyChecked = append(ReviewersAlreadyChecked, user) continue } ReviewersToUnrequest = slices.Delete(ReviewersToUnrequest, idx, idx+1) if len(Reviewers) == 0 { break } } } } LogDebug("Unrequesting reviewes for", PRtoString(prinfo.PR), ReviewersToUnrequest) err = gitea.UnrequestReview(org, repo, idx, ReviewersToUnrequest...) if err != nil { LogError("Failed to unrequest reviewers for", PRtoString(prinfo.PR), err) } } func readPRData(gitea GiteaPRFetcher, pr *models.PullRequest, currentSet []*PRInfo, config *AutogitConfig) ([]*PRInfo, error) { for _, p := range currentSet { if pr.Index == p.PR.Index && pr.Base.Repo.Name == p.PR.Base.Repo.Name && pr.Base.Repo.Owner.UserName == p.PR.Base.Repo.Owner.UserName { return nil, nil } } retSet := []*PRInfo{&PRInfo{PR: pr}} // only need to extact there on PrjGit PR org, repo, _ := config.GetPrjGit() if pr.Base.Repo.Name == repo && pr.Base.Repo.Owner.UserName == org { _, refPRs := ExtractDescriptionAndPRs(bufio.NewScanner(strings.NewReader(pr.Body))) for _, prdata := range refPRs { pr, err := gitea.GetPullRequest(prdata.Org, prdata.Repo, prdata.Num) if err != nil { return nil, err } data, err := readPRData(gitea, pr, slices.Concat(currentSet, retSet), config) if err != nil { return nil, err } retSet = slices.Concat(retSet, data) } } return retSet, nil } var Timeline_RefIssueNotFound error = errors.New("RefIssue not found on the timeline") func LastPrjGitRefOnTimeline(botUser string, gitea GiteaPRTimelineReviewFetcher, org, repo string, num int64, config *AutogitConfig) (*models.PullRequest, error) { timeline, err := gitea.GetTimeline(org, repo, num) if err != nil { LogError("Failed to fetch timeline for", org, repo, "#", num, err) return nil, err } prjGitOrg, prjGitRepo, prjGitBranch := config.GetPrjGit() for idx := len(timeline) - 1; idx >= 0; idx-- { item := timeline[idx] issue := item.RefIssue if item.Type == TimelineCommentType_PullRequestRef && issue != nil && issue.Repository != nil && issue.Repository.Owner == prjGitOrg && issue.Repository.Name == prjGitRepo { if !config.NoProjectGitPR { if issue.User.UserName != botUser { continue } } pr, err := gitea.GetPullRequest(prjGitOrg, prjGitRepo, issue.Index) if err != nil { switch err.(type) { case *repository.RepoGetPullRequestNotFound: // deleted? continue default: LogDebug("PrjGit RefIssue fetch error from timeline", issue.Index, err) continue } } LogDebug("found ref PR on timeline:", PRtoString(pr)) if pr.Base.Name != prjGitBranch { LogDebug(" -> not matching:", pr.Base.Name, prjGitBranch) continue } _, prs := ExtractDescriptionAndPRs(bufio.NewScanner(strings.NewReader(item.RefIssue.Body))) for _, pr := range prs { if pr.Org == org && pr.Repo == repo && pr.Num == num { LogDebug("Found PrjGit PR in Timeline:", issue.Index) // found prjgit PR in timeline. Return it return gitea.GetPullRequest(prjGitOrg, prjGitRepo, issue.Index) } } } } LogDebug("PrjGit RefIssue not found on timeline in", org, repo, num) return nil, Timeline_RefIssueNotFound } func FetchPRSet(user string, gitea GiteaPRTimelineReviewFetcher, org, repo string, num int64, config *AutogitConfig) (*PRSet, error) { var pr *models.PullRequest var err error prjGitOrg, prjGitRepo, _ := config.GetPrjGit() if prjGitOrg == org && prjGitRepo == repo { if pr, err = gitea.GetPullRequest(org, repo, num); err != nil { return nil, err } } else { if pr, err = LastPrjGitRefOnTimeline(user, gitea, org, repo, num, config); err != nil && err != Timeline_RefIssueNotFound { return nil, err } if pr == nil { if pr, err = gitea.GetPullRequest(org, repo, num); err != nil { return nil, err } } } prs, err := readPRData(gitea, pr, nil, config) if err != nil { return nil, err } for _, pr := range prs { org, repo, idx := pr.PRComponents() reviews, err := FetchGiteaReviews(gitea, org, repo, idx) if err != nil { LogError("Error fetching reviews for", PRtoString(pr.PR), ":", err) } pr.Reviews = reviews } return &PRSet{ PRs: prs, Config: config, BotUser: user, }, nil } func (prset *PRSet) RemoveReviewers(gitea GiteaUnreviewTimelineFetcher, reviewers []string) { for _, prinfo := range prset.PRs { prinfo.RemoveReviewers(gitea, reviewers, prset.BotUser) } } func (rs *PRSet) Find(pr *models.PullRequest) (*PRInfo, bool) { for _, p := range rs.PRs { if p.PR.Base.RepoID == pr.Base.RepoID && p.PR.Head.Sha == pr.Head.Sha && p.PR.Base.Name == pr.Base.Name { return p, true } } return nil, false } func (rs *PRSet) AddPR(pr *models.PullRequest) *PRInfo { if pr, found := rs.Find(pr); found { return pr } prinfo := &PRInfo{ PR: pr, } rs.PRs = append(rs.PRs, prinfo) return prinfo } func (rs *PRSet) IsPrjGitPR(pr *models.PullRequest) bool { org, repo, branch := rs.Config.GetPrjGit() return pr.Base.Name == branch && pr.Base.Repo.Name == repo && pr.Base.Repo.Owner.UserName == org } var PRSet_PrjGitMissing error = errors.New("No PrjGit PR found") var PRSet_MultiplePrjGit error = errors.New("Multiple PrjGit PRs in one review set") func (rs *PRSet) GetPrjGitPR() (*PRInfo, error) { var ret *PRInfo for _, prinfo := range rs.PRs { if rs.IsPrjGitPR(prinfo.PR) { if ret == nil { ret = prinfo } else { return nil, PRSet_MultiplePrjGit } } } if ret != nil { return ret, nil } return nil, PRSet_PrjGitMissing } func (rs *PRSet) NeedRecreatingPrjGit(currentBranchHash string) bool { pr, err := rs.GetPrjGitPR() if err != nil { return true } return pr.PR.Base.Sha == currentBranchHash } func (rs *PRSet) IsConsistent() bool { prjpr_info, err := rs.GetPrjGitPR() if err != nil { return false } prjpr := prjpr_info.PR _, prjpr_set := ExtractDescriptionAndPRs(bufio.NewScanner(strings.NewReader(prjpr.Body))) if len(prjpr_set) != len(rs.PRs)-1 { // 1 to many mapping LogDebug("Number of PR from links:", len(prjpr_set), "is not what's expected", len(rs.PRs)-1) return false } next_rs: for _, prinfo := range rs.PRs { if prinfo.PR.State != "open" { return false } if prjpr == prinfo.PR { continue } for _, pr := range prjpr_set { if strings.EqualFold(prinfo.PR.Base.Repo.Owner.UserName, pr.Org) && strings.EqualFold(prinfo.PR.Base.Repo.Name, pr.Repo) && prinfo.PR.Index == pr.Num { continue next_rs } } LogDebug(" PR: ", PRtoString(prinfo.PR), "not found in project git PRSet") return false } return true } func (rs *PRSet) FindMissingAndExtraReviewers(maintainers MaintainershipData, idx int) (missing, extra []string) { configReviewers := ParseReviewers(rs.Config.Reviewers) // remove reviewers that were already requested and are not stale prjMaintainers := maintainers.ListProjectMaintainers(nil) LogDebug("project maintainers:", prjMaintainers) pr := rs.PRs[idx] if rs.IsPrjGitPR(pr.PR) { missing = slices.Concat(configReviewers.Prj, configReviewers.PrjOptional) if rs.HasAutoStaging { missing = append(missing, Bot_BuildReview) } LogDebug("PrjGit submitter:", pr.PR.User.UserName) // only need project maintainer reviews if: // * not created by a bot and has other PRs, or // * not created by maintainer noReviewPRCreators := prjMaintainers if len(rs.PRs) > 1 { noReviewPRCreators = append(noReviewPRCreators, rs.BotUser) } if slices.Contains(noReviewPRCreators, pr.PR.User.UserName) || pr.Reviews.IsReviewedByOneOf(prjMaintainers...) { LogDebug("Project already reviewed by a project maintainer, remove rest") // do not remove reviewers if they are also maintainers prjMaintainers = slices.DeleteFunc(prjMaintainers, func(m string) bool { return slices.Contains(missing, m) }) extra = slices.Concat(prjMaintainers, []string{rs.BotUser}) } else { // if bot not created PrjGit or prj maintainer, we need to add project reviewers here if slices.Contains(noReviewPRCreators, pr.PR.User.UserName) { LogDebug("No need for project maintainers") extra = slices.Concat(prjMaintainers, []string{rs.BotUser}) } else { LogDebug("Adding prjMaintainers to PrjGit") missing = append(missing, prjMaintainers...) } } } else { pkg := pr.PR.Base.Repo.Name pkgMaintainers := maintainers.ListPackageMaintainers(pkg, nil) Maintainers := slices.Concat(prjMaintainers, pkgMaintainers) noReviewPkgPRCreators := pkgMaintainers LogDebug("packakge maintainers:", Maintainers) missing = slices.Concat(configReviewers.Pkg, configReviewers.PkgOptional) if slices.Contains(noReviewPkgPRCreators, pr.PR.User.UserName) || pr.Reviews.IsReviewedByOneOf(Maintainers...) { // submitter is maintainer or already reviewed LogDebug("Package reviewed by maintainer (or subitter is maintainer), remove the rest of them") // do not remove reviewers if they are also maintainers Maintainers = slices.DeleteFunc(Maintainers, func(m string) bool { return slices.Contains(missing, m) }) extra = slices.Concat(Maintainers, []string{rs.BotUser}) } else { // maintainer review is missing LogDebug("Adding package maintainers to package git") missing = append(missing, pkgMaintainers...) } } slices.Sort(missing) missing = slices.Compact(missing) slices.Sort(extra) extra = slices.Compact(extra) // submitters cannot review their own work if idx := slices.Index(missing, pr.PR.User.UserName); idx != -1 { missing = slices.Delete(missing, idx, idx+1) } LogDebug("PR: ", PRtoString(pr.PR)) LogDebug(" preliminary add reviewers for PR:", missing) LogDebug(" preliminary rm reviewers for PR:", extra) // remove missing reviewers that are already done or already pending for idx := 0; idx < len(missing); { user := missing[idx] if pr.Reviews.HasPendingReviewBy(user) || pr.Reviews.IsReviewedBy(user) { missing = slices.Delete(missing, idx, idx+1) LogDebug(" removing done/pending reviewer:", user) } else { idx++ } } // remove extra reviews that are actually only pending, and only pending by us for idx := 0; idx < len(extra); { user := extra[idx] rr := pr.Reviews.FindReviewRequester(user) if rr != nil && rr.User.UserName == rs.BotUser && pr.Reviews.HasPendingReviewBy(user) { // good to remove this review idx++ } else { // this review should not be considered as extra by us LogDebug(" - cannot find? to remove", user) if rr != nil { LogDebug(" ", rr.User.UserName, "vs.", rs.BotUser, pr.Reviews.HasPendingReviewBy(user)) } extra = slices.Delete(extra, idx, idx+1) } } LogDebug(" add reviewers for PR:", missing) LogDebug(" rm reviewers for PR:", extra) return missing, extra } func (rs *PRSet) AssignReviewers(gitea GiteaReviewFetcherAndRequesterAndUnrequester, maintainers MaintainershipData) error { for idx, pr := range rs.PRs { missingReviewers, extraReviewers := rs.FindMissingAndExtraReviewers(maintainers, idx) if len(missingReviewers) > 0 { LogDebug(" Requesting reviews from:", missingReviewers) if !IsDryRun { for _, r := range missingReviewers { if _, err := gitea.RequestReviews(pr.PR, r); err != nil { LogError("Cannot create reviews on", PRtoString(pr.PR), "for user:", r, err) } } } } if len(extraReviewers) > 0 { LogDebug(" UnRequesting reviews from:", extraReviewers) if !IsDryRun { for _, r := range extraReviewers { org, repo, idx := pr.PRComponents() if err := gitea.UnrequestReview(org, repo, idx, r); err != nil { LogError("Cannot unrequest reviews on", PRtoString(pr.PR), "for user:", r, err) } } } } } return nil } func (rs *PRSet) RemoveClosedPRs() { rs.PRs = slices.DeleteFunc(rs.PRs, func(pr *PRInfo) bool { return pr.PR.State != "open" }) } func (rs *PRSet) IsApproved(gitea GiteaPRChecker, maintainers MaintainershipData) bool { configReviewers := ParseReviewers(rs.Config.Reviewers) is_manually_reviewed_ok := false if need_manual_review := rs.Config.ManualMergeOnly || rs.Config.ManualMergeProject; need_manual_review { // Groups are expanded here because any group member can issue "merge ok" to the BotUser groups := rs.Config.ReviewGroups prjgit, err := rs.GetPrjGitPR() if err == nil && prjgit != nil { reviewers := slices.Concat(configReviewers.Prj, maintainers.ListProjectMaintainers(groups)) LogDebug("Fetching reviews for", prjgit.PR.Base.Repo.Owner.UserName, prjgit.PR.Base.Repo.Name, prjgit.PR.Index) r, err := FetchGiteaReviews(gitea, prjgit.PR.Base.Repo.Owner.UserName, prjgit.PR.Base.Repo.Name, prjgit.PR.Index) if err != nil { LogError("Cannot fetch gita reaviews for PR:", err) return false } r.RequestedReviewers = reviewers prjgit.Reviews = r if prjgit.Reviews.IsManualMergeOK() { is_manually_reviewed_ok = true } } if !is_manually_reviewed_ok && !rs.Config.ManualMergeProject { for _, pr := range rs.PRs { if rs.IsPrjGitPR(pr.PR) { continue } pkg := pr.PR.Base.Repo.Name reviewers := slices.Concat(configReviewers.Pkg, maintainers.ListPackageMaintainers(pkg, groups)) LogDebug("Fetching reviews for", pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index) r, err := FetchGiteaReviews(gitea, pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index) if err != nil { LogError("Cannot fetch gita reaviews for PR:", err) return false } r.RequestedReviewers = reviewers pr.Reviews = r if !pr.Reviews.IsManualMergeOK() { LogInfo("Not approved manual merge. PR:", pr.PR.URL) return false } } is_manually_reviewed_ok = true } if !is_manually_reviewed_ok { LogInfo("manual merge not ok") return false } } for _, pr := range rs.PRs { var reviewers []string var pkg string if rs.IsPrjGitPR(pr.PR) { reviewers = configReviewers.Prj if rs.HasAutoStaging { reviewers = append(reviewers, Bot_BuildReview) } pkg = "" } else { reviewers = configReviewers.Pkg pkg = pr.PR.Base.Repo.Name } if strings.HasPrefix(pr.PR.Title, "WIP:") { LogInfo("WIP PR. Ignoring") return false } r, err := FetchGiteaReviews(gitea, pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index) if err != nil { LogError("Cannot fetch gitea reaviews for PR:", err) return false } r.RequestedReviewers = reviewers is_manually_reviewed_ok = r.IsApproved() LogDebug("PR to", pr.PR.Base.Repo.Name, "reviewed?", is_manually_reviewed_ok) if !is_manually_reviewed_ok { if GetLoggingLevel() > LogLevelInfo { LogDebug("missing reviewers:", r.MissingReviews()) } return false } if need_maintainer_review := !rs.IsPrjGitPR(pr.PR) || pr.PR.User.UserName != rs.BotUser; need_maintainer_review { // Do not expand groups here, as the group-review-bot will ACK if group has reviewed. if is_manually_reviewed_ok = maintainers.IsApproved(pkg, r.Reviews, pr.PR.User.UserName, nil); !is_manually_reviewed_ok { LogDebug(" not approved?", pkg) return false } } else { LogDebug("PrjGit PR -- bot created, no need for review") } } return is_manually_reviewed_ok } func (rs *PRSet) Merge(gitea GiteaReviewUnrequester, git Git) error { prjgit_info, err := rs.GetPrjGitPR() if err != nil { return err } prjgit := prjgit_info.PR _, _, prjgitBranch := rs.Config.GetPrjGit() remote, err := git.GitClone(DefaultGitPrj, prjgitBranch, prjgit.Base.Repo.SSHURL) PanicOnError(err) git.GitExecOrPanic(DefaultGitPrj, "fetch", remote, prjgit.Head.Sha) // if other changes merged, check if we have conflicts /* rev := strings.TrimSpace(git.GitExecWithOutputOrPanic(DefaultGitPrj, "merge-base", "HEAD", prjgit.Base.Sha, prjgit.Head.Sha)) if rev != prjgit.Base.Sha { return fmt.Errorf("Base.Sha (%s) not yet merged into project-git. Aborting merge.", prjgit.Base.Sha) } */ /* rev := git.GitExecWithOutputOrPanic(common.DefaultGitPrj, "rev-list", "-1", "HEAD") if rev != prjgit.Base.Sha { panic("FIXME") } */ msg := fmt.Sprintf("Merging\n\nPR: %s/%s!%d", prjgit.Base.Repo.Owner.UserName, prjgit.Base.Repo.Name, prjgit.Index) err = git.GitExec(DefaultGitPrj, "merge", "--no-ff", "-m", msg, prjgit.Head.Sha) if err != nil { status, statusErr := git.GitStatus(DefaultGitPrj) if statusErr != nil { return fmt.Errorf("Failed to merge: %w . Status also failed: %w", err, statusErr) } // we can only resolve conflicts with .gitmodules for _, s := range status { if s.Status == GitStatus_Unmerged { panic("Can't handle conflicts yet") if s.Path != ".gitmodules" { return err } submodules, err := git.GitSubmoduleList(DefaultGitPrj, "MERGE_HEAD") if err != nil { return fmt.Errorf("Failed to fetch submodules during merge resolution: %w", err) } s1, err := git.GitExecWithOutput(DefaultGitPrj, "cat-file", "blob", s.States[0]) if err != nil { return fmt.Errorf("Failed fetching data during .gitmodules merge resoulution: %w", err) } s2, err := git.GitExecWithOutput(DefaultGitPrj, "cat-file", "blob", s.States[1]) if err != nil { return fmt.Errorf("Failed fetching data during .gitmodules merge resoulution: %w", err) } s3, err := git.GitExecWithOutput(DefaultGitPrj, "cat-file", "blob", s.States[2]) if err != nil { return fmt.Errorf("Failed fetching data during .gitmodules merge resoulution: %w", err) } subs1, err := ParseSubmodulesFile(strings.NewReader(s1)) if err != nil { return fmt.Errorf("Failed parsing submodule file [%s] in merge: %w", s.States[0], err) } subs2, err := ParseSubmodulesFile(strings.NewReader(s2)) if err != nil { return fmt.Errorf("Failed parsing submodule file [%s] in merge: %w", s.States[0], err) } subs3, err := ParseSubmodulesFile(strings.NewReader(s3)) if err != nil { return fmt.Errorf("Failed parsing submodule file [%s] in merge: %w", s.States[0], err) } // merge from subs3 (target), subs1 (orig), subs2 (2-nd base that is missing from target base) // this will update submodules mergedSubs := slices.Concat(subs1, subs2, subs3) var filteredSubs []Submodule = make([]Submodule, 0, max(len(subs1), len(subs2), len(subs3))) nextSub: for subName := range submodules { for i := range mergedSubs { if path.Base(mergedSubs[i].Path) == subName { filteredSubs = append(filteredSubs, mergedSubs[i]) continue nextSub } } return fmt.Errorf("Cannot find submodule for path: %s", subName) } out, err := os.Create(path.Join(git.GetPath(), DefaultGitPrj, ".gitmodules")) if err != nil { return fmt.Errorf("Can't open .gitmodules for writing: %w", err) } if err = WriteSubmodules(filteredSubs, out); err != nil { return fmt.Errorf("Can't write .gitmodules: %w", err) } if out.Close(); err != nil { return fmt.Errorf("Can't close .gitmodules: %w", err) } git.GitExecOrPanic(DefaultGitPrj, "add", ".gitmodules") git.GitExecOrPanic(DefaultGitPrj, "-c", "core.editor=true", "merge", "--continue") } } } // FF all non-prj git and unrequest reviews. for _, prinfo := range rs.PRs { // remove pending review requests repo := prinfo.PR.Base.Repo head := prinfo.PR.Head id := prinfo.PR.Index reviewers := make([]string, len(prinfo.PR.RequestedReviewers)) for idx := range prinfo.PR.RequestedReviewers { r := prinfo.PR.RequestedReviewers[idx] if r != nil { reviewers[idx] = r.UserName } } if err := gitea.UnrequestReview(repo.Owner.UserName, repo.Name, id, reviewers...); err != nil { LogError("Cannot unrequest reviews in PR:", repo.Owner.UserName, repo.Name, id, reviewers, ": ", err) } // PrjGit already merged above, so skip here. if rs.IsPrjGitPR(prinfo.PR) { continue } br := rs.Config.Branch if len(br) == 0 { // if branch is unspecified, take it from the PR as it // matches default branch already br = prinfo.PR.Base.Name } else if br != prinfo.PR.Base.Name { panic(prinfo.PR.Base.Name + " is expected to match " + br) } prinfo.RemoteName, err = git.GitClone(repo.Name, br, repo.SSHURL) PanicOnError(err) git.GitExecOrPanic(repo.Name, "fetch", prinfo.RemoteName, head.Sha) git.GitExecOrPanic(repo.Name, "merge", "--ff", head.Sha) } // push changes if !IsDryRun { git.GitExecOrPanic(DefaultGitPrj, "push", remote) } else { LogInfo("*** WOULD push", DefaultGitPrj, "changes to", remote) } for _, prinfo := range rs.PRs { if rs.IsPrjGitPR(prinfo.PR) { continue } repo := prinfo.PR.Base.Repo if !IsDryRun { git.GitExecOrPanic(repo.Name, "push", prinfo.RemoteName) } else { LogInfo("*** WOULD push", repo.Name, "to", prinfo.RemoteName) } } return nil }