From 0b552407fe668207625ba9e3dd8cc222bc1529d7 Mon Sep 17 00:00:00 2001 From: BtbN Date: Sat, 9 Aug 2025 22:12:33 +0200 Subject: [PATCH 1/2] fix: prevent pull requests from being merged multiple times --- options/locale_next/locale_en-US.json | 1 + routers/api/v1/repo/pull.go | 3 +++ routers/web/repo/pull.go | 4 ++++ services/pull/merge.go | 28 ++++++++++++++++++++---- services/pull/merge_test.go | 31 +++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 5d489a167a..55abe17f07 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -44,6 +44,7 @@ "relativetime.2months": "two months ago", "relativetime.1year": "last year", "relativetime.2years": "two years ago", + "repo.pulls.already_merged": "Merge failed: This pull request has already been merged.", "repo.pulls.merged_title_desc": { "one": "merged %[1]d commit from %[2]s into %[3]s %[4]s", "other": "merged %[1]d commits from %[2]s into %[3]s %[4]s" diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index e7ff533d6a..812468f6b4 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -1016,6 +1016,9 @@ func MergePullRequest(ctx *context.APIContext) { } else if models.IsErrMergeUnrelatedHistories(err) { conflictError := err.(models.ErrMergeUnrelatedHistories) ctx.JSON(http.StatusConflict, conflictError) + } else if models.IsErrPullRequestHasMerged(err) { + conflictError := err.(models.ErrPullRequestHasMerged) + ctx.JSON(http.StatusConflict, conflictError) } else if git.IsErrPushOutOfDate(err) { ctx.Error(http.StatusConflict, "Merge", "merge push out of date") } else if models.IsErrSHADoesNotMatch(err) { diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 48e132df5c..e0ad172ccc 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -1445,6 +1445,10 @@ func MergePullRequest(ctx *context.Context) { log.Debug("MergeUnrelatedHistories error: %v", err) ctx.Flash.Error(ctx.Tr("repo.pulls.unrelated_histories")) ctx.JSONRedirect(issue.Link()) + } else if models.IsErrPullRequestHasMerged(err) { + log.Debug("MergePullRequestHasMerged error: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pulls.already_merged")) + ctx.JSONRedirect(issue.Link()) } else if git.IsErrPushOutOfDate(err) { log.Debug("MergePushOutOfDate error: %v", err) ctx.Flash.Error(ctx.Tr("repo.pulls.merge_out_of_date")) diff --git a/services/pull/merge.go b/services/pull/merge.go index 55ce869497..a2542176f0 100644 --- a/services/pull/merge.go +++ b/services/pull/merge.go @@ -239,7 +239,30 @@ func AddCommitMessageTrailer(message, tailerKey, tailerValue string) string { // Merge merges pull request to base repository. // Caller should check PR is ready to be merged (review and status checks) func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, baseGitRepo *git.Repository, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error { - if err := pr.LoadBaseRepo(ctx); err != nil { + pullWorkingPool.CheckIn(fmt.Sprint(pr.ID)) + defer pullWorkingPool.CheckOut(fmt.Sprint(pr.ID)) + + pr, err := issues_model.GetPullRequestByID(ctx, pr.ID) + if err != nil { + log.Error("Unable to load pull request itself: %v", err) + return fmt.Errorf("unable to load pull request itself: %w", err) + } + + if pr.HasMerged { + return models.ErrPullRequestHasMerged{ + ID: pr.ID, + IssueID: pr.IssueID, + HeadRepoID: pr.HeadRepoID, + BaseRepoID: pr.BaseRepoID, + HeadBranch: pr.HeadBranch, + BaseBranch: pr.BaseBranch, + } + } + + if err := pr.LoadIssue(ctx); err != nil { + log.Error("Unable to load issue: %v", err) + return fmt.Errorf("unable to load issue: %w", err) + } else if err := pr.LoadBaseRepo(ctx); err != nil { log.Error("Unable to load base repo: %v", err) return fmt.Errorf("unable to load base repo: %w", err) } else if err := pr.LoadHeadRepo(ctx); err != nil { @@ -247,9 +270,6 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U return fmt.Errorf("unable to load head repo: %w", err) } - pullWorkingPool.CheckIn(fmt.Sprint(pr.ID)) - defer pullWorkingPool.CheckOut(fmt.Sprint(pr.ID)) - prUnit, err := pr.BaseRepo.GetUnit(ctx, unit.TypePullRequests) if err != nil { log.Error("pr.BaseRepo.GetUnit(unit.TypePullRequests): %v", err) diff --git a/services/pull/merge_test.go b/services/pull/merge_test.go index 3684c2120a..aa033b8cdb 100644 --- a/services/pull/merge_test.go +++ b/services/pull/merge_test.go @@ -9,7 +9,12 @@ import ( "strings" "testing" + "forgejo.org/models" + issues_model "forgejo.org/models/issues" repo_model "forgejo.org/models/repo" + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + "forgejo.org/modules/gitrepo" "forgejo.org/modules/setting" "forgejo.org/modules/test" @@ -146,3 +151,29 @@ func TestLoadMergeMessageTemplates(t *testing.T) { require.NoError(t, LoadMergeMessageTemplates()) assert.Empty(t, mergeMessageTemplates) } + +func TestMergeMergedPR(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + require.NoError(t, pr.LoadBaseRepo(t.Context())) + + gitRepo, err := gitrepo.OpenRepository(t.Context(), pr.BaseRepo) + require.NoError(t, err) + defer gitRepo.Close() + + assert.True(t, pr.HasMerged) + pr.HasMerged = false + + err = Merge(t.Context(), pr, doer, gitRepo, repo_model.MergeStyleRebase, "", "I should not exist", false) + require.Error(t, err) + assert.True(t, models.IsErrPullRequestHasMerged(err)) + + if mergeErr, ok := err.(models.ErrPullRequestHasMerged); ok { + assert.Equal(t, pr.ID, mergeErr.ID) + assert.Equal(t, pr.IssueID, mergeErr.IssueID) + assert.Equal(t, pr.HeadBranch, mergeErr.HeadBranch) + assert.Equal(t, pr.BaseBranch, mergeErr.BaseBranch) + } +} From fa5011b988d4b715ace8cfb1cdff68bec55f7aa6 Mon Sep 17 00:00:00 2001 From: BtbN Date: Sun, 10 Aug 2025 01:34:58 +0200 Subject: [PATCH 2/2] fix: CreateDeclarativeRepo should allow merging PRs when PRs are enabled --- tests/test_utils.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_utils.go b/tests/test_utils.go index ee786624a2..d72ac476da 100644 --- a/tests/test_utils.go +++ b/tests/test_utils.go @@ -495,6 +495,25 @@ func CreateDeclarativeRepo(t *testing.T, owner *user_model.User, name string, en } if enabledUnits != nil { opts.EnabledUnits = optional.Some(enabledUnits) + + for _, unitType := range enabledUnits { + if unitType == unit_model.TypePullRequests { + opts.UnitConfig = optional.Some(map[unit_model.Type]convert.Conversion{ + unit_model.TypePullRequests: &repo_model.PullRequestsConfig{ + AllowMerge: true, + AllowRebase: true, + AllowRebaseMerge: true, + AllowSquash: true, + AllowFastForwardOnly: true, + AllowManualMerge: true, + AllowRebaseUpdate: true, + DefaultMergeStyle: repo_model.MergeStyleMerge, + DefaultUpdateStyle: repo_model.UpdateStyleMerge, + }, + }) + break + } + } } if disabledUnits != nil { opts.DisabledUnits = optional.Some(disabledUnits)