276b9da3be
Integration (Selective) / check (pull_request) Successful in 2s
gofmt / Format checking (pull_request) Successful in 4s
go-generate-check / go-generate-check (pull_request) Successful in 12s
gofmt / Linter and static analysis (pull_request) Successful in 59s
go-test-unit / go-test-unit (pull_request) Successful in 1m1s
Integration (Selective) / integration (pull_request) Successful in 9m14s
Implement the core of the package-removal workflow: when a package's submodule disappears from the project git, rename that package's project branch to its "-removed" variant (e.g. "factory" -> "factory-removed"). Renaming is anchored on the submodule disappearing, not on the removal issue, so it also happens when the issue process is bypassed. - common: add a RenameBranch Gitea primitive (wrapper over RepoRenameBranch) exposed through a small GiteaBranchRenamer interface and the master Gitea interface, with regenerated mocks. Renaming the default branch is handled by Gitea itself (it reassigns the default), so no SetDefaultBranch is needed. - common: add DetectRemovedSubmodules (diff two project-git submodule lists) and RenameRemovedPackageBranches (best-effort, idempotent: skip packages whose branch is already gone, resolve the default branch when config.Branch is empty, and keep going if one package fails). - workflow-pr: hook the rename into the two points where a submodule can disappear: after a ProjectGit PR merge (reusing the already computed removed-submodule set) and in the consistency check (comparing the previous project HEAD with the freshly cloned one), the latter covering bypassed removals such as a direct push. Assisted-by: Anthropic:claude-opus-4.8
1299 lines
46 KiB
Go
1299 lines
46 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"src.opensuse.org/autogits/common"
|
|
"src.opensuse.org/autogits/common/gitea-generated/models"
|
|
)
|
|
|
|
const maxFetchRetries = 3
|
|
|
|
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)
|
|
|
|
prefix := ""
|
|
for _, pr := range prset.PRs {
|
|
if prset.IsPrjGitPR(pr.PR) {
|
|
continue
|
|
}
|
|
if pr.PR.State != common.GiteaPRState_Open {
|
|
// remove PRs that are not open from description
|
|
continue
|
|
}
|
|
if strings.HasPrefix(pr.PR.Title, "WIP:") {
|
|
prefix = "WIP: "
|
|
}
|
|
org, repo, idx := pr.PRComponents()
|
|
|
|
title_refs = append(title_refs, repo)
|
|
ref := fmt.Sprintf(common.PrPattern, org, repo, idx)
|
|
refs = append(refs, ref)
|
|
}
|
|
|
|
slices.Sort(title_refs)
|
|
slices.Sort(refs)
|
|
|
|
title = prefix + "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(ctx context.Context, repo *models.Repository) error {
|
|
if repo.AllowManualMerge {
|
|
return nil
|
|
}
|
|
|
|
// modify repo to allow above
|
|
common.LogDebugCtx(ctx, "Adjusting repo to accept manual merges:", repo.Owner.UserName+"/"+repo.Name)
|
|
newRepo, err := Gitea.WithContext(ctx).SetRepoOptions(repo.Owner.UserName, repo.Name, true)
|
|
if err != nil {
|
|
common.LogErrorCtx(ctx, "Failed to set repo to manual merges:", err)
|
|
} else {
|
|
repo.AllowManualMerge = newRepo.AllowManualMerge
|
|
}
|
|
return err
|
|
}
|
|
|
|
// fetchGitIfNecessary checks if the commit is already available locally to reduce network accesses.
|
|
func fetchGitIfNecessary(ctx context.Context, git common.Git, repoPath, sha string) error {
|
|
if err := git.GitExec(repoPath, "cat-file", "-e", sha); err == nil {
|
|
return nil
|
|
}
|
|
|
|
var err error
|
|
// We retry up to maxFetchRetries times with a delay to handle eventual consistency in the remote.
|
|
for i := 0; i < maxFetchRetries; i++ {
|
|
if err = git.GitExec(repoPath, "fetch", "--depth", "1", common.DefaultGitRemote, sha); err == nil {
|
|
return nil
|
|
}
|
|
common.LogInfoCtx(ctx, "Retrying fetch for", sha, "in", repoPath, "attempt", i+1)
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// getAddedLinesFromChanges fetches the required commits and extracts added lines from .changes files.
|
|
func (prp *PRProcessor) getAddedLinesFromChanges(pkgPath, mergeBase, headSha string) (string, error) {
|
|
git := prp.git
|
|
if err := fetchGitIfNecessary(prp.ctx, git, pkgPath, mergeBase); err != nil {
|
|
common.LogDebugCtx(prp.ctx, "Failed to fetch mergeBase:", mergeBase, "in", pkgPath, err)
|
|
return "", err
|
|
}
|
|
if err := fetchGitIfNecessary(prp.ctx, git, pkgPath, headSha); err != nil {
|
|
common.LogDebugCtx(prp.ctx, "Failed to fetch headSha:", headSha, "in", pkgPath, err)
|
|
return "", err
|
|
}
|
|
|
|
diffOut, err := git.GitExecWithOutput(pkgPath, "diff", "-U0", mergeBase+".."+headSha, "--", "*.changes")
|
|
if err != nil {
|
|
return "", fmt.Errorf("git diff failed: %w", err)
|
|
}
|
|
|
|
var addedLines []string
|
|
for _, line := range strings.Split(diffOut, "\n") {
|
|
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++ ") {
|
|
addedLines = append(addedLines, strings.TrimPrefix(line, "+"))
|
|
}
|
|
}
|
|
|
|
return strings.TrimSpace(strings.Join(addedLines, "\n")), nil
|
|
}
|
|
|
|
// populateEmptyPRBody fetches the added lines from a .changes file and updates the PR body if it is empty.
|
|
func (prp *PRProcessor) populateEmptyPRBody(prset *common.PRSet, pr *models.PullRequest) error {
|
|
if pr.Body != "" || pr.State != common.GiteaPRState_Open {
|
|
return nil
|
|
}
|
|
|
|
git := prp.git
|
|
prjPath := prp.getPrjPath()
|
|
submoduleName := prset.ResolvePackageName(pr.Base.Repo.Name)
|
|
pkgPath := path.Join(prjPath, submoduleName)
|
|
|
|
if stat, err := os.Stat(path.Join(git.GetPath(), pkgPath)); err != nil || !stat.IsDir() {
|
|
common.LogDebugCtx(prp.ctx, "Package path does not exist locally, skipping PR body population:", pkgPath)
|
|
return nil
|
|
}
|
|
|
|
mergeBase := pr.MergeBase
|
|
if mergeBase == "" && pr.Base != nil {
|
|
mergeBase = pr.Base.Sha
|
|
}
|
|
var headSha string
|
|
if pr.Head != nil {
|
|
headSha = pr.Head.Sha
|
|
}
|
|
if mergeBase == "" || headSha == "" {
|
|
return nil
|
|
}
|
|
|
|
newBody, err := prp.getAddedLinesFromChanges(pkgPath, mergeBase, headSha)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if newBody == "" {
|
|
return nil
|
|
}
|
|
|
|
pr.Body = newBody
|
|
if !common.IsDryRun {
|
|
_, err := Gitea.WithContext(prp.ctx).UpdatePullRequest(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Index, &models.EditPullRequestOption{
|
|
Body: newBody,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update PR body: %w", err)
|
|
}
|
|
common.LogInfoCtx(prp.ctx, "Populated empty PR body for", pr.Base.Repo.Name, "#", pr.Index)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func updateSubmoduleInPR(ctx context.Context, prjPath, submodule, headSha string, git common.Git) {
|
|
common.LogDebugCtx(ctx, "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(prjPath, "submodule", "update", "--init", "--checkout", "--depth", "1", submodule)
|
|
|
|
// Explicitly fetch the headSha into the submodule to ensure it's available.
|
|
subPath := path.Join(prjPath, submodule)
|
|
err := fetchGitIfNecessary(ctx, git, subPath, headSha)
|
|
common.PanicOnError(err)
|
|
|
|
common.PanicOnError(git.GitExec(subPath, "checkout", "-f", headSha))
|
|
}
|
|
|
|
type PRProcessor struct {
|
|
config *common.AutogitConfig
|
|
prset *common.PRSet
|
|
git common.Git
|
|
ctx context.Context
|
|
|
|
prjPath string
|
|
|
|
cachedIssueMap map[int64]*models.Issue
|
|
}
|
|
|
|
func (prp *PRProcessor) getPrjPath() string {
|
|
if prp.prjPath != "" {
|
|
return prp.prjPath
|
|
}
|
|
return prp.config.GetPrjGitLocalDir()
|
|
}
|
|
|
|
func AllocatePRProcessor(ctx context.Context, 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 := common.TaskRefPR(org, repo, id)
|
|
common.LogInfoCtx(ctx, "*** Starting processing PR:", PRstr, "branch:", branch)
|
|
|
|
config := configs.GetPrjGitConfig(org, repo, branch)
|
|
if config == nil {
|
|
if req.Base.Repo.DefaultBranch == branch {
|
|
common.LogDebugCtx(ctx, "Default branch submission...", org, repo)
|
|
config = configs.GetPrjGitConfig(org, repo, "")
|
|
}
|
|
}
|
|
if config == nil {
|
|
common.LogErrorCtx(ctx, "Cannot find config for PR.")
|
|
return nil, fmt.Errorf("Cannot find config for PR")
|
|
}
|
|
|
|
if common.GetLoggingLevel() >= common.LogLevelDebug {
|
|
cjson, _ := json.Marshal(config)
|
|
common.LogDebugCtx(ctx, "found config:", string(cjson))
|
|
}
|
|
|
|
// 1. Resolve PRSet early via API to find if we have an associated Project PR
|
|
prset, err := common.FetchPRSet(BotUsers, Gitea.WithContext(ctx), req.Base.Repo.Owner.UserName, req.Base.Repo.Name, req.Index, config)
|
|
if err != nil {
|
|
common.LogErrorCtx(ctx, "Cannot fetch PRSet:", err)
|
|
return nil, err
|
|
}
|
|
|
|
prjGitPRbranch := prGitBranchNameForPR(repo, id)
|
|
prjGitOrg, prjGitRepo, prjGitBranch := config.GetPrjGit()
|
|
prjGitDir := config.GetPrjGitLocalDir()
|
|
workspacePath := common.GetPrjGitPRDir(prjGitDir, prjGitRepo, "@"+prjGitPRbranch)
|
|
|
|
git, err := GitHandler.ReadExistingPathWithPrjCtx(ctx, config.Organization, workspacePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if prjGitPR, err := prset.GetPrjGitPR(); err != nil {
|
|
if err == common.PRSet_PrjGitMissing {
|
|
// No ProjectGit
|
|
} else {
|
|
git.Close() //nolint:errcheck
|
|
return nil, err
|
|
}
|
|
} else {
|
|
// if we already have a git repository @ the current path, then we can just move the repos here...
|
|
newWorkspacePath := common.GetPrjGitPRDir(prjGitOrg, prjGitRepo, fmt.Sprintf("#%d", prjGitPR.PR.Index))
|
|
// Acquire a new GitHandler for the isolated workspace (which locks workspacePath natively)
|
|
newGit, err := GitHandler.ReadExistingPathWithPrjCtx(ctx, config.Organization, newWorkspacePath)
|
|
if err != nil {
|
|
git.Close() //nolint:errcheck
|
|
return nil, err
|
|
}
|
|
|
|
if _, err = os.Stat(filepath.Join(newGit.GetPath(), newWorkspacePath, ".git")); errors.Is(err, os.ErrNotExist) {
|
|
if _, err = os.Stat(filepath.Join(git.GetPath(), workspacePath, ".git")); err == nil {
|
|
if err = os.MkdirAll(filepath.Dir(filepath.Join(newGit.GetPath(), newWorkspacePath)), 0777); err != nil {
|
|
git.Close() //nolint:errcheck
|
|
newGit.Close() //nolint:errcheck
|
|
return nil, err
|
|
}
|
|
if err = os.Rename(filepath.Join(git.GetPath(), workspacePath), filepath.Join(newGit.GetPath(), newWorkspacePath)); err != nil {
|
|
git.Close() //nolint:errcheck
|
|
newGit.Close() //nolint:errcheck
|
|
return nil, err
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
git.Close() //nolint:errcheck
|
|
git = newGit
|
|
workspacePath = newWorkspacePath
|
|
}
|
|
|
|
lock := acquirePRLock(workspacePath)
|
|
defer releasePRLock(lock)
|
|
|
|
success := false
|
|
defer func() {
|
|
if !success {
|
|
git.Close() //nolint:errcheck
|
|
}
|
|
}()
|
|
|
|
// if we already have the folder, then nothing to do, and we can process it
|
|
// if we do not, we need to set it up as clone of both local base + sync with remote
|
|
if _, err = os.Stat(filepath.Join(git.GetPath(), workspacePath, ".git")); errors.Is(err, os.ErrNotExist) {
|
|
baseGit, err := GitHandler.CreateGitHandlerCtx(ctx, config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
PrjGitRepo, err := Gitea.WithContext(ctx).GetRepository(prjGitOrg, prjGitRepo)
|
|
if err != nil {
|
|
baseGit.Close() //nolint:errcheck
|
|
return nil, err
|
|
}
|
|
|
|
// Sync the base ProjectGit
|
|
basePrjPath := config.GetPrjGitLocalDir()
|
|
_, err = baseGit.GitClone(basePrjPath, prjGitBranch, PrjGitRepo.SSHURL)
|
|
if err != nil {
|
|
baseGit.Close() //nolint:errcheck
|
|
return nil, err
|
|
}
|
|
|
|
common.LogInfoCtx(ctx, "Creating isolated PR workspace:", workspacePath)
|
|
|
|
if err := os.RemoveAll(path.Join(git.GetPath(), workspacePath)); err != nil {
|
|
baseGit.Close() //nolint:errcheck
|
|
return nil, err
|
|
}
|
|
if _, err := git.GitCloneLocal(workspacePath, basePrjPath); err != nil {
|
|
baseGit.Close() //nolint:errcheck
|
|
return nil, err
|
|
}
|
|
|
|
// Close baseGit to release the lock on the base Rocket Folder natively
|
|
baseGit.Close() //nolint:errcheck
|
|
}
|
|
|
|
prset.WorkspacePath = workspacePath
|
|
success = true
|
|
return &PRProcessor{
|
|
config: config,
|
|
git: git,
|
|
ctx: ctx,
|
|
prset: prset,
|
|
prjPath: workspacePath,
|
|
}, nil
|
|
}
|
|
|
|
// GetOriginalIssue fetches and caches the Gitea issue referencing the PR.
|
|
func (prp *PRProcessor) GetOriginalIssue(issueIdx int64) (*models.Issue, error) {
|
|
if prp.cachedIssueMap == nil {
|
|
prp.cachedIssueMap = make(map[int64]*models.Issue)
|
|
}
|
|
if issue, found := prp.cachedIssueMap[issueIdx]; found {
|
|
return issue, nil
|
|
}
|
|
prjGitOrg, prjGitRepo, _ := prp.config.GetPrjGit()
|
|
gitea := Gitea.WithContext(prp.ctx)
|
|
issue, err := gitea.GetIssue(prjGitOrg, prjGitRepo, issueIdx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prp.cachedIssueMap[issueIdx] = issue
|
|
return issue, nil
|
|
}
|
|
|
|
// FindPackageNameFromIssue tries to find the originating Gitea issue to get
|
|
// the canonical 'AS <packageName>' alias if one was defined, defaulting to
|
|
// the Gitea repository name.
|
|
func (prp *PRProcessor) FindPackageNameFromIssue(pr *common.PRInfo) string {
|
|
_, repo, _ := pr.PRComponents()
|
|
if matches := common.SeeIssueRx.FindStringSubmatch(pr.PR.Body); len(matches) > 1 {
|
|
if issueIdx, err := strconv.ParseInt(matches[1], 10, 64); err == nil {
|
|
if issue, err := prp.GetOriginalIssue(issueIdx); err == nil && issue != nil {
|
|
creator := ""
|
|
if issue.User != nil {
|
|
creator = issue.User.UserName
|
|
}
|
|
if newRepos := common.FindNewReposInIssueBody(issue.Body, creator); newRepos != nil {
|
|
for _, nr := range newRepos.Repos {
|
|
if nr.Repository == repo {
|
|
if common.IsValidPackageName(nr.PackageName) {
|
|
common.LogInfoCtx(prp.ctx, "Found canonical package name alias in issue:", nr.PackageName)
|
|
return nr.PackageName
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
} else if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to fetch original issue from Gitea:", err)
|
|
}
|
|
}
|
|
}
|
|
return repo
|
|
}
|
|
|
|
// FindMaintainersFromIssue tries to find the originating Gitea issue to get
|
|
// the list of maintainer users and groups for the given package repository, if
|
|
// any were defined. Both channels are returned from a single parse of the issue
|
|
// body to avoid re-parsing it twice.
|
|
func (prp *PRProcessor) FindMaintainersFromIssue(pr *common.PRInfo, repo string) (users, groups []string) {
|
|
if matches := common.SeeIssueRx.FindStringSubmatch(pr.PR.Body); len(matches) > 1 {
|
|
if issueIdx, err := strconv.ParseInt(matches[1], 10, 64); err == nil {
|
|
if issue, err := prp.GetOriginalIssue(issueIdx); err == nil && issue != nil {
|
|
creator := ""
|
|
if issue.User != nil {
|
|
creator = issue.User.UserName
|
|
}
|
|
if newRepos := common.FindNewReposInIssueBody(issue.Body, creator); newRepos != nil {
|
|
for _, nr := range newRepos.Repos {
|
|
if nr.Repository == repo {
|
|
return nr.Maintainers, nr.MaintainerGroups
|
|
}
|
|
}
|
|
}
|
|
} else if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to fetch original issue from Gitea:", err)
|
|
}
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// updatePackageMaintainers validates the existence of the users in Gitea and
|
|
// the existence of the groups' definition files, then updates the
|
|
// _maintainership.json file of the project with the package's maintainer users
|
|
// and groups.
|
|
func (prp *PRProcessor) updatePackageMaintainers(prjPath, submoduleName string, maintainers, groups []string) error {
|
|
giteaClient := Gitea.WithContext(prp.ctx)
|
|
for _, m := range maintainers {
|
|
exists, err := giteaClient.UserExists(m)
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Error checking Gitea user existence for", m, ":", err)
|
|
return fmt.Errorf("failed to check Gitea user existence for %s: %w", m, err)
|
|
}
|
|
if !exists {
|
|
common.LogErrorCtx(prp.ctx, "Invalid maintainer user:", m)
|
|
return fmt.Errorf("invalid maintainer: %s", m)
|
|
}
|
|
}
|
|
for _, g := range groups {
|
|
if err := common.GroupExists(giteaClient, g); err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Invalid maintainer group:", g, ":", err)
|
|
return fmt.Errorf("invalid maintainer group: %s", g)
|
|
}
|
|
}
|
|
|
|
maintFilePath := path.Join(prp.git.GetPath(), prjPath, common.MaintainershipFile)
|
|
maintData, err := os.ReadFile(maintFilePath)
|
|
if err == nil {
|
|
m, err := common.ParseMaintainershipData(maintData)
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to parse maintainership file:", err)
|
|
return err
|
|
}
|
|
m.AddPackageMaintainers(submoduleName, maintainers)
|
|
m.AddPackageMaintainerGroups(submoduleName, groups)
|
|
f, err := os.Create(maintFilePath)
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to open maintainership file for writing:", err)
|
|
return err
|
|
}
|
|
if errWrite := m.WriteMaintainershipFile(f); errWrite != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to write maintainership file:", errWrite)
|
|
_ = f.Close()
|
|
return errWrite
|
|
}
|
|
if errClose := f.Close(); errClose != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to close maintainership file:", errClose)
|
|
return errClose
|
|
}
|
|
common.LogInfoCtx(prp.ctx, "Successfully updated", common.MaintainershipFile, "for", submoduleName, "with maintainers:", strings.Join(maintainers, ", "), "and groups:", strings.Join(groups, ", "))
|
|
if err := prp.git.GitExec(prjPath, "add", common.MaintainershipFile); err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to git-add", common.MaintainershipFile, ":", err)
|
|
return err
|
|
}
|
|
|
|
} else if os.IsNotExist(err) {
|
|
common.LogDebugCtx(prp.ctx, "No", common.MaintainershipFile, "found in ProjectGit. Creating a new versioned one.")
|
|
m := &common.MaintainershipMap{
|
|
Data: make(map[string][]string),
|
|
Formatter: &common.VersionedFormatter{
|
|
Header: &common.MaintainershipHeader{
|
|
Document: "obs-maintainers",
|
|
Version: "1.0",
|
|
},
|
|
GroupsOnly: make(map[string][]string),
|
|
UsersOnly: make(map[string][]string),
|
|
},
|
|
}
|
|
m.AddPackageMaintainers(submoduleName, maintainers)
|
|
m.AddPackageMaintainerGroups(submoduleName, groups)
|
|
f, err := os.Create(maintFilePath)
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to create maintainership file:", err)
|
|
return err
|
|
}
|
|
if errWrite := m.WriteMaintainershipFile(f); errWrite != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to write new maintainership file:", errWrite)
|
|
_ = f.Close()
|
|
return errWrite
|
|
}
|
|
if errClose := f.Close(); errClose != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to close new maintainership file:", errClose)
|
|
return errClose
|
|
}
|
|
common.LogInfoCtx(prp.ctx, "Successfully created new", common.MaintainershipFile, "for", submoduleName, "with maintainers:", strings.Join(maintainers, ", "), "and groups:", strings.Join(groups, ", "))
|
|
if err := prp.git.GitExec(prjPath, "add", common.MaintainershipFile); err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to git-add", common.MaintainershipFile, ":", err)
|
|
return err
|
|
}
|
|
} else {
|
|
common.LogErrorCtx(prp.ctx, "Failed to read maintainership file:", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (prp *PRProcessor) SetSubmodulesToMatchPRSet(prset *common.PRSet) error {
|
|
git := prp.git
|
|
prjPath := prp.getPrjPath()
|
|
subList, err := git.GitSubmoduleList(prjPath, "HEAD")
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Error fetching submodule list for PrjGit", err)
|
|
return err
|
|
}
|
|
|
|
submodulesData, err := git.GitCatFile(prjPath, "HEAD", ".gitmodules")
|
|
if err == nil {
|
|
prset.Submodules, err = common.ParseSubmodulesFile(bytes.NewReader(submodulesData))
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Cannot parse .gitmodules:", err)
|
|
return err
|
|
}
|
|
} else {
|
|
common.LogErrorCtx(prp.ctx, "Cannot read .gitmodules from project git:", err)
|
|
}
|
|
|
|
PrjGitOrg, _, _ := prset.Config.GetPrjGit()
|
|
for _, pr := range prset.PRs {
|
|
if prset.IsPrjGitPR(pr.PR) {
|
|
continue
|
|
}
|
|
|
|
org, repo, idx := pr.PRComponents()
|
|
prHead := pr.PR.Head.Sha
|
|
revert := false
|
|
|
|
// Resolve submodule name. By default, it is the repository name.
|
|
// If it exists, .gitmodules mapping is used; otherwise, returns the repo name.
|
|
submoduleName := prset.ResolvePackageName(repo)
|
|
|
|
if pr.PR.State != common.GiteaPRState_Open {
|
|
prjGitPR, _ := prset.GetPrjGitPR()
|
|
if prjGitPR != nil {
|
|
// remove PR from PrjGit
|
|
var valid bool
|
|
if prHead, valid = git.GitSubmoduleCommitId(prjPath, submoduleName, prjGitPR.PR.MergeBase); !valid {
|
|
common.LogInfoCtx(prp.ctx, "Submodule "+submoduleName+" not found in merge base. Deleting added submodule from project branch.")
|
|
if errRm := git.GitExec(prjPath, "rm", "-f", submoduleName); errRm != nil {
|
|
common.LogDebugCtx(prp.ctx, "Failed to remove submodule", submoduleName, ":", errRm)
|
|
}
|
|
os.RemoveAll(path.Join(git.GetPath(), prjPath, submoduleName))
|
|
commitMsg := fmt.Sprintf("auto-created: remove package %s referencing closed PR %d", submoduleName, idx)
|
|
if errCommit := git.GitExec(prjPath, "commit", "-a", "-m", commitMsg); errCommit != nil {
|
|
common.LogDebugCtx(prp.ctx, "Failed to commit submodule removal:", errCommit)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
revert = true
|
|
}
|
|
|
|
// find submodule by name in the submodule list
|
|
submodule_found := false
|
|
for submodulePath, id := range subList {
|
|
if path.Base(submodulePath) == submoduleName {
|
|
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(prp.ctx, prjPath, submodulePath, prHead, git)
|
|
status, err := git.GitStatus(prjPath)
|
|
common.LogDebugCtx(prp.ctx, "status:", status)
|
|
common.LogDebugCtx(prp.ctx, "submodule", repo, " hash:", id, " -> ", prHead)
|
|
common.PanicOnError(err)
|
|
common.PanicOnError(git.GitExec(prjPath, "commit", "-a", "-m", commitMsg))
|
|
|
|
pr.PR.Head.Sha = prHead // update the prset
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if !submodule_found {
|
|
submoduleName = prp.FindPackageNameFromIssue(pr)
|
|
common.LogInfoCtx(prp.ctx, "Adding new submodule", submoduleName, "to PrjGit")
|
|
ref := fmt.Sprintf(common.PrPattern, org, repo, idx)
|
|
commitMsg := fmt.Sprintln("Add package", submoduleName, "\n\nThis commit was autocreated by", GitAuthor, "\n\nreferencing PRs:\n", ref)
|
|
|
|
relpath, err := common.RelativeRepositoryPath(PrjGitOrg, pr.PR.Base.Repo.CloneURL)
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Cannot calculate relative path for repository", pr.PR.Base.Repo.CloneURL, err)
|
|
return err
|
|
}
|
|
|
|
if pr.PR.Base.Name != "" && !common.IsValidBranchName(pr.PR.Base.Name) {
|
|
common.LogErrorCtx(prp.ctx, "Invalid base branch name in PR submodules: ", pr.PR.Base.Name)
|
|
return fmt.Errorf("invalid branch name in submodules")
|
|
}
|
|
|
|
submoduleGitDir := path.Join(git.GetPath(), prjPath, ".git", "modules", submoduleName)
|
|
if _, err := os.Stat(submoduleGitDir); !os.IsNotExist(err) {
|
|
common.LogInfoCtx(prp.ctx, "Fetching existing cached submodule directory for", submoduleName)
|
|
git.GitExecOrPanic(prjPath, "--git-dir="+submoduleGitDir, "--work-tree=.", "fetch", common.DefaultGitRemote, pr.PR.Base.Name)
|
|
}
|
|
|
|
// Use --name explicitly so the [submodule "X"] section header in
|
|
// .gitmodules matches the package name (matters when the Gitea
|
|
// repo name was sanitised, e.g. "libxml++" -> "libxml__").
|
|
git.GitExecOrPanic(prjPath, "submodule", "add", "-f", "--name", submoduleName, "-b", pr.PR.Base.Name, relpath, submoduleName)
|
|
|
|
updateSubmoduleInPR(prp.ctx, prjPath, submoduleName, prHead, git)
|
|
|
|
// Update local _maintainership.json in project repository if package maintainers are defined in original issue
|
|
maintainers, groups := prp.FindMaintainersFromIssue(pr, repo)
|
|
if len(maintainers) == 0 && len(groups) == 0 && prp.config.PackageMaintainersRequired {
|
|
common.LogErrorCtx(prp.ctx, "Package", submoduleName, "has no maintainers but PackageMaintainersRequired is set")
|
|
return fmt.Errorf("package %s requires maintainers but none were specified", submoduleName)
|
|
}
|
|
if len(maintainers) > 0 || len(groups) > 0 {
|
|
if err := prp.updatePackageMaintainers(prjPath, submoduleName, maintainers, groups); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
common.PanicOnError(git.GitExec(prjPath, "commit", "-a", "-m", commitMsg))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (prp *PRProcessor) CreatePRjGitPR(prjGitPRbranch string, prset *common.PRSet) error {
|
|
git := prp.git
|
|
prjPath := prp.getPrjPath()
|
|
PrjGitOrg, PrjGitRepo, PrjGitBranch := prset.Config.GetPrjGit()
|
|
PrjGit, err := Gitea.WithContext(prp.ctx).GetRepository(PrjGitOrg, PrjGitRepo)
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to fetch PrjGit repository data.", PrjGitOrg, PrjGitRepo, err)
|
|
return err
|
|
}
|
|
RemoteName, err := git.GitClone(prjPath, PrjGitBranch, PrjGit.SSHURL)
|
|
common.PanicOnError(err)
|
|
git.GitExecOrPanic(prjPath, "checkout", "-B", prjGitPRbranch, RemoteName+"/"+PrjGitBranch)
|
|
|
|
headCommit, err := git.GitBranchHead(prjPath, prjGitPRbranch)
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to fetch PrjGit branch", prjGitPRbranch, err)
|
|
return err
|
|
}
|
|
if err := prp.SetSubmodulesToMatchPRSet(prset); err != nil {
|
|
return err
|
|
}
|
|
newHeadCommit, err := git.GitBranchHead(prjPath, prjGitPRbranch)
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to fetch updated PrjGit branch", prjGitPRbranch, err)
|
|
return err
|
|
}
|
|
|
|
if !common.IsDryRun && !prp.config.NoProjectGitPR {
|
|
if headCommit != newHeadCommit {
|
|
common.PanicOnError(git.GitExec(prjPath, "push", RemoteName, "+HEAD:"+prjGitPRbranch))
|
|
}
|
|
|
|
title, desc := PrjGitDescription(prset)
|
|
pr, err, isNew := Gitea.WithContext(prp.ctx).CreatePullRequestIfNotExist(PrjGit, prjGitPRbranch, PrjGitBranch, title, desc)
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Error creating PrjGit PR:", err)
|
|
return err
|
|
}
|
|
org := PrjGit.Owner.UserName
|
|
repo := PrjGit.Name
|
|
idx := pr.Index
|
|
if isNew {
|
|
Gitea.WithContext(prp.ctx).SetLabels(org, repo, idx, []string{prset.Config.Label(common.Label_StagingAuto)})
|
|
}
|
|
Gitea.WithContext(prp.ctx).UpdatePullRequest(org, repo, idx, &models.EditPullRequestOption{
|
|
RemoveDeadline: true,
|
|
})
|
|
|
|
prinfo := prset.AddPR(pr)
|
|
prinfo.RemoteName = RemoteName
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (prp *PRProcessor) RebaseAndSkipSubmoduleCommits(prset *common.PRSet, branch string) error {
|
|
git := prp.git
|
|
prjPath := prp.getPrjPath()
|
|
PrjGitPR, err := prset.GetPrjGitPR()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
remoteBranch := PrjGitPR.RemoteName + "/" + branch
|
|
|
|
common.LogDebugCtx(prp.ctx, "Rebasing on top of", remoteBranch)
|
|
for conflict := git.GitExec(prjPath, "rebase", remoteBranch); conflict != nil; {
|
|
err = git.GitResolveConflicts(prjPath, PrjGitPR.PR.MergeBase, PrjGitPR.PR.Base.Sha, PrjGitPR.PR.Head.Sha)
|
|
if err != nil && !strings.Contains(err.Error(), "Cannot automatically resolve conflict:") {
|
|
git.GitExecOrPanic(prjPath, "rebase", "--abort")
|
|
return err
|
|
}
|
|
conflict = git.GitExec(prjPath, "rebase", "--skip")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var updatePrjGitMissmatchError error = errors.New("Commits do not match.")
|
|
var requestProcessorError_requeue error = errors.New("Transient error, requeing after 5 seconds.")
|
|
|
|
func (prp *PRProcessor) UpdatePrjGitPR(prset *common.PRSet, rebase bool) error {
|
|
_, _, PrjGitBranch := prset.Config.GetPrjGit()
|
|
PrjGitPR, err := prset.GetPrjGitPR()
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Updating PrjGitPR but not found?", err)
|
|
return err
|
|
}
|
|
|
|
git := prp.git
|
|
prjPath := prp.getPrjPath()
|
|
if len(prset.PRs) == 1 {
|
|
if len(PrjGitPR.RemoteName) == 0 {
|
|
PrjGitPR.RemoteName, _ = git.GitClone(prjPath, "", PrjGitPR.PR.Base.Repo.SSHURL)
|
|
}
|
|
git.GitExecOrPanic(prjPath, "fetch", PrjGitPR.RemoteName, PrjGitPR.PR.Head.Sha)
|
|
common.LogDebugCtx(prp.ctx, "Only project git in PR. Nothing to update.")
|
|
return nil
|
|
}
|
|
|
|
PrjGit := PrjGitPR.PR.Base.Repo
|
|
prjGitPRbranch := PrjGitPR.PR.Head.Ref
|
|
|
|
PrjGitPR.RemoteName, err = git.GitClone(prjPath, prjGitPRbranch, PrjGitPR.PR.Head.Repo.SSHURL)
|
|
common.PanicOnError(err)
|
|
git.GitExecOrPanic(prjPath, "fetch", PrjGitPR.RemoteName, PrjGitBranch)
|
|
|
|
headCommit, err := git.GitBranchHead(prjPath, prjGitPRbranch)
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to fetch PrjGit branch", prjGitPRbranch, err)
|
|
return err
|
|
}
|
|
if headCommit != PrjGitPR.PR.Head.Sha {
|
|
common.LogErrorCtx(prp.ctx, "HeadCommit:", headCommit, "is not what's expected from the PR:", PrjGitPR.PR.Head.Ref, " Requeing.")
|
|
return errors.Join(requestProcessorError_requeue, updatePrjGitMissmatchError)
|
|
}
|
|
|
|
forcePush := false
|
|
if rebase {
|
|
if err := prp.RebaseAndSkipSubmoduleCommits(prset, PrjGitBranch); err != nil {
|
|
return err
|
|
}
|
|
forcePush = true
|
|
}
|
|
|
|
if err := prp.SetSubmodulesToMatchPRSet(prset); err != nil {
|
|
return err
|
|
}
|
|
newHeadCommit, err := git.GitBranchHead(prjPath, prjGitPRbranch)
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Failed to fetch updated PrjGit branch", prjGitPRbranch, err)
|
|
return err
|
|
}
|
|
|
|
PrjGitTitle, PrjGitBody := PrjGitDescription(prset)
|
|
if slices.Contains(BotUsers, PrjGitPR.PR.User.UserName) {
|
|
if PrjGitPR.PR.Title != PrjGitTitle || PrjGitPR.PR.Body != PrjGitBody {
|
|
common.LogDebugCtx(prp.ctx, "New title:", PrjGitTitle)
|
|
common.LogDebugCtx(prp.ctx, PrjGitBody)
|
|
}
|
|
} else {
|
|
// TODO: find our first comment in timeline
|
|
|
|
}
|
|
|
|
if !common.IsDryRun {
|
|
if headCommit != newHeadCommit {
|
|
// New commit needed, but we cannot push it due to settings.
|
|
if PrjGitPR.PR.Base.RepoID != PrjGitPR.PR.Head.RepoID && !PrjGitPR.PR.AllowMaintainerEdit {
|
|
prset.UpdateError = fmt.Errorf("cannot update Project Git: maintainer edits disallowed on fork")
|
|
common.LogInfo("Cannot update ProjectGit - it's out of sync with packages")
|
|
return nil
|
|
}
|
|
|
|
// TODO: Fix when adding feature if remote is ReadOnly or RW.
|
|
params := []string{"push", PrjGitPR.RemoteName, "+HEAD:" + prjGitPRbranch}
|
|
if forcePush {
|
|
params = slices.Insert(params, 1, "-f")
|
|
}
|
|
common.PanicOnError(git.GitExec(prjPath, params...))
|
|
PrjGitPR.PR.Head.Sha = newHeadCommit
|
|
Gitea.WithContext(prp.ctx).SetLabels(PrjGit.Owner.UserName, PrjGit.Name, PrjGitPR.PR.Index, []string{prset.Config.Label("PR/updated")})
|
|
}
|
|
|
|
// update PR
|
|
isPrTitleSame := func(CurrentTitle, NewTitle string) bool {
|
|
ctlen := len(CurrentTitle)
|
|
for _, suffix := range []string{"...", "…"} {
|
|
slen := len(suffix)
|
|
if ctlen > 250 && strings.HasSuffix(CurrentTitle, suffix) && len(NewTitle) > ctlen {
|
|
NewTitle = NewTitle[0:ctlen-slen] + suffix
|
|
if CurrentTitle == NewTitle {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return CurrentTitle == NewTitle
|
|
}
|
|
if slices.Contains(BotUsers, PrjGitPR.PR.User.UserName) && (PrjGitPR.PR.Body != PrjGitBody || !isPrTitleSame(PrjGitPR.PR.Title, PrjGitTitle)) {
|
|
Gitea.WithContext(prp.ctx).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 (prp *PRProcessor) Process(ctx context.Context, req *models.PullRequest) (err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
common.LogInfoCtx(prp.ctx, "panic cought in PRProcessor --- recovered")
|
|
common.LogErrorCtx(prp.ctx, string(debug.Stack()))
|
|
err = fmt.Errorf("Panic crash: %v", r)
|
|
}
|
|
}()
|
|
config := prp.config
|
|
git := prp.git
|
|
prset := prp.prset
|
|
|
|
// requests against project are not handled here
|
|
common.LogInfoCtx(prp.ctx, "processing opened PR:", req.URL)
|
|
prOrg := req.Base.Repo.Owner.UserName
|
|
prRepo := req.Base.Repo.Name
|
|
prNo := req.Index
|
|
|
|
common.LogErrorCtx(prp.ctx, req)
|
|
|
|
if prset == nil {
|
|
var err error
|
|
prset, err = common.FetchPRSet(BotUsers, Gitea.WithContext(prp.ctx), prOrg, prRepo, prNo, config)
|
|
if err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Cannot fetch PRSet:", err)
|
|
return err
|
|
}
|
|
prset.WorkspacePath = prp.getPrjPath()
|
|
}
|
|
|
|
common.LogInfoCtx(prp.ctx, "processing PRSet of size:", len(prset.PRs))
|
|
|
|
merge_errors := prset.PrepareForMerge(git)
|
|
mergeable := merge_errors == nil || merge_errors == common.ErrMergeProjectConflicts_Submodules
|
|
if !mergeable {
|
|
common.LogErrorCtx(prp.ctx, "PRs are NOT mergeable.")
|
|
} else {
|
|
common.LogInfoCtx(prp.ctx, "PRs are in mergeable state.")
|
|
}
|
|
|
|
prjGitPRbranch := prGitBranchNameForPR(prRepo, prNo)
|
|
prjGitPR, err := prset.GetPrjGitPR()
|
|
if err == common.PRSet_PrjGitMissing {
|
|
if req.State != common.GiteaPRState_Open {
|
|
common.LogDebugCtx(prp.ctx, "This PR is closed and no ProjectGit PR. Ignoring.")
|
|
return nil
|
|
}
|
|
common.LogDebugCtx(prp.ctx, "Missing PrjGit. Need to create one under branch", prjGitPRbranch)
|
|
|
|
if err = prp.CreatePRjGitPR(prjGitPRbranch, prset); err != nil {
|
|
return err
|
|
}
|
|
} else if err == nil {
|
|
common.LogDebugCtx(prp.ctx, "Found PrjGit PR:", common.PRtoString(prjGitPR.PR))
|
|
if prjGitPR.PR == nil || prjGitPR.PR.Head == nil || prjGitPR.PR.Head.Repo == nil {
|
|
common.LogInfoCtx(prp.ctx, "PrjGit PR branch has been removed. Cannot process.")
|
|
return fmt.Errorf("PrjGit PR branch is removed. Cannot process.")
|
|
}
|
|
prjGitPRbranch = prjGitPR.PR.Head.Ref
|
|
|
|
if prjGitPR.PR.State != common.GiteaPRState_Open {
|
|
if prjGitPR.PR.HasMerged {
|
|
// update branches in project
|
|
prjPath := prp.getPrjPath()
|
|
prjGitPR.RemoteName, err = git.GitClone(prjPath, prjGitPRbranch, prjGitPR.PR.Head.Repo.SSHURL)
|
|
common.PanicOnError(err)
|
|
|
|
old_pkgs, err := git.GitSubmoduleList(prjPath, prjGitPR.PR.MergeBase)
|
|
common.PanicOnError(err)
|
|
new_pkgs, err := git.GitSubmoduleList(prjPath, 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(prp.ctx, config, git, prjPath, pkgs)
|
|
|
|
removed := make([]string, 0)
|
|
for pkg, commit := range pkgs {
|
|
if commit == "" {
|
|
removed = append(removed, pkg)
|
|
}
|
|
}
|
|
if len(removed) > 0 {
|
|
common.RenameRemovedPackageBranches(prp.ctx, Gitea.WithContext(prp.ctx), config.Organization, config.Branch, removed)
|
|
}
|
|
}
|
|
|
|
// manually merge or close entire prset that is still open
|
|
for _, pr := range prset.PRs {
|
|
if pr.PR.State == common.GiteaPRState_Open {
|
|
//org, repo, idx := pr.PRComponents()
|
|
if prjGitPR.PR.HasMerged {
|
|
// TODO: use timeline here because this can spam if ManualMergePR fails
|
|
// Gitea.WithContext(prp.ctx).AddComment(pr.PR, "This PR is merged via the associated Project PR.")
|
|
//err = Gitea.ManualMergePR(org, repo, idx, pr.PR.Head.Sha, pr.PR.Head.Sha, false)
|
|
//if _, ok := err.(*repository.RepoMergePullRequestConflict); !ok {
|
|
// common.PanicOnError(err)
|
|
//}
|
|
// } else {
|
|
// Gitea.WithContext(prp.ctx).AddComment(pr.PR, "Closing here because the associated Project PR has been closed.")
|
|
// Gitea.WithContext(prp.ctx).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 = prp.UpdatePrjGitPR(prset, merge_errors == common.ErrMergeProjectConflicts_Submodules); 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.LogErrorCtx(prp.ctx, "Error fetching PrjGitPR:", err)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
common.LogDebugCtx(prp.ctx, "Updated PR")
|
|
|
|
// populate empty PR bodies for package PRs
|
|
for _, prInfo := range prset.PRs {
|
|
if prset.IsPrjGitPR(prInfo.PR) {
|
|
continue
|
|
}
|
|
if err := prp.populateEmptyPRBody(prset, prInfo.PR); err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Error populating empty PR body:", err)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
// TODO: this is broken...
|
|
if pr, err := prset.GetPrjGitPR(); err == nil && false {
|
|
prjPath := prp.getPrjPath()
|
|
common.LogDebugCtx(prp.ctx, "Submodule parse begin")
|
|
orig_subs, err := git.GitSubmoduleList(prjPath, pr.PR.MergeBase)
|
|
common.PanicOnError(err)
|
|
new_subs, err := git.GitSubmoduleList(prjPath, "HEAD")
|
|
common.PanicOnError(err)
|
|
common.LogDebugCtx(prp.ctx, "Submodule parse done")
|
|
|
|
reset_submodule := func(submodule, sha string) {
|
|
updateSubmoduleInPR(prp.ctx, prjPath, submodule, sha, git)
|
|
}
|
|
|
|
common.LogDebugCtx(prp.ctx, "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.LogErrorCtx(prp.ctx, path, "-- commits not match", commit, pr.PR.Head.Sha)
|
|
}
|
|
}
|
|
if !found {
|
|
reset_submodule(path, old)
|
|
}
|
|
}
|
|
}
|
|
|
|
stats, err := git.GitStatus(prjPath)
|
|
common.LogDebugCtx(prp.ctx, "Check Done", len(stats), "changes")
|
|
common.PanicOnError(err)
|
|
if len(stats) > 0 {
|
|
git.GitExecOrPanic(prjPath, "commit", "-a", "-m", "Sync submodule updates with PR-set")
|
|
common.PanicOnError(git.GitDeinitSubmodules(prjPath))
|
|
if !common.IsDryRun {
|
|
git.GitExecOrPanic(prjPath, "push")
|
|
}
|
|
}
|
|
}
|
|
|
|
if prjGitPR != nil {
|
|
common.LogDebugCtx(prp.ctx, " num of reviewers:", len(prjGitPR.PR.RequestedReviewers))
|
|
} else {
|
|
common.LogInfoCtx(prp.ctx, "* No prjgit")
|
|
}
|
|
maintainers, err := common.FetchProjectMaintainershipData(Gitea.WithContext(prp.ctx), config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
prjPath := prp.getPrjPath()
|
|
// update prset if we should build it or not
|
|
if prjGitPR != nil {
|
|
if file, err := git.GitCatFile(prjPath, prjGitPR.PR.Head.Sha, "staging.config"); err == nil {
|
|
prset.HasAutoStaging = (file != nil)
|
|
common.LogDebugCtx(prp.ctx, " -> automatic staging enabled?:", prset.HasAutoStaging)
|
|
}
|
|
}
|
|
|
|
// 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 && slices.Contains(prset.BotUsers, prjGitPR.PR.User.UserName) {
|
|
common.LogDebugCtx(prp.ctx, " --> checking if superflous PR")
|
|
prjPath := prp.getPrjPath()
|
|
diff, err := git.GitDiff(prjPath, prjGitPR.PR.MergeBase, prjGitPR.PR.Head.Sha)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(diff) == 0 {
|
|
common.LogInfoCtx(prp.ctx, "PR is no-op and can be closed. Closing.")
|
|
if !common.IsDryRun {
|
|
Gitea.WithContext(prp.ctx).AddComment(prjGitPR.PR, "Pull request no longer contains any changes. Closing.")
|
|
_, err = Gitea.WithContext(prp.ctx).UpdatePullRequest(prjGitPR.PR.Base.Repo.Owner.UserName, prjGitPR.PR.Base.Repo.Name, prjGitPR.PR.Index, &models.EditPullRequestOption{
|
|
State: common.GiteaPRState_Closed,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
common.LogDebugCtx(prp.ctx, " --> NOT superflous PR")
|
|
}
|
|
|
|
for _, pr := range prset.PRs {
|
|
if err := verifyRepositoryConfiguration(ctx, pr.PR.Base.Repo); err != nil {
|
|
common.LogErrorCtx(prp.ctx, "Cannot set manual merge... aborting processing")
|
|
return err
|
|
}
|
|
}
|
|
|
|
common.LogInfoCtx(prp.ctx, "Consistent PRSet:", prset.IsConsistent())
|
|
common.LogInfoCtx(prp.ctx, "Reviewed?", prset.IsApproved(Gitea.WithContext(prp.ctx), maintainers))
|
|
if prset.IsConsistent() && prset.IsApproved(Gitea.WithContext(prp.ctx), maintainers) && mergeable {
|
|
common.LogInfoCtx(prp.ctx, "Merging...")
|
|
|
|
if prp.prjPath != prp.config.GetPrjGitLocalDir() {
|
|
if prjGitPR != nil && prjGitPR.PR != nil && prjGitPR.PR.Head != nil {
|
|
// If we are in an isolated workspace, push the workspace HEAD to origin's unique head branch
|
|
prBranchName := "refs/internal/pr#" + strconv.FormatInt(prjGitPR.PR.Index, 10)
|
|
|
|
common.LogInfoCtx(prp.ctx, "Pushing workspace changes just-in-time to remote ProjectGit PR branch:", prBranchName)
|
|
prjPath := prp.getPrjPath()
|
|
if err = git.GitExec(prjPath, "push", common.DefaultGitRemote, "HEAD:"+prBranchName); err != nil {
|
|
return err
|
|
}
|
|
// Close the workspace Git handler to release its locks
|
|
git.Close() //nolint:errcheck
|
|
} else {
|
|
return fmt.Errorf("ProjectGit PR or Head is missing, cannot push workspace changes")
|
|
}
|
|
|
|
// Allocate the single project Git handler
|
|
git, err = GitHandler.CreateGitHandlerCtx(ctx, prp.config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer git.Close() //nolint:errcheck
|
|
}
|
|
|
|
if err = prset.Merge(Gitea.WithContext(prp.ctx), git); err != nil {
|
|
if errors.Is(err, common.PRSet_PushRejectedRetry) {
|
|
return errors.Join(requestProcessorError_requeue, err)
|
|
}
|
|
return err
|
|
}
|
|
} else {
|
|
err = prset.AssignReviewers(Gitea.WithContext(prp.ctx), maintainers)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// prLocks serialises concurrent processing of the same PR.
|
|
// Both the RabbitMQ event loop and the consistency-checker goroutine call
|
|
// ProcesPullRequest; without this lock they can race on reviewer add/remove.
|
|
// Key format: "org/repo#num"
|
|
var prLocks sync.Map // map[string]*sync.Mutex
|
|
|
|
func prLockKey(pr *models.PullRequest) string {
|
|
return common.TaskRefIssue(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Index)
|
|
}
|
|
|
|
func acquirePRLock(key string) *sync.Mutex {
|
|
v, _ := prLocks.LoadOrStore(key, &sync.Mutex{})
|
|
mu := v.(*sync.Mutex)
|
|
mu.Lock()
|
|
return mu
|
|
}
|
|
|
|
func releasePRLock(mu *sync.Mutex) {
|
|
mu.Unlock()
|
|
}
|
|
|
|
type RequestProcessor struct {
|
|
configuredRepos map[string][]*common.AutogitConfig
|
|
}
|
|
|
|
func (w *RequestProcessor) GetConfiguredRepos() map[string][]*common.AutogitConfig {
|
|
return w.configuredRepos
|
|
}
|
|
|
|
func (w *RequestProcessor) Process(ctx context.Context, pr *models.PullRequest, prset *common.PRSet) error {
|
|
configs, ok := w.configuredRepos[pr.Base.Repo.Owner.UserName]
|
|
if !ok {
|
|
return fmt.Errorf("no config found for org %s", pr.Base.Repo.Owner.UserName)
|
|
}
|
|
return ProcesPullRequest(ctx, pr, configs, prset)
|
|
}
|
|
|
|
func ProcesPullRequest(ctx context.Context, pr *models.PullRequest, configs common.AutogitConfigs, prset *common.PRSet) error {
|
|
if len(configs) < 1 {
|
|
// ignoring pull request against unconfigured project (could be just regular sources?)
|
|
return nil
|
|
}
|
|
|
|
lock := acquirePRLock(prLockKey(pr))
|
|
defer releasePRLock(lock)
|
|
|
|
PRProcessor, err := AllocatePRProcessor(ctx, pr, configs)
|
|
if err != nil {
|
|
common.LogErrorCtx(ctx, err)
|
|
return err
|
|
}
|
|
defer PRProcessor.git.Close() //nolint:errcheck
|
|
|
|
return PRProcessor.Process(ctx, pr)
|
|
|
|
}
|
|
|
|
func (w *RequestProcessor) ProcessFunc(ctx context.Context, request *common.Request) (err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
common.LogErrorCtx(ctx, "panic caught in RequestProcessor --- recovered")
|
|
common.LogErrorCtx(ctx, string(debug.Stack()))
|
|
err = fmt.Errorf("Panic crash: %v", r)
|
|
}
|
|
}()
|
|
|
|
var pr *models.PullRequest
|
|
if req, ok := request.Data.(*common.PullRequestWebhookEvent); ok {
|
|
ctx = common.ContextWithPR(ctx, req.Pull_Request.Base.Repo.Owner.Username, req.Pull_Request.Base.Repo.Name, req.Pull_Request.Number)
|
|
// Skip pull_request_sync events triggered by the bot's own pushes to
|
|
// prjgit branches. Those would re-run AssignReviewers immediately
|
|
// after the bot itself just set them, producing spurious add/remove
|
|
// cycles. Human-triggered syncs have a different sender and are still
|
|
// processed normally.
|
|
if request.Type == common.RequestType_PRSync && slices.Contains(BotUsers, req.Sender.Username) {
|
|
common.LogDebugCtx(ctx, "Skipping self-triggered pull_request_sync from", req.Sender.Username,
|
|
"on", req.Pull_Request.Base.Repo.Owner.Username+"/"+req.Pull_Request.Base.Repo.Name,
|
|
"#", req.Pull_Request.Number)
|
|
return nil
|
|
}
|
|
|
|
pr, err = Gitea.WithContext(ctx).GetPullRequest(req.Pull_Request.Base.Repo.Owner.Username, req.Pull_Request.Base.Repo.Name, req.Pull_Request.Number)
|
|
if err != nil {
|
|
common.LogErrorCtx(ctx, "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.IssueCommentWebhookEvent); ok {
|
|
ctx = common.ContextWithPR(ctx, req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
|
|
if req.Comment == nil || req.Comment.Pull_Request_Url == "" {
|
|
// Comment on a plain issue — re-trigger issue processing.
|
|
// Ignore comments made by the bot itself to avoid feedback loops.
|
|
if req.Sender != nil && slices.Contains(BotUsers, req.Sender.Username) {
|
|
common.LogDebugCtx(ctx, "Ignoring issue_comment from bot itself:", req.Sender.Username)
|
|
return nil
|
|
}
|
|
issue, err := Gitea.WithContext(ctx).GetIssue(req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
|
|
if err != nil {
|
|
common.LogErrorCtx(ctx, "Cannot find issue for issue_comment event:", req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
|
|
return err
|
|
}
|
|
configs, ok := w.configuredRepos[req.Repository.Owner.Username]
|
|
if !ok {
|
|
common.LogErrorCtx(ctx, "*** Cannot find config for org:", req.Repository.Owner.Username)
|
|
return nil
|
|
}
|
|
processor := &IssueProcessor{issue: issue, ctx: ctx}
|
|
return processor.ProcessIssue(configs)
|
|
}
|
|
pr, err = Gitea.WithContext(ctx).GetPullRequest(req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
|
|
if err != nil {
|
|
common.LogErrorCtx(ctx, "Cannot find PR for issue:", req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
|
|
return err
|
|
}
|
|
} else if req, ok := request.Data.(*common.IssueWebhookEvent); ok {
|
|
ctx = common.ContextWithIssue(ctx, req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
|
|
issue, err := Gitea.WithContext(ctx).GetIssue(req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
|
|
if err != nil {
|
|
common.LogErrorCtx(ctx, "Cannot find issue for issue event:", req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
|
|
return err
|
|
}
|
|
configs, ok := w.configuredRepos[req.Repository.Owner.Username]
|
|
if !ok {
|
|
common.LogErrorCtx(ctx, "*** Cannot find config for org:", req.Repository.Owner.Username)
|
|
return nil
|
|
}
|
|
processor := &IssueProcessor{
|
|
issue: issue,
|
|
ctx: ctx,
|
|
}
|
|
return processor.ProcessIssue(configs)
|
|
} else {
|
|
common.LogErrorCtx(ctx, "*** 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.LogErrorCtx(ctx, "*** Cannot find config for org:", pr.Base.Repo.Owner.UserName)
|
|
return nil
|
|
}
|
|
if err = ProcesPullRequest(ctx, pr, configs, nil); errors.Is(err, requestProcessorError_requeue) {
|
|
// Retry after a delay in a background goroutine so the event loop is
|
|
// not blocked while we wait. The per-PR lock inside ProcesPullRequest
|
|
// ensures no other processing races with the retry.
|
|
go func() {
|
|
time.Sleep(time.Second * 5)
|
|
if err := ProcesPullRequest(ctx, pr, configs, nil); err != nil {
|
|
common.LogErrorCtx(ctx, "requeue retry failed:", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
return err
|
|
}
|