Make manual merge autodetection optional and add manual merge as merge method (#12543)

* Make auto check manual merge as a chooseable mod and add manual merge way on ui

as title, Before this pr, we use same way with GH to check manually merge.
It good, but in some special cases, misjudgments can occur. and it's hard
to fix this bug. So I add option to allow repo manager block "auto check manual merge"
function, Then it will have same style like gitlab(allow empty pr). and to compensate for
not being able to detect THE PR merge automatically, I added a manual approach.

Signed-off-by: a1012112796 <1012112796@qq.com>

* make swager

* api support

* ping ci

* fix TestPullCreate_EmptyChangesWithCommits

* Apply suggestions from code review

Co-authored-by: zeripath <art27@cantab.net>

* Apply review suggestions and add test

* Apply suggestions from code review

Co-authored-by: zeripath <art27@cantab.net>

* fix build

* test error message

* make fmt

* Fix indentation issues identified by @silverwind

Co-authored-by: silverwind <me@silverwind.io>

* Fix tests and make manually merged disabled error on API the same

Signed-off-by: Andrew Thornton <art27@cantab.net>

* a small nit

* fix wrong commit id error

* fix bug

* simple test

* fix test

Co-authored-by: zeripath <art27@cantab.net>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: techknowlogick <techknowlogick@gitea.io>
mj-v1.14.3
a1012112796 3 years ago committed by GitHub
parent 523efa433b
commit a5279b74b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url"
"testing" "testing"
"time" "time"
@ -71,6 +72,23 @@ func doAPICreateRepository(ctx APITestContext, empty bool, callback ...func(*tes
} }
} }
func doAPIEditRepository(ctx APITestContext, editRepoOption *api.EditRepoOption, callback ...func(*testing.T, api.Repository)) func(*testing.T) {
return func(t *testing.T) {
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s?token=%s", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), ctx.Token), editRepoOption)
if ctx.ExpectedCode != 0 {
ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
return
}
resp := ctx.Session.MakeRequest(t, req, http.StatusOK)
var repository api.Repository
DecodeJSON(t, resp, &repository)
if len(callback) > 0 {
callback[0](t, repository)
}
}
}
func doAPIAddCollaborator(ctx APITestContext, username string, mode models.AccessMode) func(*testing.T) { func doAPIAddCollaborator(ctx APITestContext, username string, mode models.AccessMode) func(*testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
permission := "read" permission := "read"
@ -256,6 +274,23 @@ func doAPIMergePullRequest(ctx APITestContext, owner, repo string, index int64)
} }
} }
func doAPIManuallyMergePullRequest(ctx APITestContext, owner, repo, commitID string, index int64) func(*testing.T) {
return func(t *testing.T) {
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge?token=%s",
owner, repo, index, ctx.Token)
req := NewRequestWithJSON(t, http.MethodPost, urlStr, &auth.MergePullRequestForm{
Do: string(models.MergeStyleManuallyMerged),
MergeCommitID: commitID,
})
if ctx.ExpectedCode != 0 {
ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
return
}
ctx.Session.MakeRequest(t, req, 200)
}
}
func doAPIGetBranch(ctx APITestContext, branch string, callback ...func(*testing.T, api.Branch)) func(*testing.T) { func doAPIGetBranch(ctx APITestContext, branch string, callback ...func(*testing.T, api.Branch)) func(*testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/branches/%s?token=%s", ctx.Username, ctx.Reponame, branch, ctx.Token) req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/branches/%s?token=%s", ctx.Username, ctx.Reponame, branch, ctx.Token)

@ -69,6 +69,7 @@ func testGit(t *testing.T, u *url.URL) {
mediaTest(t, &httpContext, little, big, littleLFS, bigLFS) mediaTest(t, &httpContext, little, big, littleLFS, bigLFS)
t.Run("BranchProtectMerge", doBranchProtectPRMerge(&httpContext, dstPath)) t.Run("BranchProtectMerge", doBranchProtectPRMerge(&httpContext, dstPath))
t.Run("CreatePRAndSetManuallyMerged", doCreatePRAndSetManuallyMerged(httpContext, httpContext, dstPath, "master", "test-manually-merge"))
t.Run("MergeFork", func(t *testing.T) { t.Run("MergeFork", func(t *testing.T) {
defer PrintCurrentTest(t)() defer PrintCurrentTest(t)()
t.Run("CreatePRAndMerge", doMergeFork(httpContext, forkedUserCtx, "master", httpContext.Username+":master")) t.Run("CreatePRAndMerge", doMergeFork(httpContext, forkedUserCtx, "master", httpContext.Username+":master"))
@ -468,6 +469,35 @@ func doMergeFork(ctx, baseCtx APITestContext, baseBranch, headBranch string) fun
} }
} }
func doCreatePRAndSetManuallyMerged(ctx, baseCtx APITestContext, dstPath, baseBranch, headBranch string) func(t *testing.T) {
return func(t *testing.T) {
defer PrintCurrentTest(t)()
var (
pr api.PullRequest
err error
lastCommitID string
)
trueBool := true
falseBool := false
t.Run("AllowSetManuallyMergedAndSwitchOffAutodetectManualMerge", doAPIEditRepository(baseCtx, &api.EditRepoOption{
HasPullRequests: &trueBool,
AllowManualMerge: &trueBool,
AutodetectManualMerge: &falseBool,
}))
t.Run("CreateHeadBranch", doGitCreateBranch(dstPath, headBranch))
t.Run("PushToHeadBranch", doGitPushTestRepository(dstPath, "origin", headBranch))
t.Run("CreateEmptyPullRequest", func(t *testing.T) {
pr, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, baseBranch, headBranch)(t)
assert.NoError(t, err)
})
lastCommitID = pr.Base.Sha
t.Run("ManuallyMergePR", doAPIManuallyMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, lastCommitID, pr.Index))
}
}
func doEnsureCanSeePull(ctx APITestContext, pr api.PullRequest) func(t *testing.T) { func doEnsureCanSeePull(ctx APITestContext, pr api.PullRequest) func(t *testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index)) req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index))

@ -115,6 +115,6 @@ func TestPullCreate_EmptyChangesWithCommits(t *testing.T) {
doc := NewHTMLParser(t, resp.Body) doc := NewHTMLParser(t, resp.Body)
text := strings.TrimSpace(doc.doc.Find(".merge-section").Text()) text := strings.TrimSpace(doc.doc.Find(".merge-section").Text())
assert.Contains(t, text, "This pull request can be merged automatically.") assert.Contains(t, text, "This branch is equal with the target branch.")
}) })
} }

@ -35,6 +35,7 @@ const (
PullRequestStatusMergeable PullRequestStatusMergeable
PullRequestStatusManuallyMerged PullRequestStatusManuallyMerged
PullRequestStatusError PullRequestStatusError
PullRequestStatusEmpty
) )
// PullRequest represents relation between pull request and repositories. // PullRequest represents relation between pull request and repositories.
@ -332,6 +333,11 @@ func (pr *PullRequest) CanAutoMerge() bool {
return pr.Status == PullRequestStatusMergeable return pr.Status == PullRequestStatusMergeable
} }
// IsEmpty returns true if this pull request is empty.
func (pr *PullRequest) IsEmpty() bool {
return pr.Status == PullRequestStatusEmpty
}
// MergeStyle represents the approach to merge commits into base branch. // MergeStyle represents the approach to merge commits into base branch.
type MergeStyle string type MergeStyle string
@ -344,6 +350,8 @@ const (
MergeStyleRebaseMerge MergeStyle = "rebase-merge" MergeStyleRebaseMerge MergeStyle = "rebase-merge"
// MergeStyleSquash squash commits into single commit before merging // MergeStyleSquash squash commits into single commit before merging
MergeStyleSquash MergeStyle = "squash" MergeStyleSquash MergeStyle = "squash"
// MergeStyleManuallyMerged pr has been merged manually, just mark it as merged directly
MergeStyleManuallyMerged MergeStyle = "manually-merged"
) )
// SetMerged sets a pull request to merged and closes the corresponding issue // SetMerged sets a pull request to merged and closes the corresponding issue

@ -101,6 +101,8 @@ type PullRequestsConfig struct {
AllowRebase bool AllowRebase bool
AllowRebaseMerge bool AllowRebaseMerge bool
AllowSquash bool AllowSquash bool
AllowManualMerge bool
AutodetectManualMerge bool
} }
// FromDB fills up a PullRequestsConfig from serialized format. // FromDB fills up a PullRequestsConfig from serialized format.
@ -120,7 +122,8 @@ func (cfg *PullRequestsConfig) IsMergeStyleAllowed(mergeStyle MergeStyle) bool {
return mergeStyle == MergeStyleMerge && cfg.AllowMerge || return mergeStyle == MergeStyleMerge && cfg.AllowMerge ||
mergeStyle == MergeStyleRebase && cfg.AllowRebase || mergeStyle == MergeStyleRebase && cfg.AllowRebase ||
mergeStyle == MergeStyleRebaseMerge && cfg.AllowRebaseMerge || mergeStyle == MergeStyleRebaseMerge && cfg.AllowRebaseMerge ||
mergeStyle == MergeStyleSquash && cfg.AllowSquash mergeStyle == MergeStyleSquash && cfg.AllowSquash ||
mergeStyle == MergeStyleManuallyMerged && cfg.AllowManualMerge
} }
// AllowedMergeStyleCount returns the total count of allowed merge styles for the PullRequestsConfig // AllowedMergeStyleCount returns the total count of allowed merge styles for the PullRequestsConfig

@ -156,6 +156,8 @@ type RepoSettingForm struct {
PullsAllowRebase bool PullsAllowRebase bool
PullsAllowRebaseMerge bool PullsAllowRebaseMerge bool
PullsAllowSquash bool PullsAllowSquash bool
PullsAllowManualMerge bool
EnableAutodetectManualMerge bool
EnableTimetracker bool EnableTimetracker bool
AllowOnlyContributorsToTrackTime bool AllowOnlyContributorsToTrackTime bool
EnableIssueDependencies bool EnableIssueDependencies bool
@ -556,11 +558,12 @@ func (f *InitializeLabelsForm) Validate(req *http.Request, errs binding.Errors)
// swagger:model MergePullRequestOption // swagger:model MergePullRequestOption
type MergePullRequestForm struct { type MergePullRequestForm struct {
// required: true // required: true
// enum: merge,rebase,rebase-merge,squash // enum: merge,rebase,rebase-merge,squash,manually-merged
Do string `binding:"Required;In(merge,rebase,rebase-merge,squash)"` Do string `binding:"Required;In(merge,rebase,rebase-merge,squash,manually-merged)"`
MergeTitleField string MergeTitleField string
MergeMessageField string MergeMessageField string
ForceMerge *bool `json:"force_merge,omitempty"` MergeCommitID string // only used for manually-merged
ForceMerge *bool `json:"force_merge,omitempty"`
} }
// Validate validates the fields // Validate validates the fields

@ -456,3 +456,12 @@ func (repo *Repository) GetCommitsFromIDs(commitIDs []string) (commits *list.Lis
return commits return commits
} }
// IsCommitInBranch check if the commit is on the branch
func (repo *Repository) IsCommitInBranch(commitID, branch string) (r bool, err error) {
stdout, err := NewCommand("branch", "--contains", commitID, branch).RunInDir(repo.Path)
if err != nil {
return false, err
}
return len(stdout) > 0, err
}

@ -63,3 +63,18 @@ func TestGetCommitWithBadCommitID(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
assert.True(t, IsErrNotExist(err)) assert.True(t, IsErrNotExist(err))
} }
func TestIsCommitInBranch(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
bareRepo1, err := OpenRepository(bareRepo1Path)
assert.NoError(t, err)
defer bareRepo1.Close()
result, err := bareRepo1.IsCommitInBranch("2839944139e0de9737a044f78b0e4b40d989a9e3", "branch1")
assert.NoError(t, err)
assert.Equal(t, true, result)
result, err = bareRepo1.IsCommitInBranch("2839944139e0de9737a044f78b0e4b40d989a9e3", "branch2")
assert.NoError(t, err)
assert.Equal(t, false, result)
}

@ -167,6 +167,10 @@ type EditRepoOption struct {
AllowRebaseMerge *bool `json:"allow_rebase_explicit,omitempty"` AllowRebaseMerge *bool `json:"allow_rebase_explicit,omitempty"`
// either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging. `has_pull_requests` must be `true`. // either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging. `has_pull_requests` must be `true`.
AllowSquash *bool `json:"allow_squash_merge,omitempty"` AllowSquash *bool `json:"allow_squash_merge,omitempty"`
// either `true` to allow mark pr as merged manually, or `false` to prevent it. `has_pull_requests` must be `true`.
AllowManualMerge *bool `json:"allow_manual_merge,omitempty"`
// either `true` to enable AutodetectManualMerge, or `false` to prevent it. `has_pull_requests` must be `true`, Note: In some special cases, misjudgments can occur.
AutodetectManualMerge *bool `json:"autodetect_manual_merge,omitempty"`
// set to `true` to archive this repository. // set to `true` to archive this repository.
Archived *bool `json:"archived,omitempty"` Archived *bool `json:"archived,omitempty"`
// set to a string like `8h30m0s` to set the mirror interval time // set to a string like `8h30m0s` to set the mirror interval time

@ -1105,6 +1105,7 @@ issues.context.delete = Delete
issues.no_content = There is no content yet. issues.no_content = There is no content yet.
issues.close_issue = Close issues.close_issue = Close
issues.pull_merged_at = `merged commit <a href="%[1]s">%[2]s</a> into <b>%[3]s</b> %[4]s` issues.pull_merged_at = `merged commit <a href="%[1]s">%[2]s</a> into <b>%[3]s</b> %[4]s`
issues.manually_pull_merged_at = `merged commit <a href="%[1]s">%[2]s</a> into <b>%[3]s</b> %[4]s manually`
issues.close_comment_issue = Comment and Close issues.close_comment_issue = Comment and Close
issues.reopen_issue = Reopen issues.reopen_issue = Reopen
issues.reopen_comment_issue = Comment and Reopen issues.reopen_comment_issue = Comment and Reopen
@ -1273,6 +1274,7 @@ pulls.compare_compare = pull from
pulls.filter_branch = Filter branch pulls.filter_branch = Filter branch
pulls.no_results = No results found. pulls.no_results = No results found.
pulls.nothing_to_compare = These branches are equal. There is no need to create a pull request. pulls.nothing_to_compare = These branches are equal. There is no need to create a pull request.
pulls.nothing_to_compare_and_allow_empty_pr = These branches are equal. This PR will be empty.
pulls.has_pull_request = `A pull request between these branches already exists: <a href="%[1]s/pulls/%[3]d">%[2]s#%[3]d</a>` pulls.has_pull_request = `A pull request between these branches already exists: <a href="%[1]s/pulls/%[3]d">%[2]s#%[3]d</a>`
pulls.create = Create Pull Request pulls.create = Create Pull Request
pulls.title_desc = wants to merge %[1]d commits from <code>%[2]s</code> into <code id="branch_target">%[3]s</code> pulls.title_desc = wants to merge %[1]d commits from <code>%[2]s</code> into <code id="branch_target">%[3]s</code>
@ -1285,6 +1287,8 @@ pulls.reopen_to_merge = Please reopen this pull request to perform a merge.
pulls.cant_reopen_deleted_branch = This pull request cannot be reopened because the branch was deleted. pulls.cant_reopen_deleted_branch = This pull request cannot be reopened because the branch was deleted.
pulls.merged = Merged pulls.merged = Merged
pulls.merged_as = The pull request has been merged as <a rel="nofollow" class="ui sha" href="%[1]s"><code>%[2]s</code></a>. pulls.merged_as = The pull request has been merged as <a rel="nofollow" class="ui sha" href="%[1]s"><code>%[2]s</code></a>.
pulls.manually_merged = Manually merged
pulls.manually_merged_as = The pull request has been manually merged as <a rel="nofollow" class="ui sha" href="%[1]s"><code>%[2]s</code></a>.
pulls.is_closed = The pull request has been closed. pulls.is_closed = The pull request has been closed.
pulls.has_merged = The pull request has been merged. pulls.has_merged = The pull request has been merged.
pulls.title_wip_desc = `<a href="#">Start the title with <strong>%s</strong></a> to prevent the pull request from being merged accidentally.` pulls.title_wip_desc = `<a href="#">Start the title with <strong>%s</strong></a> to prevent the pull request from being merged accidentally.`
@ -1292,6 +1296,7 @@ pulls.cannot_merge_work_in_progress = This pull request is marked as a work in p
pulls.data_broken = This pull request is broken due to missing fork information. pulls.data_broken = This pull request is broken due to missing fork information.
pulls.files_conflicted = This pull request has changes conflicting with the target branch. pulls.files_conflicted = This pull request has changes conflicting with the target branch.
pulls.is_checking = "Merge conflict checking is in progress. Try again in few moments." pulls.is_checking = "Merge conflict checking is in progress. Try again in few moments."
pulls.is_empty = "This branch is equal with the target branch."
pulls.required_status_check_failed = Some required checks were not successful. pulls.required_status_check_failed = Some required checks were not successful.
pulls.required_status_check_missing = Some required checks are missing. pulls.required_status_check_missing = Some required checks are missing.
pulls.required_status_check_administrator = As an administrator, you may still merge this pull request. pulls.required_status_check_administrator = As an administrator, you may still merge this pull request.
@ -1312,6 +1317,7 @@ pulls.reject_count_1 = "%d change request"
pulls.reject_count_n = "%d change requests" pulls.reject_count_n = "%d change requests"
pulls.waiting_count_1 = "%d waiting review" pulls.waiting_count_1 = "%d waiting review"
pulls.waiting_count_n = "%d waiting reviews" pulls.waiting_count_n = "%d waiting reviews"
pulls.wrong_commit_id = "commit id must be a commit id on the target branch"
pulls.no_merge_desc = This pull request cannot be merged because all repository merge options are disabled. pulls.no_merge_desc = This pull request cannot be merged because all repository merge options are disabled.
pulls.no_merge_helper = Enable merge options in the repository settings or merge the pull request manually. pulls.no_merge_helper = Enable merge options in the repository settings or merge the pull request manually.
@ -1322,6 +1328,8 @@ pulls.merge_pull_request = Merge Pull Request
pulls.rebase_merge_pull_request = Rebase and Merge pulls.rebase_merge_pull_request = Rebase and Merge
pulls.rebase_merge_commit_pull_request = Rebase and Merge (--no-ff) pulls.rebase_merge_commit_pull_request = Rebase and Merge (--no-ff)
pulls.squash_merge_pull_request = Squash and Merge pulls.squash_merge_pull_request = Squash and Merge
pulls.merge_manually = Manually merged
pulls.merge_commit_id = The merge commit ID
pulls.require_signed_wont_sign = The branch requires signed commits but this merge will not be signed pulls.require_signed_wont_sign = The branch requires signed commits but this merge will not be signed
pulls.invalid_merge_option = You cannot use this merge option for this pull request. pulls.invalid_merge_option = You cannot use this merge option for this pull request.
pulls.merge_conflict = Merge Failed: There was a conflict whilst merging. Hint: Try a different strategy pulls.merge_conflict = Merge Failed: There was a conflict whilst merging. Hint: Try a different strategy
@ -1545,6 +1553,8 @@ settings.pulls.allow_merge_commits = Enable Commit Merging
settings.pulls.allow_rebase_merge = Enable Rebasing to Merge Commits settings.pulls.allow_rebase_merge = Enable Rebasing to Merge Commits
settings.pulls.allow_rebase_merge_commit = Enable Rebasing with explicit merge commits (--no-ff) settings.pulls.allow_rebase_merge_commit = Enable Rebasing with explicit merge commits (--no-ff)
settings.pulls.allow_squash_commits = Enable Squashing to Merge Commits settings.pulls.allow_squash_commits = Enable Squashing to Merge Commits
settings.pulls.allow_manual_merge = Enable Mark PR as manually merged
settings.pulls.enable_autodetect_manual_merge = Enable autodetect manual merge (Note: In some special cases, misjudgments can occur)
settings.projects_desc = Enable Repository Projects settings.projects_desc = Enable Repository Projects
settings.admin_settings = Administrator Settings settings.admin_settings = Administrator Settings
settings.admin_enable_health_check = Enable Repository Health Checks (git fsck) settings.admin_enable_health_check = Enable Repository Health Checks (git fsck)

@ -769,13 +769,31 @@ func MergePullRequest(ctx *context.APIContext) {
return return
} }
if !pr.CanAutoMerge() { if pr.HasMerged {
ctx.Error(http.StatusMethodNotAllowed, "PR not in mergeable state", "Please try again later") ctx.Error(http.StatusMethodNotAllowed, "PR already merged", "")
return return
} }
if pr.HasMerged { // handle manually-merged mark
ctx.Error(http.StatusMethodNotAllowed, "PR already merged", "") if models.MergeStyle(form.Do) == models.MergeStyleManuallyMerged {
if err = pull_service.MergedManually(pr, ctx.User, ctx.Repo.GitRepo, form.MergeCommitID); err != nil {
if models.IsErrInvalidMergeStyle(err) {
ctx.Error(http.StatusMethodNotAllowed, "Invalid merge style", fmt.Errorf("%s is not allowed an allowed merge style for this repository", models.MergeStyle(form.Do)))
return
}
if strings.Contains(err.Error(), "Wrong commit ID") {
ctx.JSON(http.StatusConflict, err)
return
}
ctx.Error(http.StatusInternalServerError, "Manually-Merged", err)
return
}
ctx.Status(http.StatusOK)
return
}
if !pr.CanAutoMerge() {
ctx.Error(http.StatusMethodNotAllowed, "PR not in mergeable state", "Please try again later")
return return
} }

@ -725,6 +725,8 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error {
AllowRebase: true, AllowRebase: true,
AllowRebaseMerge: true, AllowRebaseMerge: true,
AllowSquash: true, AllowSquash: true,
AllowManualMerge: true,
AutodetectManualMerge: false,
} }
} else { } else {
config = unit.PullRequestsConfig() config = unit.PullRequestsConfig()
@ -745,6 +747,12 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error {
if opts.AllowSquash != nil { if opts.AllowSquash != nil {
config.AllowSquash = *opts.AllowSquash config.AllowSquash = *opts.AllowSquash
} }
if opts.AllowManualMerge != nil {
config.AllowManualMerge = *opts.AllowManualMerge
}
if opts.AutodetectManualMerge != nil {
config.AutodetectManualMerge = *opts.AutodetectManualMerge
}
units = append(units, models.RepoUnit{ units = append(units, models.RepoUnit{
RepoID: repo.ID, RepoID: repo.ID,

@ -429,6 +429,14 @@ func PrepareCompareDiff(
if headCommitID == compareInfo.MergeBase { if headCommitID == compareInfo.MergeBase {
ctx.Data["IsNothingToCompare"] = true ctx.Data["IsNothingToCompare"] = true
if unit, err := repo.GetUnit(models.UnitTypePullRequests); err == nil {
config := unit.PullRequestsConfig()
if !config.AutodetectManualMerge {
ctx.Data["AllowEmptyPr"] = !(baseBranch == headBranch && ctx.Repo.Repository.Name == headRepo.Name)
} else {
ctx.Data["AllowEmptyPr"] = false
}
}
return true return true
} }

@ -1491,6 +1491,8 @@ func ViewIssue(ctx *context.Context) {
ctx.Data["MergeStyle"] = models.MergeStyleRebaseMerge ctx.Data["MergeStyle"] = models.MergeStyleRebaseMerge
} else if prConfig.AllowSquash { } else if prConfig.AllowSquash {
ctx.Data["MergeStyle"] = models.MergeStyleSquash ctx.Data["MergeStyle"] = models.MergeStyleSquash
} else if prConfig.AllowManualMerge {
ctx.Data["MergeStyle"] = models.MergeStyleManuallyMerged
} else { } else {
ctx.Data["MergeStyle"] = "" ctx.Data["MergeStyle"] = ""
} }
@ -1531,6 +1533,22 @@ func ViewIssue(ctx *context.Context) {
pull.HeadRepo != nil && pull.HeadRepo != nil &&
git.IsBranchExist(pull.HeadRepo.RepoPath(), pull.HeadBranch) && git.IsBranchExist(pull.HeadRepo.RepoPath(), pull.HeadBranch) &&
(!pull.HasMerged || ctx.Data["HeadBranchCommitID"] == ctx.Data["PullHeadCommitID"]) (!pull.HasMerged || ctx.Data["HeadBranchCommitID"] == ctx.Data["PullHeadCommitID"])
stillCanManualMerge := func() bool {
if pull.HasMerged || issue.IsClosed || !ctx.IsSigned {
return false
}
if pull.CanAutoMerge() || pull.IsWorkInProgress() || pull.IsChecking() {
return false
}
if (ctx.User.IsAdmin || ctx.Repo.IsAdmin()) && prConfig.AllowManualMerge {
return true
}
return false
}
ctx.Data["StillCanManualMerge"] = stillCanManualMerge()
} }
// Get Dependencies // Get Dependencies

@ -33,6 +33,7 @@ import (
"code.gitea.io/gitea/services/gitdiff" "code.gitea.io/gitea/services/gitdiff"
pull_service "code.gitea.io/gitea/services/pull" pull_service "code.gitea.io/gitea/services/pull"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
"github.com/unknwon/com"
) )
const ( const (
@ -794,15 +795,36 @@ func MergePullRequest(ctx *context.Context) {
return return
} }
if !pr.CanAutoMerge() { if pr.HasMerged {
ctx.Flash.Error(ctx.Tr("repo.pulls.no_merge_not_ready")) ctx.Flash.Error(ctx.Tr("repo.pulls.has_merged"))
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(issue.Index)) ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index))
return return
} }
if pr.HasMerged { // handle manually-merged mark
ctx.Flash.Error(ctx.Tr("repo.pulls.has_merged")) if models.MergeStyle(form.Do) == models.MergeStyleManuallyMerged {
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(issue.Index)) if err = pull_service.MergedManually(pr, ctx.User, ctx.Repo.GitRepo, form.MergeCommitID); err != nil {
if models.IsErrInvalidMergeStyle(err) {
ctx.Flash.Error(ctx.Tr("repo.pulls.invalid_merge_option"))
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index))
return
} else if strings.Contains(err.Error(), "Wrong commit ID") {
ctx.Flash.Error(ctx.Tr("repo.pulls.wrong_commit_id"))
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index))
return
}
ctx.ServerError("MergedManually", err)
return
}
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index))
return
}
if !pr.CanAutoMerge() {
ctx.Flash.Error(ctx.Tr("repo.pulls.no_merge_not_ready"))
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index))
return return
} }

@ -321,6 +321,8 @@ func SettingsPost(ctx *context.Context) {
AllowRebase: form.PullsAllowRebase, AllowRebase: form.PullsAllowRebase,
AllowRebaseMerge: form.PullsAllowRebaseMerge, AllowRebaseMerge: form.PullsAllowRebaseMerge,
AllowSquash: form.PullsAllowSquash, AllowSquash: form.PullsAllowSquash,
AllowManualMerge: form.PullsAllowManualMerge,
AutodetectManualMerge: form.EnableAutodetectManualMerge,
}, },
}) })
} else if !models.UnitTypePullRequests.UnitGlobalDisabled() { } else if !models.UnitTypePullRequests.UnitGlobalDisabled() {

@ -116,7 +116,7 @@ func getMergeCommit(pr *models.PullRequest) (*git.Commit, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("git rev-list --ancestry-path --merges --reverse: %v", err) return nil, fmt.Errorf("git rev-list --ancestry-path --merges --reverse: %v", err)
} else if len(mergeCommit) < 40 { } else if len(mergeCommit) < 40 {
// PR was fast-forwarded, so just use last commit of PR // PR was maybe fast-forwarded, so just use last commit of PR
mergeCommit = commitID[:40] mergeCommit = commitID[:40]
} }
@ -137,6 +137,21 @@ func getMergeCommit(pr *models.PullRequest) (*git.Commit, error) {
// manuallyMerged checks if a pull request got manually merged // manuallyMerged checks if a pull request got manually merged
// When a pull request got manually merged mark the pull request as merged // When a pull request got manually merged mark the pull request as merged
func manuallyMerged(pr *models.PullRequest) bool { func manuallyMerged(pr *models.PullRequest) bool {
if err := pr.LoadBaseRepo(); err != nil {
log.Error("PullRequest[%d].LoadBaseRepo: %v", pr.ID, err)
return false
}
if unit, err := pr.BaseRepo.GetUnit(models.UnitTypePullRequests); err == nil {
config := unit.PullRequestsConfig()
if !config.AutodetectManualMerge {
return false
}
} else {
log.Error("PullRequest[%d].BaseRepo.GetUnit(models.UnitTypePullRequests): %v", pr.ID, err)
return false
}
commit, err := getMergeCommit(pr) commit, err := getMergeCommit(pr)
if err != nil { if err != nil {
log.Error("PullRequest[%d].getMergeCommit: %v", pr.ID, err) log.Error("PullRequest[%d].getMergeCommit: %v", pr.ID, err)

@ -615,3 +615,54 @@ func CheckPRReadyToMerge(pr *models.PullRequest, skipProtectedFilesCheck bool) (
return nil return nil
} }
// MergedManually mark pr as merged manually
func MergedManually(pr *models.PullRequest, doer *models.User, baseGitRepo *git.Repository, commitID string) (err error) {
prUnit, err := pr.BaseRepo.GetUnit(models.UnitTypePullRequests)
if err != nil {
return
}
prConfig := prUnit.PullRequestsConfig()
// Check if merge style is correct and allowed
if !prConfig.IsMergeStyleAllowed(models.MergeStyleManuallyMerged) {
return models.ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: models.MergeStyleManuallyMerged}
}
if len(commitID) < 40 {
return fmt.Errorf("Wrong commit ID")
}
commit, err := baseGitRepo.GetCommit(commitID)
if err != nil {
if git.IsErrNotExist(err) {
return fmt.Errorf("Wrong commit ID")
}
return
}
ok, err := baseGitRepo.IsCommitInBranch(commitID, pr.BaseBranch)
if err != nil {
return
}
if !ok {
return fmt.Errorf("Wrong commit ID")
}
pr.MergedCommitID = commitID
pr.MergedUnix = timeutil.TimeStamp(commit.Author.When.Unix())
pr.Status = models.PullRequestStatusManuallyMerged
pr.Merger = doer
pr.MergerID = doer.ID
merged := false
if merged, err = pr.SetMerged(); err != nil {
return
} else if !merged {
return fmt.Errorf("SetMerged failed")
}
notification.NotifyMergePullRequest(pr, doer)
log.Info("manuallyMerged[%d]: Marked as manually merged into %s/%s by commit id: %s", pr.ID, pr.BaseRepo.Name, pr.BaseBranch, commit.ID.String())
return nil
}

@ -80,7 +80,7 @@ func TestPatch(pr *models.PullRequest) error {
pr.MergeBase = strings.TrimSpace(pr.MergeBase) pr.MergeBase = strings.TrimSpace(pr.MergeBase)
// 2. Check for conflicts // 2. Check for conflicts
if conflicts, err := checkConflicts(pr, gitRepo, tmpBasePath); err != nil || conflicts { if conflicts, err := checkConflicts(pr, gitRepo, tmpBasePath); err != nil || conflicts || pr.Status == models.PullRequestStatusEmpty {
return err return err
} }
@ -125,8 +125,9 @@ func checkConflicts(pr *models.PullRequest, gitRepo *git.Repository, tmpBasePath
// 1a. if the size of that patch is 0 - there can be no conflicts! // 1a. if the size of that patch is 0 - there can be no conflicts!
if stat.Size() == 0 { if stat.Size() == 0 {
log.Debug("PullRequest[%d]: Patch is empty - ignoring", pr.ID) log.Debug("PullRequest[%d]: Patch is empty - ignoring", pr.ID)
pr.Status = models.PullRequestStatusMergeable pr.Status = models.PullRequestStatusEmpty
pr.ConflictedFiles = []string{} pr.ConflictedFiles = []string{}
pr.ChangedProtectedFiles = []string{}
return false, nil return false, nil
} }

@ -83,7 +83,17 @@
{{end}} {{end}}
{{if .IsNothingToCompare}} {{if .IsNothingToCompare}}
<div class="ui segment">{{.i18n.Tr "repo.pulls.nothing_to_compare"}}</div> {{if and $.IsSigned $.AllowEmptyPr (not .Repository.IsArchived) }}
<div class="ui segment">{{.i18n.Tr "repo.pulls.nothing_to_compare_and_allow_empty_pr"}}</div>
<div class="ui info message show-form-container">
<button class="ui button green show-form">{{.i18n.Tr "repo.pulls.new"}}</button>
</div>
<div class="pullrequest-form" style="display: none">
{{template "repo/issue/new_form" .}}
</div>
{{else}}
<div class="ui segment">{{.i18n.Tr "repo.pulls.nothing_to_compare"}}</div>
{{end}}
{{else if and .PageIsComparePull (gt .CommitCount 0)}} {{else if and .PageIsComparePull (gt .CommitCount 0)}}
{{if .HasPullRequest}} {{if .HasPullRequest}}
<div class="ui segment"> <div class="ui segment">

@ -124,7 +124,11 @@
<span class="text grey"> <span class="text grey">
<a class="author" href="{{.Poster.HomeLink}}">{{.Poster.GetDisplayName}}</a> <a class="author" href="{{.Poster.HomeLink}}">{{.Poster.GetDisplayName}}</a>
{{$link := printf "%s/commit/%s" $.Repository.HTMLURL $.Issue.PullRequest.MergedCommitID}} {{$link := printf "%s/commit/%s" $.Repository.HTMLURL $.Issue.PullRequest.MergedCommitID}}
{{$.i18n.Tr "repo.issues.pull_merged_at" $link (ShortSha $.Issue.PullRequest.MergedCommitID) ($.BaseTarget|Escape) $createdStr | Str2html}} {{if eq $.Issue.PullRequest.Status 3}}
{{$.i18n.Tr "repo.issues.manually_pull_merged_at" $link (ShortSha $.Issue.PullRequest.MergedCommitID) $.BaseTarget $createdStr | Str2html}}
{{else}}
{{$.i18n.Tr "repo.issues.pull_merged_at" $link (ShortSha $.Issue.PullRequest.MergedCommitID) $.BaseTarget $createdStr | Str2html}}
{{end}}
</span> </span>
</div> </div>
{{else if eq .Type 3 5 6}} {{else if eq .Type 3 5 6}}

@ -118,6 +118,7 @@
{{- else if and .EnableStatusCheck (or (not $.LatestCommitStatus) .RequiredStatusCheckState.IsPending .RequiredStatusCheckState.IsWarning)}}yellow {{- else if and .EnableStatusCheck (or (not $.LatestCommitStatus) .RequiredStatusCheckState.IsPending .RequiredStatusCheckState.IsWarning)}}yellow
{{- else if and .AllowMerge .RequireSigned (not .WillSign)}}red {{- else if and .AllowMerge .RequireSigned (not .WillSign)}}red
{{- else if .Issue.PullRequest.IsChecking}}yellow {{- else if .Issue.PullRequest.IsChecking}}yellow
{{- else if .Issue.PullRequest.IsEmpty}}grey
{{- else if .Issue.PullRequest.CanAutoMerge}}green {{- else if .Issue.PullRequest.CanAutoMerge}}green
{{- else}}red{{end}}">{{svg "octicon-git-merge" 32}}</a> {{- else}}red{{end}}">{{svg "octicon-git-merge" 32}}</a>
<div class="content"> <div class="content">
@ -128,7 +129,11 @@
<div class="item text"> <div class="item text">
{{if .Issue.PullRequest.MergedCommitID}} {{if .Issue.PullRequest.MergedCommitID}}
{{$link := printf "%s/commit/%s" $.Repository.HTMLURL .Issue.PullRequest.MergedCommitID}} {{$link := printf "%s/commit/%s" $.Repository.HTMLURL .Issue.PullRequest.MergedCommitID}}
{{$.i18n.Tr "repo.pulls.merged_as" $link (ShortSha .Issue.PullRequest.MergedCommitID) | Safe}} {{if eq $.Issue.PullRequest.Status 3}}
{{$.i18n.Tr "repo.pulls.manually_merged_as" $link (ShortSha .Issue.PullRequest.MergedCommitID) | Safe}}
{{else}}
{{$.i18n.Tr "repo.pulls.merged_as" $link (ShortSha .Issue.PullRequest.MergedCommitID) | Safe}}
{{end}}
{{else}} {{else}}
{{$.i18n.Tr "repo.pulls.has_merged"}} {{$.i18n.Tr "repo.pulls.has_merged"}}
{{end}} {{end}}
@ -176,6 +181,11 @@
<i class="icon icon-octicon">{{svg "octicon-sync"}}</i> <i class="icon icon-octicon">{{svg "octicon-sync"}}</i>
{{$.i18n.Tr "repo.pulls.is_checking"}} {{$.i18n.Tr "repo.pulls.is_checking"}}
</div> </div>
{{else if .Issue.PullRequest.IsEmpty}}
<div class="item text grey">
<i class="icon icon-octicon">{{svg "octicon-alert" 16}}</i>
{{$.i18n.Tr "repo.pulls.is_empty"}}
</div>
{{else if .Issue.PullRequest.CanAutoMerge}} {{else if .Issue.PullRequest.CanAutoMerge}}
{{if .IsBlockedByApprovals}} {{if .IsBlockedByApprovals}}
<div class="item"> <div class="item">
@ -350,6 +360,22 @@
</form> </form>
</div> </div>
{{end}} {{end}}
{{if and $prUnit.PullRequestsConfig.AllowManualMerge $.IsRepoAdmin}}
<div class="ui form manually-merged-fields" style="display: none">
<form action="{{.Link}}/merge" method="post">
{{.CsrfTokenHtml}}
<div class="field">
<input type="text" name="merge_commit_id" placeholder="{{$.i18n.Tr "repo.pulls.merge_commit_id"}}">
</div>
<button class="ui red button" type="submit" name="do" value="manually-merged">
{{$.i18n.Tr "repo.pulls.merge_manually"}}
</button>
<button class="ui button merge-cancel">
{{$.i18n.Tr "cancel"}}
</button>
</form>
</div>
{{end}}
<div class="dib"> <div class="dib">
<div class="ui {{if $notAllOverridableChecksOk}}red{{else}}green{{end}} buttons merge-button"> <div class="ui {{if $notAllOverridableChecksOk}}red{{else}}green{{end}} buttons merge-button">
<button class="ui button" data-do="{{.MergeStyle}}"> <button class="ui button" data-do="{{.MergeStyle}}">
@ -367,6 +393,9 @@
{{if eq .MergeStyle "squash"}} {{if eq .MergeStyle "squash"}}
{{$.i18n.Tr "repo.pulls.squash_merge_pull_request"}} {{$.i18n.Tr "repo.pulls.squash_merge_pull_request"}}
{{end}} {{end}}
{{if eq .MergeStyle "manually-merged"}}
{{$.i18n.Tr "repo.pulls.merge_manually"}}
{{end}}
</span> </span>
</button> </button>
{{if gt $prUnit.PullRequestsConfig.AllowedMergeStyleCount 1}} {{if gt $prUnit.PullRequestsConfig.AllowedMergeStyleCount 1}}
@ -385,6 +414,9 @@
{{if $prUnit.PullRequestsConfig.AllowSquash}} {{if $prUnit.PullRequestsConfig.AllowSquash}}
<div class="item{{if eq .MergeStyle "squash"}} active selected{{end}}" data-do="squash">{{$.i18n.Tr "repo.pulls.squash_merge_pull_request"}}</div> <div class="item{{if eq .MergeStyle "squash"}} active selected{{end}}" data-do="squash">{{$.i18n.Tr "repo.pulls.squash_merge_pull_request"}}</div>
{{end}} {{end}}
{{if and $prUnit.PullRequestsConfig.AllowManualMerge $.IsRepoAdmin}}
<div class="item{{if eq .MergeStyle "manually-merged"}} active selected{{end}}" data-do="manually-merged">{{$.i18n.Tr "repo.pulls.merge_manually"}}</div>
{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
@ -492,6 +524,30 @@
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
{{if $.StillCanManualMerge}}
<div class="ui divider"></div>
<div class="ui form manually-merged-fields" style="display: none">
<form action="{{.Link}}/merge" method="post">
{{.CsrfTokenHtml}}
<div class="field">
<input type="text" name="merge_commit_id" placeholder="{{$.i18n.Tr "repo.pulls.merge_commit_id"}}">
</div>
<button class="ui red button" type="submit" name="do" value="manually-merged">
{{$.i18n.Tr "repo.pulls.merge_manually"}}
</button>
<button class="ui button merge-cancel">
{{$.i18n.Tr "cancel"}}
</button>
</form>
</div>
<div class="ui red buttons merge-button">
<button class="ui button" data-do="manually-merged">
{{$.i18n.Tr "repo.pulls.merge_manually"}}
</button>
</div>
{{end}}
</div> </div>
</div> </div>
</div> </div>

@ -20,7 +20,7 @@
{{end}} {{end}}
</div> </div>
{{if .HasMerged}} {{if .HasMerged}}
<div class="ui purple large label">{{svg "octicon-git-merge"}} {{.i18n.Tr "repo.pulls.merged"}}</div> <div class="ui purple large label">{{svg "octicon-git-merge" 16}} {{if eq .Issue.PullRequest.Status 3}}{{.i18n.Tr "repo.pulls.manually_merged"}}{{else}}{{.i18n.Tr "repo.pulls.merged"}}{{end}}</div>
{{else if .Issue.IsClosed}} {{else if .Issue.IsClosed}}
<div class="ui red large label">{{if .Issue.IsPull}}{{svg "octicon-git-pull-request"}}{{else}}{{svg "octicon-issue-closed"}}{{end}} {{.i18n.Tr "repo.issues.closed_title"}}</div> <div class="ui red large label">{{if .Issue.IsPull}}{{svg "octicon-git-pull-request"}}{{else}}{{svg "octicon-issue-closed"}}{{end}} {{.i18n.Tr "repo.issues.closed_title"}}</div>
{{else if .Issue.IsPull}} {{else if .Issue.IsPull}}

@ -330,6 +330,18 @@
<label>{{.i18n.Tr "repo.settings.pulls.allow_squash_commits"}}</label> <label>{{.i18n.Tr "repo.settings.pulls.allow_squash_commits"}}</label>
</div> </div>
</div> </div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_manual_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowManualMerge)}}checked{{end}}>
<label>{{.i18n.Tr "repo.settings.pulls.allow_manual_merge"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="enable_autodetect_manual_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AutodetectManualMerge)}}checked{{end}}>
<label>{{.i18n.Tr "repo.settings.pulls.enable_autodetect_manual_merge"}}</label>
</div>
</div>
</div> </div>
{{end}} {{end}}

@ -13586,6 +13586,11 @@
"description": "EditRepoOption options when editing a repository's properties", "description": "EditRepoOption options when editing a repository's properties",
"type": "object", "type": "object",
"properties": { "properties": {
"allow_manual_merge": {
"description": "either `true` to allow mark pr as merged manually, or `false` to prevent it. `has_pull_requests` must be `true`.",
"type": "boolean",
"x-go-name": "AllowManualMerge"
},
"allow_merge_commits": { "allow_merge_commits": {
"description": "either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits. `has_pull_requests` must be `true`.", "description": "either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits. `has_pull_requests` must be `true`.",
"type": "boolean", "type": "boolean",
@ -13611,6 +13616,11 @@
"type": "boolean", "type": "boolean",
"x-go-name": "Archived" "x-go-name": "Archived"
}, },
"autodetect_manual_merge": {
"description": "either `true` to enable AutodetectManualMerge, or `false` to prevent it. `has_pull_requests` must be `true`, Note: In some special cases, misjudgments can occur.",
"type": "boolean",
"x-go-name": "AutodetectManualMerge"
},
"default_branch": { "default_branch": {
"description": "sets the default branch for this repository.", "description": "sets the default branch for this repository.",
"type": "string", "type": "string",
@ -14596,9 +14606,13 @@
"merge", "merge",
"rebase", "rebase",
"rebase-merge", "rebase-merge",
"squash" "squash",
"manually-merged"
] ]
}, },
"MergeCommitID": {
"type": "string"
},
"MergeMessageField": { "MergeMessageField": {
"type": "string" "type": "string"
}, },

Loading…
Cancel
Save