diff --git a/api/queries_pr.go b/api/queries_pr.go index 2a940b5a7..59cf04460 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -140,14 +140,6 @@ type PullRequestReviewStatus struct { ReviewRequired bool } -type PullRequestMergeMethod int - -const ( - PullRequestMergeMethodMerge PullRequestMergeMethod = iota - PullRequestMergeMethodRebase - PullRequestMergeMethodSquash -) - func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus { var status PullRequestReviewStatus switch pr.ReviewDecision { @@ -1088,47 +1080,6 @@ func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) e return err } -func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m PullRequestMergeMethod, body *string) error { - mergeMethod := githubv4.PullRequestMergeMethodMerge - switch m { - case PullRequestMergeMethodRebase: - mergeMethod = githubv4.PullRequestMergeMethodRebase - case PullRequestMergeMethodSquash: - mergeMethod = githubv4.PullRequestMergeMethodSquash - } - - var mutation struct { - MergePullRequest struct { - PullRequest struct { - ID githubv4.ID - } - } `graphql:"mergePullRequest(input: $input)"` - } - - input := githubv4.MergePullRequestInput{ - PullRequestID: pr.ID, - MergeMethod: &mergeMethod, - } - - if m == PullRequestMergeMethodSquash { - commitHeadline := githubv4.String(fmt.Sprintf("%s (#%d)", pr.Title, pr.Number)) - input.CommitHeadline = &commitHeadline - } - if body != nil { - commitBody := githubv4.String(*body) - input.CommitBody = &commitBody - } - - variables := map[string]interface{}{ - "input": input, - } - - gql := graphQLClient(client.http, repo.RepoHost()) - err := gql.MutateNamed(context.Background(), "PullRequestMerge", &mutation, variables) - - return err -} - func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) error { var mutation struct { MarkPullRequestReadyForReview struct { diff --git a/pkg/cmd/pr/merge/http.go b/pkg/cmd/pr/merge/http.go new file mode 100644 index 000000000..ad1a9a78a --- /dev/null +++ b/pkg/cmd/pr/merge/http.go @@ -0,0 +1,138 @@ +package merge + +import ( + "context" + "net/http" + "strings" + + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/shurcooL/githubv4" + "github.com/shurcooL/graphql" +) + +type PullRequestMergeMethod int + +const ( + PullRequestMergeMethodMerge PullRequestMergeMethod = iota + PullRequestMergeMethodRebase + PullRequestMergeMethodSquash +) + +type mergePayload struct { + repo ghrepo.Interface + pullRequestID string + method PullRequestMergeMethod + auto bool + commitSubject string + setCommitSubject bool + commitBody string + setCommitBody bool +} + +// TODO: drop after githubv4 gets updated +type EnablePullRequestAutoMergeInput struct { + githubv4.MergePullRequestInput +} + +func mergePullRequest(client *http.Client, payload mergePayload) error { + input := githubv4.MergePullRequestInput{ + PullRequestID: githubv4.ID(payload.pullRequestID), + } + + switch payload.method { + case PullRequestMergeMethodMerge: + m := githubv4.PullRequestMergeMethodMerge + input.MergeMethod = &m + case PullRequestMergeMethodRebase: + m := githubv4.PullRequestMergeMethodRebase + input.MergeMethod = &m + case PullRequestMergeMethodSquash: + m := githubv4.PullRequestMergeMethodSquash + input.MergeMethod = &m + } + + if payload.setCommitSubject { + commitHeadline := githubv4.String(payload.commitSubject) + input.CommitHeadline = &commitHeadline + } + if payload.setCommitBody { + commitBody := githubv4.String(payload.commitBody) + input.CommitBody = &commitBody + } + + variables := map[string]interface{}{ + "input": input, + } + + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(payload.repo.RepoHost()), client) + + if payload.auto { + var mutation struct { + EnablePullRequestAutoMerge struct { + ClientMutationId string + } `graphql:"enablePullRequestAutoMerge(input: $input)"` + } + variables["input"] = EnablePullRequestAutoMergeInput{input} + return gql.MutateNamed(context.Background(), "PullRequestAutoMerge", &mutation, variables) + } + + var mutation struct { + MergePullRequest struct { + ClientMutationId string + } `graphql:"mergePullRequest(input: $input)"` + } + return gql.MutateNamed(context.Background(), "PullRequestMerge", &mutation, variables) +} + +func disableAutoMerge(client *http.Client, repo ghrepo.Interface, prID string) error { + var mutation struct { + DisablePullRequestAutoMerge struct { + ClientMutationId string + } `graphql:"disablePullRequestAutoMerge(input: {pullRequestId: $prID})"` + } + + variables := map[string]interface{}{ + "prID": githubv4.ID(prID), + } + + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), client) + return gql.MutateNamed(context.Background(), "PullRequestAutoMergeDisable", &mutation, variables) +} + +func getMergeText(client *http.Client, repo ghrepo.Interface, prID string, mergeMethod PullRequestMergeMethod) (string, error) { + var method githubv4.PullRequestMergeMethod + switch mergeMethod { + case PullRequestMergeMethodMerge: + method = githubv4.PullRequestMergeMethodMerge + case PullRequestMergeMethodRebase: + method = githubv4.PullRequestMergeMethodRebase + case PullRequestMergeMethodSquash: + method = githubv4.PullRequestMergeMethodSquash + } + + var query struct { + Node struct { + PullRequest struct { + ViewerMergeBodyText string `graphql:"viewerMergeBodyText(mergeType: $method)"` + } `graphql:"...on PullRequest"` + } `graphql:"node(id: $prID)"` + } + + variables := map[string]interface{}{ + "prID": githubv4.ID(prID), + "method": method, + } + + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), client) + err := gql.QueryNamed(context.Background(), "PullRequestMergeText", &query, variables) + if err != nil { + // Tolerate this API missing in older GitHub Enterprise + if strings.Contains(err.Error(), "Field 'viewerMergeBodyText' doesn't exist") { + return "", nil + } + return "", err + } + + return query.Node.PullRequest.ViewerMergeBodyText, nil +} diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 4c8c946c0..f2923573c 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -16,6 +16,7 @@ import ( "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/pkg/surveyext" "github.com/spf13/cobra" ) @@ -29,9 +30,13 @@ type MergeOptions struct { SelectorArg string DeleteBranch bool - MergeMethod api.PullRequestMergeMethod + MergeMethod PullRequestMergeMethod - Body *string + AutoMergeEnable bool + AutoMergeDisable bool + + Body string + BodySet bool IsDeleteBranchIndicated bool CanDeleteLocalBranch bool @@ -74,15 +79,15 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm methodFlags := 0 if flagMerge { - opts.MergeMethod = api.PullRequestMergeMethodMerge + opts.MergeMethod = PullRequestMergeMethodMerge methodFlags++ } if flagRebase { - opts.MergeMethod = api.PullRequestMergeMethodRebase + opts.MergeMethod = PullRequestMergeMethodRebase methodFlags++ } if flagSquash { - opts.MergeMethod = api.PullRequestMergeMethodSquash + opts.MergeMethod = PullRequestMergeMethodSquash methodFlags++ } if methodFlags == 0 { @@ -96,11 +101,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm opts.IsDeleteBranchIndicated = cmd.Flags().Changed("delete-branch") opts.CanDeleteLocalBranch = !cmd.Flags().Changed("repo") - - if cmd.Flags().Changed("body") { - bodyStr, _ := cmd.Flags().GetString("body") - opts.Body = &bodyStr - } + opts.BodySet = cmd.Flags().Changed("body") if runF != nil { return runF(opts) @@ -110,10 +111,12 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm } cmd.Flags().BoolVarP(&opts.DeleteBranch, "delete-branch", "d", false, "Delete the local and remote branch after merge") - cmd.Flags().StringP("body", "b", "", "Body for merge commit") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Body `text` for the merge commit") cmd.Flags().BoolVarP(&flagMerge, "merge", "m", false, "Merge the commits with the base branch") cmd.Flags().BoolVarP(&flagRebase, "rebase", "r", false, "Rebase the commits onto the base branch") cmd.Flags().BoolVarP(&flagSquash, "squash", "s", false, "Squash the commits into one commit and merge it into the base branch") + cmd.Flags().BoolVar(&opts.AutoMergeEnable, "auto", false, "Automatically merge only after necessary requirements are met") + cmd.Flags().BoolVar(&opts.AutoMergeDisable, "disable-auto", false, "Disable auto-merge for this pull request") return cmd } @@ -131,6 +134,19 @@ func mergeRun(opts *MergeOptions) error { return err } + isTerminal := opts.IO.IsStdoutTTY() + + if opts.AutoMergeDisable { + err := disableAutoMerge(httpClient, baseRepo, pr.ID) + if err != nil { + return err + } + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "%s Auto-merge disabled for pull request #%d\n", cs.SuccessIconWithColor(cs.Green), pr.Number) + } + return nil + } + if opts.SelectorArg == "" { localBranchLastCommit, err := git.LastCommit() if err == nil { @@ -148,18 +164,24 @@ func mergeRun(opts *MergeOptions) error { deleteBranch := opts.DeleteBranch crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner() - isTerminal := opts.IO.IsStdoutTTY() isPRAlreadyMerged := pr.State == "MERGED" if !isPRAlreadyMerged { - mergeMethod := opts.MergeMethod + payload := mergePayload{ + repo: baseRepo, + pullRequestID: pr.ID, + method: opts.MergeMethod, + auto: opts.AutoMergeEnable, + commitBody: opts.Body, + setCommitBody: opts.BodySet, + } if opts.InteractiveMode { r, err := api.GitHubRepo(apiClient, baseRepo) if err != nil { return err } - mergeMethod, err = mergeMethodSurvey(r) + payload.method, err = mergeMethodSurvey(r) if err != nil { return err } @@ -167,32 +189,72 @@ func mergeRun(opts *MergeOptions) error { if err != nil { return err } - confirm, err := confirmSurvey() + + allowEditMsg := payload.method != PullRequestMergeMethodRebase + + action, err := confirmSurvey(allowEditMsg) if err != nil { - return err + return fmt.Errorf("unable to confirm: %w", err) } - if !confirm { + + if action == shared.EditCommitMessageAction { + var editorCommand string + editorCommand, err = cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + + if !payload.setCommitBody { + payload.commitBody, err = getMergeText(httpClient, baseRepo, pr.ID, payload.method) + if err != nil { + return err + } + } + + payload.commitBody, err = commitMsgSurvey(payload.commitBody, editorCommand) + if err != nil { + return err + } + payload.setCommitBody = true + + action, err = confirmSurvey(false) + if err != nil { + return fmt.Errorf("unable to confirm: %w", err) + } + } + if action == shared.CancelAction { fmt.Fprintln(opts.IO.ErrOut, "Cancelled.") return cmdutil.SilentError } } - err = api.PullRequestMerge(apiClient, baseRepo, pr, mergeMethod, opts.Body) + err = mergePullRequest(httpClient, payload) if err != nil { return err } if isTerminal { - action := "Merged" - switch mergeMethod { - case api.PullRequestMergeMethodRebase: - action = "Rebased and merged" - case api.PullRequestMergeMethodSquash: - action = "Squashed and merged" + if payload.auto { + method := "" + switch payload.method { + case PullRequestMergeMethodRebase: + method = " via rebase" + case PullRequestMergeMethodSquash: + method = " via squash" + } + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d will be automatically merged%s when all requirements are met\n", cs.SuccessIconWithColor(cs.Green), pr.Number, method) + } else { + action := "Merged" + switch payload.method { + case PullRequestMergeMethodRebase: + action = "Rebased and merged" + case PullRequestMergeMethodSquash: + action = "Squashed and merged" + } + fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.SuccessIconWithColor(cs.Magenta), action, pr.Number, pr.Title) } - fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.SuccessIconWithColor(cs.Magenta), action, pr.Number, pr.Title) } - } else if !opts.IsDeleteBranchIndicated && opts.InteractiveMode && !crossRepoPR { + } else if !opts.IsDeleteBranchIndicated && opts.InteractiveMode && !crossRepoPR && !opts.AutoMergeEnable { err := prompt.SurveyAskOne(&survey.Confirm{ Message: fmt.Sprintf("Pull request #%d was already merged. Delete the branch locally?", pr.Number), Default: false, @@ -204,7 +266,7 @@ func mergeRun(opts *MergeOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d was already merged\n", cs.WarningIcon(), pr.Number) } - if !deleteBranch || crossRepoPR { + if !deleteBranch || crossRepoPR || opts.AutoMergeEnable { return nil } @@ -259,23 +321,23 @@ func mergeRun(opts *MergeOptions) error { return nil } -func mergeMethodSurvey(baseRepo *api.Repository) (api.PullRequestMergeMethod, error) { +func mergeMethodSurvey(baseRepo *api.Repository) (PullRequestMergeMethod, error) { type mergeOption struct { title string - method api.PullRequestMergeMethod + method PullRequestMergeMethod } var mergeOpts []mergeOption if baseRepo.MergeCommitAllowed { - opt := mergeOption{title: "Create a merge commit", method: api.PullRequestMergeMethodMerge} + opt := mergeOption{title: "Create a merge commit", method: PullRequestMergeMethodMerge} mergeOpts = append(mergeOpts, opt) } if baseRepo.RebaseMergeAllowed { - opt := mergeOption{title: "Rebase and merge", method: api.PullRequestMergeMethodRebase} + opt := mergeOption{title: "Rebase and merge", method: PullRequestMergeMethodRebase} mergeOpts = append(mergeOpts, opt) } if baseRepo.SquashMergeAllowed { - opt := mergeOption{title: "Squash and merge", method: api.PullRequestMergeMethodSquash} + opt := mergeOption{title: "Squash and merge", method: PullRequestMergeMethodSquash} mergeOpts = append(mergeOpts, opt) } @@ -287,7 +349,6 @@ func mergeMethodSurvey(baseRepo *api.Repository) (api.PullRequestMergeMethod, er mergeQuestion := &survey.Select{ Message: "What merge method would you like to use?", Options: surveyOpts, - Default: "Create a merge commit", } var result int @@ -316,12 +377,50 @@ func deleteBranchSurvey(opts *MergeOptions, crossRepoPR bool) (bool, error) { return opts.DeleteBranch, nil } -func confirmSurvey() (bool, error) { - var confirm bool - submit := &survey.Confirm{ - Message: "Submit?", - Default: true, +func confirmSurvey(allowEditMsg bool) (shared.Action, error) { + const ( + submitLabel = "Submit" + editCommitMsgLabel = "Edit commit message" + cancelLabel = "Cancel" + ) + + options := []string{submitLabel} + if allowEditMsg { + options = append(options, editCommitMsgLabel) + } + options = append(options, cancelLabel) + + var result string + submit := &survey.Select{ + Message: "What's next?", + Options: options, + } + err := prompt.SurveyAskOne(submit, &result) + if err != nil { + return shared.CancelAction, fmt.Errorf("could not prompt: %w", err) + } + + switch result { + case submitLabel: + return shared.SubmitAction, nil + case editCommitMsgLabel: + return shared.EditCommitMessageAction, nil + default: + return shared.CancelAction, nil } - err := prompt.SurveyAskOne(submit, &confirm) - return confirm, err +} + +func commitMsgSurvey(msg string, editorCommand string) (string, error) { + var result string + q := &surveyext.GhEditor{ + EditorCommand: editorCommand, + Editor: &survey.Editor{ + Message: "Body", + AppendDefault: true, + Default: msg, + FileName: "*.md", + }, + } + err := prompt.SurveyAskOne(q, &result) + return result, err } diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index ce772f1e6..90e8d69c2 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -27,12 +27,11 @@ import ( func Test_NewCmdMerge(t *testing.T) { tests := []struct { - name string - args string - isTTY bool - want MergeOptions - wantBody string - wantErr string + name string + args string + isTTY bool + want MergeOptions + wantErr string }{ { name: "number argument", @@ -43,8 +42,10 @@ func Test_NewCmdMerge(t *testing.T) { DeleteBranch: false, IsDeleteBranchIndicated: false, CanDeleteLocalBranch: true, - MergeMethod: api.PullRequestMergeMethodMerge, + MergeMethod: PullRequestMergeMethodMerge, InteractiveMode: true, + Body: "", + BodySet: false, }, }, { @@ -56,8 +57,10 @@ func Test_NewCmdMerge(t *testing.T) { DeleteBranch: false, IsDeleteBranchIndicated: true, CanDeleteLocalBranch: true, - MergeMethod: api.PullRequestMergeMethodMerge, + MergeMethod: PullRequestMergeMethodMerge, InteractiveMode: true, + Body: "", + BodySet: false, }, }, { @@ -69,10 +72,11 @@ func Test_NewCmdMerge(t *testing.T) { DeleteBranch: false, IsDeleteBranchIndicated: false, CanDeleteLocalBranch: true, - MergeMethod: api.PullRequestMergeMethodMerge, + MergeMethod: PullRequestMergeMethodMerge, InteractiveMode: true, + Body: "cool", + BodySet: true, }, - wantBody: "cool", }, { name: "no argument with --repo override", @@ -138,12 +142,8 @@ func Test_NewCmdMerge(t *testing.T) { assert.Equal(t, tt.want.CanDeleteLocalBranch, opts.CanDeleteLocalBranch) assert.Equal(t, tt.want.MergeMethod, opts.MergeMethod) assert.Equal(t, tt.want.InteractiveMode, opts.InteractiveMode) - - if tt.wantBody == "" { - assert.Nil(t, opts.Body) - } else { - assert.Equal(t, tt.wantBody, *opts.Body) - } + assert.Equal(t, tt.want.Body, opts.Body) + assert.Equal(t, tt.want.BodySet, opts.BodySet) }) } } @@ -506,7 +506,7 @@ func TestPrMerge_squash(t *testing.T) { httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) assert.Equal(t, "SQUASH", input["mergeMethod"].(string)) - assert.Equal(t, "The title of the PR (#3)", input["commitHeadline"].(string)) + assert.NotContains(t, input, "commitHeadline") })) _, cmdTeardown := run.Stub() @@ -610,25 +610,19 @@ func TestPRMerge_interactive(t *testing.T) { assert.Equal(t, "MERGE", input["mergeMethod"].(string)) assert.NotContains(t, input, "commitHeadline") })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) cs.Register("git -c log.ShowSignature=false log --pretty=format:%H,%s -1", 0, "") cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "") - cs.Register(`git checkout master`, 0, "") - cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "") - cs.Register(`git branch -D blueberries`, 0, "") as, surveyTeardown := prompt.InitAskStubber() defer surveyTeardown() - as.StubOne(0) // Merge method survey - as.StubOne(true) // Delete branch survey - as.StubOne(true) // Confirm submit survey + as.StubOne(0) // Merge method survey + as.StubOne(false) // Delete branch survey + as.StubOne("Submit") // Confirm submit survey output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -682,8 +676,8 @@ func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) { as, surveyTeardown := prompt.InitAskStubber() defer surveyTeardown() - as.StubOne(0) // Merge method survey - as.StubOne(true) // Confirm submit survey + as.StubOne(0) // Merge method survey + as.StubOne("Submit") // Confirm submit survey output, err := runCommand(http, "blueberries", true, "-d") if err != nil { @@ -694,6 +688,64 @@ func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) { test.ExpectLines(t, output.Stderr(), "Merged pull request #3", "Deleted branch blueberries and switched to branch master") } +func TestPRMerge_interactiveSquashEditCommitMsg(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [{ + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"}, + "id": "THE-ID", + "number": 3, + "title": "title" + }] } } } }`)) + http.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "mergeCommitAllowed": true, + "rebaseMergeAllowed": true, + "squashMergeAllowed": true + } } }`)) + http.Register( + httpmock.GraphQL(`query PullRequestMergeText\b`), + httpmock.StringResponse(` + { "data": { "node": { + "viewerMergeBodyText": "" + } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "SQUASH", input["mergeMethod"].(string)) + assert.Equal(t, "cool story", input["commitBody"].(string)) + })) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register("git -c log.ShowSignature=false log --pretty=format:%H,%s -1", 0, "") + cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "") + + as, surveyTeardown := prompt.InitAskStubber() + defer surveyTeardown() + + as.StubOne(2) // Merge method survey + as.StubOne(false) // Delete branch survey + as.StubOne("Edit commit message") // Confirm submit survey + as.StubOne("cool story") // Edit commit message survey + as.StubOne("Submit") // Confirm submit survey + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) + } + + assert.Equal(t, "✓ Squashed and merged pull request #3 (title)\n", output.Stderr()) +} + func TestPRMerge_interactiveCancelled(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) @@ -724,9 +776,9 @@ func TestPRMerge_interactiveCancelled(t *testing.T) { as, surveyTeardown := prompt.InitAskStubber() defer surveyTeardown() - as.StubOne(0) // Merge method survey - as.StubOne(true) // Delete branch survey - as.StubOne(false) // Confirm submit survey + as.StubOne(0) // Merge method survey + as.StubOne(true) // Delete branch survey + as.StubOne("Cancel") // Confirm submit survey output, err := runCommand(http, "blueberries", true, "") if !errors.Is(err, cmdutil.SilentError) { @@ -747,5 +799,89 @@ func Test_mergeMethodSurvey(t *testing.T) { as.StubOne(0) // Select first option which is rebase merge method, err := mergeMethodSurvey(repo) assert.Nil(t, err) - assert.Equal(t, api.PullRequestMergeMethodRebase, method) + assert.Equal(t, PullRequestMergeMethodRebase, method) +} + +func TestMergeRun_autoMerge(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStderrTTY(true) + + tr := initFakeHTTP() + defer tr.Verify(t) + + tr.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "THE-ID", + "number": 123, + "title": "The title of the PR", + "state": "OPEN", + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"} + } } } }`)) + tr.Register( + httpmock.GraphQL(`mutation PullRequestAutoMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "SQUASH", input["mergeMethod"].(string)) + })) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + err := mergeRun(&MergeOptions{ + IO: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: tr}, nil + }, + SelectorArg: "https://github.com/OWNER/REPO/pull/123", + AutoMergeEnable: true, + MergeMethod: PullRequestMergeMethodSquash, + }) + assert.NoError(t, err) + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "✓ Pull request #123 will be automatically merged via squash when all requirements are met\n", stderr.String()) +} + +func TestMergeRun_disableAutoMerge(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStderrTTY(true) + + tr := initFakeHTTP() + defer tr.Verify(t) + + tr.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "THE-ID", + "number": 123, + "title": "The title of the PR", + "state": "OPEN", + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"} + } } } }`)) + tr.Register( + httpmock.GraphQL(`mutation PullRequestAutoMergeDisable\b`), + httpmock.StringResponse(`{}`)) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + err := mergeRun(&MergeOptions{ + IO: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: tr}, nil + }, + SelectorArg: "https://github.com/OWNER/REPO/pull/123", + AutoMergeDisable: true, + }) + assert.NoError(t, err) + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "✓ Auto-merge disabled for pull request #123\n", stderr.String()) } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 8ef40d047..836f83889 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -21,6 +21,7 @@ const ( PreviewAction CancelAction MetadataAction + EditCommitMessageAction noMilestone = "(none)" )