diff --git a/api/queries_pr.go b/api/queries_pr.go index c84dd2880..b18022ddb 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 { @@ -1074,47 +1066,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..7261be806 --- /dev/null +++ b/pkg/cmd/pr/merge/http.go @@ -0,0 +1,100 @@ +package merge + +import ( + "context" + "net/http" + + "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 { + AnablePullRequestAutoMerge 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) +} diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 9003765d2..37953060e 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -30,7 +30,10 @@ type MergeOptions struct { SelectorArg string DeleteBranch bool - MergeMethod api.PullRequestMergeMethod + MergeMethod PullRequestMergeMethod + + AutoMerge bool + AutoMergeDisable bool Body string @@ -75,15 +78,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 { @@ -110,6 +113,8 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm 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.AutoMerge, "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 } @@ -127,6 +132,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 { @@ -144,18 +162,26 @@ 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.AutoMerge, + commitBody: opts.Body, + } + if opts.Body != "" { + payload.setCommitBody = true + } 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 } @@ -164,7 +190,7 @@ func mergeRun(opts *MergeOptions) error { return err } - allowEditMsg := mergeMethod != api.PullRequestMergeMethodRebase + allowEditMsg := payload.method != PullRequestMergeMethodRebase action, err := confirmSurvey(allowEditMsg) if err != nil { @@ -178,19 +204,19 @@ func mergeRun(opts *MergeOptions) error { return err } - if opts.Body == "" { - if mergeMethod == api.PullRequestMergeMethodMerge { - opts.Body = pr.Title + if payload.commitBody == "" { + if payload.method == PullRequestMergeMethodMerge { + payload.commitBody = pr.Title } else { - opts.Body = pr.ViewerMergeBodyText + payload.commitBody = pr.ViewerMergeBodyText } } - msg, err := commitMsgSurvey(opts.Body, editorCommand) + payload.commitBody, err = commitMsgSurvey(payload.commitBody, editorCommand) if err != nil { return err } - opts.Body = msg + payload.setCommitBody = true action, err = confirmSurvey(false) if err != nil { @@ -203,27 +229,38 @@ func mergeRun(opts *MergeOptions) error { } } - var body *string - if opts.Body != "" { - body = &opts.Body + if !payload.setCommitSubject && payload.method == PullRequestMergeMethodSquash { + payload.commitSubject = fmt.Sprintf("%s (#%d)", pr.Title, pr.Number) + payload.setCommitSubject = true } - err = api.PullRequestMerge(apiClient, baseRepo, pr, mergeMethod, 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 automatically get merged%s when all requiremenets 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.AutoMerge { err := prompt.SurveyAskOne(&survey.Confirm{ Message: fmt.Sprintf("Pull request #%d was already merged. Delete the branch locally?", pr.Number), Default: false, @@ -235,7 +272,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.AutoMerge { return nil } @@ -290,23 +327,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) } @@ -318,7 +355,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 diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 94c75c2c2..6b3165e61 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -42,7 +42,7 @@ func Test_NewCmdMerge(t *testing.T) { DeleteBranch: false, IsDeleteBranchIndicated: false, CanDeleteLocalBranch: true, - MergeMethod: api.PullRequestMergeMethodMerge, + MergeMethod: PullRequestMergeMethodMerge, InteractiveMode: true, }, }, @@ -55,7 +55,7 @@ func Test_NewCmdMerge(t *testing.T) { DeleteBranch: false, IsDeleteBranchIndicated: true, CanDeleteLocalBranch: true, - MergeMethod: api.PullRequestMergeMethodMerge, + MergeMethod: PullRequestMergeMethodMerge, InteractiveMode: true, }, }, @@ -68,7 +68,7 @@ func Test_NewCmdMerge(t *testing.T) { DeleteBranch: false, IsDeleteBranchIndicated: false, CanDeleteLocalBranch: true, - MergeMethod: api.PullRequestMergeMethodMerge, + MergeMethod: PullRequestMergeMethodMerge, InteractiveMode: true, Body: "cool", }, @@ -787,5 +787,5 @@ 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) }