diff --git a/integrations/api_helper_for_declarative_test.go b/integrations/api_helper_for_declarative_test.go index 5da72b7fb15a..181a6469467c 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 85f08606ee4b..04cdf633bd0e 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 07c73ceac682..a5247f56ec5f 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 b53d988c58b7..7107f43b0fed 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 ceea87866281..13b2c6254606 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 9e46791ec607..817ba3bfaca3 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 000000000000..dfe5d776a0f2 --- /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 d056888130e6..0fa3bdf14f72 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 000000000000..fd73f2b0fb0f --- /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 ecedb56686d9..dc295765629b 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 3aed4abdf35b..bc58991085b7 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 add04cb4a78d..56f7387097df 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 000000000000..cb089cd89a7d --- /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 000000000000..0a0ad6d9fe21 --- /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 000000000000..498b267a8c78 --- /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 000000000000..a5196d1be8fb --- /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 000000000000..90464be0785a Binary files /dev/null and b/modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df differ diff --git a/modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf b/modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf new file mode 100644 index 000000000000..cf9d59f7aed4 Binary files /dev/null and b/modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf differ diff --git a/modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af b/modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af new file mode 100644 index 000000000000..efc69b12e66d --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af @@ -0,0 +1 @@ +x%n0 ;)0H1 P](F2Tk7|wu]{OқHp8$A1"\aRff4 #ZL:J\-#fO2sN6ӯN;v# 3p׺5py^yL)xۼs_n1]ާa_)@X \ No newline at end of file diff --git a/modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1 b/modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1 new file mode 100644 index 000000000000..74e848ffcce7 Binary files /dev/null and b/modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1 differ 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 000000000000..d6e616d90224 Binary files /dev/null and b/modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab differ 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 000000000000..271cffb98331 --- /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 000000000000..0e2dc872fa9f --- /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 000000000000..33d2a219e273 Binary files /dev/null and b/modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f differ diff --git a/modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f b/modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f new file mode 100644 index 000000000000..d64847cf20d3 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f @@ -0,0 +1,3 @@ +xAJAE])!VtM"YF=@uw5$D\yoh +n?lxbMd,TC7f%uĔP3Jr;i:ԎJ,`5P)a̔1ƞ +9ym9U.nIgYOlG,:=qs$DMwa_S6o9X \ No newline at end of file diff --git a/modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640 b/modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640 new file mode 100644 index 000000000000..9cd9d008e1e1 Binary files /dev/null and b/modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640 differ diff --git a/modules/git/tests/repos/repo5_pulls/objects/info/packs b/modules/git/tests/repos/repo5_pulls/objects/info/packs new file mode 100644 index 000000000000..8bbc84872442 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/info/packs @@ -0,0 +1,2 @@ +P pack-81423f591973f5d9dab89cc45afa1c544448133e.pack + diff --git a/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx new file mode 100644 index 000000000000..b66df2316469 Binary files /dev/null and b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx differ 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 000000000000..a5dfc5ebde86 Binary files /dev/null and b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack differ diff --git a/modules/git/tests/repos/repo5_pulls/packed-refs b/modules/git/tests/repos/repo5_pulls/packed-refs new file mode 100644 index 000000000000..d0012b544181 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/packed-refs @@ -0,0 +1,5 @@ +# pack-refs with: peeled fully-peeled sorted +c83380d7056593c51a699d12b9c00627bd5743e9 refs/heads/test-patch-1 +c83380d7056593c51a699d12b9c00627bd5743e9 refs/pull/1/head +111cac04bd7d20301964e27a93698aabb5781b80 refs/pull/1/merge +72866af952e98d02a73003501836074b286a78f6 refs/tags/v0.9.99 diff --git a/modules/git/tests/repos/repo5_pulls/refs/heads/master b/modules/git/tests/repos/repo5_pulls/refs/heads/master new file mode 100644 index 000000000000..9a8e3b2a3425 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/refs/heads/master @@ -0,0 +1 @@ +d8e0bbb45f200e67d9a784ce55bd90821af45ebd diff --git a/modules/git/tests/repos/repo5_pulls/refs/heads/master-clone b/modules/git/tests/repos/repo5_pulls/refs/heads/master-clone new file mode 100644 index 000000000000..9a8e3b2a3425 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/refs/heads/master-clone @@ -0,0 +1 @@ +d8e0bbb45f200e67d9a784ce55bd90821af45ebd diff --git a/modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1 b/modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1 new file mode 100644 index 000000000000..d8b26cb03776 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1 @@ -0,0 +1 @@ +58a4bcc53ac13e7ff76127e0fb518b5262bf09af diff --git a/modules/git/tests/repos/repo5_pulls/refs/pull/4/head b/modules/git/tests/repos/repo5_pulls/refs/pull/4/head new file mode 100644 index 000000000000..d8b26cb03776 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/refs/pull/4/head @@ -0,0 +1 @@ +58a4bcc53ac13e7ff76127e0fb518b5262bf09af diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index c040386ca70c..271fa62953bc 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1560,6 +1560,14 @@ pulls.squash_merge_pull_request = Create squash commit pulls.merge_manually = Manually merged pulls.merge_commit_id = The merge commit ID pulls.require_signed_wont_sign = The branch requires signed commits but this merge will not be signed +pulls.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. Hint: Try a different strategy pulls.merge_conflict_summary = Error Message @@ -1588,9 +1596,16 @@ pulls.outdated_with_base_branch = This branch is out-of-date with the base branc pulls.closed_at = `closed this pull request %[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 6587037ea3f8..8fa9a0ed6537 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 d6f349e332c1..91bb57f3fd79 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 403fab00cd3b..2e7fec86db8a 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 d905c075e3cf..620b76f46d36 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 000000000000..389546ed5784 --- /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 5c3adc1cd3eb..bacee9a13ca7 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 143f3d50d098..ec4cc2aa0784 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 fe295cbe03ac..8cc4d8888845 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 5cef3c356f04..d226c60ec229 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 e7604e3f924d..6ecabb4020bb 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 7ff7f247fca6..235f4c8fc266 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 0b7d1d74c221..d63cde60ecf1 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",