enhance: allow auto-closing PRs from PRs (#39393)

On GitHub, one can close PRs via `Fixes: #123` references which was not
possible on Gitea before, but now is. Verified fully that behaviour
matches GH and ensured no regressions for external trackers.
This commit is contained in:
silverwind
2026-09-25 11:35:13 +00:00
committed by GitHub
parent 455f30a7b6
commit 6d59229331
5 changed files with 42 additions and 8 deletions
+8 -2
View File
@@ -10,6 +10,7 @@ import (
"gitea.dev/models/db"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/references"
@@ -202,8 +203,13 @@ func (issue *Issue) verifyReferencedIssue(stdCtx context.Context, ctx *crossRefe
return nil, references.XRefActionNone, err
}
// Close/reopen actions can only be set from pull requests to issues
if refIssue.IsPull || !issue.IsPull {
// Close/reopen actions can only be set from pull requests, reopen only to issues
if !issue.IsPull || (refIssue.IsPull && refAction == references.XRefActionReopens) {
refAction = references.XRefActionNone
}
// With an external tracker, pull requests are referenced as "!N"
if refAction != references.XRefActionNone && !ref.IsPull && refIssue.IsPull && refIssue.Repo.UnitEnabled(stdCtx, unit.TypeExternalTracker) {
refAction = references.XRefActionNone
}
+15 -2
View File
@@ -10,6 +10,7 @@ import (
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/references"
@@ -24,22 +25,26 @@ func TestXRef_AddCrossReferences(t *testing.T) {
itarget := testCreateIssue(t, 1, 2, "title1", "content1", false)
// PR to close issue #1
content := fmt.Sprintf("content2, closes #%d", itarget.Index)
content := fmt.Sprintf("content2, closes #%d, fixes #2", itarget.Index)
pr := testCreateIssue(t, 1, 2, "title2", content, true)
ref := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: itarget.ID, RefIssueID: pr.ID, RefCommentID: 0})
assert.Equal(t, issues_model.CommentTypePullRef, ref.Type)
assert.Equal(t, pr.RepoID, ref.RefRepoID)
assert.True(t, ref.RefIsPull)
assert.Equal(t, references.XRefActionCloses, ref.RefAction)
ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: pr.ID, RefCommentID: 0})
assert.Equal(t, references.XRefActionCloses, ref.RefAction)
// Comment on PR to reopen issue #1
content = fmt.Sprintf("content2, reopens #%d", itarget.Index)
content = fmt.Sprintf("content2, reopens #%d, reopens #2", itarget.Index)
c := testCreateComment(t, 2, pr.ID, content)
ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: itarget.ID, RefIssueID: pr.ID, RefCommentID: c.ID})
assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type)
assert.Equal(t, pr.RepoID, ref.RefRepoID)
assert.True(t, ref.RefIsPull)
assert.Equal(t, references.XRefActionReopens, ref.RefAction)
ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: pr.ID, RefCommentID: c.ID})
assert.Equal(t, references.XRefActionNone, ref.RefAction)
// Issue mentioning issue #1
content = fmt.Sprintf("content3, mentions #%d", itarget.Index)
@@ -66,6 +71,14 @@ func TestXRef_AddCrossReferences(t *testing.T) {
content = fmt.Sprintf("content6, mentions org3/repo3#%d", itarget.Index)
i = testCreateIssue(t, 4, 5, "title6", content, false)
unittest.AssertNotExistsBean(t, &issues_model.Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0})
assert.NoError(t, db.DeleteBeans(t.Context(), &repo_model.RepoUnit{RepoID: 1, Type: unit.TypeIssues}))
assert.NoError(t, db.Insert(t.Context(), &repo_model.RepoUnit{RepoID: 1, Type: unit.TypeExternalTracker, Config: &repo_model.ExternalTrackerConfig{}}))
c = testCreateComment(t, 2, pr.ID, "fixes #2, fixes !3")
ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: pr.ID, RefCommentID: c.ID})
assert.Equal(t, references.XRefActionNone, ref.RefAction)
ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 3, RefIssueID: pr.ID, RefCommentID: c.ID})
assert.Equal(t, references.XRefActionCloses, ref.RefAction)
}
func TestXRef_NeuterCrossReferences(t *testing.T) {
+2
View File
@@ -85,6 +85,7 @@ type IssueReference struct {
Index int64
Owner string
Name string
IsPull bool
Action XRefAction
TimeLog string
}
@@ -123,6 +124,7 @@ func rawToIssueReferenceList(reflist []*rawReference) []IssueReference {
Index: r.index,
Owner: r.owner,
Name: r.name,
IsPull: r.isPull,
Action: r.action,
TimeLog: r.timeLog,
}
+8 -2
View File
@@ -18,6 +18,7 @@ import (
issues_model "gitea.dev/models/issues"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
@@ -188,8 +189,8 @@ func UpdateIssuesCommit(ctx context.Context, doer *user_model.User, repo *repo_m
return err
}
// Only issues can be closed/reopened this way, and user needs the correct permissions
if refIssue.IsPull || !canclose {
// Only issues can be reopened this way, and user needs the correct permissions
if !canclose || (refIssue.IsPull && ref.Action == references.XRefActionReopens) {
continue
}
@@ -198,6 +199,11 @@ func UpdateIssuesCommit(ctx context.Context, doer *user_model.User, repo *repo_m
continue
}
// With an external tracker, pull requests are referenced as "!N"
if !ref.IsPull && refIssue.IsPull && refRepo.UnitEnabled(ctx, unit.TypeExternalTracker) {
continue
}
if !repo.CloseIssuesViaCommitInAnyBranch {
// If the issue was specified to be in a particular branch, don't allow commits in other branches to close it
if refIssue.Ref != "" {
+9 -2
View File
@@ -42,7 +42,7 @@ func TestUpdateIssuesCommit(t *testing.T) {
CommitterName: "User Two",
AuthorEmail: "user2@example.com",
AuthorName: "User Two",
Message: "close #2",
Message: "close #2, reopen #2",
},
}
@@ -56,7 +56,7 @@ func TestUpdateIssuesCommit(t *testing.T) {
PosterID: user.ID,
IssueID: 1,
}
issueBean := &issues_model.Issue{RepoID: repo.ID, Index: 4}
issueBean := &issues_model.Issue{RepoID: repo.ID, Index: 2}
unittest.AssertNotExistsBean(t, commentBean)
unittest.AssertNotExistsBean(t, &issues_model.Issue{RepoID: repo.ID, Index: 2}, "is_closed=1")
@@ -117,6 +117,13 @@ func TestUpdateIssuesCommit(t *testing.T) {
unittest.AssertExistsAndLoadBean(t, commentBean)
unittest.AssertExistsAndLoadBean(t, issueBean, "is_closed=1")
unittest.CheckConsistencyFor(t, &activities_model.Action{})
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 48})
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
assert.NoError(t, UpdateIssuesCommit(t.Context(), admin, repo, []*repository.PushCommit{{Sha1: "abcdef4", Message: "close #1"}}, repo.DefaultBranch))
unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID, Index: 1}, "is_closed=0")
assert.NoError(t, UpdateIssuesCommit(t.Context(), admin, repo, []*repository.PushCommit{{Sha1: "abcdef5", Message: "close !1"}}, repo.DefaultBranch))
unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID, Index: 1}, "is_closed=1")
}
func TestUpdateIssuesCommit_Colon(t *testing.T) {