Skip to content

Commit a61f329

Browse files
committed
Auto merge pull requests when all checks succeeded
Signed-off-by: kolaente <[email protected]>
1 parent 99082ee commit a61f329

File tree

15 files changed

+556
-96
lines changed

15 files changed

+556
-96
lines changed

models/migrations/migrations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ var migrations = []Migration{
210210
NewMigration("Add Branch Protection Block Outdated Branch", addBlockOnOutdatedBranch),
211211
// v138 -> v139
212212
NewMigration("Add ResolveDoerID to Comment table", addResolveDoerIDCommentColumn),
213+
// v139 -> v140
214+
NewMigration("Add auto merge table", addAutoMergeTable),
213215
}
214216

215217
// GetCurrentDBVersion returns the current db version

models/migrations/v139.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright 2020 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package migrations
6+
7+
import "xorm.io/xorm"
8+
9+
func addAutoMergeTable(x *xorm.Engine) error {
10+
type MergeStyle string
11+
type ScheduledPullRequestMerge struct {
12+
ID int64 `xorm:"pk autoincr"`
13+
PullID int64 `xorm:"BIGINT"`
14+
UserID int64 `xorm:"BIGINT"`
15+
MergeStyle MergeStyle `xorm:"varchar(50)"`
16+
Message string `xorm:"TEXT"`
17+
}
18+
19+
return x.Sync2(&ScheduledPullRequestMerge{})
20+
}

models/pull.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,3 +636,16 @@ func (pr *PullRequest) updateCommitDivergence(e Engine, ahead, behind int) error
636636
func (pr *PullRequest) IsSameRepo() bool {
637637
return pr.BaseRepoID == pr.HeadRepoID
638638
}
639+
640+
// GetPullRequestByHeadBranch returns a pr by head branch
641+
func GetPullRequestByHeadBranch(headBranch string, repo *Repository) (pr *PullRequest, err error) {
642+
pr = &PullRequest{}
643+
exists, err := x.Where("head_branch = ? AND head_repo_id = ?", headBranch, repo.ID).Get(pr)
644+
if !exists {
645+
return nil, ErrPullRequestNotExist{
646+
HeadBranch: headBranch,
647+
HeadRepoID: repo.ID,
648+
}
649+
}
650+
return
651+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2019 Gitea. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package models
6+
7+
import "code.gitea.io/gitea/modules/timeutil"
8+
9+
// ScheduledPullRequestMerge represents a pull request scheduled for merging when checks succeed
10+
type ScheduledPullRequestMerge struct {
11+
ID int64 `xorm:"pk autoincr"`
12+
PullID int64 `xorm:"BIGINT"`
13+
UserID int64 `xorm:"BIGINT"`
14+
User *User `xorm:"-"`
15+
MergeStyle MergeStyle `xorm:"varchar(50)"`
16+
Message string `xorm:"TEXT"`
17+
CreatedUnix timeutil.TimeStamp `xorm:"created"`
18+
}
19+
20+
// ScheduleAutoMerge schedules a pull request to be merged when all checks succeed
21+
func ScheduleAutoMerge(opts *ScheduledPullRequestMerge) (err error) {
22+
// Check if we already have a merge scheduled for that pull request
23+
exists, err := x.Exist(&ScheduledPullRequestMerge{PullID: opts.PullID})
24+
if err != nil {
25+
return
26+
}
27+
if exists {
28+
// Maybe FIXME: Should we return a custom error here?
29+
return nil
30+
}
31+
32+
_, err = x.Insert(opts)
33+
return err
34+
}
35+
36+
// GetScheduledMergeRequestByPullID gets a scheduled pull request merge by pull request id
37+
func GetScheduledMergeRequestByPullID(pullID int64) (exists bool, scheduledPRM *ScheduledPullRequestMerge, err error) {
38+
scheduledPRM = &ScheduledPullRequestMerge{}
39+
exists, err = x.Where("pull_id = ?", pullID).Get(scheduledPRM)
40+
if err != nil || !exists {
41+
return
42+
}
43+
scheduledPRM.User, err = getUserByID(x, scheduledPRM.UserID)
44+
return
45+
}
46+
47+
// RemoveScheduledMergeRequest cancels a previously scheduled pull request
48+
func RemoveScheduledMergeRequest(scheduledPR *ScheduledPullRequestMerge) (err error) {
49+
if scheduledPR.ID == 0 && scheduledPR.PullID != 0 {
50+
_, err = x.Where("pull_id = ?", scheduledPR.PullID).Delete(&ScheduledPullRequestMerge{})
51+
return
52+
}
53+
_, err = x.Where("id = ? AND pull_id = ?", scheduledPR.ID, scheduledPR.PullID).Delete(&ScheduledPullRequestMerge{})
54+
return
55+
}

modules/auth/repo_form.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -483,10 +483,11 @@ func (f *InitializeLabelsForm) Validate(ctx *macaron.Context, errs binding.Error
483483
type MergePullRequestForm struct {
484484
// required: true
485485
// enum: merge,rebase,rebase-merge,squash
486-
Do string `binding:"Required;In(merge,rebase,rebase-merge,squash)"`
487-
MergeTitleField string
488-
MergeMessageField string
489-
ForceMerge *bool `json:"force_merge,omitempty"`
486+
Do string `binding:"Required;In(merge,rebase,rebase-merge,squash)"`
487+
MergeTitleField string
488+
MergeMessageField string
489+
ForceMerge *bool `json:"force_merge,omitempty"`
490+
MergeWhenChecksSucceed bool
490491
}
491492

492493
// Validate validates the fields

modules/git/commit.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@ func (c *Commit) GetSubModule(entryname string) (*SubModule, error) {
467467
}
468468

469469
// GetBranchName gets the closes branch name (as returned by 'git name-rev')
470+
// FIXME: This get only one branch, but one commit can be part of multiple branches
470471
func (c *Commit) GetBranchName() (string, error) {
471472
data, err := NewCommand("name-rev", c.ID.String()).RunInDirBytes(c.repo.Path)
472473
if err != nil {
@@ -477,6 +478,25 @@ func (c *Commit) GetBranchName() (string, error) {
477478
return strings.Split(strings.Split(string(data), " ")[1], "~")[0], nil
478479
}
479480

481+
// GetBranchNames returns all branches a commit is part of
482+
func (c *Commit) GetBranchNames() (branchNames []string, err error) {
483+
data, err := NewCommand("name-rev", c.ID.String()).RunInDirBytes(c.repo.Path)
484+
if err != nil {
485+
return
486+
}
487+
488+
namesRaw := strings.Split(string(data), "\n")
489+
for _, s := range namesRaw {
490+
s = strings.TrimSpace(s)
491+
if s == "" {
492+
continue
493+
}
494+
branchNames = append(branchNames, strings.Split(strings.Split(s, " ")[1], "~")[0])
495+
}
496+
497+
return
498+
}
499+
480500
// CommitFileStatus represents status of files in a commit.
481501
type CommitFileStatus struct {
482502
Added []string

modules/repofiles/commit_status.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"code.gitea.io/gitea/models"
1111
"code.gitea.io/gitea/modules/git"
12+
"code.gitea.io/gitea/services/automerge"
1213
)
1314

1415
// CreateCommitStatus creates a new CommitStatus given a bunch of parameters
@@ -37,5 +38,12 @@ func CreateCommitStatus(repo *models.Repository, creator *models.User, sha strin
3738
return fmt.Errorf("NewCommitStatus[repo_id: %d, user_id: %d, sha: %s]: %v", repo.ID, creator.ID, sha, err)
3839
}
3940

41+
if status.State.IsSuccess() {
42+
err = automerge.MergeScheduledPullRequest(sha, repo)
43+
if err != nil {
44+
return fmt.Errorf("MergeScheduledPullRequest[repo_id: %d, user_id: %d, sha: %s]: %v", repo.ID, creator.ID, sha, err)
45+
}
46+
}
47+
4048
return nil
4149
}

options/locale/locale_en-US.ini

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,14 @@ pulls.rebase_merge_pull_request = Rebase and Merge
11351135
pulls.rebase_merge_commit_pull_request = Rebase and Merge (--no-ff)
11361136
pulls.squash_merge_pull_request = Squash and Merge
11371137
pulls.require_signed_wont_sign = The branch requires signed commits but this merge will not be signed
1138+
pulls.merge_pull_request_now = Merge Pull Request Now
1139+
pulls.rebase_merge_pull_request_now = Rebase and Merge Now
1140+
pulls.rebase_merge_commit_pull_request_now = Rebase and Merge Now (--no-ff)
1141+
pulls.squash_merge_pull_request_now = Squash and Merge Now
1142+
pulls.merge_pull_request_on_status_success = Merge Pull Request When All Checks Succeed
1143+
pulls.rebase_merge_pull_request_on_status_success = Rebase and Merge When All Checks Succeed
1144+
pulls.rebase_merge_commit_pull_request_on_status_success = Rebase and Merge (--no-ff) When All Checks Succeed
1145+
pulls.squash_merge_pull_request_on_status_success = Squash and Merge When All Checks Succeed
11381146
pulls.invalid_merge_option = You cannot use this merge option for this pull request.
11391147
pulls.merge_conflict = Merge Failed: There was a conflict whilst merging: %[1]s<br>%[2]s<br>Hint: Try a different strategy
11401148
pulls.rebase_conflict = Merge Failed: There was a conflict whilst rebasing commit: %[1]s<br>%[2]s<br>%[3]s<br>Hint:Try a different strategy
@@ -1154,6 +1162,11 @@ pulls.update_not_allowed = You are not allowed to update branch
11541162
pulls.outdated_with_base_branch = This branch is out-of-date with the base branch
11551163
pulls.closed_at = `closed this pull request <a id="%[1]s" href="#%[1]s">%[2]s</a>`
11561164
pulls.reopened_at = `reopened this pull request <a id="%[1]s" href="#%[1]s">%[2]s</a>`
1165+
pulls.merge_on_status_success_success = The pull request was successfully scheduled to merge when all checks succeed.
1166+
pulls.pr_has_pending_merge_on_success = This pull request has been set to auto merge when all checks succeed by %[1]s %[2]s.
1167+
pulls.merge_pull_on_success_cancel = Cancel automatic merge
1168+
pulls.pull_request_not_scheduled = This pull request is not scheduled to auto merge.
1169+
pulls.pull_request_schedule_canceled = This pull request was successfully canceled for auto merge.
11571170
11581171
milestones.new = New Milestone
11591172
milestones.open_tab = %d Open

routers/repo/issue.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,13 @@ func ViewIssue(ctx *context.Context) {
11041104
ctx.ServerError("GetReviewersByIssueID", err)
11051105
return
11061106
}
1107+
1108+
// Check if there is a pending pr merge
1109+
ctx.Data["HasPendingPullRequestMerge"], ctx.Data["PendingPullRequestMerge"], err = models.GetScheduledMergeRequestByPullID(pull.ID)
1110+
if err != nil {
1111+
ctx.ServerError("GetReviewersByIssueID", err)
1112+
return
1113+
}
11071114
}
11081115

11091116
// Get Dependencies

routers/repo/pull.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,34 @@ func MergePullRequest(ctx *context.Context, form auth.MergePullRequestForm) {
810810
return
811811
}
812812

813+
lastCommitStatus, err := pr.GetLastCommitStatus()
814+
if err != nil {
815+
return
816+
}
817+
if form.MergeWhenChecksSucceed && !lastCommitStatus.State.IsSuccess() {
818+
err = models.ScheduleAutoMerge(&models.ScheduledPullRequestMerge{
819+
PullID: pr.ID,
820+
UserID: ctx.User.ID,
821+
MergeStyle: models.MergeStyle(form.Do),
822+
Message: message,
823+
})
824+
if err != nil {
825+
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
826+
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(pr.Index))
827+
return
828+
}
829+
ctx.Flash.Success(ctx.Tr("repo.pulls.merge_on_status_success_success"))
830+
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(pr.Index))
831+
return
832+
}
833+
// Removing an auto merge pull request is something we can execute whether or not a pull request auto merge was
834+
// scheduled before, hece we can remove it without checking for its existence.
835+
err = models.RemoveScheduledMergeRequest(&models.ScheduledPullRequestMerge{PullID: pr.ID})
836+
if err != nil {
837+
ctx.ServerError("RemoveScheduledMergeRequest", err)
838+
return
839+
}
840+
813841
if err = pull_service.Merge(pr, ctx.User, ctx.Repo.GitRepo, models.MergeStyle(form.Do), message); err != nil {
814842
if models.IsErrInvalidMergeStyle(err) {
815843
ctx.Flash.Error(ctx.Tr("repo.pulls.invalid_merge_option"))
@@ -860,6 +888,33 @@ func MergePullRequest(ctx *context.Context, form auth.MergePullRequestForm) {
860888
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(pr.Index))
861889
}
862890

891+
// CancelAutoMergePullRequest cancels a scheduled pr
892+
func CancelAutoMergePullRequest(ctx *context.Context) {
893+
issue := checkPullInfo(ctx)
894+
if ctx.Written() {
895+
return
896+
}
897+
pr := issue.PullRequest
898+
exists, scheduledInfo, err := models.GetScheduledMergeRequestByPullID(pr.ID)
899+
if err != nil {
900+
ctx.ServerError("GetScheduledMergeRequestByPullID", err)
901+
return
902+
}
903+
if !exists {
904+
ctx.Flash.Error(ctx.Tr("repo.pulls.pull_request_not_scheduled"))
905+
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(pr.Index))
906+
return
907+
}
908+
err = models.RemoveScheduledMergeRequest(scheduledInfo)
909+
if err != nil {
910+
ctx.ServerError("RemoveScheduledMergeRequest", err)
911+
return
912+
}
913+
914+
ctx.Flash.Success(ctx.Tr("repo.pulls.pull_request_schedule_canceled"))
915+
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index))
916+
}
917+
863918
func stopTimerIfAvailable(user *models.User, issue *models.Issue) error {
864919

865920
if models.StopwatchExists(user.ID, issue.ID) {

routers/routes/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,7 @@ func RegisterRoutes(m *macaron.Macaron) {
895895
m.Get(".patch", repo.DownloadPullPatch)
896896
m.Get("/commits", context.RepoRef(), repo.ViewPullCommits)
897897
m.Post("/merge", context.RepoMustNotBeArchived(), bindIgnErr(auth.MergePullRequestForm{}), repo.MergePullRequest)
898+
m.Post("/cancel_auto_merge", context.RepoMustNotBeArchived(), repo.CancelAutoMergePullRequest)
898899
m.Post("/update", repo.UpdatePullRequest)
899900
m.Post("/cleanup", context.RepoMustNotBeArchived(), context.RepoRef(), repo.CleanUpPullRequest)
900901
m.Group("/files", func() {

0 commit comments

Comments
 (0)