fix: do not ignore automerge while a PR is checking for conflicts (#8189)
Some checks are pending
Integration tests for the release process / release-simulation (push) Waiting to run
/ release (push) Waiting to run
testing-integration / test-unit (push) Has been skipped
testing-integration / test-sqlite (push) Has been skipped
testing / backend-checks (push) Has been skipped
testing / frontend-checks (push) Has been skipped
testing / test-unit (push) Has been skipped
testing / test-e2e (push) Has been skipped
testing / test-mysql (push) Has been skipped
testing / test-pgsql (push) Has been skipped
testing / test-sqlite (push) Has been skipped
testing / test-remote-cacher (redis) (push) Has been skipped
testing / test-remote-cacher (valkey) (push) Has been skipped
testing / test-remote-cacher (garnet) (push) Has been skipped
testing / test-remote-cacher (redict) (push) Has been skipped
testing / security-check (push) Has been skipped

Automerge can be ignored when the following race happens:

* Conflict check is happening on a repository and
  `pr.Status = issues_model.PullRequestStatusChecking` for all open pull
  requests (this happens every time a pull request is merged).
* While the conflict check is ongoing, an event (Forgejo Actions being
  successful for instance) happens and and `StartPRCheckAndAutoMerge*` is called.
* Because `pr.CanAutoMerge()` is false, the pull request is not
  selected and not added to the automerge queue.
* When the conflict check completes and `pr.CanAutoMerge()` becomes
  true, there no longer is a task in the auto merge queue and the
  auto merge does not happen.

This is fixed by adding a task to the auto merge queue when the conflict check for a pull request completes. This is done when the mutx protecting the conflict check task is released to prevent a deadlock when a synchronous queues are used in the following situation:

* the conflict check task finds the pull request is mergeable
* it schedules the auto merge tasks that finds it must be merged
* merging concludes with scheduling a conflict check task

Avoid an extra loop where a conflict check task queues an auto merge task that will schedule a conflict check task if the pull request can be merged. The auto merge row is removed from the database before merging. It would otherwise be removed after the merge commit is received via the git hook which happens asynchronously and can lead to a race.

StartPRCheckAndAutoMerge is modified to re-use HeadCommitID when available, such as when called after a pull request conflict check.

---

A note on tests: they cover the new behavior, i.e. automerge being triggered by a successful conflict check. This is also on the critical paths for every test that involve creating, merging or updating a pull request.

- `tests/integration/git_test.go`
- `tests/integration/actions_commit_status_test.go`
- `tests/integration/api_helper_for_declarative_test.go`
- `tests/integration/patch_status_test.go`
- `tests/integration/pull_merge_test.go`

The [missing fixture file](https://codeberg.org/forgejo/forgejo/pulls/8189/files#diff-b86fdd79108b3ba3cb2e56ffcfd1be2a7b32f46c) for the auto merge table can be verified to be necessary simply by removing it an observing that the integration tests fail.

The [scheduling of the auto merge task](https://codeberg.org/forgejo/forgejo/pulls/8189/files#diff-9489262e93967f6bb2db41837f37c06f4e70d978) in `testPR` can be verified to be required by moving it in the `testPRProtected` function and observing that the tests hang forever because of the deadlock.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
  - [ ] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] I do not want this change to show in the release notes.
- [x] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Bug fixes
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/8189): <!--number 8189 --><!--line 0 --><!--description ZG8gbm90IGlnbm9yZSBhdXRvbWVyZ2Ugd2hpbGUgYSBQUiBpcyBjaGVja2luZyBmb3IgY29uZmxpY3Rz-->do not ignore automerge while a PR is checking for conflicts<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8189
Reviewed-by: Michael Kriese <michael.kriese@gmx.de>
Reviewed-by: Lucas <sclu1034@noreply.codeberg.org>
Co-authored-by: Earl Warren <contact@earl-warren.org>
Co-committed-by: Earl Warren <contact@earl-warren.org>
This commit is contained in:
Earl Warren 2025-06-17 10:58:07 +02:00 committed by Earl Warren
parent 16dbc0efd3
commit adc273e3a8
7 changed files with 94 additions and 29 deletions

View file

@ -0,0 +1 @@
[] # empty

View file

@ -10,6 +10,7 @@ import (
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
user_model "forgejo.org/models/user"
"forgejo.org/modules/log"
"forgejo.org/modules/timeutil"
)
@ -58,13 +59,15 @@ func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pullID int64,
return ErrAlreadyScheduledToAutoMerge{PullID: pullID}
}
_, err := db.GetEngine(ctx).Insert(&AutoMerge{
scheduledPRM, err := db.GetEngine(ctx).Insert(&AutoMerge{
DoerID: doer.ID,
PullID: pullID,
MergeStyle: style,
Message: message,
DeleteBranchAfterMerge: deleteBranch,
})
log.Trace("ScheduleAutoMerge %+v for PR %d", scheduledPRM, pullID)
return err
}
@ -81,6 +84,8 @@ func GetScheduledMergeByPullID(ctx context.Context, pullID int64) (bool, *AutoMe
return false, nil, err
}
log.Trace("GetScheduledMergeByPullID found %+v for PR %d", scheduledPRM, pullID)
scheduledPRM.Doer = doer
return true, scheduledPRM, nil
}
@ -94,6 +99,8 @@ func DeleteScheduledAutoMerge(ctx context.Context, pullID int64) error {
return db.ErrNotExist{Resource: "auto_merge", ID: pullID}
}
log.Trace("DeleteScheduledAutoMerge %+v for PR %d", scheduledPRM, pullID)
_, err = db.GetEngine(ctx).ID(scheduledPRM.ID).Delete(&AutoMerge{})
return err
}

View file

@ -107,6 +107,7 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
return
}
if !exists {
log.Trace("GetScheduledMergeByPullID found nothing for PR %d", pullID)
return
}
@ -204,6 +205,10 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
return
}
if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) {
log.Error("DeleteScheduledAutoMerge[%d]: %v", pr.ID, err)
}
if err := pull_service.Merge(ctx, pr, doer, baseGitRepo, 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.

View file

@ -28,6 +28,7 @@ import (
"forgejo.org/modules/timeutil"
asymkey_service "forgejo.org/services/asymkey"
notify_service "forgejo.org/services/notify"
shared_automerge "forgejo.org/services/shared/automerge"
)
// prPatchCheckerQueue represents a queue to handle update pull request tests
@ -170,7 +171,7 @@ func isSignedIfRequired(ctx context.Context, pr *issues_model.PullRequest, doer
// checkAndUpdateStatus checks if pull request is possible to leaving checking status,
// and set to be either conflict or mergeable.
func checkAndUpdateStatus(ctx context.Context, pr *issues_model.PullRequest) {
func checkAndUpdateStatus(ctx context.Context, pr *issues_model.PullRequest) bool {
// If status has not been changed to conflict by testPatch then we are mergeable
if pr.Status == issues_model.PullRequestStatusChecking {
pr.Status = issues_model.PullRequestStatusMergeable
@ -184,12 +185,15 @@ func checkAndUpdateStatus(ctx context.Context, pr *issues_model.PullRequest) {
if has {
log.Trace("Not updating status for %-v as it is due to be rechecked", pr)
return
return false
}
if err := pr.UpdateColsIfNotMerged(ctx, "merge_base", "status", "conflicted_files", "changed_protected_files"); err != nil {
log.Error("Update[%-v]: %v", pr, err)
return false
}
return true
}
// getMergeCommit checks if a pull request has been merged
@ -339,15 +343,22 @@ func handler(items ...string) []string {
}
func testPR(id int64) {
pullWorkingPool.CheckIn(fmt.Sprint(id))
defer pullWorkingPool.CheckOut(fmt.Sprint(id))
ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("Test PR[%d] from patch checking queue", id))
defer finished()
if pr, updated := testPRProtected(ctx, id); pr != nil && updated {
shared_automerge.AddToQueueIfMergeable(ctx, pr)
}
}
func testPRProtected(ctx context.Context, id int64) (*issues_model.PullRequest, bool) {
pullWorkingPool.CheckIn(fmt.Sprint(id))
defer pullWorkingPool.CheckOut(fmt.Sprint(id))
pr, err := issues_model.GetPullRequestByID(ctx, id)
if err != nil {
log.Error("Unable to GetPullRequestByID[%d] for testPR: %v", id, err)
return
return nil, false
}
log.Trace("Testing %-v", pr)
@ -357,12 +368,12 @@ func testPR(id int64) {
if pr.HasMerged {
log.Trace("%-v is already merged (status: %s, merge commit: %s)", pr, pr.Status, pr.MergedCommitID)
return
return nil, false
}
if manuallyMerged(ctx, pr) {
log.Trace("%-v is manually merged (status: %s, merge commit: %s)", pr, pr.Status, pr.MergedCommitID)
return
return nil, false
}
if err := TestPatch(pr); err != nil {
@ -371,9 +382,10 @@ func testPR(id int64) {
if err := pr.UpdateCols(ctx, "status"); err != nil {
log.Error("update pr [%-v] status to PullRequestStatusError failed: %v", pr, err)
}
return
return nil, false
}
checkAndUpdateStatus(ctx, pr)
return pr, checkAndUpdateStatus(ctx, pr)
}
// CheckPRsForBaseBranch check all pulls with baseBrannch

View file

@ -21,9 +21,9 @@ import (
var PRAutoMergeQueue *queue.WorkerPoolQueue[string]
func addToQueue(pr *issues_model.PullRequest, sha string) {
log.Trace("Adding pullID: %d to the pull requests patch checking queue with sha %s", pr.ID, sha)
log.Trace("Adding pullID: %d to the automerge queue with sha %s", pr.ID, sha)
if err := PRAutoMergeQueue.Push(fmt.Sprintf("%d_%s", pr.ID, sha)); err != nil {
log.Error("Error adding pullID: %d to the pull requests patch checking queue %v", pr.ID, err)
log.Error("Error adding pullID: %d to the automerge queue %v", pr.ID, err)
}
}
@ -43,32 +43,29 @@ func StartPRCheckAndAutoMergeBySHA(ctx context.Context, sha string, repo *repo_m
return nil
}
// 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.CanAutoMerge() {
return
}
if err := pull.LoadBaseRepo(ctx); err != nil {
log.Error("LoadBaseRepo: %v", err)
return
commitID := pull.HeadCommitID
if commitID == "" {
commitID = getCommitIDFromRefName(ctx, pull)
}
gitRepo, err := gitrepo.OpenRepository(ctx, pull.BaseRepo)
if err != nil {
log.Error("OpenRepository: %v", err)
return
}
defer gitRepo.Close()
commitID, err := gitRepo.GetRefCommitID(pull.GetGitRefName())
if err != nil {
log.Error("GetRefCommitID: %v", err)
if commitID == "" {
return
}
addToQueue(pull, commitID)
}
var AddToQueueIfMergeable = func(ctx context.Context, pull *issues_model.PullRequest) {
if pull.Status == issues_model.PullRequestStatusMergeable {
StartPRCheckAndAutoMerge(ctx, pull)
}
}
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 := gitrepo.OpenRepository(ctx, repo)
if err != nil {
@ -118,3 +115,24 @@ func getPullRequestsByHeadSHA(ctx context.Context, sha string, repo *repo_model.
return pulls, nil
}
func getCommitIDFromRefName(ctx context.Context, pull *issues_model.PullRequest) string {
if err := pull.LoadBaseRepo(ctx); err != nil {
log.Error("LoadBaseRepo: %v", err)
return ""
}
gitRepo, err := gitrepo.OpenRepository(ctx, pull.BaseRepo)
if err != nil {
log.Error("OpenRepository: %v", err)
return ""
}
defer gitRepo.Close()
commitID, err := gitRepo.GetRefCommitID(pull.GetGitRefName())
if err != nil {
log.Error("GetRefCommitID: %v", err)
return ""
}
return commitID
}

View file

@ -71,11 +71,11 @@ TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test?multiStatements=true TEST_
### Run pgsql integration tests
Setup a pgsql database inside docker
```
docker run -e "POSTGRES_DB=test" -e POSTGRES_PASSWORD=postgres -p 5432:5432 --rm --name pgsql postgres:latest #(Ctrl-c to stop the database)
docker run -e "POSTGRES_DB=test" -e POSTGRES_PASSWORD=postgres POSTGRESQL_FSYNC=off POSTGRESQL_EXTRA_FLAGS="-c full_page_writes=off" -p 5432:5432 --rm --name pgsql data.forgejo.org/oci/bitnami/postgresql:16 #(Ctrl-c to stop the database)
```
Start tests based on the database container
```
TEST_STORAGE_TYPE=local TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=test TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-pgsql
TEST_STORAGE_TYPE=local TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=test TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make 'test-pgsql#Test'
```
### Running individual tests

View file

@ -4,6 +4,7 @@
package integration
import (
"context"
"fmt"
"net/http"
"net/url"
@ -20,7 +21,10 @@ import (
user_model "forgejo.org/models/user"
"forgejo.org/modules/git"
"forgejo.org/modules/optional"
"forgejo.org/modules/test"
pull_service "forgejo.org/services/pull"
files_service "forgejo.org/services/repository/files"
shared_automerge "forgejo.org/services/shared/automerge"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
@ -51,6 +55,20 @@ func TestPatchStatus(t *testing.T) {
})
defer f()
testAutomergeQueued := func(t *testing.T, pr *issues_model.PullRequest, expected issues_model.PullRequestStatus) {
t.Helper()
var actual issues_model.PullRequestStatus = -1
defer test.MockVariableValue(&shared_automerge.AddToQueueIfMergeable, func(ctx context.Context, pull *issues_model.PullRequest) {
actual = pull.Status
})()
pull_service.AddToTaskQueue(t.Context(), pr)
assert.Eventually(t, func() bool {
return expected == actual
}, time.Second*5, time.Millisecond*200)
}
testRepoFork(t, session, "user2", repo.Name, "org3", "forked-repo")
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "org3", Name: "forked-repo"})
@ -91,7 +109,9 @@ func TestPatchStatus(t *testing.T) {
require.NoError(t, git.NewCommand(t.Context(), "push", "fork", "HEAD:normal").Run(&git.RunOpts{Dir: dstPath}))
testPullCreateDirectly(t, session, repo.OwnerName, repo.Name, repo.DefaultBranch, forkRepo.OwnerName, forkRepo.Name, "normal", "across repo normal")
test(t, unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: forkRepo.ID, HeadBranch: "normal"}, "flow = 0"))
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: forkRepo.ID, HeadBranch: "normal"}, "flow = 0")
test(t, pr)
testAutomergeQueued(t, pr, issues_model.PullRequestStatusMergeable)
})
t.Run("Same repository", func(t *testing.T) {
@ -144,7 +164,9 @@ func TestPatchStatus(t *testing.T) {
t.Run("Existing", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
test(t, unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: forkRepo.ID, HeadBranch: "normal"}, "flow = 0"))
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: forkRepo.ID, HeadBranch: "normal"}, "flow = 0")
test(t, pr)
testAutomergeQueued(t, pr, issues_model.PullRequestStatusConflict)
})
t.Run("New", func(t *testing.T) {