From a61f329ff509b3306a913f7b769b5d402076eb04 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 4 May 2020 22:09:21 +0200 Subject: [PATCH 001/196] Auto merge pull requests when all checks succeeded Signed-off-by: kolaente --- models/migrations/migrations.go | 2 + models/migrations/v139.go | 20 ++ models/pull.go | 13 + models/scheduled_pull_request_merge.go | 55 ++++ modules/auth/repo_form.go | 9 +- modules/git/commit.go | 20 ++ modules/repofiles/commit_status.go | 8 + options/locale/locale_en-US.ini | 13 + routers/repo/issue.go | 7 + routers/repo/pull.go | 55 ++++ routers/routes/routes.go | 1 + services/automerge/pull_auto_merge.go | 116 +++++++ templates/repo/issue/view_content/pull.tmpl | 321 ++++++++++++++------ templates/swagger/v1_json.tmpl | 3 + web_src/less/_repository.less | 9 + 15 files changed, 556 insertions(+), 96 deletions(-) create mode 100644 models/migrations/v139.go create mode 100644 models/scheduled_pull_request_merge.go create mode 100644 services/automerge/pull_auto_merge.go diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 6868aad7b190..91567d08e7a0 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -210,6 +210,8 @@ var migrations = []Migration{ NewMigration("Add Branch Protection Block Outdated Branch", addBlockOnOutdatedBranch), // v138 -> v139 NewMigration("Add ResolveDoerID to Comment table", addResolveDoerIDCommentColumn), + // v139 -> v140 + NewMigration("Add auto merge table", addAutoMergeTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v139.go b/models/migrations/v139.go new file mode 100644 index 000000000000..48a7955ac739 --- /dev/null +++ b/models/migrations/v139.go @@ -0,0 +1,20 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import "xorm.io/xorm" + +func addAutoMergeTable(x *xorm.Engine) error { + type MergeStyle string + type ScheduledPullRequestMerge struct { + ID int64 `xorm:"pk autoincr"` + PullID int64 `xorm:"BIGINT"` + UserID int64 `xorm:"BIGINT"` + MergeStyle MergeStyle `xorm:"varchar(50)"` + Message string `xorm:"TEXT"` + } + + return x.Sync2(&ScheduledPullRequestMerge{}) +} diff --git a/models/pull.go b/models/pull.go index 9f1f485266a5..1d15f9d57263 100644 --- a/models/pull.go +++ b/models/pull.go @@ -636,3 +636,16 @@ func (pr *PullRequest) updateCommitDivergence(e Engine, ahead, behind int) error func (pr *PullRequest) IsSameRepo() bool { return pr.BaseRepoID == pr.HeadRepoID } + +// GetPullRequestByHeadBranch returns a pr by head branch +func GetPullRequestByHeadBranch(headBranch string, repo *Repository) (pr *PullRequest, err error) { + pr = &PullRequest{} + exists, err := x.Where("head_branch = ? AND head_repo_id = ?", headBranch, repo.ID).Get(pr) + if !exists { + return nil, ErrPullRequestNotExist{ + HeadBranch: headBranch, + HeadRepoID: repo.ID, + } + } + return +} diff --git a/models/scheduled_pull_request_merge.go b/models/scheduled_pull_request_merge.go new file mode 100644 index 000000000000..27382df070ba --- /dev/null +++ b/models/scheduled_pull_request_merge.go @@ -0,0 +1,55 @@ +// Copyright 2019 Gitea. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package models + +import "code.gitea.io/gitea/modules/timeutil" + +// ScheduledPullRequestMerge represents a pull request scheduled for merging when checks succeed +type ScheduledPullRequestMerge struct { + ID int64 `xorm:"pk autoincr"` + PullID int64 `xorm:"BIGINT"` + UserID int64 `xorm:"BIGINT"` + User *User `xorm:"-"` + MergeStyle MergeStyle `xorm:"varchar(50)"` + Message string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +// ScheduleAutoMerge schedules a pull request to be merged when all checks succeed +func ScheduleAutoMerge(opts *ScheduledPullRequestMerge) (err error) { + // Check if we already have a merge scheduled for that pull request + exists, err := x.Exist(&ScheduledPullRequestMerge{PullID: opts.PullID}) + if err != nil { + return + } + if exists { + // Maybe FIXME: Should we return a custom error here? + return nil + } + + _, err = x.Insert(opts) + return err +} + +// GetScheduledMergeRequestByPullID gets a scheduled pull request merge by pull request id +func GetScheduledMergeRequestByPullID(pullID int64) (exists bool, scheduledPRM *ScheduledPullRequestMerge, err error) { + scheduledPRM = &ScheduledPullRequestMerge{} + exists, err = x.Where("pull_id = ?", pullID).Get(scheduledPRM) + if err != nil || !exists { + return + } + scheduledPRM.User, err = getUserByID(x, scheduledPRM.UserID) + return +} + +// RemoveScheduledMergeRequest cancels a previously scheduled pull request +func RemoveScheduledMergeRequest(scheduledPR *ScheduledPullRequestMerge) (err error) { + if scheduledPR.ID == 0 && scheduledPR.PullID != 0 { + _, err = x.Where("pull_id = ?", scheduledPR.PullID).Delete(&ScheduledPullRequestMerge{}) + return + } + _, err = x.Where("id = ? AND pull_id = ?", scheduledPR.ID, scheduledPR.PullID).Delete(&ScheduledPullRequestMerge{}) + return +} diff --git a/modules/auth/repo_form.go b/modules/auth/repo_form.go index 6c3421e4f7d8..8daf6eaa1c89 100644 --- a/modules/auth/repo_form.go +++ b/modules/auth/repo_form.go @@ -483,10 +483,11 @@ func (f *InitializeLabelsForm) Validate(ctx *macaron.Context, errs binding.Error type MergePullRequestForm struct { // required: true // enum: merge,rebase,rebase-merge,squash - Do string `binding:"Required;In(merge,rebase,rebase-merge,squash)"` - MergeTitleField string - MergeMessageField string - ForceMerge *bool `json:"force_merge,omitempty"` + Do string `binding:"Required;In(merge,rebase,rebase-merge,squash)"` + MergeTitleField string + MergeMessageField string + ForceMerge *bool `json:"force_merge,omitempty"` + MergeWhenChecksSucceed bool } // Validate validates the fields diff --git a/modules/git/commit.go b/modules/git/commit.go index e65782912fea..671a9cca7d06 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -467,6 +467,7 @@ func (c *Commit) GetSubModule(entryname string) (*SubModule, error) { } // GetBranchName gets the closes branch name (as returned by 'git name-rev') +// FIXME: This get only one branch, but one commit can be part of multiple branches func (c *Commit) GetBranchName() (string, error) { data, err := NewCommand("name-rev", c.ID.String()).RunInDirBytes(c.repo.Path) if err != nil { @@ -477,6 +478,25 @@ func (c *Commit) GetBranchName() (string, error) { return strings.Split(strings.Split(string(data), " ")[1], "~")[0], nil } +// GetBranchNames returns all branches a commit is part of +func (c *Commit) GetBranchNames() (branchNames []string, err error) { + data, err := NewCommand("name-rev", c.ID.String()).RunInDirBytes(c.repo.Path) + if err != nil { + return + } + + namesRaw := strings.Split(string(data), "\n") + for _, s := range namesRaw { + s = strings.TrimSpace(s) + if s == "" { + continue + } + branchNames = append(branchNames, strings.Split(strings.Split(s, " ")[1], "~")[0]) + } + + return +} + // CommitFileStatus represents status of files in a commit. type CommitFileStatus struct { Added []string diff --git a/modules/repofiles/commit_status.go b/modules/repofiles/commit_status.go index 3d93c58d8582..a21fbc3690f1 100644 --- a/modules/repofiles/commit_status.go +++ b/modules/repofiles/commit_status.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/automerge" ) // CreateCommitStatus creates a new CommitStatus given a bunch of parameters @@ -37,5 +38,12 @@ func CreateCommitStatus(repo *models.Repository, creator *models.User, sha strin return fmt.Errorf("NewCommitStatus[repo_id: %d, user_id: %d, sha: %s]: %v", repo.ID, creator.ID, sha, err) } + if status.State.IsSuccess() { + err = automerge.MergeScheduledPullRequest(sha, repo) + if err != nil { + return fmt.Errorf("MergeScheduledPullRequest[repo_id: %d, user_id: %d, sha: %s]: %v", repo.ID, creator.ID, sha, err) + } + } + return nil } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index fe685735298b..a448ed99d760 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1135,6 +1135,14 @@ pulls.rebase_merge_pull_request = Rebase and Merge pulls.rebase_merge_commit_pull_request = Rebase and Merge (--no-ff) pulls.squash_merge_pull_request = Squash and Merge pulls.require_signed_wont_sign = The branch requires signed commits but this merge will not be signed +pulls.merge_pull_request_now = Merge Pull Request Now +pulls.rebase_merge_pull_request_now = Rebase and Merge Now +pulls.rebase_merge_commit_pull_request_now = Rebase and Merge Now (--no-ff) +pulls.squash_merge_pull_request_now = Squash and Merge Now +pulls.merge_pull_request_on_status_success = Merge Pull Request When All Checks Succeed +pulls.rebase_merge_pull_request_on_status_success = Rebase and Merge When All Checks Succeed +pulls.rebase_merge_commit_pull_request_on_status_success = Rebase and Merge (--no-ff) When All Checks Succeed +pulls.squash_merge_pull_request_on_status_success = Squash and Merge When All Checks Succeed 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: %[1]s
%[2]s
Hint: Try a different strategy pulls.rebase_conflict = Merge Failed: There was a conflict whilst rebasing commit: %[1]s
%[2]s
%[3]s
Hint:Try a different strategy @@ -1154,6 +1162,11 @@ pulls.update_not_allowed = You are not allowed to update branch pulls.outdated_with_base_branch = This branch is out-of-date with the base branch pulls.closed_at = `closed this pull request %[2]s` pulls.reopened_at = `reopened this pull request %[2]s` +pulls.merge_on_status_success_success = The pull request was successfully scheduled to merge when all checks succeed. +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. +pulls.merge_pull_on_success_cancel = Cancel automatic merge +pulls.pull_request_not_scheduled = This pull request is not scheduled to auto merge. +pulls.pull_request_schedule_canceled = This pull request was successfully canceled for auto merge. milestones.new = New Milestone milestones.open_tab = %d Open diff --git a/routers/repo/issue.go b/routers/repo/issue.go index 6a713f90610b..e4b1894537a8 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -1104,6 +1104,13 @@ func ViewIssue(ctx *context.Context) { ctx.ServerError("GetReviewersByIssueID", err) return } + + // Check if there is a pending pr merge + ctx.Data["HasPendingPullRequestMerge"], ctx.Data["PendingPullRequestMerge"], err = models.GetScheduledMergeRequestByPullID(pull.ID) + if err != nil { + ctx.ServerError("GetReviewersByIssueID", err) + return + } } // Get Dependencies diff --git a/routers/repo/pull.go b/routers/repo/pull.go index d23c93d0b658..fcc3631de4c6 100644 --- a/routers/repo/pull.go +++ b/routers/repo/pull.go @@ -810,6 +810,34 @@ func MergePullRequest(ctx *context.Context, form auth.MergePullRequestForm) { return } + lastCommitStatus, err := pr.GetLastCommitStatus() + if err != nil { + return + } + if form.MergeWhenChecksSucceed && !lastCommitStatus.State.IsSuccess() { + err = models.ScheduleAutoMerge(&models.ScheduledPullRequestMerge{ + PullID: pr.ID, + UserID: ctx.User.ID, + MergeStyle: models.MergeStyle(form.Do), + Message: message, + }) + if err != nil { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(pr.Index)) + return + } + ctx.Flash.Success(ctx.Tr("repo.pulls.merge_on_status_success_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(pr.Index)) + return + } + // Removing an auto merge pull request is something we can execute whether or not a pull request auto merge was + // scheduled before, hece we can remove it without checking for its existence. + err = models.RemoveScheduledMergeRequest(&models.ScheduledPullRequestMerge{PullID: pr.ID}) + if err != nil { + ctx.ServerError("RemoveScheduledMergeRequest", err) + return + } + if err = pull_service.Merge(pr, ctx.User, ctx.Repo.GitRepo, models.MergeStyle(form.Do), message); err != nil { if models.IsErrInvalidMergeStyle(err) { ctx.Flash.Error(ctx.Tr("repo.pulls.invalid_merge_option")) @@ -860,6 +888,33 @@ func MergePullRequest(ctx *context.Context, form auth.MergePullRequestForm) { ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(pr.Index)) } +// CancelAutoMergePullRequest cancels a scheduled pr +func CancelAutoMergePullRequest(ctx *context.Context) { + issue := checkPullInfo(ctx) + if ctx.Written() { + return + } + pr := issue.PullRequest + exists, scheduledInfo, err := models.GetScheduledMergeRequestByPullID(pr.ID) + if err != nil { + ctx.ServerError("GetScheduledMergeRequestByPullID", err) + return + } + if !exists { + ctx.Flash.Error(ctx.Tr("repo.pulls.pull_request_not_scheduled")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(pr.Index)) + return + } + err = models.RemoveScheduledMergeRequest(scheduledInfo) + if err != nil { + ctx.ServerError("RemoveScheduledMergeRequest", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.pulls.pull_request_schedule_canceled")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index)) +} + func stopTimerIfAvailable(user *models.User, issue *models.Issue) error { if models.StopwatchExists(user.ID, issue.ID) { diff --git a/routers/routes/routes.go b/routers/routes/routes.go index f3bd42f02acc..229662c2bd79 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -895,6 +895,7 @@ func RegisterRoutes(m *macaron.Macaron) { m.Get(".patch", repo.DownloadPullPatch) m.Get("/commits", context.RepoRef(), repo.ViewPullCommits) m.Post("/merge", context.RepoMustNotBeArchived(), bindIgnErr(auth.MergePullRequestForm{}), repo.MergePullRequest) + m.Post("/cancel_auto_merge", context.RepoMustNotBeArchived(), repo.CancelAutoMergePullRequest) m.Post("/update", repo.UpdatePullRequest) m.Post("/cleanup", context.RepoMustNotBeArchived(), context.RepoRef(), repo.CleanUpPullRequest) m.Group("/files", func() { diff --git a/services/automerge/pull_auto_merge.go b/services/automerge/pull_auto_merge.go new file mode 100644 index 000000000000..e2536ae174e6 --- /dev/null +++ b/services/automerge/pull_auto_merge.go @@ -0,0 +1,116 @@ +// Copyright 2019 Gitea. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package automerge + +import ( + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + pullservice "code.gitea.io/gitea/services/pull" +) + +// This package merges a previously scheduled pull request on successful status check. +// It is a separate package to avoid cyclic dependencies. + +// MergeScheduledPullRequest merges a previously scheduled pull request when all checks succeeded +// Maybe FIXME: Move the whole check this function does into a separate go routine and just ping from here? +func MergeScheduledPullRequest(sha string, repo *models.Repository) (err error) { + // First, get the branch associated with that commit sha + r, err := git.OpenRepository(repo.RepoPath()) + if err != nil { + return err + } + defer r.Close() + commitID := git.MustIDFromString(sha) + tree := git.NewTree(r, commitID) + commit := &git.Commit{ + Tree: *tree, + ID: commitID, + } + branches, err := commit.GetBranchNames() + if err != nil { + return err + } + + for _, branch := range branches { + // We get the branch name with a \n at the end which is not in the db so we strip it out + branch = strings.Trim(branch, "\n") + // Then get all prs for that branch + pr, err := models.GetPullRequestByHeadBranch(branch, repo) + if err != nil { + // If there is no pull request for this branch, we don't try to merge it. + if models.IsErrPullRequestNotExist(err) { + continue + } + return err + } + if pr.HasMerged { + log.Info("PR scheduled for auto merge is already merged [ID: %d", pr.ID) + return nil + } + + // Check if there is a scheduled pr in the db + exists, scheduledPRM, err := models.GetScheduledMergeRequestByPullID(pr.ID) + if err != nil { + return err + } + if !exists { + log.Info("No scheduled pull request merge exists for this pr [PRID: %d]", pr.ID) + return nil + } + + // Get all checks for this pr + // We get the latest sha commit hash again to handle the case where the check of a previous push + // did not succeed or was not finished yet. + + err = pr.GetHeadRepo() + if err != nil { + return err + } + + headGitRepo, err := git.OpenRepository(pr.HeadRepo.RepoPath()) + if err != nil { + return err + } + defer headGitRepo.Close() + + headBranchExist := headGitRepo.IsBranchExist(pr.HeadBranch) + + if pr.HeadRepo == nil || !headBranchExist { + log.Info("Head branch of auto merge pr does not exist [HeadRepoID: %d, Branch: %s, PRID: %d]", pr.HeadRepoID, pr.HeadBranch, pr.ID) + return nil + } + + // Check if all checks succeeded + pass, err := pullservice.IsPullCommitStatusPass(pr) + if err != nil { + return err + } + if !pass { + log.Info("Scheduled auto merge pr has unsuccessful status checks [PRID: %d, Commit: %s]", pr.ID, sha) + return nil + } + + // Merge if all checks succeeded + doer, err := models.GetUserByID(scheduledPRM.UserID) + if err != nil { + return err + } + // FIXME: Is headGitRepo the right thing to use here? Maybe we should get the git repo based on scheduledPRM.RepoID? + err = pullservice.Merge(pr, doer, headGitRepo, scheduledPRM.MergeStyle, scheduledPRM.Message) + if err != nil { + return err + } + + // Remove the schedule from the db + err = models.RemoveScheduledMergeRequest(scheduledPRM) + if err != nil { + return err + } + } + return nil +} diff --git a/templates/repo/issue/view_content/pull.tmpl b/templates/repo/issue/view_content/pull.tmpl index 3aee0773f859..1c20b4325545 100644 --- a/templates/repo/issue/view_content/pull.tmpl +++ b/templates/repo/issue/view_content/pull.tmpl @@ -191,114 +191,251 @@ {{if .AllowMerge}} {{$prUnit := .Repository.MustGetUnit $.UnitTypePullRequests}} {{$approvers := .Issue.PullRequest.GetApprovers}} + {{/* We build a custom variable here to not clutter the template with checks if .LatestCommitStatus exists. */}} + {{$statusChecksPending := false}} + {{if .LatestCommitStatus}} + {{$statusChecksPending = eq .LatestCommitStatus.State "pending"}} + {{end}} + {{if or $prUnit.PullRequestsConfig.AllowMerge $prUnit.PullRequestsConfig.AllowRebase $prUnit.PullRequestsConfig.AllowRebaseMerge $prUnit.PullRequestsConfig.AllowSquash}}
+ {{ $mergeButtonColor := "green"}} + {{if $statusChecksPending}} + {{ $mergeButtonColor = "blue"}} + {{end}} + {{ if .HasPendingPullRequestMerge }} + {{ $mergeButtonColor = "red"}} + {{ $createdPRMergeStr:= TimeSinceUnix .PendingPullRequestMerge.CreatedUnix $.Lang }} +
+ {{$.i18n.Tr "repo.pulls.pr_has_pending_merge_on_success" .PendingPullRequestMerge.User.Name $createdPRMergeStr | Safe }} +
+ {{end}} {{if $prUnit.PullRequestsConfig.AllowMerge}} -