refactor(automerge): fix error handling, populate recent automerge tasks on restart (#39001)

* Refactor "automerge" related code, clarify many details (including "unique queue item", start check by pull head or commit)
* Fix automerge queue handler's error handling, clarify error messages
* Populate recent automerge tasks on restart to restore the previous aborted automerge tasks

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Willem Kokke
2026-08-28 22:17:01 +01:00
committed by GitHub
parent 1652dfe62a
commit 60326ca03d
15 changed files with 246 additions and 176 deletions

View File

@@ -8,7 +8,7 @@ import (
"errors"
"fmt"
"net/url"
"strings"
"strconv"
"time"
"gitea.dev/models/db"
@@ -123,7 +123,9 @@ func (run *ActionRun) RefLink() string {
func (run *ActionRun) PrettyRef() string {
refName := git.RefName(run.Ref)
if refName.IsPull() {
return "#" + strings.TrimSuffix(strings.TrimPrefix(run.Ref, git.PullPrefix), "/head")
if pullIndex, ok := refName.PullIndex(); ok {
return "#" + strconv.FormatInt(pullIndex, 10)
}
}
return refName.ShortName()
}

View File

@@ -415,7 +415,7 @@ func (pr *PullRequest) getReviewedByLines(ctx context.Context, writer io.Writer)
// GetGitHeadRefName returns git ref for hidden pull request branch
func (pr *PullRequest) GetGitHeadRefName() string { // TODO: make it return RefName but not string
return fmt.Sprintf("%s%d/head", git.PullPrefix, pr.Index)
return git.RefNameFromPullIndex(pr.Index).String()
}
// GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR)

View File

@@ -80,6 +80,12 @@ func GetScheduledMergeByPullID(ctx context.Context, pullID int64) (bool, *AutoMe
return true, scheduledPRM, err
}
func GetScheduledMergePullIDsSince(ctx context.Context, since timeutil.TimeStamp) ([]int64, error) {
var pullIDs []int64
err := db.GetEngine(ctx).Table(&AutoMerge{}).Where("created_unix >= ?", since).Cols("pull_id").Find(&pullIDs)
return pullIDs, err
}
// DeleteScheduledAutoMerge delete a scheduled pull request
func DeleteScheduledAutoMerge(ctx context.Context, pullID int64) error {
exist, scheduledPRM, err := GetScheduledMergeByPullID(ctx, pullID)

View File

@@ -6,6 +6,7 @@ package git
import (
"context"
"regexp"
"strconv"
"strings"
"gitea.dev/modules/git/gitcmd"
@@ -18,6 +19,7 @@ const (
RemotePrefix = "refs/remotes/"
// PullPrefix is the base directory of the pull information of git.
PullPrefix = "refs/pull/"
pullSuffix = "/head"
)
// refNamePatternInvalid is regular expression with unallowed characters in git reference name
@@ -93,6 +95,10 @@ func RefNameFromCommit(shortName string) RefName {
return RefName(shortName)
}
func RefNameFromPullIndex(prIndex int64) RefName {
return RefName(PullPrefix + strconv.FormatInt(prIndex, 10) + pullSuffix)
}
func (ref RefName) String() string {
return string(ref)
}
@@ -134,14 +140,21 @@ func (ref RefName) BranchName() string {
return ref.nameWithoutPrefix(BranchPrefix)
}
// PullName returns the pull request name part of refs like refs/pull/<pull_name>/head
func (ref RefName) PullName() string {
func (ref RefName) PullIndex() (int64, bool) {
refName := string(ref)
lastIdx := strings.LastIndexByte(refName[len(PullPrefix):], '/')
if strings.HasPrefix(refName, PullPrefix) && lastIdx > -1 {
return refName[len(PullPrefix) : lastIdx+len(PullPrefix)]
s, ok := strings.CutPrefix(refName, PullPrefix)
if !ok {
return 0, false
}
return ""
pullStr, last, ok := strings.CutLast(s, "/")
if !ok || last != "head" {
return 0, false
}
pullIndex, err := strconv.ParseInt(pullStr, 10, 64)
if err != nil {
return 0, false
}
return pullIndex, true
}
// ForBranchName returns the branch name part of refs like refs/for/<branch_name>
@@ -165,7 +178,7 @@ func (ref RefName) ShortName() string {
return ref.RemoteName()
}
if ref.IsPull() {
return ref.PullName()
return strings.TrimSuffix(ref.nameWithoutPrefix(PullPrefix), pullSuffix)
}
if ref.IsFor() {
return ref.ForBranchName()

View File

@@ -19,10 +19,12 @@ func TestRefName(t *testing.T) {
assert.Equal(t, "release/foo", RefName("refs/tags/release/foo").TagName())
// Test pull names
assert.Equal(t, "1", RefName("refs/pull/1/head").PullName())
pullIndex, ok := RefName("refs/pull/1/head").PullIndex()
assert.True(t, ok)
assert.EqualValues(t, 1, pullIndex)
assert.True(t, RefName("refs/pull/1/head").IsPull())
assert.True(t, RefName("refs/pull/1/merge").IsPull())
assert.Equal(t, "my/pull", RefName("refs/pull/my/pull/head").PullName())
assert.Equal(t, "my/pull", RefName("refs/pull/my/pull/head").ShortName())
// Test for branch names
assert.Equal(t, "main", RefName("refs/for/main").ForBranchName())

View File

@@ -152,7 +152,7 @@ func InitWebInstalled(ctx context.Context) {
mirror_service.InitSyncMirrors()
mustInit(webhook.Init)
mustInit(pull_service.Init)
mustInit(automerge.Init)
mustInitCtx(ctx, automerge.Init)
mustInit(task.Init)
mustInit(repo_migrations.Init)
mustInit(websocket_service.Init)

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
@@ -22,6 +23,7 @@ import (
"gitea.dev/modules/log"
"gitea.dev/modules/process"
"gitea.dev/modules/queue"
"gitea.dev/modules/timeutil"
"gitea.dev/services/automergequeue"
notify_service "gitea.dev/services/notify"
pull_service "gitea.dev/services/pull"
@@ -29,29 +31,40 @@ import (
)
// Init runs the task queue to that handles auto merges
func Init() error {
func Init(ctx context.Context) error {
notify_service.RegisterNotifier(NewNotifier())
automergequeue.AutoMergeQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "pr_auto_merge", handler)
automergequeue.AutoMergeQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "pr_auto_merge",
func(items ...automergequeue.AutoMergeItem) (unhandled []automergequeue.AutoMergeItem) {
for _, item := range items {
handleAutoMergeItem(item)
}
return nil
},
)
if automergequeue.AutoMergeQueue == nil {
return errors.New("unable to create pr_auto_merge queue")
}
go graceful.GetManager().RunWithCancel(automergequeue.AutoMergeQueue)
populateRecentAutoMergeItems(ctx)
return nil
}
// handle passed PR IDs and test the PRs
func handler(items ...string) []string {
for _, s := range items {
var id int64
var sha string
if _, err := fmt.Sscanf(s, "%d_%s", &id, &sha); err != nil {
log.Error("could not parse data from pr_auto_merge queue (%v): %v", s, err)
func populateRecentAutoMergeItems(ctx context.Context) {
// in case Gitea's restart aborted some scheduled auto-merge pull requests, try to re-start the recent ones
pullIDs, err := pull_model.GetScheduledMergePullIDsSince(ctx, timeutil.TimeStampNow().AddDuration(-24*time.Hour))
if err != nil {
log.Error("Failed to get recent scheduled auto-merge pull requests: %v", err)
return
}
for _, pullID := range pullIDs {
pull, err := issues_model.GetPullRequestByID(ctx, pullID)
if err != nil {
log.Error("Failed to get scheduled pull request [%d]: %v", pullID, err)
continue
}
handlePullRequestAutoMerge(id, sha)
automergequeue.StartAutoMergeCheckByPullHead(ctx, pull)
}
return nil
}
// ScheduleAutoMerge if schedule is false and no error, pull can be merged directly
@@ -69,7 +82,7 @@ func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pull *issues_
scheduled = err == nil
if scheduled {
log.Trace("Pull request [%d] scheduled for auto merge with style [%s] and message [%s]", pull.ID, style, message)
automergequeue.StartPRCheckAndAutoMerge(ctx, pull)
automergequeue.StartAutoMergeCheckByPullHead(ctx, pull)
}
return scheduled, err
}
@@ -86,124 +99,85 @@ func RemoveScheduledAutoMerge(ctx context.Context, doer *user_model.User, pull *
})
}
// StartPRCheckAndAutoMergeBySHA start an automerge check and auto merge task for all pull requests of repository and SHA
func StartPRCheckAndAutoMergeBySHA(ctx context.Context, sha string, repo *repo_model.Repository) error {
pulls, err := getPullRequestsByHeadSHA(ctx, sha, repo, func(pr *issues_model.PullRequest) bool {
return !pr.HasMerged && pr.IsStatusMergeable()
})
var errSkipAutoMerge = errors.New("skip auto merge")
func handleAutoMergeItem(item automergequeue.AutoMergeItem) {
ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), "AutoMerge: "+string(item))
defer finished()
fields := strings.Split(string(item), ":")
if len(fields) != 3 || fields[0] != "pr" {
return
}
pullIDStr, headCommitID := fields[1], fields[2]
pullID, _ := strconv.ParseInt(pullIDStr, 10, 64)
pr, err := issues_model.GetPullRequestByID(ctx, pullID)
if err != nil {
return err
log.Error("AutoMerge: GetPullRequestByID[%d]: %v", pullID, err)
return
}
for _, pr := range pulls {
automergequeue.AddToQueue(pr, sha)
err = handlePullRequestAutoMerge(ctx, pr, headCommitID)
if errors.Is(err, errSkipAutoMerge) {
log.Debug("AutoMerge: skipping pull request [%d] auto merge: %v", pullID, err)
} else if err != nil {
log.Error("AutoMerge: failed to auto merge pull request [%d]: %v", pullID, err)
} else {
log.Info("AutoMerge: auto merge pull request [%d]", pullID)
}
return nil
}
func getPullRequestsByHeadSHA(ctx context.Context, sha string, repo *repo_model.Repository, filter func(*issues_model.PullRequest) bool) (map[int64]*issues_model.PullRequest, error) {
gitRepo, err := git.OpenRepository(ctx, repo)
if err != nil {
return nil, err
}
defer gitRepo.Close()
refs, err := gitRepo.GetRefsBySha(ctx, sha, "")
if err != nil {
return nil, err
}
pulls := make(map[int64]*issues_model.PullRequest)
for _, ref := range refs {
// Each pull branch starts with refs/pull/ we then go from there to find the index of the pr and then
// use that to get the pr.
if strings.HasPrefix(ref, git.PullPrefix) {
parts := strings.Split(ref[len(git.PullPrefix):], "/")
// e.g. 'refs/pull/1/head' would be []string{"1", "head"}
if len(parts) != 2 {
log.Error("getPullRequestsByHeadSHA found broken pull ref [%s] on repo [%-v]", ref, repo)
continue
}
prIndex, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
log.Error("getPullRequestsByHeadSHA found broken pull ref [%s] on repo [%-v]", ref, repo)
continue
}
p, err := issues_model.GetPullRequestByIndex(ctx, repo.ID, prIndex)
if err != nil {
// If there is no pull request for this branch, we don't try to merge it.
if issues_model.IsErrPullRequestNotExist(err) {
continue
}
return nil, err
}
if filter(p) {
pulls[p.ID] = p
}
}
}
return pulls, nil
}
// handlePullRequestAutoMerge merge the pull request if all checks are successful
func handlePullRequestAutoMerge(pullID int64, sha string) {
ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(),
fmt.Sprintf("Handle AutoMerge of PR[%d] with sha[%s]", pullID, sha))
defer finished()
func handlePullRequestAutoMerge(ctx context.Context, pr *issues_model.PullRequest, expectedHeadCommitID string) error {
_ = pr.LoadIssue(ctx)
if (pr.Issue != nil && pr.Issue.IsClosed) || pr.HasMerged {
// if the PR has been closed or merged, delete the automerge record and skip
err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID)
if err != nil {
return errors.Join(errSkipAutoMerge, err)
}
return nil
}
pr, err := issues_model.GetPullRequestByID(ctx, pullID)
if err != nil {
log.Error("GetPullRequestByID[%d]: %v", pullID, err)
return
if !pr.IsStatusMergeable() || pr.IsWorkInProgress(ctx) {
// quick check: if the PR can't be merged, just skip
return errors.Join(errSkipAutoMerge, errors.New("pull request is not mergeable or is work in progress"))
}
// Check if there is a scheduled pr in the db
exists, scheduledPRM, err := pull_model.GetScheduledMergeByPullID(ctx, pr.ID)
if err != nil {
log.Error("%-v GetScheduledMergeByPullID: %v", pr, err)
return
return fmt.Errorf("failed to get scheduled auto-merge: %w", err)
}
if !exists {
return
return errors.Join(errSkipAutoMerge, errors.New("pull request doesn't exist"))
}
if err = pr.LoadBaseRepo(ctx); err != nil {
log.Error("%-v LoadBaseRepo: %v", pr, err)
return
return fmt.Errorf("failed to load base repo: %w", err)
}
if err = pr.LoadHeadRepo(ctx); err != nil {
return fmt.Errorf("failed to load head repo: %w", err)
}
// check the sha is the same as pull request head commit id
baseGitRepo, err := git.OpenRepository(ctx, pr.BaseRepo)
if err != nil {
log.Error("OpenRepository: %v", err)
return
return fmt.Errorf("failed to open base git repo: %w", err)
}
defer baseGitRepo.Close()
headCommitID, err := baseGitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
if err != nil {
log.Error("GetRefCommitID: %v", err)
return
return fmt.Errorf("failed to get ref commit ID: %w", err)
}
if headCommitID != sha {
log.Warn("Head commit id of auto merge %-v does not match sha [%s], it may means the head branch has been updated. Just ignore this request because a new request expected in the queue", pr, sha)
return
if headCommitID != expectedHeadCommitID {
return errors.Join(errSkipAutoMerge, errors.New("head commit ID changed"))
}
// Get all checks for this pr
// We get the latest sha commit hash again to handle the case where the check of a previous push
// did not succeed or was not finished yet.
if err = pr.LoadHeadRepo(ctx); err != nil {
log.Error("%-v LoadHeadRepo: %v", pr, err)
return
}
switch pr.Flow {
case issues_model.PullRequestFlowGithub:
@@ -212,67 +186,58 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
headBranchExist, _ = git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch)
}
if !headBranchExist {
log.Warn("Head branch of auto merge %-v does not exist [HeadRepoID: %d, Branch: %s]", pr, pr.HeadRepoID, pr.HeadBranch)
return
return errors.Join(errSkipAutoMerge, errors.New("head branch does not exist"))
}
case issues_model.PullRequestFlowAGit:
headBranchExist := git.IsReferenceExist(ctx, pr.BaseRepo, pr.GetGitHeadRefName())
if !headBranchExist {
log.Warn("Head branch of auto merge %-v does not exist [HeadRepoID: %d, Branch(Agit): %s]", pr, pr.HeadRepoID, pr.HeadBranch)
return
return errors.Join(errSkipAutoMerge, errors.New("head branch (agit) does not exist"))
}
default:
log.Error("wrong flow type %d", pr.Flow)
return
return errors.Join(errSkipAutoMerge, errors.New("unsupported pull request git flow type"))
}
// Check if all checks succeeded
pass, err := pull_service.IsPullCommitStatusPass(ctx, pr)
if err != nil {
log.Error("%-v IsPullCommitStatusPass: %v", pr, err)
return
return fmt.Errorf("failed to check pull commit status: %w", err)
}
if !pass {
log.Info("Scheduled auto merge %-v has unsuccessful status checks", pr)
return
return errors.Join(errSkipAutoMerge, errors.New("unsuccessful status checks"))
}
// Merge if all checks succeeded
doer, err := user_model.GetUserByID(ctx, scheduledPRM.DoerID)
_, doer, err := user_model.GetPossibleUserByID(ctx, scheduledPRM.DoerID)
if err != nil {
log.Error("Unable to get scheduled User[%d]: %v", scheduledPRM.DoerID, err)
return
return fmt.Errorf("failed to get scheduled user[%d]: %w", scheduledPRM.DoerID, err)
}
perm, err := access_model.GetDoerRepoPermission(ctx, pr.BaseRepo, doer)
if err != nil {
log.Error("GetDoerRepoPermission %-v: %v", pr.BaseRepo, err)
return
return fmt.Errorf("failed to get doer repo permission: %w", err)
}
if err := pull_service.CheckPullMergeable(ctx, doer, &perm, pr, pull_service.MergeCheckTypeGeneral, scheduledPRM.MergeStyle, false); err != nil {
if errors.Is(err, pull_service.ErrNotReadyToMerge) {
log.Info("%-v was scheduled to automerge by an unauthorized user", pr)
return
}
log.Error("%-v CheckPullMergeable: %v", pr, err)
return
return errors.Join(errSkipAutoMerge, errors.New("pull request is not mergeable"))
}
if err := pull_service.Merge(pr, doer, scheduledPRM.MergeStyle, "", scheduledPRM.Message, true); err != nil {
log.Error("pull_service.Merge: %v", err)
// FIXME: if merge failed, we should display some error message to the pull request page.
// FIXME: if merge failed, we should display some error message to the pull request page, or retry later.
// The resolution is add a new column on automerge table named `error_message` to store the error message and displayed
// on the pull request page. But this should not be finished in a bug fix PR which will be backport to release branch.
return
return fmt.Errorf("failed to merge PR:%d: %w", pr.ID, err)
}
deleteBranchAfterMerge, err := pull_service.ShouldDeleteBranchAfterMerge(ctx, &scheduledPRM.DeleteBranchAfterMerge, pr.BaseRepo, pr)
if err != nil {
log.Error("ShouldDeleteBranchAfterMerge: %v", err)
} else if deleteBranchAfterMerge {
if err = repo_service.DeleteBranchAfterMerge(ctx, doer, pr.ID, nil); err != nil {
log.Error("DeleteBranchAfterMerge: %v", err)
// the PR has been merged, so no error should be returned after this point
{
deleteBranchAfterMerge, err := pull_service.ShouldDeleteBranchAfterMerge(ctx, &scheduledPRM.DeleteBranchAfterMerge, pr.BaseRepo, pr)
if err != nil {
log.Error("ShouldDeleteBranchAfterMerge: %v", err)
} else if deleteBranchAfterMerge {
if err = repo_service.DeleteBranchAfterMerge(ctx, doer, pr.ID, nil); err != nil {
log.Error("DeleteBranchAfterMerge: %v", err)
}
}
}
return nil
}

View File

@@ -14,6 +14,7 @@ import (
"gitea.dev/modules/repository"
"gitea.dev/services/automergequeue"
notify_service "gitea.dev/services/notify"
pull_service "gitea.dev/services/pull"
)
type automergeNotifier struct {
@@ -30,9 +31,7 @@ func NewNotifier() notify_service.Notifier {
func (n *automergeNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) {
// as a missing / blocking reviews could have blocked a pending automerge let's recheck
if review.Type == issues_model.ReviewTypeApprove {
if err := StartPRCheckAndAutoMergeBySHA(ctx, review.CommitID, pr.BaseRepo); err != nil {
log.Error("StartPullRequestAutoMergeCheckBySHA: %v", err)
}
automergequeue.StartAutoMergeCheckByPullHead(ctx, pr)
}
}
@@ -46,13 +45,20 @@ func (n *automergeNotifier) PullReviewDismiss(ctx context.Context, doer *user_mo
return
}
// as reviews could have blocked a pending automerge let's recheck
automergequeue.StartPRCheckAndAutoMerge(ctx, review.Issue.PullRequest)
automergequeue.StartAutoMergeCheckByPullHead(ctx, review.Issue.PullRequest)
}
func (n *automergeNotifier) CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, sender *user_model.User, status *git_model.CommitStatus) {
if status.State.IsSuccess() {
if err := StartPRCheckAndAutoMergeBySHA(ctx, commit.Sha1, repo); err != nil {
log.Error("MergeScheduledPullRequest[repo_id: %d, user_id: %d, sha: %s]: %w", repo.ID, sender.ID, commit.Sha1, err)
}
if !status.State.IsSuccess() {
return
}
pulls, err := pull_service.GetMergeablePullRequestsByHeadCommitID(ctx, repo, commit.Sha1)
if err != nil {
log.Error("GetMergeablePullRequestsByHeadCommitID: %v", err)
return
}
for _, pr := range pulls {
automergequeue.StartAutoMergeCheckByPullHead(ctx, pr)
}
}

View File

@@ -6,7 +6,7 @@ package automergequeue
import (
"context"
"errors"
"fmt"
"strconv"
issues_model "gitea.dev/models/issues"
"gitea.dev/modules/git"
@@ -14,21 +14,23 @@ import (
"gitea.dev/modules/queue"
)
var AutoMergeQueue *queue.WorkerPoolQueue[string]
// AutoMergeItem is for the unique queue, so the item type can't be JSON which doesn't have deterministic key order.
// Since the queue is a unique queue, the item must contain commit ID, otherwise the new commit ID will be ignored.
type AutoMergeItem string
var AddToQueue = func(pr *issues_model.PullRequest, sha string) {
log.Trace("Adding pullID: %d to the pull requests patch checking queue with sha %s", pr.ID, sha)
if err := AutoMergeQueue.Push(fmt.Sprintf("%d_%s", pr.ID, sha)); err != nil && !errors.Is(err, queue.ErrAlreadyInQueue) {
log.Error("Error adding pullID: %d to the pull requests patch checking queue %v", pr.ID, err)
var AutoMergeQueue *queue.WorkerPoolQueue[AutoMergeItem]
var AddToQueue = func(item AutoMergeItem) {
if err := AutoMergeQueue.Push(item); err != nil && !errors.Is(err, queue.ErrAlreadyInQueue) {
log.Error("Error adding %v to the automerge queue: %v", item, err)
}
}
// StartPRCheckAndAutoMerge start an automerge check and auto merge task for a pull request
func StartPRCheckAndAutoMerge(ctx context.Context, pull *issues_model.PullRequest) {
if pull == nil || pull.HasMerged || !pull.IsStatusMergeable() {
return
}
func StartAutoMergeCheckByPullCommit(pullID int64, commitID string) {
AddToQueue(AutoMergeItem("pr:" + strconv.FormatInt(pullID, 10) + ":" + commitID))
}
func StartAutoMergeCheckByPullHead(ctx context.Context, pull *issues_model.PullRequest) {
if err := pull.LoadBaseRepo(ctx); err != nil {
log.Error("LoadBaseRepo: %v", err)
return
@@ -40,11 +42,11 @@ func StartPRCheckAndAutoMerge(ctx context.Context, pull *issues_model.PullReques
return
}
defer gitRepo.Close()
commitID, err := gitRepo.GetRefCommitID(ctx, pull.GetGitHeadRefName())
if err != nil {
log.Error("GetRefCommitID: %v", err)
return
}
AddToQueue(pull, commitID)
StartAutoMergeCheckByPullCommit(pull.ID, commitID)
}

View File

@@ -348,8 +348,8 @@ func loadPullRequestsForRun(ctx context.Context, run *actions_model.ActionRun) (
var prs issues_model.PullRequestList
switch {
case run.Event.IsPullRequest() || run.Event.IsPullRequestReview():
index, err := strconv.ParseInt(refName.PullName(), 10, 64)
if err != nil {
index, ok := refName.PullIndex()
if !ok {
return result, nil
}
pr, err := issues_model.GetPullRequestByIndex(ctx, run.RepoID, index)

View File

@@ -297,7 +297,7 @@ func markPullRequestAsMergeable(ctx context.Context, pr *issues_model.PullReques
} else if !exist {
return
}
automergequeue.StartPRCheckAndAutoMerge(ctx, pr)
automergequeue.StartAutoMergeCheckByPullHead(ctx, pr)
}
// getMergeCommit checks if a pull request has been merged

View File

@@ -117,10 +117,9 @@ func TestMarkPullRequestAsMergeable(t *testing.T) {
prPatchCheckerQueue = nil
}()
addToQueueShaChan := make(chan string, 1)
defer test.MockVariableValue(&automergequeue.AddToQueue, func(pr *issues_model.PullRequest, sha string) {
addToQueueShaChan <- sha
})()
addToQueuePullChan := make(chan automergequeue.AutoMergeItem, 1)
defer test.MockVariableValue(&automergequeue.AddToQueue, func(item automergequeue.AutoMergeItem) { addToQueuePullChan <- item })()
ctx := t.Context()
_, _ = db.GetEngine(ctx).ID(2).Update(&issues_model.PullRequest{Status: issues_model.PullRequestStatusChecking})
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
@@ -140,8 +139,8 @@ func TestMarkPullRequestAsMergeable(t *testing.T) {
require.Equal(t, issues_model.PullRequestStatusMergeable, pr.Status)
select {
case sha := <-addToQueueShaChan:
assert.Equal(t, "985f0301dba5e7b34be866819cd15ad3d8f508ee", sha) // ref: refs/pull/3/head
case item := <-addToQueuePullChan:
assert.EqualValues(t, "pr:2:985f0301dba5e7b34be866819cd15ad3d8f508ee", item) // ref: refs/pull/3/head
case <-time.After(1 * time.Second):
assert.FailNow(t, "Timeout: nothing was added to automergequeue")
}

55
services/pull/ref.go Normal file
View File

@@ -0,0 +1,55 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pull
import (
"context"
"errors"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/git"
"gitea.dev/modules/util"
)
func EnumPullRequestsByHeadCommitID(ctx context.Context, repo *repo_model.Repository, commitID string, filter func(*issues_model.PullRequest) bool) (pulls []*issues_model.PullRequest, _ error) {
gitRepo, err := git.OpenRepository(ctx, repo)
if err != nil {
return nil, err
}
defer gitRepo.Close()
refs, err := gitRepo.GetRefsBySha(ctx, commitID, git.PullPrefix)
if err != nil {
return nil, err
}
for _, refStr := range refs {
ref := git.RefName(refStr)
prIndex, ok := ref.PullIndex()
if !ok {
continue
}
pull, err := issues_model.GetPullRequestByIndex(ctx, repo.ID, prIndex)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
continue // ignore non-existing pull requests
}
return nil, err
}
if filter(pull) {
pulls = append(pulls, pull)
}
}
return pulls, nil
}
func GetMergeablePullRequestsByHeadCommitID(ctx context.Context, repo *repo_model.Repository, commitID string) ([]*issues_model.PullRequest, error) {
return EnumPullRequestsByHeadCommitID(ctx, repo, commitID, func(pr *issues_model.PullRequest) bool {
_ = pr.LoadIssue(ctx)
return pr.Issue != nil && !pr.Issue.IsClosed && !pr.HasMerged && pr.IsStatusMergeable()
})
}

22
services/pull/ref_test.go Normal file
View File

@@ -0,0 +1,22 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pull_test
import (
"testing"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/services/pull"
"github.com/stretchr/testify/assert"
)
func TestGetMergeablePullRequestsByHeadCommitID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
pulls, err := pull.GetMergeablePullRequestsByHeadCommitID(t.Context(), repo1, "985f0301dba5e7b34be866819cd15ad3d8f508ee")
assert.NoError(t, err)
assert.Len(t, pulls, 1)
}

View File

@@ -808,22 +808,20 @@ func TestPullAutoMergeAfterCommitStatusSucceed(t *testing.T) {
})
session.MakeRequest(t, req, http.StatusSeeOther)
oldAutoMergeAddToQueue := automergequeue.AddToQueue
addToQueueShaChan := make(chan string, 1)
automergequeue.AddToQueue = func(pr *issues_model.PullRequest, sha string) {
addToQueueShaChan <- sha
}
addToQueuePullChan := make(chan automergequeue.AutoMergeItem, 1)
resetAutoMergeQueueMock := test.MockVariableValue(&automergequeue.AddToQueue, func(item automergequeue.AutoMergeItem) { addToQueuePullChan <- item })
// first time insert automerge record, return true
scheduled, err := automerge.ScheduleAutoMerge(t.Context(), user1, pr, repo_model.MergeStyleMerge, "auto merge test", false)
assert.NoError(t, err)
assert.True(t, scheduled)
// and the pr should be added to automergequeue, in case it is already "mergeable"
select {
case <-addToQueueShaChan:
case <-addToQueuePullChan:
case <-time.After(time.Second):
assert.FailNow(t, "Timeout: nothing was added to automergequeue")
}
automergequeue.AddToQueue = oldAutoMergeAddToQueue
resetAutoMergeQueueMock()
// second time insert automerge record, return false because it does exist
scheduled, err = automerge.ScheduleAutoMerge(t.Context(), user1, pr, repo_model.MergeStyleMerge, "auto merge test", false)