This used to happen as a side-effect of a different code path
that was removed in b96b784b38
545 lines
16 KiB
Go
545 lines
16 KiB
Go
package common
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"slices"
|
|
"strings"
|
|
|
|
"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
|
|
}
|
|
|
|
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 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(gitea GiteaPRTimelineFetcher, org, repo string, num int64, prjGitOrg, prjGitRepo string) (*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
|
|
}
|
|
|
|
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 {
|
|
|
|
_, 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 GiteaPRTimelineFetcher, 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(gitea, org, repo, num, prjGitOrg, prjGitRepo); 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
|
|
}
|
|
|
|
return &PRSet{
|
|
PRs: prs,
|
|
Config: config,
|
|
BotUser: user,
|
|
}, nil
|
|
}
|
|
|
|
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 prinfo.PR.Base.Repo.Owner.UserName == pr.Org && prinfo.PR.Base.Repo.Name == pr.Repo && prinfo.PR.Index == pr.Num {
|
|
continue next_rs
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (rs *PRSet) AssignReviewers(gitea GiteaReviewFetcherAndRequester, maintainers MaintainershipData) error {
|
|
configReviewers := ParseReviewers(rs.Config.Reviewers)
|
|
|
|
for _, pr := range rs.PRs {
|
|
reviewers := []string{}
|
|
|
|
if rs.IsPrjGitPR(pr.PR) {
|
|
reviewers = slices.Concat(configReviewers.Prj, configReviewers.PrjOptional)
|
|
LogDebug("PrjGit submitter:", pr.PR.User.UserName)
|
|
if len(rs.PRs) == 1 {
|
|
reviewers = slices.Concat(reviewers, maintainers.ListProjectMaintainers())
|
|
}
|
|
} else {
|
|
pkg := pr.PR.Base.Repo.Name
|
|
reviewers = slices.Concat(configReviewers.Pkg, maintainers.ListProjectMaintainers(), maintainers.ListPackageMaintainers(pkg), configReviewers.PkgOptional)
|
|
}
|
|
|
|
slices.Sort(reviewers)
|
|
reviewers = slices.Compact(reviewers)
|
|
|
|
// submitters do not need to review their own work
|
|
if idx := slices.Index(reviewers, pr.PR.User.UserName); idx != -1 {
|
|
reviewers = slices.Delete(reviewers, idx, idx+1)
|
|
}
|
|
|
|
LogDebug("PR: ", pr.PR.Base.Repo.Name, pr.PR.Index)
|
|
LogDebug("reviewers for PR:", reviewers)
|
|
|
|
// remove reviewers that were already requested and are not stale
|
|
reviews, err := FetchGiteaReviews(gitea, reviewers, pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index)
|
|
if err != nil {
|
|
LogError("Error fetching reviews:", err)
|
|
return err
|
|
}
|
|
|
|
for idx := 0; idx < len(reviewers); {
|
|
user := reviewers[idx]
|
|
if reviews.HasPendingReviewBy(user) || reviews.IsReviewedBy(user) {
|
|
reviewers = slices.Delete(reviewers, idx, idx+1)
|
|
LogDebug("removing reviewer:", user)
|
|
} else {
|
|
idx++
|
|
}
|
|
}
|
|
|
|
// get maintainers associated with the PR too
|
|
if len(reviewers) > 0 {
|
|
LogDebug("Requesting reviews from:", reviewers)
|
|
if !IsDryRun {
|
|
for _, r := range reviewers {
|
|
if _, err := gitea.RequestReviews(pr.PR, r); err != nil {
|
|
LogError("Cannot create reviews on", fmt.Sprintf("%s/%s!%d for [%s]", pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index, strings.Join(reviewers, ", ")), 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 {
|
|
prjgit, err := rs.GetPrjGitPR()
|
|
if err == nil && prjgit != nil {
|
|
reviewers := slices.Concat(configReviewers.Prj, maintainers.ListProjectMaintainers())
|
|
LogDebug("Fetching reviews for", prjgit.PR.Base.Repo.Owner.UserName, prjgit.PR.Base.Repo.Name, prjgit.PR.Index)
|
|
r, err := FetchGiteaReviews(gitea, reviewers, 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
|
|
}
|
|
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))
|
|
LogDebug("Fetching reviews for", pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index)
|
|
r, err := FetchGiteaReviews(gitea, reviewers, 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
|
|
}
|
|
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
|
|
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, reviewers, 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
|
|
}
|
|
|
|
is_manually_reviewed_ok = r.IsApproved()
|
|
LogDebug(pr.PR.Base.Repo.Name, is_manually_reviewed_ok)
|
|
if !is_manually_reviewed_ok {
|
|
return false
|
|
}
|
|
|
|
if need_maintainer_review := !rs.IsPrjGitPR(pr.PR) || pr.PR.User.UserName != rs.BotUser; need_maintainer_review {
|
|
if is_manually_reviewed_ok = maintainers.IsApproved(pkg, r.reviews, pr.PR.User.UserName); !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
|
|
}
|