File gitea-pusher-is-merge-author.patch of Package gitea

From 180aaba69422d3ae283b141385d32838fec66f9e Mon Sep 17 00:00:00 2001
From: Adam Majer <amajer@suse.com>
Date: Tue, 10 Feb 2026 12:05:00 +0100
Subject: [PATCH 1/9] Pusher is author of manual merged changes

In manual merge detected changes, the pushing user should be
the de-facto author of the merge, not the committer. For ff-only
merges, the author (PR owner) often have nothing to do with the
merger. Similarly, even if a merge commit exists, it does not
indicate that the merge commit author is the merger.

If pusher is for some reason unavailable, we fall back to the
old method of using committer or owning organization as the author.
---
 services/pull/check.go | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/services/pull/check.go b/services/pull/check.go
index f6e8433cf2672..81211a9b1aba8 100644
--- a/services/pull/check.go
+++ b/services/pull/check.go
@@ -362,7 +362,17 @@ func manuallyMerged(ctx context.Context, pr *issues_model.PullRequest) bool {
 		return false
 	}
 
-	merger, _ := user_model.GetUserByEmail(ctx, commit.Author.Email)
+	var merger *user_model.User
+	if branch, err := git_model.GetBranch(ctx, pr.BaseRepoID, pr.BaseBranch); err == nil {
+		if err := branch.LoadPusher(ctx); err != nil {
+			log.Error("LoadPusher[%d:%s]: %v", pr.BaseRepoID, pr.BaseBranch, err)
+		}
+		merger = branch.Pusher
+	}
+
+	if merger == nil {
+		merger, _ = user_model.GetUserByEmail(ctx, commit.Author.Email)
+	}
 
 	// When the commit author is unknown set the BaseRepo owner as merger
 	if merger == nil {

From 6317ddbbb84100e46beafaf1c814213e0753c615 Mon Sep 17 00:00:00 2001
From: Adam Majer <amajer@suse.com>
Date: Tue, 10 Feb 2026 12:52:18 +0100
Subject: [PATCH 2/9] Add integration test

---
 tests/integration/manual_merge_test.go | 77 ++++++++++++++++++++++++++
 1 file changed, 77 insertions(+)
 create mode 100644 tests/integration/manual_merge_test.go

diff --git a/tests/integration/manual_merge_test.go b/tests/integration/manual_merge_test.go
new file mode 100644
index 0000000000000..8974d32e6a0f7
--- /dev/null
+++ b/tests/integration/manual_merge_test.go
@@ -0,0 +1,77 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"fmt"
+	"net/url"
+	"testing"
+	"time"
+
+	auth_model "code.gitea.io/gitea/models/auth"
+	issues_model "code.gitea.io/gitea/models/issues"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	api "code.gitea.io/gitea/modules/structs"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestManualMergeAutodetect(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+		// user2 is the repo owner
+		// user1 is the pusher/merger
+		user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session2 := loginUser(t, user2.Name)
+
+		// Create a repo owned by user2
+		repoName := "manual-merge-autodetect"
+		var repo api.Repository
+		user2Ctx := NewAPITestContext(t, user2.Name, repoName, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+		doAPICreateRepository(user2Ctx, false, func(t *testing.T, r api.Repository) {
+			repo = r
+		})(t)
+
+		// Enable autodetect manual merge
+		TrueValue := true
+		doAPIEditRepository(user2Ctx, &api.EditRepoOption{
+			HasPullRequests:       &TrueValue,
+			AllowManualMerge:      &TrueValue,
+			AutodetectManualMerge: &TrueValue,
+		})(t)
+
+		// Create a PR from a branch
+		branchName := "feature"
+		testEditFileToNewBranch(t, session2, user2.Name, repo.Name, repo.DefaultBranch, branchName, "README.md", "Manual Merge Test")
+
+		apiPull, err := doAPICreatePullRequest(NewAPITestContext(t, user1.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository), user2.Name, repo.Name, repo.DefaultBranch, branchName)(t)
+		assert.NoError(t, err)
+
+		// user1 clones and pushes the branch to master (fast-forward)
+		dstPath := t.TempDir()
+		u, _ := url.Parse(giteaURL.String())
+		u.Path = fmt.Sprintf("%s/%s.git", user2.Name, repo.Name)
+		u.User = url.UserPassword(user1.Name, userPassword)
+
+		doGitClone(dstPath, u)(t)
+		doGitMerge(dstPath, "origin/"+branchName)(t)
+		doGitPushTestRepository(dstPath, "origin", repo.DefaultBranch)(t)
+
+		// Wait for the PR to be marked as merged by the background task
+		var pr *issues_model.PullRequest
+		require.Eventually(t, func() bool {
+			pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: apiPull.ID})
+			return pr.HasMerged
+		}, 10*time.Second, 100*time.Millisecond)
+
+		// Check if the PR is merged and who is the merger
+		pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: apiPull.ID})
+		assert.True(t, pr.HasMerged)
+		assert.Equal(t, issues_model.PullRequestStatusManuallyMerged, pr.Status)
+		// Merger should be user1 (the pusher), not the commit author (user2) or repo owner (user2)
+		assert.Equal(t, user1.ID, pr.MergerID)
+	})
+}

From 9d3d2e3e2376c7fed887f3e1af73ccede940834b Mon Sep 17 00:00:00 2001
From: Adam Majer <amajer@suse.com>
Date: Mon, 2 Mar 2026 15:01:34 +0100
Subject: [PATCH 3/9] remove commit author as merger

---
 services/pull/check.go | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/services/pull/check.go b/services/pull/check.go
index 81211a9b1aba8..93cf99c74e905 100644
--- a/services/pull/check.go
+++ b/services/pull/check.go
@@ -370,10 +370,6 @@ func manuallyMerged(ctx context.Context, pr *issues_model.PullRequest) bool {
 		merger = branch.Pusher
 	}
 
-	if merger == nil {
-		merger, _ = user_model.GetUserByEmail(ctx, commit.Author.Email)
-	}
-
 	// When the commit author is unknown set the BaseRepo owner as merger
 	if merger == nil {
 		if pr.BaseRepo.Owner == nil {

From 4e15f7a4929be6facd0c2adcbed5b24f45ae24c7 Mon Sep 17 00:00:00 2001
From: Adam Majer <amajer@suse.com>
Date: Mon, 2 Mar 2026 15:05:14 +0100
Subject: [PATCH 4/9] fix comment

---
 services/pull/check.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/services/pull/check.go b/services/pull/check.go
index 93cf99c74e905..2d70f03d44487 100644
--- a/services/pull/check.go
+++ b/services/pull/check.go
@@ -370,7 +370,7 @@ func manuallyMerged(ctx context.Context, pr *issues_model.PullRequest) bool {
 		merger = branch.Pusher
 	}
 
-	// When the commit author is unknown set the BaseRepo owner as merger
+	// When the doer (pusher) is unknown set the BaseRepo owner as merger
 	if merger == nil {
 		if pr.BaseRepo.Owner == nil {
 			if err = pr.BaseRepo.LoadOwner(ctx); err != nil {

From 97da5ce42bb99703985a6fb37c48192a7a8182eb Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Mon, 2 Mar 2026 22:18:34 +0800
Subject: [PATCH 5/9] avoid ghost user

---
 services/pull/check.go | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/services/pull/check.go b/services/pull/check.go
index 2d70f03d44487..fe88028646c07 100644
--- a/services/pull/check.go
+++ b/services/pull/check.go
@@ -364,10 +364,13 @@ func manuallyMerged(ctx context.Context, pr *issues_model.PullRequest) bool {
 
 	var merger *user_model.User
 	if branch, err := git_model.GetBranch(ctx, pr.BaseRepoID, pr.BaseBranch); err == nil {
-		if err := branch.LoadPusher(ctx); err != nil {
+		if err := branch.LoadPusher(ctx); err == nil {
+			if branch.Pusher.ID > 0 {
+				merger = branch.Pusher
+			}
+		} else {
 			log.Error("LoadPusher[%d:%s]: %v", pr.BaseRepoID, pr.BaseBranch, err)
 		}
-		merger = branch.Pusher
 	}
 
 	// When the doer (pusher) is unknown set the BaseRepo owner as merger

From 92710ffd89c4808df61f4109ced1f66dfd609a5c Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Mon, 2 Mar 2026 22:24:02 +0800
Subject: [PATCH 6/9] modern go: new(val)

---
 tests/integration/manual_merge_test.go | 22 ++++++++++------------
 1 file changed, 10 insertions(+), 12 deletions(-)

diff --git a/tests/integration/manual_merge_test.go b/tests/integration/manual_merge_test.go
index 8974d32e6a0f7..4cfba9a225e45 100644
--- a/tests/integration/manual_merge_test.go
+++ b/tests/integration/manual_merge_test.go
@@ -13,6 +13,7 @@ import (
 	issues_model "code.gitea.io/gitea/models/issues"
 	"code.gitea.io/gitea/models/unittest"
 	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/setting"
 	api "code.gitea.io/gitea/modules/structs"
 
 	"github.com/stretchr/testify/assert"
@@ -29,36 +30,33 @@ func TestManualMergeAutodetect(t *testing.T) {
 
 		// Create a repo owned by user2
 		repoName := "manual-merge-autodetect"
-		var repo api.Repository
+		defaultBranch := setting.Repository.DefaultBranch
 		user2Ctx := NewAPITestContext(t, user2.Name, repoName, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
-		doAPICreateRepository(user2Ctx, false, func(t *testing.T, r api.Repository) {
-			repo = r
-		})(t)
+		doAPICreateRepository(user2Ctx, false)(t)
 
 		// Enable autodetect manual merge
-		TrueValue := true
 		doAPIEditRepository(user2Ctx, &api.EditRepoOption{
-			HasPullRequests:       &TrueValue,
-			AllowManualMerge:      &TrueValue,
-			AutodetectManualMerge: &TrueValue,
+			HasPullRequests:       new(true),
+			AllowManualMerge:      new(true),
+			AutodetectManualMerge: new(true),
 		})(t)
 
 		// Create a PR from a branch
 		branchName := "feature"
-		testEditFileToNewBranch(t, session2, user2.Name, repo.Name, repo.DefaultBranch, branchName, "README.md", "Manual Merge Test")
+		testEditFileToNewBranch(t, session2, user2.Name, repoName, defaultBranch, branchName, "README.md", "Manual Merge Test")
 
-		apiPull, err := doAPICreatePullRequest(NewAPITestContext(t, user1.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository), user2.Name, repo.Name, repo.DefaultBranch, branchName)(t)
+		apiPull, err := doAPICreatePullRequest(NewAPITestContext(t, user1.Name, repoName, auth_model.AccessTokenScopeWriteRepository), user2.Name, repoName, defaultBranch, branchName)(t)
 		assert.NoError(t, err)
 
 		// user1 clones and pushes the branch to master (fast-forward)
 		dstPath := t.TempDir()
 		u, _ := url.Parse(giteaURL.String())
-		u.Path = fmt.Sprintf("%s/%s.git", user2.Name, repo.Name)
+		u.Path = fmt.Sprintf("%s/%s.git", user2.Name, repoName)
 		u.User = url.UserPassword(user1.Name, userPassword)
 
 		doGitClone(dstPath, u)(t)
 		doGitMerge(dstPath, "origin/"+branchName)(t)
-		doGitPushTestRepository(dstPath, "origin", repo.DefaultBranch)(t)
+		doGitPushTestRepository(dstPath, "origin", defaultBranch)(t)
 
 		// Wait for the PR to be marked as merged by the background task
 		var pr *issues_model.PullRequest

From 6563441c7ebf0612738df19b1ff3d34b844f2858 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Mon, 2 Mar 2026 22:29:48 +0800
Subject: [PATCH 7/9] refactor "get merger"

---
 services/pull/check.go | 46 ++++++++++++++++++++++++------------------
 1 file changed, 26 insertions(+), 20 deletions(-)

diff --git a/services/pull/check.go b/services/pull/check.go
index fe88028646c07..ead63565fc62f 100644
--- a/services/pull/check.go
+++ b/services/pull/check.go
@@ -333,6 +333,28 @@ func getMergeCommit(ctx context.Context, pr *issues_model.PullRequest) (*git.Com
 	return commit, nil
 }
 
+func getMergerForManuallyMergedPullRequest(ctx context.Context, pr *issues_model.PullRequest) (*user_model.User, error) {
+	var errs []error
+	if branch, err := git_model.GetBranch(ctx, pr.BaseRepoID, pr.BaseBranch); err == nil {
+		// LoadPusher uses ghost for non-existing user
+		if err := branch.LoadPusher(ctx); err == nil && branch.Pusher.ID > 0 {
+			return branch.Pusher, nil
+		} else if err != nil {
+			errs = append(errs, err)
+		}
+	} else {
+		errs = append(errs, err)
+	}
+
+	// When the doer (pusher) is unknown set the BaseRepo owner as merger
+	err := pr.BaseRepo.LoadOwner(ctx)
+	if err == nil {
+		return pr.BaseRepo.Owner, nil
+	}
+	errs = append(errs, err)
+	return nil, fmt.Errorf("unable to find merger for manually merged pull request: %w", errors.Join(errs...))
+}
+
 // manuallyMerged checks if a pull request got manually merged
 // When a pull request got manually merged mark the pull request as merged
 func manuallyMerged(ctx context.Context, pr *issues_model.PullRequest) bool {
@@ -362,26 +384,10 @@ func manuallyMerged(ctx context.Context, pr *issues_model.PullRequest) bool {
 		return false
 	}
 
-	var merger *user_model.User
-	if branch, err := git_model.GetBranch(ctx, pr.BaseRepoID, pr.BaseBranch); err == nil {
-		if err := branch.LoadPusher(ctx); err == nil {
-			if branch.Pusher.ID > 0 {
-				merger = branch.Pusher
-			}
-		} else {
-			log.Error("LoadPusher[%d:%s]: %v", pr.BaseRepoID, pr.BaseBranch, err)
-		}
-	}
-
-	// When the doer (pusher) is unknown set the BaseRepo owner as merger
-	if merger == nil {
-		if pr.BaseRepo.Owner == nil {
-			if err = pr.BaseRepo.LoadOwner(ctx); err != nil {
-				log.Error("%-v BaseRepo.LoadOwner: %v", pr, err)
-				return false
-			}
-		}
-		merger = pr.BaseRepo.Owner
+	merger, err := getMergerForManuallyMergedPullRequest(ctx, pr)
+	if err != nil {
+		log.Error("%-v getMergerForManuallyMergedPullRequest: %v", pr, err)
+		return false
 	}
 
 	if merged, err := SetMerged(ctx, pr, commit.ID.String(), timeutil.TimeStamp(commit.Author.When.Unix()), merger, issues_model.PullRequestStatusManuallyMerged); err != nil {

From 91849fa3a1441b52d8a5f0a7ac9708fa9d184f4b Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Mon, 2 Mar 2026 22:38:06 +0800
Subject: [PATCH 8/9] fix pusher check

---
 services/pull/check.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/services/pull/check.go b/services/pull/check.go
index ead63565fc62f..304ef87fc3d38 100644
--- a/services/pull/check.go
+++ b/services/pull/check.go
@@ -337,7 +337,7 @@ func getMergerForManuallyMergedPullRequest(ctx context.Context, pr *issues_model
 	var errs []error
 	if branch, err := git_model.GetBranch(ctx, pr.BaseRepoID, pr.BaseBranch); err == nil {
 		// LoadPusher uses ghost for non-existing user
-		if err := branch.LoadPusher(ctx); err == nil && branch.Pusher.ID > 0 {
+		if err := branch.LoadPusher(ctx); branch.Pusher != nil && branch.Pusher.ID > 0 {
 			return branch.Pusher, nil
 		} else if err != nil {
 			errs = append(errs, err)

From 0806a5cb63bec863bee7b8033019dff64ed66ef5 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Mon, 2 Mar 2026 22:41:04 +0800
Subject: [PATCH 9/9] avoid err shadow

---
 services/pull/check.go | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/services/pull/check.go b/services/pull/check.go
index 304ef87fc3d38..6486ca79df320 100644
--- a/services/pull/check.go
+++ b/services/pull/check.go
@@ -335,15 +335,15 @@ func getMergeCommit(ctx context.Context, pr *issues_model.PullRequest) (*git.Com
 
 func getMergerForManuallyMergedPullRequest(ctx context.Context, pr *issues_model.PullRequest) (*user_model.User, error) {
 	var errs []error
-	if branch, err := git_model.GetBranch(ctx, pr.BaseRepoID, pr.BaseBranch); err == nil {
-		// LoadPusher uses ghost for non-existing user
-		if err := branch.LoadPusher(ctx); branch.Pusher != nil && branch.Pusher.ID > 0 {
+	if branch, err := git_model.GetBranch(ctx, pr.BaseRepoID, pr.BaseBranch); err != nil {
+		errs = append(errs, err)
+	} else {
+		err := branch.LoadPusher(ctx) // LoadPusher uses ghost for non-existing user
+		if branch.Pusher != nil && branch.Pusher.ID > 0 {
 			return branch.Pusher, nil
 		} else if err != nil {
 			errs = append(errs, err)
 		}
-	} else {
-		errs = append(errs, err)
 	}
 
 	// When the doer (pusher) is unknown set the BaseRepo owner as merger
openSUSE Build Service is sponsored by