mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-02 10:54:25 +02:00
fix: PR merge (#39442)
* Revert the behavior introduced by #30805 * Now the PR status is still managed in Gitea's code where the operation is triggerred but not in post-receive hook * Fix #39254 and many more related bugs. * Fix #39124 ``` // MarkAsMerged sets a pull request to merged and closes the corresponding issue // To make sure the pull request is marked as merged correctly, the caller uses multiple-stage operations: // 1. Create a temp repo from base, merge the head into the temp repo, and get the merged commit ID and timestamp, // 2. The merged commit ID and related information are stored into pull request // 3. Push the merged commit to the base repo // 4. Call MarkAsMerged to mark the pull request as merged and do post-processing (notification, close issues, etc) // // If failure occurs in step 1/2/3: the pull request is still open, the base repo is not changed, the doer can start a new merge. // If failure occurs in step 4: the pull request can be marked as merged by the merged commit ID stored in it later. ```
This commit is contained in:
1 parent
dcef88a233
commit
ea91028028
20 files changed
+359
-356
No files matched your search
@@ -985,15 +985,7 @@ func MergePullRequest(ctx *context.APIContext) {
|
||||
// handle manually-merged mark
|
||||
if manuallyMerged {
|
||||
if err := pull_service.MergedManually(ctx, pr, ctx.Doer, ctx.Repo.GitRepo, form.MergeCommitID); err != nil {
|
||||
if pull_service.IsErrInvalidMergeStyle(err) {
|
||||
ctx.APIError(http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do)))
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "Wrong commit ID") {
|
||||
ctx.APIError(http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusOK)
|
||||
@@ -1040,7 +1032,7 @@ func MergePullRequest(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := pull_service.Merge(pr, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if pull_service.IsErrInvalidMergeStyle(err) {
|
||||
ctx.APIError(http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do)))
|
||||
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"gitea.dev/modules/private"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/audit"
|
||||
@@ -51,7 +50,7 @@ func hookPostReceiveCollectPushUpdates(opts *private.HookOptions, repo *repo_mod
|
||||
return updates
|
||||
}
|
||||
|
||||
func hookPostReceiveSyncDatabaseBranches(ctx *gitea_context.PrivateContext, opts *private.HookOptions, repo *repo_model.Repository, updates []*repo_module.PushUpdateOptions) bool {
|
||||
func hookPostReceiveSyncDatabaseBranches(ctx *gitea_context.PrivateContext, opts *private.HookOptions, repo *repo_model.Repository, updates []*repo_module.PushUpdateOptions) {
|
||||
branchesToSync := make([]*repo_module.PushUpdateOptions, 0, len(updates))
|
||||
for _, update := range updates {
|
||||
if !update.RefFullName.IsBranch() {
|
||||
@@ -59,8 +58,8 @@ func hookPostReceiveSyncDatabaseBranches(ctx *gitea_context.PrivateContext, opts
|
||||
}
|
||||
if update.IsDelRef() {
|
||||
if err := git_model.MarkBranchAsDeleted(ctx, repo.ID, update.RefFullName.BranchName(), update.PusherID); err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to mark branch %s as deleted: %v", update.RefFullName, err)
|
||||
return false
|
||||
log.Error("failed to mark branch %s as deleted: %v", update.RefFullName, err)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
branchesToSync = append(branchesToSync, update)
|
||||
@@ -70,13 +69,13 @@ func hookPostReceiveSyncDatabaseBranches(ctx *gitea_context.PrivateContext, opts
|
||||
}
|
||||
|
||||
if len(branchesToSync) == 0 {
|
||||
return true
|
||||
return
|
||||
}
|
||||
|
||||
gitRepo, err := git.RepositoryFromRequestContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to open repository: %v", err)
|
||||
return false
|
||||
log.Error("failed to open git repo: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
branchNames := make([]string, 0, len(branchesToSync))
|
||||
@@ -85,12 +84,9 @@ func hookPostReceiveSyncDatabaseBranches(ctx *gitea_context.PrivateContext, opts
|
||||
branchNames = append(branchNames, update.RefFullName.BranchName())
|
||||
commitIDs = append(commitIDs, update.NewCommitID)
|
||||
}
|
||||
|
||||
if err = repo_service.SyncBranchesToDB(ctx, repo.ID, opts.UserID, gitRepo, branchNames, commitIDs); err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to sync branch to DB: %v", err)
|
||||
return false
|
||||
log.Error("failed to sync branch to DB: %v", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// HookPostReceive updates services and users
|
||||
@@ -100,74 +96,63 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
|
||||
setting.PanicInDevOrTesting("wiki hook-post-receive is not supported")
|
||||
return
|
||||
}
|
||||
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
|
||||
return
|
||||
}
|
||||
|
||||
// In the post-receive hook, the git repo has received all changes,
|
||||
// So even if any error happens, nothing can be stopped or undone, so the errors should be skipped
|
||||
repo := ctx.Repo.Repository
|
||||
// first, collect updates and sync branches
|
||||
updates := hookPostReceiveCollectPushUpdates(opts, repo)
|
||||
if !hookPostReceiveSyncDatabaseBranches(ctx, opts, repo, updates) {
|
||||
return
|
||||
}
|
||||
updates := hookPostReceiveCollectPushUpdates(opts, repo) // first, collect updates and sync branches
|
||||
hookPostReceiveSyncDatabaseBranches(ctx, opts, repo, updates)
|
||||
hookPostReceiveSyncRepoDefaultBranch(ctx, opts, repo)
|
||||
hookPostReceiveUpdateRepoByOptions(ctx, opts, repo)
|
||||
|
||||
// handle pull request merging, a pull request action should push at least 1 commit
|
||||
if opts.PushTrigger == repo_module.PushTriggerPRMergeToBase {
|
||||
if !hookPostReceiveHandlePullRequestMerging(ctx, opts, updates) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !hookPostReceiveUpdateRepoByOptions(ctx, opts, repo) {
|
||||
return
|
||||
}
|
||||
|
||||
// push async updates
|
||||
// handle async updates (e.g.: notification, cache management, repo update time, etc.)
|
||||
if err := repo_service.PushUpdates(updates...); err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to push updates: %v", err)
|
||||
return
|
||||
log.Error("failed to push updates: %v", err)
|
||||
}
|
||||
|
||||
hookPostReceiveRespondWithTrailer(ctx, opts, repo)
|
||||
}
|
||||
|
||||
func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts *private.HookOptions, repo *repo_model.Repository) bool {
|
||||
func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts *private.HookOptions, repo *repo_model.Repository) {
|
||||
isPrivate := opts.GitPushOptions.Bool(private.GitPushOptionRepoPrivate)
|
||||
isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate)
|
||||
// Handle Push Options
|
||||
if isPrivate.Has() || isTemplate.Has() {
|
||||
if !ctx.Repo.Permission.IsAdmin() {
|
||||
ctx.PrivateUserErrorf(http.StatusNotFound, "permission denied")
|
||||
return false
|
||||
}
|
||||
if !isPrivate.Has() && !isTemplate.Has() {
|
||||
return
|
||||
}
|
||||
|
||||
// Only honor these options while the repo is still empty (the push-to-create
|
||||
// case). On a populated repo a bare "git push -o repo.private=..." would
|
||||
// silently flip visibility, bypassing the audit log, webhooks and notifications.
|
||||
if !repo.IsEmpty {
|
||||
return true
|
||||
}
|
||||
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
|
||||
return
|
||||
}
|
||||
|
||||
// The repo is empty and being initialized by this push, so there is no
|
||||
// dependent state (webhooks, notifications, visibility fan-out) to reconcile
|
||||
// yet; setting the flags directly is sufficient in this push-to-create case.
|
||||
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
|
||||
repo.IsPrivate = isPrivate.Value()
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||
log.Error("failed to update repo is_private: %v", err)
|
||||
} else {
|
||||
audit.RecordAs(ctx, ctx.Doer, audit_model.RepositoryVisibility, repo, "visibility", repo.IsPrivate)
|
||||
}
|
||||
}
|
||||
if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() {
|
||||
repo.IsTemplate = isTemplate.Value()
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_template"); err != nil {
|
||||
log.Error("failed to update repo is_template: %v", err)
|
||||
}
|
||||
if !ctx.Repo.Permission.IsAdmin() {
|
||||
return
|
||||
}
|
||||
|
||||
// Only honor these options while the repo is still empty (the push-to-create
|
||||
// case). On a populated repo a bare "git push -o repo.private=..." would
|
||||
// silently flip visibility, bypassing the audit log, webhooks and notifications.
|
||||
if !repo.IsEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
// The repo is empty and being initialized by this push, so there is no
|
||||
// dependent state (webhooks, notifications, visibility fan-out) to reconcile
|
||||
// yet; setting the flags directly is sufficient in this push-to-create case.
|
||||
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
|
||||
repo.IsPrivate = isPrivate.Value()
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||
log.Error("failed to update repo is_private: %v", err)
|
||||
} else {
|
||||
audit.RecordAs(ctx, ctx.Doer, audit_model.RepositoryVisibility, repo, "visibility", repo.IsPrivate)
|
||||
}
|
||||
}
|
||||
if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() {
|
||||
repo.IsTemplate = isTemplate.Value()
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_template"); err != nil {
|
||||
log.Error("failed to update repo is_template: %v", err)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hookPostReceiveRespondWithTrailer(ctx *gitea_context.PrivateContext, opts *private.HookOptions, repo *repo_model.Repository) {
|
||||
@@ -229,29 +214,6 @@ func hookPostReceiveRespondWithTrailer(ctx *gitea_context.PrivateContext, opts *
|
||||
ctx.JSON(http.StatusOK, private.HookPostReceiveResult{Results: results})
|
||||
}
|
||||
|
||||
// hookPostReceiveHandlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit
|
||||
func hookPostReceiveHandlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, updates []*repo_module.PushUpdateOptions) bool {
|
||||
if len(updates) == 0 {
|
||||
ctx.PrivateInternalErrorf("Pushing a merged PR (pr:%d) no commits pushed ", opts.PullRequestID)
|
||||
return false
|
||||
}
|
||||
|
||||
pr, err := issues_model.GetPullRequestByID(ctx, opts.PullRequestID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to get pull request %d: %v", opts.PullRequestID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// FIXME: Maybe we need a `PullRequestStatusMerged` status for PRs that are merged, currently we use the previous status
|
||||
// here to keep it as before, that maybe PullRequestStatusMergeable
|
||||
_, err = pull_service.SetMerged(ctx, pr, updates[len(updates)-1].NewCommitID, timeutil.TimeStampNow(), ctx.Doer, pr.Status)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to set pr %d to merged: %v", pr.ID, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hookPostReceiveSyncRepoDefaultBranch(ctx *gitea_context.PrivateContext, opts *private.HookOptions, repo *repo_model.Repository) {
|
||||
hasBranch := false
|
||||
for _, refFullName := range opts.RefFullNames {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package private
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
pull_model "gitea.dev/models/pull"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/private"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHandlePullRequestMerging(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
pr, err := issues_model.GetUnmergedPullRequest(t.Context(), 1, 1, "branch2", "master", issues_model.PullRequestFlowGithub)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
err = pull_model.ScheduleAutoMerge(t.Context(), user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID})
|
||||
|
||||
ctx, resp := contexttest.MockPrivateContext(t, "/")
|
||||
ctx.Doer = user2
|
||||
hookPostReceiveHandlePullRequestMerging(ctx, &private.HookOptions{
|
||||
PullRequestID: pr.ID,
|
||||
UserID: 2,
|
||||
}, []*repo_module.PushUpdateOptions{
|
||||
{NewCommitID: "01234567"},
|
||||
})
|
||||
assert.Empty(t, resp.Body.String())
|
||||
pr, err = issues_model.GetPullRequestByID(t.Context(), pr.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, pr.HasMerged)
|
||||
assert.Equal(t, "01234567", pr.MergedCommitID)
|
||||
|
||||
unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{ID: autoMerge.ID})
|
||||
}
|
||||
@@ -45,6 +45,9 @@ func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName strin
|
||||
}
|
||||
|
||||
func loadContextDoerPermission(ctx *gitea_context.PrivateContext, userID int64, extDoerData string) bool {
|
||||
if ctx.Doer != nil {
|
||||
return true
|
||||
}
|
||||
doer, err := user.GetDoerPermissionUser(ctx, userID, extDoerData)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Failed to get user: %d, error: %v", userID, err)
|
||||
|
||||
@@ -1097,7 +1097,7 @@ func MergePullRequest(ctx *context.Context) {
|
||||
switch {
|
||||
case pull_service.IsErrInvalidMergeStyle(err):
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option"))
|
||||
case strings.Contains(err.Error(), "Wrong commit ID"):
|
||||
case errors.Is(err, util.ErrInvalidArgument):
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.wrong_commit_id"))
|
||||
default:
|
||||
ctx.ServerError("MergedManually", err)
|
||||
@@ -1131,7 +1131,7 @@ func MergePullRequest(ctx *context.Context) {
|
||||
|
||||
if form.MergeWhenChecksSucceed {
|
||||
// delete all scheduled auto merges
|
||||
_ = pull_model.DeleteScheduledAutoMerge(ctx, pr.ID)
|
||||
_, _ = pull_model.DeleteScheduledAutoMerge(ctx, pr.ID)
|
||||
// schedule auto merge
|
||||
scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message, deleteBranchAfterMerge)
|
||||
if err != nil {
|
||||
@@ -1145,7 +1145,7 @@ func MergePullRequest(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := pull_service.Merge(pr, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if pull_service.IsErrInvalidMergeStyle(err) {
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option"))
|
||||
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
|
||||
|
||||
Reference in new issue
Block a user