From 59b30f060a840cde305952ef7bc344fa4101c0d5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sat, 7 May 2022 19:05:52 +0200 Subject: [PATCH] Auto merge pull requests when all checks succeeded via API (#9307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix indention Signed-off-by: kolaente * Add option to merge a pr right now without waiting for the checks to succeed Signed-off-by: kolaente * Fix lint Signed-off-by: kolaente * Add scheduled pr merge to tables used for testing Signed-off-by: kolaente * Add status param to make GetPullRequestByHeadBranch reusable Signed-off-by: kolaente * Move "Merge now" to a seperate button to make the ui clearer Signed-off-by: kolaente * Update models/scheduled_pull_request_merge.go Co-authored-by: 赵智超 <1012112796@qq.com> * Update web_src/js/index.js Co-authored-by: 赵智超 <1012112796@qq.com> * Update web_src/js/index.js Co-authored-by: 赵智超 <1012112796@qq.com> * Re-add migration after merge * Fix frontend lint * Fix version compare * Add vendored dependencies * Add basic tets * Make sure the api route is capable of scheduling PRs for merging * Fix comparing version * make vendor * adopt refactor * apply suggestion: User -> Doer * init var once * Fix Test * Update templates/repo/issue/view_content/comments.tmpl * adopt * nits * next * code format * lint * use same name schema; rm CreateUnScheduledPRToAutoMergeComment * API: can not create schedule twice * Add TestGetBranchNamesForSha * nits * new go routine for each pull to merge * Update models/pull.go Co-authored-by: a1012112796 <1012112796@qq.com> * Update models/scheduled_pull_request_merge.go Co-authored-by: a1012112796 <1012112796@qq.com> * fix & add renaming sugestions * Update services/automerge/pull_auto_merge.go Co-authored-by: a1012112796 <1012112796@qq.com> * fix conflict relicts * apply latest refactors * fix: migration after merge * Update models/error.go Co-authored-by: delvh * Update options/locale/locale_en-US.ini Co-authored-by: delvh * Update options/locale/locale_en-US.ini Co-authored-by: delvh * adapt latest refactors * fix test * use more context * skip potential edgecases * document func usage * GetBranchNamesForSha() -> GetRefsBySha() * start refactoring * ajust to new changes * nit * docu nit * the great check move * move checks for branchprotection into own package * resolve todo now ... * move & rename * unexport if posible * fix * check if merge is allowed before merge on scheduled pull * debugg * wording * improve SetDefaults & nits * NotAllowedToMerge -> DisallowedToMerge * fix test * merge files * use package "errors" * merge files * add string names * other implementation for gogit * adapt refactor * more context for models/pull.go * GetUserRepoPermission use context * more ctx * use context for loading pull head/base-repo * more ctx * more ctx * models.LoadIssueCtx() * models.LoadIssueCtx() * Handle pull_service.Merge in one DB transaction * add TODOs * next * next * next * more ctx * more ctx * Start refactoring structure of old pull code ... * move code into new packages * shorter names ... and finish **restructure** * Update models/branches.go Co-authored-by: zeripath * finish UpdateProtectBranch * more and fix * update datum * template: use "svg" helper * rename prQueue 2 prPatchCheckerQueue * handle automerge in queue * lock pull on git&db actions ... * lock pull on git&db actions ... * add TODO notes * the regex * transaction in tests * GetRepositoryByIDCtx * shorter table name and lint fix * close transaction bevore notify * Update models/pull.go * next * CheckPullMergable check all branch protections! * Update routers/web/repo/pull.go * CheckPullMergable check all branch protections! * Revert "PullService lock via pullID (#19520)" (for now...) This reverts commit 6cde7c9159a5ea75a10356feb7b8c7ad4c434a9a. * Update services/pull/check.go * Use for a repo action one database transaction * Apply suggestions from code review * Apply suggestions from code review Co-authored-by: delvh * Update services/issue/status.go Co-authored-by: delvh * Update services/issue/status.go Co-authored-by: delvh * use db.WithTx() * gofmt * make pr.GetDefaultMergeMessage() context aware * make MergePullRequestForm.SetDefaults context aware * use db.WithTx() * pull.SetMerged only with context * fix deadlock in `test-sqlite\#TestAPIBranchProtection` * dont forget templates * db.WithTx allow to set the parentCtx * handle db transaction in service packages but not router * issue_service.ChangeStatus just had caused another deadlock :/ it has to do something with how notification package is handled * if we merge a pull in one database transaktion, we get a lock, because merge infoce internal api that cant handle open db sessions to the same repo * ajust to current master * Apply suggestions from code review Co-authored-by: delvh * dont open db transaction in router * make generate-swagger * one _success less * wording nit * rm * adapt * remove not needed test files * rm less diff & use attr in JS * ... * Update services/repository/files/commit.go Co-authored-by: wxiaoguang * ajust db schema for PullAutoMerge * skip broken pull refs * more context in error messages * remove webUI part for another pull * remove more WebUI only parts * API: add CancleAutoMergePR * Apply suggestions from code review Co-authored-by: wxiaoguang * fix lint * Apply suggestions from code review * cancle -> cancel Co-authored-by: delvh * change queue identifyer * fix swagger * prevent nil issue * fix and dont drop error * as per @zeripath * Update integrations/git_test.go Co-authored-by: delvh * Update integrations/git_test.go Co-authored-by: delvh * more declarative integration tests (dedup code) * use assert.False/True helper Co-authored-by: 赵智超 <1012112796@qq.com> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: delvh Co-authored-by: zeripath Co-authored-by: wxiaoguang --- .../api_helper_for_declarative_test.go | 31 +++ integrations/git_test.go | 83 ++++++ integrations/pull_status_test.go | 31 ++- integrations/repo_commits_test.go | 12 +- models/issue_comment.go | 6 + models/migrations/migrations.go | 2 + models/migrations/v214.go | 23 ++ models/pull.go | 14 + models/pull/automerge.go | 143 +++++++++++ modules/git/repo_branch_gogit.go | 16 ++ modules/git/repo_branch_nogogit.go | 12 + modules/git/repo_branch_test.go | 41 +++ modules/git/tests/repos/repo5_pulls/HEAD | 1 + modules/git/tests/repos/repo5_pulls/config | 6 + .../git/tests/repos/repo5_pulls/description | 1 + .../git/tests/repos/repo5_pulls/info/exclude | 6 + .../1a/2959532d2d18daa87bbd9f9d16051bef7b51df | Bin 0 -> 119 bytes .../56/51a1c4a48c47484a7a00a967ba4b6dde070bbf | Bin 0 -> 120 bytes .../58/a4bcc53ac13e7ff76127e0fb518b5262bf09af | 1 + .../6d/0b4cca434953833618fcd3dd7acff42c800df1 | Bin 0 -> 120 bytes .../a5/2ca5af1b0277638ce20797f80bb1a2997470ab | Bin 0 -> 120 bytes .../bf/4dc0709be60f043821351ff4bb2b17e5cabbb2 | 2 + .../d8/e0bbb45f200e67d9a784ce55bd90821af45ebd | 2 + .../ed/5119b3c1f45547b6785bc03eac7f87570fa17f | Bin 0 -> 660 bytes .../ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f | 3 + .../ee/469963e76ae1bb7ee83d7510df2864e6c8c640 | Bin 0 -> 650 bytes .../repos/repo5_pulls/objects/info/packs | 2 + ...423f591973f5d9dab89cc45afa1c544448133e.idx | Bin 0 -> 1408 bytes ...23f591973f5d9dab89cc45afa1c544448133e.pack | Bin 0 -> 2363 bytes .../git/tests/repos/repo5_pulls/packed-refs | 5 + .../tests/repos/repo5_pulls/refs/heads/master | 1 + .../repos/repo5_pulls/refs/heads/master-clone | 1 + .../repos/repo5_pulls/refs/heads/test-patch-1 | 1 + .../tests/repos/repo5_pulls/refs/pull/4/head | 1 + options/locale/locale_en-US.ini | 17 +- routers/api/v1/api.go | 3 +- routers/api/v1/repo/pull.go | 90 +++++++ routers/init.go | 2 + routers/web/repo/issue.go | 8 + services/automerge/automerge.go | 241 ++++++++++++++++++ services/forms/repo_form.go | 1 + services/pull/commit_status.go | 10 +- services/pull/merge.go | 6 + services/pull/pull.go | 2 +- services/repository/files/commit.go | 7 + .../repo/issue/view_content/comments.tmpl | 12 +- templates/swagger/v1_json.tmpl | 49 ++++ 47 files changed, 869 insertions(+), 26 deletions(-) create mode 100644 models/migrations/v214.go create mode 100644 models/pull/automerge.go create mode 100644 modules/git/tests/repos/repo5_pulls/HEAD create mode 100644 modules/git/tests/repos/repo5_pulls/config create mode 100644 modules/git/tests/repos/repo5_pulls/description create mode 100644 modules/git/tests/repos/repo5_pulls/info/exclude create mode 100644 modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df create mode 100644 modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf create mode 100644 modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af create mode 100644 modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1 create mode 100644 modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab create mode 100644 modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2 create mode 100644 modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd create mode 100644 modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f create mode 100644 modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f create mode 100644 modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640 create mode 100644 modules/git/tests/repos/repo5_pulls/objects/info/packs create mode 100644 modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx create mode 100644 modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack create mode 100644 modules/git/tests/repos/repo5_pulls/packed-refs create mode 100644 modules/git/tests/repos/repo5_pulls/refs/heads/master create mode 100644 modules/git/tests/repos/repo5_pulls/refs/heads/master-clone create mode 100644 modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1 create mode 100644 modules/git/tests/repos/repo5_pulls/refs/pull/4/head create mode 100644 services/automerge/automerge.go diff --git a/integrations/api_helper_for_declarative_test.go b/integrations/api_helper_for_declarative_test.go index 5da72b7fb1..181a646946 100644 --- a/integrations/api_helper_for_declarative_test.go +++ b/integrations/api_helper_for_declarative_test.go @@ -314,6 +314,37 @@ func doAPIManuallyMergePullRequest(ctx APITestContext, owner, repo, commitID str } } +func doAPIAutoMergePullRequest(ctx APITestContext, owner, repo 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, &forms.MergePullRequestForm{ + MergeMessageField: "doAPIMergePullRequest Merge", + Do: string(repo_model.MergeStyleMerge), + MergeWhenChecksSucceed: true, + }) + + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, 200) + } +} + +func doAPICancelAutoMergePullRequest(ctx APITestContext, owner, repo 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 := NewRequest(t, http.MethodDelete, urlStr) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, 204) + } +} + func doAPIGetBranch(ctx APITestContext, branch string, callback ...func(*testing.T, api.Branch)) func(*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) diff --git a/integrations/git_test.go b/integrations/git_test.go index 85f08606ee..04cdf633bd 100644 --- a/integrations/git_test.go +++ b/integrations/git_test.go @@ -82,6 +82,7 @@ func testGit(t *testing.T, u *url.URL) { t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &httpContext, "master", "test/head")) t.Run("BranchProtectMerge", doBranchProtectPRMerge(&httpContext, dstPath)) + t.Run("AutoMerge", doAutoPRMerge(&httpContext, dstPath)) t.Run("CreatePRAndSetManuallyMerged", doCreatePRAndSetManuallyMerged(httpContext, httpContext, dstPath, "master", "test-manually-merge")) t.Run("MergeFork", func(t *testing.T) { defer PrintCurrentTest(t)() @@ -615,6 +616,88 @@ func doBranchDelete(ctx APITestContext, owner, repo, branch string) func(*testin } } +func doAutoPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) { + return func(t *testing.T) { + defer PrintCurrentTest(t)() + + ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame) + + t.Run("CheckoutProtected", doGitCheckoutBranch(dstPath, "protected")) + t.Run("PullProtected", doGitPull(dstPath, "origin", "protected")) + t.Run("GenerateCommit", func(t *testing.T) { + _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-") + assert.NoError(t, err) + }) + t.Run("PushToUnprotectedBranch", doGitPushTestRepository(dstPath, "origin", "protected:unprotected3")) + var pr api.PullRequest + var err error + t.Run("CreatePullRequest", func(t *testing.T) { + pr, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, "protected", "unprotected3")(t) + assert.NoError(t, err) + }) + + // Request repository commits page + req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d/commits", baseCtx.Username, baseCtx.Reponame, pr.Index)) + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + // Get first commit URL + commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Last().Attr("href") + assert.True(t, exists) + assert.NotEmpty(t, commitURL) + + commitID := path.Base(commitURL) + + // Call API to add Pending status for commit + t.Run("CreateStatus", doAPICreateCommitStatus(ctx, commitID, api.CommitStatusPending)) + + // Cancel not existing auto merge + ctx.ExpectedCode = http.StatusNotFound + t.Run("CancelAutoMergePR", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) + + // Add auto merge request + ctx.ExpectedCode = http.StatusCreated + t.Run("AutoMergePR", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) + + // Can not create schedule twice + ctx.ExpectedCode = http.StatusConflict + t.Run("AutoMergePRTwice", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) + + // Cancel auto merge request + ctx.ExpectedCode = http.StatusNoContent + t.Run("CancelAutoMergePR", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) + + // Add auto merge request + ctx.ExpectedCode = http.StatusCreated + t.Run("AutoMergePR", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) + + // Check pr status + ctx.ExpectedCode = 0 + pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t) + assert.NoError(t, err) + assert.False(t, pr.HasMerged) + + // Call API to add Failure status for commit + t.Run("CreateStatus", doAPICreateCommitStatus(ctx, commitID, api.CommitStatusFailure)) + + // Check pr status + pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t) + assert.NoError(t, err) + assert.False(t, pr.HasMerged) + + // Call API to add Success status for commit + t.Run("CreateStatus", doAPICreateCommitStatus(ctx, commitID, api.CommitStatusSuccess)) + + // wait to let gitea merge stuff + time.Sleep(time.Second) + + // test pr status + pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t) + assert.NoError(t, err) + assert.True(t, pr.HasMerged) + } +} + func doCreateAgitFlowPull(dstPath string, ctx *APITestContext, baseBranch, headBranch string) func(t *testing.T) { return func(t *testing.T) { defer PrintCurrentTest(t)() diff --git a/integrations/pull_status_test.go b/integrations/pull_status_test.go index 07c73ceac6..a5247f56ec 100644 --- a/integrations/pull_status_test.go +++ b/integrations/pull_status_test.go @@ -63,20 +63,13 @@ func TestPullCreate_CommitStatus(t *testing.T) { api.CommitStatusWarning: "warning sign icon yellow", } + testCtx := NewAPITestContext(t, "user1", "repo1") + // Update commit status, and check if icon is updated as well for _, status := range statusList { // Call API to add status for commit - token := getTokenForLoggedInUser(t, session) - req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/user1/repo1/statuses/%s?token=%s", commitID, token), - api.CreateStatusOption{ - State: status, - TargetURL: "http://test.ci/", - Description: "", - Context: "testci", - }, - ) - session.MakeRequest(t, req, http.StatusCreated) + t.Run("CreateStatus", doAPICreateCommitStatus(testCtx, commitID, status)) req = NewRequestf(t, "GET", "/user1/repo1/pulls/1/commits") resp = session.MakeRequest(t, req, http.StatusOK) @@ -94,6 +87,24 @@ func TestPullCreate_CommitStatus(t *testing.T) { }) } +func doAPICreateCommitStatus(ctx APITestContext, commitID string, status api.CommitStatusState) func(*testing.T) { + return func(t *testing.T) { + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s?token=%s", ctx.Username, ctx.Reponame, commitID, ctx.Token), + api.CreateStatusOption{ + State: status, + TargetURL: "http://test.ci/", + Description: "", + Context: "testci", + }, + ) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, http.StatusCreated) + } +} + func TestPullCreate_EmptyChangesWithCommits(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { session := loginUser(t, "user1") diff --git a/integrations/repo_commits_test.go b/integrations/repo_commits_test.go index b53d988c58..7107f43b0f 100644 --- a/integrations/repo_commits_test.go +++ b/integrations/repo_commits_test.go @@ -36,7 +36,6 @@ func doTestRepoCommitWithStatus(t *testing.T, state string, classes ...string) { defer prepareTestEnv(t)() session := loginUser(t, "user2") - token := getTokenForLoggedInUser(t, session) // Request repository commits page req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master") @@ -49,16 +48,7 @@ func doTestRepoCommitWithStatus(t *testing.T, state string, classes ...string) { assert.NotEmpty(t, commitURL) // Call API to add status for commit - req = NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/statuses/"+path.Base(commitURL)+"?token="+token, - api.CreateStatusOption{ - State: api.CommitStatusState(state), - TargetURL: "http://test.ci/", - Description: "", - Context: "testci", - }, - ) - - resp = session.MakeRequest(t, req, http.StatusCreated) + t.Run("CreateStatus", doAPICreateCommitStatus(NewAPITestContext(t, "user2", "repo1"), path.Base(commitURL), api.CommitStatusState(state))) req = NewRequest(t, "GET", "/user2/repo1/commits/branch/master") resp = session.MakeRequest(t, req, http.StatusOK) diff --git a/models/issue_comment.go b/models/issue_comment.go index ceea878662..13b2c62546 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -110,6 +110,10 @@ const ( CommentTypeDismissReview // 33 Change issue ref CommentTypeChangeIssueRef + // 34 pr was scheduled to auto merge when checks succeed + CommentTypePRScheduledToAutoMerge + // 35 pr was un scheduled to auto merge when checks succeed + CommentTypePRUnScheduledToAutoMerge ) var commentStrings = []string{ @@ -147,6 +151,8 @@ var commentStrings = []string{ "project_board", "dismiss_review", "change_issue_ref", + "pull_scheduled_merge", + "pull_cancel_scheduled_merge", } func (t CommentType) String() string { diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 9e46791ec6..817ba3bfac 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -383,6 +383,8 @@ var migrations = []Migration{ NewMigration("Add package tables", addPackageTables), // v213 -> v214 NewMigration("Add allow edits from maintainers to PullRequest table", addAllowMaintainerEdit), + // v214 -> v215 + NewMigration("Add auto merge table", addAutoMergeTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v214.go b/models/migrations/v214.go new file mode 100644 index 0000000000..dfe5d776a0 --- /dev/null +++ b/models/migrations/v214.go @@ -0,0 +1,23 @@ +// Copyright 2022 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 PullAutoMerge struct { + ID int64 `xorm:"pk autoincr"` + PullID int64 `xorm:"UNIQUE"` + DoerID int64 `xorm:"NOT NULL"` + MergeStyle MergeStyle `xorm:"varchar(30)"` + Message string `xorm:"LONGTEXT"` + CreatedUnix int64 `xorm:"created"` + } + + return x.Sync2(&PullAutoMerge{}) +} diff --git a/models/pull.go b/models/pull.go index d056888130..0fa3bdf14f 100644 --- a/models/pull.go +++ b/models/pull.go @@ -20,6 +20,8 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" ) // PullRequestType defines pull request type @@ -675,6 +677,18 @@ func (pr *PullRequest) IsSameRepo() bool { return pr.BaseRepoID == pr.HeadRepoID } +// GetPullRequestsByHeadBranch returns all prs by head branch +// Since there could be multiple prs with the same head branch, this function returns a slice of prs +func GetPullRequestsByHeadBranch(ctx context.Context, headBranch string, headRepoID int64) ([]*PullRequest, error) { + log.Trace("GetPullRequestsByHeadBranch: headBranch: '%s', headRepoID: '%d'", headBranch, headRepoID) + prs := make([]*PullRequest, 0, 2) + if err := db.GetEngine(ctx).Where(builder.Eq{"head_branch": headBranch, "head_repo_id": headRepoID}). + Find(&prs); err != nil { + return nil, err + } + return prs, nil +} + // GetBaseBranchHTMLURL returns the HTML URL of the base branch func (pr *PullRequest) GetBaseBranchHTMLURL() string { if err := pr.LoadBaseRepo(); err != nil { diff --git a/models/pull/automerge.go b/models/pull/automerge.go new file mode 100644 index 0000000000..fd73f2b0fb --- /dev/null +++ b/models/pull/automerge.go @@ -0,0 +1,143 @@ +// Copyright 2022 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 pull + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" +) + +// AutoMerge represents a pull request scheduled for merging when checks succeed +type AutoMerge struct { + ID int64 `xorm:"pk autoincr"` + PullID int64 `xorm:"UNIQUE"` + DoerID int64 `xorm:"NOT NULL"` + Doer *user_model.User `xorm:"-"` + MergeStyle repo_model.MergeStyle `xorm:"varchar(30)"` + Message string `xorm:"LONGTEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +// TableName return database table name for xorm +func (AutoMerge) TableName() string { + return "pull_auto_merge" +} + +func init() { + db.RegisterModel(new(AutoMerge)) +} + +// ErrAlreadyScheduledToAutoMerge represents a "PullRequestHasMerged"-error +type ErrAlreadyScheduledToAutoMerge struct { + PullID int64 +} + +func (err ErrAlreadyScheduledToAutoMerge) Error() string { + return fmt.Sprintf("pull request is already scheduled to auto merge when checks succeed [pull_id: %d]", err.PullID) +} + +// IsErrAlreadyScheduledToAutoMerge checks if an error is a ErrAlreadyScheduledToAutoMerge. +func IsErrAlreadyScheduledToAutoMerge(err error) bool { + _, ok := err.(ErrAlreadyScheduledToAutoMerge) + return ok +} + +// ScheduleAutoMerge schedules a pull request to be merged when all checks succeed +func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pullID int64, style repo_model.MergeStyle, message string) error { + // Check if we already have a merge scheduled for that pull request + if exists, _, err := GetScheduledMergeByPullID(ctx, pullID); err != nil { + return err + } else if exists { + return ErrAlreadyScheduledToAutoMerge{PullID: pullID} + } + + if _, err := db.GetEngine(ctx).Insert(&AutoMerge{ + DoerID: doer.ID, + PullID: pullID, + MergeStyle: style, + Message: message, + }); err != nil { + return err + } + + pr, err := models.GetPullRequestByID(ctx, pullID) + if err != nil { + return err + } + + _, err = createAutoMergeComment(ctx, models.CommentTypePRScheduledToAutoMerge, pr, doer) + return err +} + +// GetScheduledMergeByPullID gets a scheduled pull request merge by pull request id +func GetScheduledMergeByPullID(ctx context.Context, pullID int64) (bool, *AutoMerge, error) { + scheduledPRM := &AutoMerge{} + exists, err := db.GetEngine(ctx).Where("pull_id = ?", pullID).Get(scheduledPRM) + if err != nil || !exists { + return false, nil, err + } + + doer, err := user_model.GetUserByIDCtx(ctx, scheduledPRM.DoerID) + if err != nil { + return false, nil, err + } + + scheduledPRM.Doer = doer + return true, scheduledPRM, nil +} + +// RemoveScheduledAutoMerge cancels a previously scheduled pull request +func RemoveScheduledAutoMerge(ctx context.Context, doer *user_model.User, pullID int64, comment bool) error { + return db.WithTx(func(ctx context.Context) error { + exist, scheduledPRM, err := GetScheduledMergeByPullID(ctx, pullID) + if err != nil { + return err + } else if !exist { + return models.ErrNotExist{ID: pullID} + } + + if _, err := db.GetEngine(ctx).ID(scheduledPRM.ID).Delete(&AutoMerge{}); err != nil { + return err + } + + // if pull got merged we don't need to add "auto-merge canceled comment" + if !comment || doer == nil { + return nil + } + + pr, err := models.GetPullRequestByID(ctx, pullID) + if err != nil { + return err + } + + _, err = createAutoMergeComment(ctx, models.CommentTypePRUnScheduledToAutoMerge, pr, doer) + return err + }, ctx) +} + +// createAutoMergeComment is a internal function, only use it for CommentTypePRScheduledToAutoMerge and CommentTypePRUnScheduledToAutoMerge CommentTypes +func createAutoMergeComment(ctx context.Context, typ models.CommentType, pr *models.PullRequest, doer *user_model.User) (comment *models.Comment, err error) { + if err = pr.LoadIssueCtx(ctx); err != nil { + return + } + + if err = pr.LoadBaseRepoCtx(ctx); err != nil { + return + } + + comment, err = models.CreateCommentCtx(ctx, &models.CreateCommentOptions{ + Type: typ, + Doer: doer, + Repo: pr.BaseRepo, + Issue: pr.Issue, + }) + return +} diff --git a/modules/git/repo_branch_gogit.go b/modules/git/repo_branch_gogit.go index ecedb56686..dc29576562 100644 --- a/modules/git/repo_branch_gogit.go +++ b/modules/git/repo_branch_gogit.go @@ -144,3 +144,19 @@ func (repo *Repository) WalkReferences(arg ObjectType, skip, limit int, walkfn f }) return i, err } + +// GetRefsBySha returns all references filtered with prefix that belong to a sha commit hash +func (repo *Repository) GetRefsBySha(sha, prefix string) ([]string, error) { + var revList []string + iter, err := repo.gogitRepo.References() + if err != nil { + return nil, err + } + err = iter.ForEach(func(ref *plumbing.Reference) error { + if ref.Hash().String() == sha && strings.HasPrefix(string(ref.Name()), prefix) { + revList = append(revList, string(ref.Name())) + } + return nil + }) + return revList, err +} diff --git a/modules/git/repo_branch_nogogit.go b/modules/git/repo_branch_nogogit.go index 3aed4abdf3..bc58991085 100644 --- a/modules/git/repo_branch_nogogit.go +++ b/modules/git/repo_branch_nogogit.go @@ -190,3 +190,15 @@ func walkShowRef(ctx context.Context, repoPath, arg string, skip, limit int, wal } return i, nil } + +// GetRefsBySha returns all references filtered with prefix that belong to a sha commit hash +func (repo *Repository) GetRefsBySha(sha, prefix string) ([]string, error) { + var revList []string + _, err := walkShowRef(repo.Ctx, repo.Path, "", 0, 0, func(walkSha, refname string) error { + if walkSha == sha && strings.HasPrefix(refname, prefix) { + revList = append(revList, refname) + } + return nil + }) + return revList, err +} diff --git a/modules/git/repo_branch_test.go b/modules/git/repo_branch_test.go index add04cb4a7..56f7387097 100644 --- a/modules/git/repo_branch_test.go +++ b/modules/git/repo_branch_test.go @@ -54,3 +54,44 @@ func BenchmarkRepository_GetBranches(b *testing.B) { } } } + +func TestGetRefsBySha(t *testing.T) { + bareRepo5Path := filepath.Join(testReposDir, "repo5_pulls") + bareRepo5, err := OpenRepository(DefaultContext, bareRepo5Path) + if err != nil { + t.Fatal(err) + } + defer bareRepo5.Close() + + // do not exist + branches, err := bareRepo5.GetRefsBySha("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", "") + assert.NoError(t, err) + assert.Len(t, branches, 0) + + // refs/pull/1/head + branches, err = bareRepo5.GetRefsBySha("c83380d7056593c51a699d12b9c00627bd5743e9", PullPrefix) + assert.NoError(t, err) + assert.EqualValues(t, []string{"refs/pull/1/head"}, branches) + + branches, err = bareRepo5.GetRefsBySha("d8e0bbb45f200e67d9a784ce55bd90821af45ebd", BranchPrefix) + assert.NoError(t, err) + assert.EqualValues(t, []string{"refs/heads/master", "refs/heads/master-clone"}, branches) + + branches, err = bareRepo5.GetRefsBySha("58a4bcc53ac13e7ff76127e0fb518b5262bf09af", BranchPrefix) + assert.NoError(t, err) + assert.EqualValues(t, []string{"refs/heads/test-patch-1"}, branches) +} + +func BenchmarkGetRefsBySha(b *testing.B) { + bareRepo5Path := filepath.Join(testReposDir, "repo5_pulls") + bareRepo5, err := OpenRepository(DefaultContext, bareRepo5Path) + if err != nil { + b.Fatal(err) + } + defer bareRepo5.Close() + + _, _ = bareRepo5.GetRefsBySha("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", "") + _, _ = bareRepo5.GetRefsBySha("d8e0bbb45f200e67d9a784ce55bd90821af45ebd", "") + _, _ = bareRepo5.GetRefsBySha("c83380d7056593c51a699d12b9c00627bd5743e9", "") + _, _ = bareRepo5.GetRefsBySha("58a4bcc53ac13e7ff76127e0fb518b5262bf09af", "") +} diff --git a/modules/git/tests/repos/repo5_pulls/HEAD b/modules/git/tests/repos/repo5_pulls/HEAD new file mode 100644 index 0000000000..cb089cd89a --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/modules/git/tests/repos/repo5_pulls/config b/modules/git/tests/repos/repo5_pulls/config new file mode 100644 index 0000000000..0a0ad6d9fe --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true +[receive] + advertisePushOptions = true diff --git a/modules/git/tests/repos/repo5_pulls/description b/modules/git/tests/repos/repo5_pulls/description new file mode 100644 index 0000000000..498b267a8c --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/modules/git/tests/repos/repo5_pulls/info/exclude b/modules/git/tests/repos/repo5_pulls/info/exclude new file mode 100644 index 0000000000..a5196d1be8 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df b/modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df new file mode 100644 index 0000000000000000000000000000000000000000..90464be0785ad94d3764f9ecd520ccc5f54a72a2 GIT binary patch literal 119 zcmV--0Eqv10V^p=O;s>7G+;0^FfcPQQP4}zEXhpI%P&f0_;|4K&77D?Lat0T*{*u$ z4_;16PJycO@pN|e3wC9A8z{N?;FnPMZ57c6?AFw`hx0G2hbjqjb#(D{)yqv`_-$D( Z)scE~S-sZIZDJ=v8p2XSZ27G+;0^FfcPQQP4}zEXhpI%P&f0_;|4K&77D?Lat0T*{*u$ z4_;16PJycO@pN|e3wC9A=QcC>dDg?-buVm71@3F4JUemB0jea()zQV*RWCP%;kRYE aR7dK`W%XJ+w~3tyX$VURwE+My#4UKW7G+;0^FfcPQQP4}zEXhpI%P&f0_;|4K&77D?Lat0T*{*u$ z4_;16PJycO@pN|e3wC9A=QcC>dDg?-buVm71@3F4JUemB0jea()zQV*RWCP%VeTKU ar0^T>zp8yWzdoyPWx&^{xw`;KJ}`yHhdIRn literal 0 HcmV?d00001 diff --git a/modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab b/modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab new file mode 100644 index 0000000000000000000000000000000000000000..d6e616d90224950de6c3ce49d25817e594cf8e02 GIT binary patch literal 120 zcmV-;0Ehp00V^p=O;s>7G+;0^FfcPQQP4}zEXhpI%P&f0_;|4K&77D?Lat0T*{*u$ z4_;16PJycO@pN|e3wC9A8z{N?;FnPMZ57c6?AFw`hx0G2hbjqjb#(D{)yqv`nEQt- aDg4I!uWBF8ug~gR8Sph~?k)fhv@bjO4?27R literal 0 HcmV?d00001 diff --git a/modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2 b/modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2 new file mode 100644 index 0000000000..271cffb983 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2 @@ -0,0 +1,2 @@ +xMN0 Yl' i%4ܟ <=}~2MccM"h֬z)q(CRIOtk27Ƚ1=GrL&]YBFt'&o?^/uѾ*Lݛů6,\ǵO +5ؤ#xj吇CA9VyBciޤ^Rs8.klyCi \ No newline at end of file diff --git a/modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd b/modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd new file mode 100644 index 0000000000..0e2dc872fa --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd @@ -0,0 +1,2 @@ +xAJAE])"VwWt EčzNU5$T9&$'1+y|f6=^XSNpE̅"R1v>W(gDJ@%WPKZ +c2D2)rm`Yyfh:j\)۩=.">W~65w<|>>/| mp?X \ No newline at end of file diff --git a/modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f b/modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f new file mode 100644 index 0000000000000000000000000000000000000000..33d2a219e273155e17a4b5a9efb8d593fb1e6653 GIT binary patch literal 660 zcmV;F0&D$v0bNpCZ`v>v-m`zj5l^iYb?r21THj1!z**1k(gfGu8-(hLIR)iFKI2z}S z?ZG%`^`NT@90t{U-9)e-vdtLN8l}}{sJdGI9#xmSEe`p0Tk58KycEx2;=PD zpVgpoxB=yyt&KuFXly+mb?=oAe0$Sr2M@m0Fe`5xv&V^~(W)b+M>Gxh7MzW5?dW09 z&JU&z7!jDh?#3o)xVJlVG&hE@KG}8zm5&Y`1f3BaZDaP~Ur)A9W7QegM$ni5U5y?m zT_{fZMZw2sJAl(&M-aiF?#b(4b<<492eU!`TSP8Y&aFNE>%=&+ABP?(F%+C=EJijl z{?fG%m7siYyUw12wc7TL34YxxArf1vvhRBGa=mJAJxZP8o(?dHxL)BrQz>Jpoo<*?ba+Pl+ z;Dn%=+(Eton(g2R&yon(&qA=Wgj^85E>h00#|PdFf7g)s-*LjU1`x^oKn7y zAfaoz3`zy$h$u)HyDEk4iiJ|(rud5`&$D1D&NC_S8KGk#KUTNAWD!uo%Mi{&O0?%BU$#A#QGw!1x4YH!}efYlI3j#0@!uFNr=ryAizUdx8iSboBsJ z@t0{rtVHDp*Z1xctu=0FZS?EE6QZ!CYxmpCUanUiTtKOF-17lu5!b8UTE5eL54E`k zhP)UrmOkvDyQ%g84{vo3XE)A#|J!^*{xyS2EY*%m2E2k&iZ`CI3>LJ)_acEES8K6V zfD@7yY7b%wwAjNBUStWdpQU6~1wsVBt8@LDAmX6j#n%JDtQ&-SW2-;NDLD<5FJ5quV7b{ k81x*^$31W)#CTDi7 z-dZ8upmM_~{O!UWhv)ewo$0@1Rc*gJeopdn#oBWW!ON~j&iRnD$NY5Tx_7NbZCO8q zUiLCAH((BsFk|=D$g21@_YYT6_>K2p)jphGpVhZA;A_;}T_=niuCu02J}Q+tS7_$} zHub&X&M!|+`%)DB;!lsPOnF|!$=W-y%$v=wZ<9&ZZDf8uqv&Vj2QNi&md*RrKOSs+ zGbd(}kSkM7wyWOxgO`(%Q$A~R&Hc-{j6X47Q?$Qa=k1M|PM3aLmP>V{o?KS1wR4-; ziI9e{lu(-;eD4MM6<&OvaK=`v=1YFy?OpG9XC?h=`@?mP!@=i_>1XR?Wk(0|Z<&7f z*Lz%_SOcR^0$2oQ1GD9SAnpYga|*!Zum%#2towjsGQe!70^~0Nikme$*+)tif4zBY l$DAWkzhpvOJcR8&U8{O@PM5itNi26s(b~%RiCNoD0RSPRtAPLj literal 0 HcmV?d00001 diff --git a/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack new file mode 100644 index 0000000000000000000000000000000000000000..a5dfc5ebde869554ba8ec95802aafd1501d44284 GIT binary patch literal 2363 zcmcJO`8yK;1AsSkUn5tf+?9KCwVHD*3^|Ijs9ebvGR%=2D}{1jZF1#|IWvSXN0G@@ zWFcp&4GSZtukZUGz8~J7-{*Os=e092wE_SDZ2#cT5x^3%li`BB5PoAOK_2el1y_>c zkxTpjj%@{CNvq^9bA&PzPsN?n?_60|aX&Abnh@S6++%48wX+dcZO#`0HTR1Z-^%G2 zvI+>jB726&uN+l*>V6sS;F@WXNuCEv9h!eQ7JQ?2==uSDZkFs+VKY)*irk3Rmos~l zAagsfV!cY`fz4_|kW&MR9^Do;(svWaZP-&Fh$T2C_n<&fQ6zIeiR-RtFg8;sf=9H< zy_oMScT6{a*+u}PrDWsyXXm5~H=SaTJ5kLI6R~7wmB^yW8qOyW(qsK{Qb`FL&So8s z=0pdnv|Y;9@tT|k5#Q4Zchq+0U@xcYsn#)=q)}Q35i)goDA!8-+L{&x|MqKtL?mu5 zqrnaNYyqfVg8r3tJ(IUCnz+wC3cn^36MUIr8@~5P;%n&*`JG^ED*GJCRx@z+E-EO> zgPygRwSf0~@AZHqqy|aO5c`)mweu1?lpl9+KWW?IozV$+&c`Q@GLoX=6vGyh-XN{8 z52oXf@)jpVwZ}hD6*Bxpe)TSM#FaYROF0E~qj~Jz0;utr{hBE1Z7FMk>iwX46$ram znwy4cc4VtWQ#Rp<(Fu_VdZ;$j>Rsc$YB6#lHcfA|{ob_0+qJ=#3dx(l4;f*XY!jml zH?a#@cE>Hai(hi>O^uU&?r3=*zH06mI64(Riprg((hXUl!W%dJbI)Q4p&7#&&y4{{ zW;Al6mR(4w^0}qWIay6)x?2hzJJT#Li}}6z(>r74ba=2t#SbE-%+)@&jNmu!V4tin zd|5qn8$r1`tTv&s?|_=T(*CmQ-k7$ATeJU#dbd!6gn31u?&0kzu~&m7F=Qt0ic!<* z5#Qq^70Z&-M6-G!9g~Hw?*(>fO!w(YH)U)Uo_OcgzJLh2aob3ny7=PO=8|oi*BjvU zY?M%27+`)WVJ9~v7UYqt#V)5M%WwaEMI;kE0NnSD$PQ1KY*WCsEwz8Fr#h3|!zcDw zgF2BF3Z_fs0B(sb)-AfZKv*(C@*_O0MxObwc zj{%q#2QrKRQw*h*Mg#n>_7$i9pnz&q?}Uq9+2FK?S#UL;6UW@8!kvCP98)mU)Bmlr z6tfiTYCytzfqO0~0eL6Sj%p1pRk)lQOungW&@eDKd2#g}&QY4kxeLhvUD(KaZq!f9 z1`VmGk!TTWWO$hXoc8Gl!lxuX{6j^n;+r5l4*!Fse8y`!S-Hm?m>u#r##!~-r^uD= zpEtx2^x1r|yuW$_ZMwyZRozUeA+SpAJ%PxHC1~qFAlgv-k5;2<@2y9NHBnxB*!ryz zLY^gpXstU#j9dfXxP-a{#Z@x4Fkw4 zF5=!GJ3mnZ+|MASdL0k}fZZ4sh5wT}8-QirSq?_mkU!+0U3Ub@8lueswhBh4EIzwr{g6PWVo9Uvg)G4F0YKbD}L zV#3=C5jA6;?>7#2Fu;9B2vMGE{ z!&%Qq8n~epMv3Q16J&I%w9j(B`A^oP(BjyP^}3G6@f8eFPuaI?vc!ngg1{179jsEn zK!myuuBO0fsIlE(zreY^*eS(l(oI<&7W~0t?xOe-7PElo*WK$p%8U=ub&|Y3vf;HS z6TH-R);_;KdB6>(DMYglS5DNpGz)lS7hKX{Uo#H^M#5)Us?rs=Q(CMRAkKV8fPi^x zbG-5P$CSFDmdnPm^MeCV@9HB!>M9UIiorPhgU6q5H^QhUBJqK)3DF<-ckeZN_{*=K zq}~GQ_s#W|bSa3ZHd%P%Gwndc&>$9rT`A4^%PKD;}$}G zb*F6~g8=uf7nb4SMRf%p1)2vv4-Mle!aGOD8je@OHZ+T+3lG_Q1VN^piCt?oGW1o+ z#+dSl0|!ZLj>JwQ7QT-otkqfp^+j?b!eyprZ@490MJHyHyig}vyW_ET)_#wY*W=X0 zd`ly&giVc^H%B%ZbHN_C?9B1r*XFM$F&`Fd5J~&06f=h0;x||A?it&rxxdgty9eq; zPF77T=-8U#@gKruPR-uccA2pY$|W7;UWG=sK>ctk_F1hoJ^~2YU zJRDNaC*~VuzV;3>_eYj{61lb%)v|^7B46%6=X`Bek!ce7?W=|AAlfr?8g1de$bIC` x{3v+{9s%[2]s` pulls.reopened_at = `reopened this pull request %[2]s` pulls.merge_instruction_hint = `You can also view command line instructions.` - pulls.merge_instruction_step1_desc = From your project repository, check out a new branch and test the changes. pulls.merge_instruction_step2_desc = Merge the changes and update on Gitea. +pulls.merge_on_status_success = The pull request was scheduled to merge when all checks succeed. +pulls.merge_on_status_success_already_scheduled = This pull request is already scheduled to merge when all checks succeed. +pulls.pr_has_pending_merge_on_success = %[1]s scheduled this pull request to auto merge when all checks succeed %[2]s. +pulls.merge_pull_on_success_cancel = Cancel auto merge +pulls.pull_request_not_scheduled = This pull request is not scheduled to auto merge. +pulls.pull_request_schedule_canceled = The auto merge was canceled for this pull request. +pulls.pull_request_scheduled_auto_merge = `scheduled this pull request to auto merge when all checks succeed %[1]s` +pulls.pull_request_canceled_scheduled_auto_merge = `canceled auto merging this pull request when all checks succeed %[1]s` milestones.new = New Milestone milestones.open_tab = %d Open diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 6587037ea3..8fa9a0ed65 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -984,7 +984,8 @@ func Routes() *web.Route { m.Post("/update", reqToken(), repo.UpdatePullRequest) m.Get("/commits", repo.GetPullRequestCommits) m.Combo("/merge").Get(repo.IsPullRequestMerged). - Post(reqToken(), mustNotBeArchived, bind(forms.MergePullRequestForm{}), repo.MergePullRequest) + Post(reqToken(), mustNotBeArchived, bind(forms.MergePullRequestForm{}), repo.MergePullRequest). + Delete(reqToken(), mustNotBeArchived, repo.CancelScheduledAutoMerge) m.Group("/reviews", func() { m.Combo(""). Get(repo.ListPullReviews). diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index d6f349e332..91bb57f3fd 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models" issues_model "code.gitea.io/gitea/models/issues" + pull_model "code.gitea.io/gitea/models/pull" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -28,6 +29,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/forms" issue_service "code.gitea.io/gitea/services/issue" pull_service "code.gitea.io/gitea/services/pull" @@ -805,6 +807,22 @@ func MergePullRequest(ctx *context.APIContext) { return } + if form.MergeWhenChecksSucceed { + scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), form.MergeTitleField) + if err != nil { + if pull_model.IsErrAlreadyScheduledToAutoMerge(err) { + ctx.Error(http.StatusConflict, "ScheduleAutoMerge", err) + return + } + ctx.Error(http.StatusInternalServerError, "ScheduleAutoMerge", err) + return + } else if scheduled { + // nothing more to do ... + ctx.Status(http.StatusCreated) + return + } + } + if err := pull_service.Merge(pr, ctx.Doer, ctx.Repo.GitRepo, repo_model.MergeStyle(form.Do), form.HeadCommitID, form.MergeTitleField); 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", repo_model.MergeStyle(form.Do))) @@ -1113,6 +1131,78 @@ func UpdatePullRequest(ctx *context.APIContext) { ctx.Status(http.StatusOK) } +// MergePullRequest cancel an auto merge scheduled for a given PullRequest by index +func CancelScheduledAutoMerge(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/pulls/{index}/merge repository repoCancelScheduledAutoMerge + // --- + // summary: Cancel the scheduled auto merge for the given pull request + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: index of the pull request to merge + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + pullIndex := ctx.ParamsInt64(":index") + pull, err := models.GetPullRequestByIndex(ctx.Repo.Repository.ID, pullIndex) + if err != nil { + if models.IsErrPullRequestNotExist(err) { + ctx.NotFound() + return + } + ctx.InternalServerError(err) + return + } + + exist, autoMerge, err := pull_model.GetScheduledMergeByPullID(ctx, pull.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + if !exist { + ctx.NotFound() + return + } + + if ctx.Doer.ID != autoMerge.DoerID { + allowed, err := models.IsUserRepoAdminCtx(ctx, ctx.Repo.Repository, ctx.Doer) + if err != nil { + ctx.InternalServerError(err) + return + } + if !allowed { + ctx.Error(http.StatusForbidden, "No permission to cancel", "user has no permission to cancel the scheduled auto merge") + return + } + } + + if err := pull_model.RemoveScheduledAutoMerge(ctx, ctx.Doer, pull.ID, true); err != nil { + ctx.InternalServerError(err) + } else { + ctx.Status(http.StatusNoContent) + } +} + // GetPullRequestCommits gets all commits associated with a given PR func GetPullRequestCommits(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/commits repository repoGetPullRequestCommits diff --git a/routers/init.go b/routers/init.go index 403fab00cd..2e7fec86db 100644 --- a/routers/init.go +++ b/routers/init.go @@ -39,6 +39,7 @@ import ( web_routers "code.gitea.io/gitea/routers/web" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/cron" "code.gitea.io/gitea/services/mailer" repo_migrations "code.gitea.io/gitea/services/migrations" @@ -147,6 +148,7 @@ func GlobalInitInstalled(ctx context.Context) { mirror_service.InitSyncMirrors() mustInit(webhook.Init) mustInit(pull_service.Init) + mustInit(automerge.Init) mustInit(task.Init) mustInit(repo_migrations.Init) eventsource.GetManager().Init() diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index d905c075e3..620b76f46d 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -24,6 +24,7 @@ import ( issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" project_model "code.gitea.io/gitea/models/project" + pull_model "code.gitea.io/gitea/models/pull" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -1662,6 +1663,13 @@ func ViewIssue(ctx *context.Context) { } ctx.Data["StillCanManualMerge"] = stillCanManualMerge() + + // Check if there is a pending pr merge + ctx.Data["HasPendingPullRequestMerge"], ctx.Data["PendingPullRequestMerge"], err = pull_model.GetScheduledMergeByPullID(ctx, pull.ID) + if err != nil { + ctx.ServerError("GetScheduledMergeByPullID", err) + return + } } // Get Dependencies diff --git a/services/automerge/automerge.go b/services/automerge/automerge.go new file mode 100644 index 0000000000..389546ed57 --- /dev/null +++ b/services/automerge/automerge.go @@ -0,0 +1,241 @@ +// Copyright 2021 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 ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + pull_model "code.gitea.io/gitea/models/pull" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/queue" + pull_service "code.gitea.io/gitea/services/pull" +) + +// prAutoMergeQueue represents a queue to handle update pull request tests +var prAutoMergeQueue queue.UniqueQueue + +// Init runs the task queue to that handles auto merges +func Init() error { + prAutoMergeQueue = queue.CreateUniqueQueue("pr_auto_merge", handle, "") + if prAutoMergeQueue == nil { + return fmt.Errorf("Unable to create pr_auto_merge Queue") + } + go graceful.GetManager().RunWithShutdownFns(prAutoMergeQueue.Run) + return nil +} + +// handle passed PR IDs and test the PRs +func handle(data ...queue.Data) []queue.Data { + for _, d := range data { + var id int64 + var sha string + if _, err := fmt.Sscanf(d.(string), "%d_%s", &id, &sha); err != nil { + log.Error("could not parse data from pr_auto_merge queue (%v): %v", d, err) + continue + } + handlePull(id, sha) + } + return nil +} + +func addToQueue(pr *models.PullRequest, sha string) { + if err := prAutoMergeQueue.PushFunc(fmt.Sprintf("%d_%s", pr.ID, sha), func() error { + log.Trace("Adding pullID: %d to the pull requests patch checking queue with sha %s", pr.ID, sha) + return nil + }); err != nil { + log.Error("Error adding pullID: %d to the pull requests patch checking queue %v", pr.ID, err) + } +} + +// ScheduleAutoMerge if schedule is false and no error, pull can be merged directly +func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pull *models.PullRequest, style repo_model.MergeStyle, message string) (scheduled bool, err error) { + lastCommitStatus, err := pull_service.GetPullRequestCommitStatusState(ctx, pull) + if err != nil { + return false, err + } + + // we don't need to schedule + if lastCommitStatus.IsSuccess() { + return false, nil + } + + return true, pull_model.ScheduleAutoMerge(ctx, doer, pull.ID, style, message) +} + +// MergeScheduledPullRequest merges a previously scheduled pull request when all checks succeeded +func MergeScheduledPullRequest(ctx context.Context, sha string, repo *repo_model.Repository) error { + pulls, err := getPullRequestsByHeadSHA(ctx, sha, repo, func(pr *models.PullRequest) bool { + return !pr.HasMerged && pr.CanAutoMerge() + }) + if err != nil { + return err + } + + for _, pr := range pulls { + addToQueue(pr, sha) + } + + return nil +} + +func getPullRequestsByHeadSHA(ctx context.Context, sha string, repo *repo_model.Repository, filter func(*models.PullRequest) bool) (map[int64]*models.PullRequest, error) { + gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + if err != nil { + return nil, err + } + defer gitRepo.Close() + + refs, err := gitRepo.GetRefsBySha(sha, "") + if err != nil { + return nil, err + } + + pulls := make(map[int64]*models.PullRequest) + + for _, ref := range refs { + // Each pull branch starts with refs/pull/ we then go from there to find the index of the pr and then + // use that to get the pr. + if strings.HasPrefix(ref, git.PullPrefix) { + parts := strings.Split(ref[len(git.PullPrefix):], "/") + + // e.g. 'refs/pull/1/head' would be []string{"1", "head"} + if len(parts) != 2 { + log.Error("getPullRequestsByHeadSHA found broken pull ref [%s] on repo [%-v]", ref, repo) + continue + } + + prIndex, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + log.Error("getPullRequestsByHeadSHA found broken pull ref [%s] on repo [%-v]", ref, repo) + continue + } + + p, err := models.GetPullRequestByIndexCtx(ctx, repo.ID, prIndex) + 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 nil, err + } + + if filter(p) { + pulls[p.ID] = p + } + } + } + + return pulls, nil +} + +func handlePull(pullID int64, sha string) { + ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), + fmt.Sprintf("Handle AutoMerge of pull[%d] with sha[%s]", pullID, sha)) + defer finished() + + pr, err := models.GetPullRequestByID(ctx, pullID) + if err != nil { + log.Error("GetPullRequestByID[%d]: %v", pullID, err) + return + } + + // Check if there is a scheduled pr in the db + exists, scheduledPRM, err := pull_model.GetScheduledMergeByPullID(ctx, pr.ID) + if err != nil { + log.Error("pull[%d] GetScheduledMergeByPullID: %v", pr.ID, err) + return + } + if !exists { + return + } + + // 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. + + if err = pr.LoadHeadRepoCtx(ctx); err != nil { + log.Error("pull[%d] LoadHeadRepoCtx: %v", pr.ID, err) + return + } + + headGitRepo, err := git.OpenRepository(ctx, pr.HeadRepo.RepoPath()) + if err != nil { + log.Error("OpenRepository: %v", err) + return + } + defer headGitRepo.Close() + + headBranchExist := headGitRepo.IsBranchExist(pr.HeadBranch) + + if pr.HeadRepo == nil || !headBranchExist { + log.Warn("Head branch of auto merge pr does not exist [HeadRepoID: %d, Branch: %s, PR ID: %d]", pr.HeadRepoID, pr.HeadBranch, pr.ID) + return + } + + // Check if all checks succeeded + pass, err := pull_service.IsPullCommitStatusPass(ctx, pr) + if err != nil { + log.Error("IsPullCommitStatusPass: %v", err) + return + } + if !pass { + log.Info("Scheduled auto merge pr has unsuccessful status checks [PullID: %d]", pr.ID) + return + } + + // Merge if all checks succeeded + doer, err := user_model.GetUserByIDCtx(ctx, scheduledPRM.DoerID) + if err != nil { + log.Error("GetUserByIDCtx: %v", err) + return + } + + perm, err := models.GetUserRepoPermission(ctx, pr.HeadRepo, doer) + if err != nil { + log.Error("GetUserRepoPermission: %v", err) + return + } + + if err := pull_service.CheckPullMergable(ctx, doer, &perm, pr, false, false); err != nil { + if errors.Is(pull_service.ErrUserNotAllowedToMerge, err) { + log.Info("PR %d was scheduled to automerge by an unauthorized user", pr.ID) + return + } + log.Error("pull[%d] CheckPullMergable: %v", pr.ID, err) + return + } + + var baseGitRepo *git.Repository + if pr.BaseRepoID == pr.HeadRepoID { + baseGitRepo = headGitRepo + } else { + if err = pr.LoadBaseRepoCtx(ctx); err != nil { + log.Error("LoadBaseRepoCtx: %v", err) + return + } + + baseGitRepo, err = git.OpenRepository(ctx, pr.BaseRepo.RepoPath()) + if err != nil { + log.Error("OpenRepository: %v", err) + return + } + defer baseGitRepo.Close() + } + + if err := pull_service.Merge(pr, doer, baseGitRepo, scheduledPRM.MergeStyle, "", scheduledPRM.Message); err != nil { + log.Error("pull_service.Merge: %v", err) + return + } +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 5c3adc1cd3..bacee9a13c 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -592,6 +592,7 @@ type MergePullRequestForm struct { MergeCommitID string // only used for manually-merged HeadCommitID string `json:"head_commit_id,omitempty"` ForceMerge *bool `json:"force_merge,omitempty"` + MergeWhenChecksSucceed bool `json:"merge_when_checks_succeed,omitempty"` DeleteBranchAfterMerge bool `json:"delete_branch_after_merge,omitempty"` } diff --git a/services/pull/commit_status.go b/services/pull/commit_status.go index 143f3d50d0..ec4cc2aa07 100644 --- a/services/pull/commit_status.go +++ b/services/pull/commit_status.go @@ -137,5 +137,13 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *models.PullRequest return "", errors.Wrap(err, "GetLatestCommitStatus") } - return MergeRequiredContextsCommitStatus(commitStatuses, pr.ProtectedBranch.StatusCheckContexts), nil + if err := pr.LoadProtectedBranchCtx(ctx); err != nil { + return "", errors.Wrap(err, "LoadProtectedBranch") + } + var requiredContexts []string + if pr.ProtectedBranch != nil { + requiredContexts = pr.ProtectedBranch.StatusCheckContexts + } + + return MergeRequiredContextsCommitStatus(commitStatuses, requiredContexts), nil } diff --git a/services/pull/merge.go b/services/pull/merge.go index fe295cbe03..8cc4d88888 100644 --- a/services/pull/merge.go +++ b/services/pull/merge.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" + pull_model "code.gitea.io/gitea/models/pull" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -46,6 +47,11 @@ func Merge(pr *models.PullRequest, doer *user_model.User, baseGitRepo *git.Repos pullWorkingPool.CheckIn(fmt.Sprint(pr.ID)) defer pullWorkingPool.CheckOut(fmt.Sprint(pr.ID)) + // Removing an auto merge pull and ignore if not exist + if err := pull_model.RemoveScheduledAutoMerge(db.DefaultContext, doer, pr.ID, false); err != nil && !models.IsErrNotExist(err) { + return err + } + prUnit, err := pr.BaseRepo.GetUnit(unit.TypePullRequests) if err != nil { log.Error("pr.BaseRepo.GetUnit(unit.TypePullRequests): %v", err) diff --git a/services/pull/pull.go b/services/pull/pull.go index 5cef3c356f..d226c60ec2 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -253,7 +253,7 @@ func AddTestPullRequestTask(doer *user_model.User, repoID int64, branch string, graceful.GetManager().RunWithShutdownContext(func(ctx context.Context) { // There is no sensible way to shut this down ":-(" // If you don't let it run all the way then you will lose data - // FIXME: graceful: AddTestPullRequestTask needs to become a queue! + // TODO: graceful: AddTestPullRequestTask needs to become a queue! prs, err := models.GetUnmergedPullRequestsByHeadInfo(repoID, branch) if err != nil { diff --git a/services/repository/files/commit.go b/services/repository/files/commit.go index e7604e3f92..6ecabb4020 100644 --- a/services/repository/files/commit.go +++ b/services/repository/files/commit.go @@ -14,6 +14,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/automerge" ) // CreateCommitStatus creates a new CommitStatus given a bunch of parameters @@ -44,6 +45,12 @@ func CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, creato return fmt.Errorf("NewCommitStatus[repo_id: %d, user_id: %d, sha: %s]: %v", repo.ID, creator.ID, sha, err) } + if status.State.IsSuccess() { + if err := automerge.MergeScheduledPullRequest(ctx, sha, repo); err != nil { + return fmt.Errorf("MergeScheduledPullRequest[repo_id: %d, user_id: %d, sha: %s]: %w", repo.ID, creator.ID, sha, err) + } + } + return nil } diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index 7ff7f247fc..235f4c8fc2 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -10,7 +10,8 @@ 22 = REVIEW, 23 = ISSUE_LOCKED, 24 = ISSUE_UNLOCKED, 25 = TARGET_BRANCH_CHANGED, 26 = DELETE_TIME_MANUAL, 27 = REVIEW_REQUEST, 28 = MERGE_PULL_REQUEST, 29 = PULL_PUSH_EVENT, 30 = PROJECT_CHANGED, 31 = PROJECT_BOARD_CHANGED - 32 = DISMISSED_REVIEW --> + 32 = DISMISSED_REVIEW, 33 = COMMENT_TYPE_CHANGE_ISSUE_REF, 34 = PR_SCHEDULE_TO_AUTO_MERGE, + 35 = CANCEL_SCHEDULED_AUTO_MERGE_PR --> {{if eq .Type 0}}
{{if .OriginalAuthor }} @@ -837,6 +838,15 @@ {{end}}
+ {{else if or (eq .Type 34) (eq .Type 35)}} +
+ {{svg "octicon-git-merge" 16}} + + {{.Poster.GetDisplayName}} + {{if eq .Type 34}}{{$.i18n.Tr "repo.pulls.pull_request_scheduled_auto_merge" $createdStr | Safe}} + {{else}}{{$.i18n.Tr "repo.pulls.pull_request_canceled_scheduled_auto_merge" $createdStr | Safe}}{{end}} + +
{{end}} {{end}} {{end}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 0b7d1d74c2..d63cde60ec 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -8015,6 +8015,51 @@ "$ref": "#/responses/error" } } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Cancel the scheduled auto merge for the given pull request", + "operationId": "repoCancelScheduledAutoMerge", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "index of the pull request to merge", + "name": "index", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } } }, "/repos/{owner}/{repo}/pulls/{index}/requested_reviewers": { @@ -16298,6 +16343,10 @@ "head_commit_id": { "type": "string", "x-go-name": "HeadCommitID" + }, + "merge_when_checks_succeed": { + "type": "boolean", + "x-go-name": "MergeWhenChecksSucceed" } }, "x-go-name": "MergePullRequestForm",