Files
autogits/workflow-pr/pr_processor.go
T
enavarro_suse 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
workflow-pr: rename package branches when a package is removed
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
2026-07-20 19:14:24 +02:00

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
}