diff --git a/models/actions/run.go b/models/actions/run.go index 976cd5e0355..ff91d36d004 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -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() } diff --git a/models/issues/pull.go b/models/issues/pull.go index f7a62a0bf5a..6121b39b89c 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -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) diff --git a/models/pull/automerge.go b/models/pull/automerge.go index 5415009fdea..3a6514f6cf0 100644 --- a/models/pull/automerge.go +++ b/models/pull/automerge.go @@ -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) diff --git a/modules/git/ref.go b/modules/git/ref.go index b3cbe8fffb3..eaed84e2b51 100644 --- a/modules/git/ref.go +++ b/modules/git/ref.go @@ -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//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/ @@ -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() diff --git a/modules/git/ref_test.go b/modules/git/ref_test.go index 5016eea9d57..5e87abdfbef 100644 --- a/modules/git/ref_test.go +++ b/modules/git/ref_test.go @@ -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()) diff --git a/routers/init.go b/routers/init.go index 483b61726ac..c4e415893b2 100644 --- a/routers/init.go +++ b/routers/init.go @@ -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) diff --git a/services/automerge/automerge.go b/services/automerge/automerge.go index f9ede5795c4..85e11ec683d 100644 --- a/services/automerge/automerge.go +++ b/services/automerge/automerge.go @@ -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 } diff --git a/services/automerge/notify.go b/services/automerge/notify.go index 04526b2adc0..02ca025f60b 100644 --- a/services/automerge/notify.go +++ b/services/automerge/notify.go @@ -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) } } diff --git a/services/automergequeue/automergequeue.go b/services/automergequeue/automergequeue.go index 22fca942202..f0b99cd45d4 100644 --- a/services/automergequeue/automergequeue.go +++ b/services/automergequeue/automergequeue.go @@ -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) } diff --git a/services/convert/convert.go b/services/convert/convert.go index da9bfb5de08..ad7cfcac5c7 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -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) diff --git a/services/pull/check.go b/services/pull/check.go index 9c1aa016770..3dcd6e67f28 100644 --- a/services/pull/check.go +++ b/services/pull/check.go @@ -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 diff --git a/services/pull/check_test.go b/services/pull/check_test.go index ad4ade837b8..4a75b101680 100644 --- a/services/pull/check_test.go +++ b/services/pull/check_test.go @@ -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") } diff --git a/services/pull/ref.go b/services/pull/ref.go new file mode 100644 index 00000000000..b3fd57cb1c3 --- /dev/null +++ b/services/pull/ref.go @@ -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() + }) +} diff --git a/services/pull/ref_test.go b/services/pull/ref_test.go new file mode 100644 index 00000000000..864cb052a55 --- /dev/null +++ b/services/pull/ref_test.go @@ -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) +} diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go index eecff58eaad..30efb48b091 100644 --- a/tests/integration/pull_merge_test.go +++ b/tests/integration/pull_merge_test.go @@ -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)