Skip to content

Commit ec3f5f9

Browse files
GiteaBotlunnywxiaoguang
authored
Move database operations of merging a pull request to post receive hook and add a transaction (#30805) (#30888)
Backport #30805 by @lunny Merging PR may fail because of various problems. The pull request may have a dirty state because there is no transaction when merging a pull request. ref #25741 (comment) This PR moves all database update operations to post-receive handler for merging a pull request and having a database transaction. That means if database operations fail, then the git merging will fail, the git client will get a fail result. There are already many tests for pull request merging, so we don't need to add a new one. Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
1 parent d4c2db3 commit ec3f5f9

File tree

8 files changed

+150
-19
lines changed

8 files changed

+150
-19
lines changed

cmd/hook.go

+3
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ Gitea or set your environment appropriately.`, "")
338338
isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
339339
repoName := os.Getenv(repo_module.EnvRepoName)
340340
pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
341+
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
341342
pusherName := os.Getenv(repo_module.EnvPusherName)
342343

343344
hookOptions := private.HookOptions{
@@ -347,6 +348,8 @@ Gitea or set your environment appropriately.`, "")
347348
GitObjectDirectory: os.Getenv(private.GitObjectDirectory),
348349
GitQuarantinePath: os.Getenv(private.GitQuarantinePath),
349350
GitPushOptions: pushOptions(),
351+
PullRequestID: prID,
352+
PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)),
350353
}
351354
oldCommitIDs := make([]string, hookBatchSize)
352355
newCommitIDs := make([]string, hookBatchSize)

modules/private/hook.go

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"code.gitea.io/gitea/modules/git"
1414
"code.gitea.io/gitea/modules/optional"
15+
"code.gitea.io/gitea/modules/repository"
1516
"code.gitea.io/gitea/modules/setting"
1617
)
1718

@@ -54,6 +55,7 @@ type HookOptions struct {
5455
GitQuarantinePath string
5556
GitPushOptions GitPushOptions
5657
PullRequestID int64
58+
PushTrigger repository.PushTrigger
5759
DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user.
5860
IsWiki bool
5961
ActionPerm int

modules/repository/env.go

+8
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,19 @@ const (
2525
EnvKeyID = "GITEA_KEY_ID" // public key ID
2626
EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID"
2727
EnvPRID = "GITEA_PR_ID"
28+
EnvPushTrigger = "GITEA_PUSH_TRIGGER"
2829
EnvIsInternal = "GITEA_INTERNAL_PUSH"
2930
EnvAppURL = "GITEA_ROOT_URL"
3031
EnvActionPerm = "GITEA_ACTION_PERM"
3132
)
3233

34+
type PushTrigger string
35+
36+
const (
37+
PushTriggerPRMergeToBase PushTrigger = "pr-merge-to-base"
38+
PushTriggerPRUpdateWithBase PushTrigger = "pr-update-with-base"
39+
)
40+
3341
// InternalPushingEnvironment returns an os environment to switch off hooks on push
3442
// It is recommended to avoid using this unless you are pushing within a transaction
3543
// or if you absolutely are sure that post-receive and pre-receive will do nothing

routers/private/hook_post_receive.go

+63-1
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,25 @@
44
package private
55

66
import (
7+
"context"
78
"fmt"
89
"net/http"
910

11+
"code.gitea.io/gitea/models/db"
1012
git_model "code.gitea.io/gitea/models/git"
1113
issues_model "code.gitea.io/gitea/models/issues"
1214
access_model "code.gitea.io/gitea/models/perm/access"
15+
pull_model "code.gitea.io/gitea/models/pull"
1316
repo_model "code.gitea.io/gitea/models/repo"
1417
user_model "code.gitea.io/gitea/models/user"
18+
"code.gitea.io/gitea/modules/cache"
1519
"code.gitea.io/gitea/modules/git"
1620
"code.gitea.io/gitea/modules/gitrepo"
1721
"code.gitea.io/gitea/modules/log"
1822
"code.gitea.io/gitea/modules/private"
1923
repo_module "code.gitea.io/gitea/modules/repository"
2024
"code.gitea.io/gitea/modules/setting"
25+
timeutil "code.gitea.io/gitea/modules/timeutil"
2126
"code.gitea.io/gitea/modules/util"
2227
"code.gitea.io/gitea/modules/web"
2328
gitea_context "code.gitea.io/gitea/services/context"
@@ -160,6 +165,14 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
160165
}
161166
}
162167

168+
// handle pull request merging, a pull request action should push at least 1 commit
169+
if opts.PushTrigger == repo_module.PushTriggerPRMergeToBase {
170+
handlePullRequestMerging(ctx, opts, ownerName, repoName, updates)
171+
if ctx.Written() {
172+
return
173+
}
174+
}
175+
163176
isPrivate := opts.GitPushOptions.Bool(private.GitPushOptionRepoPrivate)
164177
isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate)
165178
// Handle Push Options
@@ -174,7 +187,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
174187
wasEmpty = repo.IsEmpty
175188
}
176189

177-
pusher, err := user_model.GetUserByID(ctx, opts.UserID)
190+
pusher, err := loadContextCacheUser(ctx, opts.UserID)
178191
if err != nil {
179192
log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err)
180193
ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
@@ -309,3 +322,52 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
309322
RepoWasEmpty: wasEmpty,
310323
})
311324
}
325+
326+
func loadContextCacheUser(ctx context.Context, id int64) (*user_model.User, error) {
327+
return cache.GetWithContextCache(ctx, "hook_post_receive_user", id, func() (*user_model.User, error) {
328+
return user_model.GetUserByID(ctx, id)
329+
})
330+
}
331+
332+
// handlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit
333+
func handlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, ownerName, repoName string, updates []*repo_module.PushUpdateOptions) {
334+
if len(updates) == 0 {
335+
ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
336+
Err: fmt.Sprintf("Pushing a merged PR (pr:%d) no commits pushed ", opts.PullRequestID),
337+
})
338+
return
339+
}
340+
341+
pr, err := issues_model.GetPullRequestByID(ctx, opts.PullRequestID)
342+
if err != nil {
343+
log.Error("GetPullRequestByID[%d]: %v", opts.PullRequestID, err)
344+
ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "GetPullRequestByID failed"})
345+
return
346+
}
347+
348+
pusher, err := loadContextCacheUser(ctx, opts.UserID)
349+
if err != nil {
350+
log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err)
351+
ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Load pusher user failed"})
352+
return
353+
}
354+
355+
pr.MergedCommitID = updates[len(updates)-1].NewCommitID
356+
pr.MergedUnix = timeutil.TimeStampNow()
357+
pr.Merger = pusher
358+
pr.MergerID = pusher.ID
359+
err = db.WithTx(ctx, func(ctx context.Context) error {
360+
// Removing an auto merge pull and ignore if not exist
361+
if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) {
362+
return fmt.Errorf("DeleteScheduledAutoMerge[%d]: %v", opts.PullRequestID, err)
363+
}
364+
if _, err := pr.SetMerged(ctx); err != nil {
365+
return fmt.Errorf("SetMerged failed: %s/%s Error: %v", ownerName, repoName, err)
366+
}
367+
return nil
368+
})
369+
if err != nil {
370+
log.Error("Failed to update PR to merged: %v", err)
371+
ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Failed to update PR to merged"})
372+
}
373+
}
+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package private
5+
6+
import (
7+
"testing"
8+
9+
"code.gitea.io/gitea/models/db"
10+
issues_model "code.gitea.io/gitea/models/issues"
11+
pull_model "code.gitea.io/gitea/models/pull"
12+
repo_model "code.gitea.io/gitea/models/repo"
13+
"code.gitea.io/gitea/models/unittest"
14+
user_model "code.gitea.io/gitea/models/user"
15+
"code.gitea.io/gitea/modules/private"
16+
repo_module "code.gitea.io/gitea/modules/repository"
17+
"code.gitea.io/gitea/services/contexttest"
18+
19+
"github.com/stretchr/testify/assert"
20+
)
21+
22+
func TestHandlePullRequestMerging(t *testing.T) {
23+
assert.NoError(t, unittest.PrepareTestDatabase())
24+
pr, err := issues_model.GetUnmergedPullRequest(db.DefaultContext, 1, 1, "branch2", "master", issues_model.PullRequestFlowGithub)
25+
assert.NoError(t, err)
26+
assert.NoError(t, pr.LoadBaseRepo(db.DefaultContext))
27+
28+
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
29+
30+
err = pull_model.ScheduleAutoMerge(db.DefaultContext, user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr")
31+
assert.NoError(t, err)
32+
33+
autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID})
34+
35+
ctx, resp := contexttest.MockPrivateContext(t, "/")
36+
handlePullRequestMerging(ctx, &private.HookOptions{
37+
PullRequestID: pr.ID,
38+
UserID: 2,
39+
}, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, []*repo_module.PushUpdateOptions{
40+
{NewCommitID: "01234567"},
41+
})
42+
assert.Equal(t, 0, len(resp.Body.String()))
43+
pr, err = issues_model.GetPullRequestByID(db.DefaultContext, pr.ID)
44+
assert.NoError(t, err)
45+
assert.True(t, pr.HasMerged)
46+
assert.EqualValues(t, "01234567", pr.MergedCommitID)
47+
48+
unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{ID: autoMerge.ID})
49+
}

services/contexttest/context_tests.go

+13
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,19 @@ func MockAPIContext(t *testing.T, reqPath string) (*context.APIContext, *httptes
9494
return ctx, resp
9595
}
9696

97+
func MockPrivateContext(t *testing.T, reqPath string) (*context.PrivateContext, *httptest.ResponseRecorder) {
98+
resp := httptest.NewRecorder()
99+
req := mockRequest(t, reqPath)
100+
base, baseCleanUp := context.NewBaseContext(resp, req)
101+
base.Data = middleware.GetContextData(req.Context())
102+
base.Locale = &translation.MockLocale{}
103+
ctx := &context.PrivateContext{Base: base}
104+
_ = baseCleanUp // during test, it doesn't need to do clean up. TODO: this can be improved later
105+
chiCtx := chi.NewRouteContext()
106+
ctx.Base.AppendContextValue(chi.RouteCtxKey, chiCtx)
107+
return ctx, resp
108+
}
109+
97110
// LoadRepo load a repo into a test context.
98111
func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) {
99112
var doer *user_model.User

services/pull/merge.go

+10-17
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import (
1818
git_model "code.gitea.io/gitea/models/git"
1919
issues_model "code.gitea.io/gitea/models/issues"
2020
access_model "code.gitea.io/gitea/models/perm/access"
21-
pull_model "code.gitea.io/gitea/models/pull"
2221
repo_model "code.gitea.io/gitea/models/repo"
2322
"code.gitea.io/gitea/models/unit"
2423
user_model "code.gitea.io/gitea/models/user"
@@ -162,12 +161,6 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U
162161
pullWorkingPool.CheckIn(fmt.Sprint(pr.ID))
163162
defer pullWorkingPool.CheckOut(fmt.Sprint(pr.ID))
164163

165-
// Removing an auto merge pull and ignore if not exist
166-
// FIXME: is this the correct point to do this? Shouldn't this be after IsMergeStyleAllowed?
167-
if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) {
168-
return err
169-
}
170-
171164
prUnit, err := pr.BaseRepo.GetUnit(ctx, unit.TypePullRequests)
172165
if err != nil {
173166
log.Error("pr.BaseRepo.GetUnit(unit.TypePullRequests): %v", err)
@@ -184,17 +177,15 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U
184177
go AddTestPullRequestTask(doer, pr.BaseRepo.ID, pr.BaseBranch, false, "", "")
185178
}()
186179

187-
pr.MergedCommitID, err = doMergeAndPush(ctx, pr, doer, mergeStyle, expectedHeadCommitID, message)
180+
_, err = doMergeAndPush(ctx, pr, doer, mergeStyle, expectedHeadCommitID, message, repo_module.PushTriggerPRMergeToBase)
188181
if err != nil {
189182
return err
190183
}
191184

192-
pr.MergedUnix = timeutil.TimeStampNow()
193-
pr.Merger = doer
194-
pr.MergerID = doer.ID
195-
196-
if _, err := pr.SetMerged(ctx); err != nil {
197-
log.Error("SetMerged %-v: %v", pr, err)
185+
// reload pull request because it has been updated by post receive hook
186+
pr, err = issues_model.GetPullRequestByID(ctx, pr.ID)
187+
if err != nil {
188+
return err
198189
}
199190

200191
if err := pr.LoadIssue(ctx); err != nil {
@@ -245,7 +236,7 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U
245236
}
246237

247238
// doMergeAndPush performs the merge operation without changing any pull information in database and pushes it up to the base repository
248-
func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string) (string, error) {
239+
func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, pushTrigger repo_module.PushTrigger) (string, error) {
249240
// Clone base repo.
250241
mergeCtx, cancel, err := createTemporaryRepoForMerge(ctx, pr, doer, expectedHeadCommitID)
251242
if err != nil {
@@ -318,11 +309,13 @@ func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *use
318309
pr.BaseRepo.Name,
319310
pr.ID,
320311
)
312+
313+
mergeCtx.env = append(mergeCtx.env, repo_module.EnvPushTrigger+"="+string(pushTrigger))
321314
pushCmd := git.NewCommand(ctx, "push", "origin").AddDynamicArguments(baseBranch + ":" + git.BranchPrefix + pr.BaseBranch)
322315

323316
// Push back to upstream.
324-
// TODO: this cause an api call to "/api/internal/hook/post-receive/...",
325-
// that prevents us from doint the whole merge in one db transaction
317+
// This cause an api call to "/api/internal/hook/post-receive/...",
318+
// If it's merge, all db transaction and operations should be there but not here to prevent deadlock.
326319
if err := pushCmd.Run(mergeCtx.RunOpts()); err != nil {
327320
if strings.Contains(mergeCtx.errbuf.String(), "non-fast-forward") {
328321
return "", &git.ErrPushOutOfDate{

services/pull/update.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
user_model "code.gitea.io/gitea/models/user"
1616
"code.gitea.io/gitea/modules/git"
1717
"code.gitea.io/gitea/modules/log"
18+
"code.gitea.io/gitea/modules/repository"
1819
)
1920

2021
// Update updates pull request with base branch.
@@ -72,7 +73,7 @@ func Update(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.
7273
BaseBranch: pr.HeadBranch,
7374
}
7475

75-
_, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", message)
76+
_, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", message, repository.PushTriggerPRUpdateWithBase)
7677

7778
defer func() {
7879
go AddTestPullRequestTask(doer, reversePR.HeadRepo.ID, reversePR.HeadBranch, false, "", "")

0 commit comments

Comments
 (0)