package main //go:generate mockgen -source=pr_processor.go -destination=mock/pr_processor.go -typed import ( "encoding/json" "fmt" "path" "runtime/debug" "slices" "strings" "github.com/opentracing/opentracing-go/log" "src.opensuse.org/autogits/common" "src.opensuse.org/autogits/common/gitea-generated/client/repository" "src.opensuse.org/autogits/common/gitea-generated/models" ) func prGitBranchNameForPR(repo string, prNo int64) string { return fmt.Sprintf("PR_%s#%d", repo, prNo) } func PrjGitDescription(prset *common.PRSet) (title string, desc string) { title_refs := make([]string, 0, len(prset.PRs)-1) refs := make([]string, 0, len(prset.PRs)-1) for _, pr := range prset.PRs { if prset.IsPrjGitPR(pr.PR) { continue } if pr.PR.State != "open" { // remove PRs that are not open from description continue } org, repo, idx := pr.PRComponents() title_refs = append(title_refs, repo) ref := fmt.Sprintf(common.PrPattern, org, repo, idx) refs = append(refs, ref) } title = "Forwarded PRs: " + strings.Join(title_refs, ", ") desc = fmt.Sprintf("This is a forwarded pull request by %s\nreferencing the following pull request(s):\n\n", GitAuthor) + strings.Join(refs, "\n") + "\n" if prset.Config.ManualMergeOnly { desc = desc + "\n### ManualMergeOnly enabled. To merge, 'merge ok' is required in either the project PR or every package PR." } if prset.Config.ManualMergeProject { desc = desc + "\n### ManualMergeProject enabled. To merge, 'merge ok' is required by project maintainer in the project PR." } if !prset.Config.ManualMergeOnly && !prset.Config.ManualMergeProject { desc = desc + "\n### Automatic merge enabled. This will merge when all review requirements are satisfied." } return } func verifyRepositoryConfiguration(repo *models.Repository) error { if repo.AutodetectManualMerge && repo.AllowManualMerge { return nil } // modify repo to allow above common.LogDebug("Adjusting repo to accept manual merges:", repo.Owner.UserName+"/"+repo.Name) _, err := Gitea.SetRepoOptions(repo.Owner.UserName, repo.Name, true) if err != nil { common.LogError("Failed to set repo to manual merges:", err) } return err } func updateSubmoduleInPR(submodule, headSha string, git common.Git) { common.LogDebug("updating submodule", submodule, "to HEAD", headSha) // NOTE: this can fail if current PrjGit is pointing to outdated, GC'ed commit // as long as we can update to newer one later, we are still OK git.GitExec(common.DefaultGitPrj, "submodule", "update", "--init", "--checkout", "--depth", "1", submodule) common.PanicOnError(git.GitExec(path.Join(common.DefaultGitPrj, submodule), "fetch", "--depth", "1", "origin", headSha)) common.PanicOnError(git.GitExec(path.Join(common.DefaultGitPrj, submodule), "checkout", "-f", headSha)) } type PRProcessor struct { config *common.AutogitConfig git common.Git } func AllocatePRProcessor(req *models.PullRequest, configs common.AutogitConfigs) (*PRProcessor, error) { org := req.Base.Repo.Owner.UserName repo := req.Base.Repo.Name id := req.Index branch := req.Base.Ref PRstr := fmt.Sprintf("%s/%s!%d", org, repo, id) common.LogInfo("*** Starting processing PR:", PRstr, "branch:", branch) config := configs.GetPrjGitConfig(org, repo, branch) if config == nil { if req.Base.Repo.DefaultBranch == branch { common.LogDebug("Default branch submission...", org, repo) config = configs.GetPrjGitConfig(org, repo, "") } } if config == nil { common.LogError("Cannot find config for PR.") return nil, fmt.Errorf("Cannot find config for PR") } if common.GetLoggingLevel() >= common.LogLevelDebug { cjson, _ := json.Marshal(config) common.LogDebug("found config:", string(cjson)) } if config == nil { common.LogError("Cannot find config for branch '%s'", req.Base.Ref) return nil, fmt.Errorf("Cannot find config for branch '%s'", req.Base.Ref) } git, err := GitHandler.CreateGitHandler(config.Organization) if err != nil { common.LogError("Cannot allocate GitHandler:", err) return nil, fmt.Errorf("Error allocating GitHandler. Err: %w", err) } common.LogDebug("git path:", git.GetPath()) // git.GitExecOrPanic("", "config", "set", "--global", "advice.submoduleMergeConflict", "false") // git.GitExecOrPanic("", "config", "set", "--global", "advice.mergeConflict", "false") return &PRProcessor{ config: config, git: git, }, nil } func (pr *PRProcessor) SetSubmodulesToMatchPRSet(prset *common.PRSet) error { git := pr.git subList, err := git.GitSubmoduleList(common.DefaultGitPrj, "HEAD") if err != nil { common.LogError("Error fetching submodule list for PrjGit", err) return err } for _, pr := range prset.PRs { if prset.IsPrjGitPR(pr.PR) { continue } org, repo, idx := pr.PRComponents() prHead := pr.PR.Head.Sha revert := false if pr.PR.State != "open" { prjGitPR, err := prset.GetPrjGitPR() if prjGitPR != nil { // remove PR from PrjGit var valid bool if prHead, valid = git.GitSubmoduleCommitId(common.DefaultGitPrj, repo, prjGitPR.PR.MergeBase); !valid { common.LogError("Failed fetching original submodule commit id for repo") return err } } revert = true } // find 'repo' in the submodule list submodule_found := false for submodulePath, id := range subList { if path.Base(submodulePath) == repo { submodule_found = true if id != prHead { ref := fmt.Sprintf(common.PrPattern, org, repo, idx) commitMsg := fmt.Sprintln("auto-created for", repo, "\n\nThis commit was autocreated by", GitAuthor, "\n\nreferencing PRs:\n", ref) if revert { commitMsg = fmt.Sprintln("auto-created for", repo, "\n\nThis commit was autocreated by", GitAuthor, "\n\nremoving PRs:\n", ref) } updateSubmoduleInPR(submodulePath, prHead, git) status, err := git.GitStatus(common.DefaultGitPrj) common.LogDebug("status:", status) common.LogDebug("submodule", repo, " hash:", id, " -> ", prHead) common.PanicOnError(err) common.PanicOnError(git.GitExec(common.DefaultGitPrj, "commit", "-a", "-m", commitMsg)) pr.PR.Head.Sha = id // update the prset } submodule_found = true break } } if !submodule_found { common.LogError("Failed to find expected repo:", repo) } } return nil } func (pr *PRProcessor) CreatePRjGitPR(prjGitPRbranch string, prset *common.PRSet) error { git := pr.git PrjGitOrg, PrjGitRepo, PrjGitBranch := prset.Config.GetPrjGit() PrjGit, err := Gitea.GetRepository(PrjGitOrg, PrjGitRepo) if err != nil { common.LogError("Failed to fetch PrjGit repository data.", PrjGitOrg, PrjGitRepo, err) return err } RemoteName, err := git.GitClone(common.DefaultGitPrj, PrjGitBranch, PrjGit.SSHURL) common.PanicOnError(err) git.GitExecOrPanic(common.DefaultGitPrj, "checkout", "-B", prjGitPRbranch, RemoteName+"/"+PrjGitBranch) headCommit, err := git.GitBranchHead(common.DefaultGitPrj, prjGitPRbranch) if err != nil { common.LogError("Failed to fetch PrjGit branch", prjGitPRbranch, err) return err } if err := pr.SetSubmodulesToMatchPRSet(prset); err != nil { return err } newHeadCommit, err := git.GitBranchHead(common.DefaultGitPrj, prjGitPRbranch) if err != nil { common.LogError("Failed to fetch updated PrjGit branch", prjGitPRbranch, err) return err } if !common.IsDryRun && !pr.config.NoProjectGitPR { if headCommit != newHeadCommit { common.PanicOnError(git.GitExec(common.DefaultGitPrj, "push", RemoteName, "+HEAD:"+prjGitPRbranch)) } title, desc := PrjGitDescription(prset) pr, err := Gitea.CreatePullRequestIfNotExist(PrjGit, prjGitPRbranch, PrjGitBranch, title, desc) if err != nil { common.LogError("Error creating PrjGit PR:", err) return err } Gitea.UpdatePullRequest(PrjGit.Owner.UserName, PrjGit.Name, pr.Index, &models.EditPullRequestOption{ RemoveDeadline: true, }) prinfo := prset.AddPR(pr) prinfo.RemoteName = RemoteName } return nil } func (pr *PRProcessor) RebaseAndSkipSubmoduleCommits(prset *common.PRSet, branch string) error { git := pr.git PrjGitPR, err := prset.GetPrjGitPR() common.PanicOnError(err) remoteBranch := PrjGitPR.RemoteName + "/" + branch common.LogDebug("Rebasing on top of", remoteBranch) for conflict := git.GitExec(common.DefaultGitPrj, "rebase", remoteBranch); conflict != nil; { statuses, err := git.GitStatus(common.DefaultGitPrj) if err != nil { git.GitExecOrPanic(common.DefaultGitPrj, "rebase", "--abort") common.PanicOnError(err) } for _, s := range statuses { if s.SubmoduleChanges != "S..." { git.GitExecOrPanic(common.DefaultGitPrj, "rebase", "--abort") return fmt.Errorf("Unexpected conflict in rebase. %v", s) } } conflict = git.GitExec(common.DefaultGitPrj, "rebase", "--skip") } return nil } func (pr *PRProcessor) UpdatePrjGitPR(prset *common.PRSet) error { _, _, PrjGitBranch := prset.Config.GetPrjGit() PrjGitPR, err := prset.GetPrjGitPR() if err != nil { common.LogError("Updating PrjGitPR but not found?", err) return err } git := pr.git if len(prset.PRs) == 1 { git.GitExecOrPanic(common.DefaultGitPrj, "fetch", PrjGitPR.RemoteName, PrjGitPR.PR.Head.Sha) common.LogDebug("Only project git in PR. Nothing to update.") return nil } PrjGit := PrjGitPR.PR.Base.Repo prjGitPRbranch := PrjGitPR.PR.Head.Name if strings.Contains(prjGitPRbranch, "/") { PrjGitPR.RemoteName, err = git.GitClone(common.DefaultGitPrj, "", PrjGit.SSHURL) git.GitExecOrPanic(common.DefaultGitPrj, "fetch", PrjGitPR.RemoteName, PrjGitPR.PR.Head.Sha) git.GitExecOrPanic(common.DefaultGitPrj, "checkout", PrjGitPR.PR.Head.Sha) common.LogInfo("Cannot update this PR as it's on another remote, not branch:", prjGitPRbranch, "Assuming this is by-design. (eg. project git PR only)") return nil } PrjGitPR.RemoteName, err = git.GitClone(common.DefaultGitPrj, prjGitPRbranch, PrjGit.SSHURL) common.PanicOnError(err) git.GitExecOrPanic(common.DefaultGitPrj, "fetch", PrjGitPR.RemoteName, PrjGitBranch) forcePush := false // trust Gitea here on mergeability if !PrjGitPR.PR.Mergeable { common.PanicOnError(pr.RebaseAndSkipSubmoduleCommits(prset, PrjGitBranch)) forcePush = true } headCommit, err := git.GitBranchHead(common.DefaultGitPrj, prjGitPRbranch) if err != nil { common.LogError("Failed to fetch PrjGit branch", prjGitPRbranch, err) return err } if err := pr.SetSubmodulesToMatchPRSet(prset); err != nil { return err } newHeadCommit, err := git.GitBranchHead(common.DefaultGitPrj, prjGitPRbranch) if err != nil { common.LogError("Failed to fetch updated PrjGit branch", prjGitPRbranch, err) return err } PrjGitTitle, PrjGitBody := PrjGitDescription(prset) if PrjGitPR.PR.Title != PrjGitTitle || PrjGitPR.PR.Body != PrjGitBody { common.LogDebug("New title:", PrjGitTitle) common.LogDebug(PrjGitBody) } if !common.IsDryRun { if headCommit != newHeadCommit { params := []string{"push", PrjGitPR.RemoteName, "+HEAD:" + prjGitPRbranch} if forcePush { params = slices.Insert(params, 1, "-f") } common.PanicOnError(git.GitExec(common.DefaultGitPrj, params...)) } // update PR if PrjGitPR.PR.Body != PrjGitBody || PrjGitPR.PR.Title != PrjGitTitle { Gitea.UpdatePullRequest(PrjGit.Owner.UserName, PrjGit.Name, PrjGitPR.PR.Index, &models.EditPullRequestOption{ RemoveDeadline: true, Title: PrjGitTitle, Body: PrjGitBody, }) } } // remove closed PRs from prset prset.RemoveClosedPRs() return nil } func (pr *PRProcessor) Process(req *models.PullRequest) error { config := pr.config git := pr.git // requests against project are not handled here common.LogInfo("processing opened PR:", req.URL) prOrg := req.Base.Repo.Owner.UserName prRepo := req.Base.Repo.Name prNo := req.Index common.LogError(req) prset, err := common.FetchPRSet(CurrentUser.UserName, Gitea, prOrg, prRepo, prNo, config) if err != nil { common.LogError("Cannot fetch PRSet:", err) return err } common.LogInfo("fetched PRSet of size:", len(prset.PRs)) prjGitPRbranch := prGitBranchNameForPR(prRepo, prNo) prjGitPR, err := prset.GetPrjGitPR() if err == common.PRSet_PrjGitMissing { common.LogDebug("Missing PrjGit. Need to create one under branch", prjGitPRbranch) if err = pr.CreatePRjGitPR(prjGitPRbranch, prset); err != nil { return err } } else if err == nil { common.LogDebug("Found PrjGit PR:", common.PRtoString(prjGitPR.PR)) prjGitPRbranch = prjGitPR.PR.Head.Name if prjGitPR.PR.State != "open" { if prjGitPR.PR.HasMerged { // update branches in project prjGitPR.RemoteName, err = git.GitClone(common.DefaultGitPrj, prjGitPRbranch, prjGitPR.PR.Base.Repo.SSHURL) common.PanicOnError(err) old_pkgs, err := git.GitSubmoduleList(common.DefaultGitPrj, prjGitPR.PR.MergeBase) common.PanicOnError(err) new_pkgs, err := git.GitSubmoduleList(common.DefaultGitPrj, prjGitPRbranch) common.PanicOnError(err) pkgs := make(map[string]string) for pkg, old_commit := range old_pkgs { if new_commit, found := new_pkgs[pkg]; found { // pkg modified if new_commit != old_commit { pkgs[pkg] = new_commit } } else { // not found, pkg removed pkgs[pkg] = "" } } for pkg, commit := range new_pkgs { if _, found := old_pkgs[pkg]; !found { // pkg added pkgs[pkg] = commit } } PrjGitSubmoduleCheck(config, git, common.DefaultGitPrj, pkgs) } // manually merge or close entire prset that is still open for _, pr := range prset.PRs { if pr.PR.State == "open" { org, repo, idx := pr.PRComponents() if prjGitPR.PR.HasMerged { Gitea.AddComment(pr.PR, "This PR is merged via the associated Project PR.") err = Gitea.ManualMergePR(org, repo, idx, pr.PR.Head.Sha, false) if _, ok := err.(*repository.RepoMergePullRequestConflict); !ok { common.PanicOnError(err) } } else { Gitea.AddComment(pr.PR, "Closing here because the associated Project PR has been closed.") Gitea.UpdatePullRequest(org, repo, idx, &models.EditPullRequestOption{ State: "closed", }) } } } return nil } if len(prset.PRs) > 1 { for _, pr := range prset.PRs { if prset.IsPrjGitPR(pr.PR) { continue } } } if err = pr.UpdatePrjGitPR(prset); err != nil { return err } } if prjGitPR == nil { prjGitPR, err = prset.GetPrjGitPR() if err == common.PRSet_PrjGitMissing && config.NoProjectGitPR { // we could be waiting for other tooling to create the // project git PR. In meantime, we can assign some // reviewers here. } else if err != nil { common.LogError("Error fetching PrjGitPR:", err) return nil } } common.LogDebug("Updated PR") // make sure that prjgit is consistent and only submodules that are to be *updated* // reset anything that changed that is not part of the prset // package removals/additions are *not* counted here org, repo, branch := config.GetPrjGit() // TODO: this is broken... if pr, err := prset.GetPrjGitPR(); err == nil && false { common.LogDebug("Submodule parse begin") orig_subs, err := git.GitSubmoduleList(common.DefaultGitPrj, pr.PR.MergeBase) common.PanicOnError(err) new_subs, err := git.GitSubmoduleList(common.DefaultGitPrj, "HEAD") common.PanicOnError(err) common.LogDebug("Submodule parse done") reset_submodule := func(submodule, sha string) { updateSubmoduleInPR(submodule, sha, git) } common.LogDebug("Checking we only change linked commits") for path, commit := range new_subs { if old, ok := orig_subs[path]; ok && old != commit { found := false for _, pr := range prset.PRs { if pr.PR.Base.Repo.Name == path && commit == pr.PR.Head.Sha { found = true break } else if pr.PR.Base.Repo.Name == path { common.LogError(path, "-- commits not match", commit, pr.PR.Head.Sha) } } if !found { reset_submodule(path, old) } } } stats, err := git.GitStatus(common.DefaultGitPrj) common.LogDebug("Check Done", len(stats), "changes") common.PanicOnError(err) if len(stats) > 0 { git.GitExecOrPanic(common.DefaultGitPrj, "commit", "-a", "-m", "Sync submodule updates with PR-set") git.GitExecQuietOrPanic(common.DefaultGitPrj, "submodule", "deinit", "--all", "--force") if !common.IsDryRun { git.GitExecOrPanic(common.DefaultGitPrj, "push") } } } if prjGitPR != nil { common.LogDebug(" num of reviewers:", len(prjGitPR.PR.RequestedReviewers)) } else { common.LogInfo("* No prjgit") } maintainers, err := common.FetchProjectMaintainershipData(Gitea, org, repo, branch) if err != nil { return err } // handle case where PrjGit PR is only one left and there are no changes, then we can just close the PR if len(prset.PRs) == 1 && prjGitPR != nil && prset.PRs[0] == prjGitPR && prjGitPR.PR.User.UserName == prset.BotUser { common.LogDebug(" --> checking if superflous PR") diff, err := git.GitDiff(common.DefaultGitPrj, prjGitPR.PR.MergeBase, prjGitPR.PR.Head.Sha) if err != nil { return err } if len(diff) == 0 { common.LogInfo("PR is no-op and can be closed. Closing.") if !common.IsDryRun { Gitea.AddComment(prjGitPR.PR, "Pull request no longer contains any changes. Closing.") _, err = Gitea.UpdatePullRequest(prjGitPR.PR.Base.Repo.Owner.UserName, prjGitPR.PR.Base.Repo.Name, prjGitPR.PR.Index, &models.EditPullRequestOption{ State: "closed", }) if err != nil { return err } } return nil } common.LogDebug(" --> NOT superflous PR") } for _, pr := range prset.PRs { if err := verifyRepositoryConfiguration(pr.PR.Base.Repo); err != nil { common.LogError("Cannot set manual merge... aborting processing") return err } } common.LogInfo("Consistent PRSet:", prset.IsConsistent()) common.LogInfo("Reviewed?", prset.IsApproved(Gitea, maintainers)) if prset.IsConsistent() && prset.IsApproved(Gitea, maintainers) { common.LogInfo("Merging...") if err = prset.Merge(Gitea, git); err != nil { common.LogError("merge error:", err) } } else { prset.AssignReviewers(Gitea, maintainers) } return err } type RequestProcessor struct { configuredRepos map[string][]*common.AutogitConfig } func ProcesPullRequest(pr *models.PullRequest, configs []*common.AutogitConfig) error { if len(configs) < 1 { // ignoring pull request against unconfigured project (could be just regular sources?) return nil } PRProcessor, err := AllocatePRProcessor(pr, configs) if err != nil { log.Error(err) return err } defer PRProcessor.git.Close() return PRProcessor.Process(pr) } func (w *RequestProcessor) ProcessFunc(request *common.Request) error { defer func() { if r := recover(); r != nil { common.LogInfo("panic cought --- recovered") common.LogError(string(debug.Stack())) } }() var pr *models.PullRequest var err error if req, ok := request.Data.(*common.PullRequestWebhookEvent); ok { pr, err = Gitea.GetPullRequest(req.Pull_Request.Base.Repo.Owner.Username, req.Pull_Request.Base.Repo.Name, req.Pull_Request.Number) if err != nil { common.LogError("Cannot find PR for issue:", req.Pull_Request.Base.Repo.Owner.Username, req.Pull_Request.Base.Repo.Name, req.Pull_Request.Number) return err } } else if req, ok := request.Data.(*common.IssueWebhookEvent); ok { pr, err = Gitea.GetPullRequest(req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number)) if err != nil { common.LogError("Cannot find PR for issue:", req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number)) return err } } else { common.LogError("*** Invalid data format for PR processing.") return fmt.Errorf("*** Invalid data format for PR processing.") } configs, ok := w.configuredRepos[pr.Base.Repo.Owner.UserName] if !ok { common.LogError("*** Cannot find config for org:", pr.Base.Repo.Owner.UserName) } return ProcesPullRequest(pr, configs) }