Skip to content

Commit adc273e

Browse files
earl-warrenEarl Warren
authored andcommitted
fix: do not ignore automerge while a PR is checking for conflicts (#8189)
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 <[email protected]> Reviewed-by: Lucas <[email protected]> Co-authored-by: Earl Warren <[email protected]> Co-committed-by: Earl Warren <[email protected]>
1 parent 16dbc0e commit adc273e

File tree

7 files changed

+94
-29
lines changed

7 files changed

+94
-29
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[] # empty

models/pull/automerge.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"forgejo.org/models/db"
1111
repo_model "forgejo.org/models/repo"
1212
user_model "forgejo.org/models/user"
13+
"forgejo.org/modules/log"
1314
"forgejo.org/modules/timeutil"
1415
)
1516

@@ -58,13 +59,15 @@ func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pullID int64,
5859
return ErrAlreadyScheduledToAutoMerge{PullID: pullID}
5960
}
6061

61-
_, err := db.GetEngine(ctx).Insert(&AutoMerge{
62+
scheduledPRM, err := db.GetEngine(ctx).Insert(&AutoMerge{
6263
DoerID: doer.ID,
6364
PullID: pullID,
6465
MergeStyle: style,
6566
Message: message,
6667
DeleteBranchAfterMerge: deleteBranch,
6768
})
69+
log.Trace("ScheduleAutoMerge %+v for PR %d", scheduledPRM, pullID)
70+
6871
return err
6972
}
7073

@@ -81,6 +84,8 @@ func GetScheduledMergeByPullID(ctx context.Context, pullID int64) (bool, *AutoMe
8184
return false, nil, err
8285
}
8386

87+
log.Trace("GetScheduledMergeByPullID found %+v for PR %d", scheduledPRM, pullID)
88+
8489
scheduledPRM.Doer = doer
8590
return true, scheduledPRM, nil
8691
}
@@ -94,6 +99,8 @@ func DeleteScheduledAutoMerge(ctx context.Context, pullID int64) error {
9499
return db.ErrNotExist{Resource: "auto_merge", ID: pullID}
95100
}
96101

102+
log.Trace("DeleteScheduledAutoMerge %+v for PR %d", scheduledPRM, pullID)
103+
97104
_, err = db.GetEngine(ctx).ID(scheduledPRM.ID).Delete(&AutoMerge{})
98105
return err
99106
}

services/automerge/automerge.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
107107
return
108108
}
109109
if !exists {
110+
log.Trace("GetScheduledMergeByPullID found nothing for PR %d", pullID)
110111
return
111112
}
112113

@@ -204,6 +205,10 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
204205
return
205206
}
206207

208+
if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) {
209+
log.Error("DeleteScheduledAutoMerge[%d]: %v", pr.ID, err)
210+
}
211+
207212
if err := pull_service.Merge(ctx, pr, doer, baseGitRepo, scheduledPRM.MergeStyle, "", scheduledPRM.Message, true); err != nil {
208213
log.Error("pull_service.Merge: %v", err)
209214
// FIXME: if merge failed, we should display some error message to the pull request page.

services/pull/check.go

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"forgejo.org/modules/timeutil"
2929
asymkey_service "forgejo.org/services/asymkey"
3030
notify_service "forgejo.org/services/notify"
31+
shared_automerge "forgejo.org/services/shared/automerge"
3132
)
3233

3334
// prPatchCheckerQueue represents a queue to handle update pull request tests
@@ -170,7 +171,7 @@ func isSignedIfRequired(ctx context.Context, pr *issues_model.PullRequest, doer
170171

171172
// checkAndUpdateStatus checks if pull request is possible to leaving checking status,
172173
// and set to be either conflict or mergeable.
173-
func checkAndUpdateStatus(ctx context.Context, pr *issues_model.PullRequest) {
174+
func checkAndUpdateStatus(ctx context.Context, pr *issues_model.PullRequest) bool {
174175
// If status has not been changed to conflict by testPatch then we are mergeable
175176
if pr.Status == issues_model.PullRequestStatusChecking {
176177
pr.Status = issues_model.PullRequestStatusMergeable
@@ -184,12 +185,15 @@ func checkAndUpdateStatus(ctx context.Context, pr *issues_model.PullRequest) {
184185

185186
if has {
186187
log.Trace("Not updating status for %-v as it is due to be rechecked", pr)
187-
return
188+
return false
188189
}
189190

190191
if err := pr.UpdateColsIfNotMerged(ctx, "merge_base", "status", "conflicted_files", "changed_protected_files"); err != nil {
191192
log.Error("Update[%-v]: %v", pr, err)
193+
return false
192194
}
195+
196+
return true
193197
}
194198

195199
// getMergeCommit checks if a pull request has been merged
@@ -339,15 +343,22 @@ func handler(items ...string) []string {
339343
}
340344

341345
func testPR(id int64) {
342-
pullWorkingPool.CheckIn(fmt.Sprint(id))
343-
defer pullWorkingPool.CheckOut(fmt.Sprint(id))
344346
ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("Test PR[%d] from patch checking queue", id))
345347
defer finished()
346348

349+
if pr, updated := testPRProtected(ctx, id); pr != nil && updated {
350+
shared_automerge.AddToQueueIfMergeable(ctx, pr)
351+
}
352+
}
353+
354+
func testPRProtected(ctx context.Context, id int64) (*issues_model.PullRequest, bool) {
355+
pullWorkingPool.CheckIn(fmt.Sprint(id))
356+
defer pullWorkingPool.CheckOut(fmt.Sprint(id))
357+
347358
pr, err := issues_model.GetPullRequestByID(ctx, id)
348359
if err != nil {
349360
log.Error("Unable to GetPullRequestByID[%d] for testPR: %v", id, err)
350-
return
361+
return nil, false
351362
}
352363

353364
log.Trace("Testing %-v", pr)
@@ -357,12 +368,12 @@ func testPR(id int64) {
357368

358369
if pr.HasMerged {
359370
log.Trace("%-v is already merged (status: %s, merge commit: %s)", pr, pr.Status, pr.MergedCommitID)
360-
return
371+
return nil, false
361372
}
362373

363374
if manuallyMerged(ctx, pr) {
364375
log.Trace("%-v is manually merged (status: %s, merge commit: %s)", pr, pr.Status, pr.MergedCommitID)
365-
return
376+
return nil, false
366377
}
367378

368379
if err := TestPatch(pr); err != nil {
@@ -371,9 +382,10 @@ func testPR(id int64) {
371382
if err := pr.UpdateCols(ctx, "status"); err != nil {
372383
log.Error("update pr [%-v] status to PullRequestStatusError failed: %v", pr, err)
373384
}
374-
return
385+
return nil, false
375386
}
376-
checkAndUpdateStatus(ctx, pr)
387+
388+
return pr, checkAndUpdateStatus(ctx, pr)
377389
}
378390

379391
// CheckPRsForBaseBranch check all pulls with baseBrannch

services/shared/automerge/automerge.go

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ import (
2121
var PRAutoMergeQueue *queue.WorkerPoolQueue[string]
2222

2323
func addToQueue(pr *issues_model.PullRequest, sha string) {
24-
log.Trace("Adding pullID: %d to the pull requests patch checking queue with sha %s", pr.ID, sha)
24+
log.Trace("Adding pullID: %d to the automerge queue with sha %s", pr.ID, sha)
2525
if err := PRAutoMergeQueue.Push(fmt.Sprintf("%d_%s", pr.ID, sha)); err != nil {
26-
log.Error("Error adding pullID: %d to the pull requests patch checking queue %v", pr.ID, err)
26+
log.Error("Error adding pullID: %d to the automerge queue %v", pr.ID, err)
2727
}
2828
}
2929

@@ -43,32 +43,29 @@ func StartPRCheckAndAutoMergeBySHA(ctx context.Context, sha string, repo *repo_m
4343
return nil
4444
}
4545

46-
// StartPRCheckAndAutoMerge start an automerge check and auto merge task for a pull request
4746
func StartPRCheckAndAutoMerge(ctx context.Context, pull *issues_model.PullRequest) {
4847
if pull == nil || pull.HasMerged || !pull.CanAutoMerge() {
4948
return
5049
}
5150

52-
if err := pull.LoadBaseRepo(ctx); err != nil {
53-
log.Error("LoadBaseRepo: %v", err)
54-
return
51+
commitID := pull.HeadCommitID
52+
if commitID == "" {
53+
commitID = getCommitIDFromRefName(ctx, pull)
5554
}
5655

57-
gitRepo, err := gitrepo.OpenRepository(ctx, pull.BaseRepo)
58-
if err != nil {
59-
log.Error("OpenRepository: %v", err)
60-
return
61-
}
62-
defer gitRepo.Close()
63-
commitID, err := gitRepo.GetRefCommitID(pull.GetGitRefName())
64-
if err != nil {
65-
log.Error("GetRefCommitID: %v", err)
56+
if commitID == "" {
6657
return
6758
}
6859

6960
addToQueue(pull, commitID)
7061
}
7162

63+
var AddToQueueIfMergeable = func(ctx context.Context, pull *issues_model.PullRequest) {
64+
if pull.Status == issues_model.PullRequestStatusMergeable {
65+
StartPRCheckAndAutoMerge(ctx, pull)
66+
}
67+
}
68+
7269
func getPullRequestsByHeadSHA(ctx context.Context, sha string, repo *repo_model.Repository, filter func(*issues_model.PullRequest) bool) (map[int64]*issues_model.PullRequest, error) {
7370
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
7471
if err != nil {
@@ -118,3 +115,24 @@ func getPullRequestsByHeadSHA(ctx context.Context, sha string, repo *repo_model.
118115

119116
return pulls, nil
120117
}
118+
119+
func getCommitIDFromRefName(ctx context.Context, pull *issues_model.PullRequest) string {
120+
if err := pull.LoadBaseRepo(ctx); err != nil {
121+
log.Error("LoadBaseRepo: %v", err)
122+
return ""
123+
}
124+
125+
gitRepo, err := gitrepo.OpenRepository(ctx, pull.BaseRepo)
126+
if err != nil {
127+
log.Error("OpenRepository: %v", err)
128+
return ""
129+
}
130+
defer gitRepo.Close()
131+
commitID, err := gitRepo.GetRefCommitID(pull.GetGitRefName())
132+
if err != nil {
133+
log.Error("GetRefCommitID: %v", err)
134+
return ""
135+
}
136+
137+
return commitID
138+
}

tests/integration/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,11 @@ TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test?multiStatements=true TEST_
7171
### Run pgsql integration tests
7272
Setup a pgsql database inside docker
7373
```
74-
docker run -e "POSTGRES_DB=test" -e POSTGRES_PASSWORD=postgres -p 5432:5432 --rm --name pgsql postgres:latest #(Ctrl-c to stop the database)
74+
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)
7575
```
7676
Start tests based on the database container
7777
```
78-
TEST_STORAGE_TYPE=local TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=test TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-pgsql
78+
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'
7979
```
8080

8181
### Running individual tests

tests/integration/patch_status_test.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package integration
55

66
import (
7+
"context"
78
"fmt"
89
"net/http"
910
"net/url"
@@ -20,7 +21,10 @@ import (
2021
user_model "forgejo.org/models/user"
2122
"forgejo.org/modules/git"
2223
"forgejo.org/modules/optional"
24+
"forgejo.org/modules/test"
25+
pull_service "forgejo.org/services/pull"
2326
files_service "forgejo.org/services/repository/files"
27+
shared_automerge "forgejo.org/services/shared/automerge"
2428
"forgejo.org/tests"
2529

2630
"github.com/stretchr/testify/assert"
@@ -51,6 +55,20 @@ func TestPatchStatus(t *testing.T) {
5155
})
5256
defer f()
5357

58+
testAutomergeQueued := func(t *testing.T, pr *issues_model.PullRequest, expected issues_model.PullRequestStatus) {
59+
t.Helper()
60+
61+
var actual issues_model.PullRequestStatus = -1
62+
defer test.MockVariableValue(&shared_automerge.AddToQueueIfMergeable, func(ctx context.Context, pull *issues_model.PullRequest) {
63+
actual = pull.Status
64+
})()
65+
66+
pull_service.AddToTaskQueue(t.Context(), pr)
67+
assert.Eventually(t, func() bool {
68+
return expected == actual
69+
}, time.Second*5, time.Millisecond*200)
70+
}
71+
5472
testRepoFork(t, session, "user2", repo.Name, "org3", "forked-repo")
5573
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "org3", Name: "forked-repo"})
5674

@@ -91,7 +109,9 @@ func TestPatchStatus(t *testing.T) {
91109
require.NoError(t, git.NewCommand(t.Context(), "push", "fork", "HEAD:normal").Run(&git.RunOpts{Dir: dstPath}))
92110
testPullCreateDirectly(t, session, repo.OwnerName, repo.Name, repo.DefaultBranch, forkRepo.OwnerName, forkRepo.Name, "normal", "across repo normal")
93111

94-
test(t, unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: forkRepo.ID, HeadBranch: "normal"}, "flow = 0"))
112+
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: forkRepo.ID, HeadBranch: "normal"}, "flow = 0")
113+
test(t, pr)
114+
testAutomergeQueued(t, pr, issues_model.PullRequestStatusMergeable)
95115
})
96116

97117
t.Run("Same repository", func(t *testing.T) {
@@ -144,7 +164,9 @@ func TestPatchStatus(t *testing.T) {
144164
t.Run("Existing", func(t *testing.T) {
145165
defer tests.PrintCurrentTest(t)()
146166

147-
test(t, unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: forkRepo.ID, HeadBranch: "normal"}, "flow = 0"))
167+
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: forkRepo.ID, HeadBranch: "normal"}, "flow = 0")
168+
test(t, pr)
169+
testAutomergeQueued(t, pr, issues_model.PullRequestStatusConflict)
148170
})
149171

150172
t.Run("New", func(t *testing.T) {

0 commit comments

Comments
 (0)