github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/pkg/cmd/pr/merge/merge.go (about)

     1  package merge
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"net/http"
     7  
     8  	"github.com/AlecAivazis/survey/v2"
     9  	"github.com/MakeNowJust/heredoc"
    10  	"github.com/cli/cli/api"
    11  	"github.com/cli/cli/git"
    12  	"github.com/cli/cli/internal/config"
    13  	"github.com/cli/cli/pkg/cmd/pr/shared"
    14  	"github.com/cli/cli/pkg/cmdutil"
    15  	"github.com/cli/cli/pkg/iostreams"
    16  	"github.com/cli/cli/pkg/prompt"
    17  	"github.com/cli/cli/pkg/surveyext"
    18  	"github.com/spf13/cobra"
    19  )
    20  
    21  type editor interface {
    22  	Edit(string, string) (string, error)
    23  }
    24  
    25  type MergeOptions struct {
    26  	HttpClient func() (*http.Client, error)
    27  	IO         *iostreams.IOStreams
    28  	Branch     func() (string, error)
    29  
    30  	Finder shared.PRFinder
    31  
    32  	SelectorArg  string
    33  	DeleteBranch bool
    34  	MergeMethod  PullRequestMergeMethod
    35  
    36  	AutoMergeEnable  bool
    37  	AutoMergeDisable bool
    38  
    39  	Body    string
    40  	BodySet bool
    41  	Editor  editor
    42  
    43  	UseAdmin                bool
    44  	IsDeleteBranchIndicated bool
    45  	CanDeleteLocalBranch    bool
    46  	InteractiveMode         bool
    47  }
    48  
    49  func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Command {
    50  	opts := &MergeOptions{
    51  		IO:         f.IOStreams,
    52  		HttpClient: f.HttpClient,
    53  		Branch:     f.Branch,
    54  	}
    55  
    56  	var (
    57  		flagMerge  bool
    58  		flagSquash bool
    59  		flagRebase bool
    60  	)
    61  
    62  	var bodyFile string
    63  
    64  	cmd := &cobra.Command{
    65  		Use:   "merge [<number> | <url> | <branch>]",
    66  		Short: "Merge a pull request",
    67  		Long: heredoc.Doc(`
    68  			Merge a pull request on GitHub.
    69  
    70  			Without an argument, the pull request that belongs to the current branch
    71  			is selected.			
    72      	`),
    73  		Args: cobra.MaximumNArgs(1),
    74  		RunE: func(cmd *cobra.Command, args []string) error {
    75  			opts.Finder = shared.NewFinder(f)
    76  
    77  			if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
    78  				return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")}
    79  			}
    80  
    81  			if len(args) > 0 {
    82  				opts.SelectorArg = args[0]
    83  			}
    84  
    85  			methodFlags := 0
    86  			if flagMerge {
    87  				opts.MergeMethod = PullRequestMergeMethodMerge
    88  				methodFlags++
    89  			}
    90  			if flagRebase {
    91  				opts.MergeMethod = PullRequestMergeMethodRebase
    92  				methodFlags++
    93  			}
    94  			if flagSquash {
    95  				opts.MergeMethod = PullRequestMergeMethodSquash
    96  				methodFlags++
    97  			}
    98  			if methodFlags == 0 {
    99  				if !opts.IO.CanPrompt() {
   100  					return &cmdutil.FlagError{Err: errors.New("--merge, --rebase, or --squash required when not running interactively")}
   101  				}
   102  				opts.InteractiveMode = true
   103  			} else if methodFlags > 1 {
   104  				return &cmdutil.FlagError{Err: errors.New("only one of --merge, --rebase, or --squash can be enabled")}
   105  			}
   106  
   107  			opts.IsDeleteBranchIndicated = cmd.Flags().Changed("delete-branch")
   108  			opts.CanDeleteLocalBranch = !cmd.Flags().Changed("repo")
   109  
   110  			bodyProvided := cmd.Flags().Changed("body")
   111  			bodyFileProvided := bodyFile != ""
   112  
   113  			if err := cmdutil.MutuallyExclusive(
   114  				"specify only one of `--auto`, `--disable-auto`, or `--admin`",
   115  				opts.AutoMergeEnable,
   116  				opts.AutoMergeDisable,
   117  				opts.UseAdmin,
   118  			); err != nil {
   119  				return err
   120  			}
   121  
   122  			if err := cmdutil.MutuallyExclusive(
   123  				"specify only one of `--body` or `--body-file`",
   124  				bodyProvided,
   125  				bodyFileProvided,
   126  			); err != nil {
   127  				return err
   128  			}
   129  			if bodyProvided || bodyFileProvided {
   130  				opts.BodySet = true
   131  				if bodyFileProvided {
   132  					b, err := cmdutil.ReadFile(bodyFile, opts.IO.In)
   133  					if err != nil {
   134  						return err
   135  					}
   136  					opts.Body = string(b)
   137  				}
   138  
   139  			}
   140  
   141  			opts.Editor = &userEditor{
   142  				io:     opts.IO,
   143  				config: f.Config,
   144  			}
   145  
   146  			if runF != nil {
   147  				return runF(opts)
   148  			}
   149  			return mergeRun(opts)
   150  		},
   151  	}
   152  
   153  	cmd.Flags().BoolVar(&opts.UseAdmin, "admin", false, "Use administrator privileges to merge a pull request that does not meet requirements")
   154  	cmd.Flags().BoolVarP(&opts.DeleteBranch, "delete-branch", "d", false, "Delete the local and remote branch after merge")
   155  	cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Body `text` for the merge commit")
   156  	cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file`")
   157  	cmd.Flags().BoolVarP(&flagMerge, "merge", "m", false, "Merge the commits with the base branch")
   158  	cmd.Flags().BoolVarP(&flagRebase, "rebase", "r", false, "Rebase the commits onto the base branch")
   159  	cmd.Flags().BoolVarP(&flagSquash, "squash", "s", false, "Squash the commits into one commit and merge it into the base branch")
   160  	cmd.Flags().BoolVar(&opts.AutoMergeEnable, "auto", false, "Automatically merge only after necessary requirements are met")
   161  	cmd.Flags().BoolVar(&opts.AutoMergeDisable, "disable-auto", false, "Disable auto-merge for this pull request")
   162  	return cmd
   163  }
   164  
   165  func mergeRun(opts *MergeOptions) error {
   166  	cs := opts.IO.ColorScheme()
   167  
   168  	findOptions := shared.FindOptions{
   169  		Selector: opts.SelectorArg,
   170  		Fields:   []string{"id", "number", "state", "title", "lastCommit", "mergeStateStatus", "headRepositoryOwner", "headRefName"},
   171  	}
   172  	pr, baseRepo, err := opts.Finder.Find(findOptions)
   173  	if err != nil {
   174  		return err
   175  	}
   176  
   177  	isTerminal := opts.IO.IsStdoutTTY()
   178  
   179  	httpClient, err := opts.HttpClient()
   180  	if err != nil {
   181  		return err
   182  	}
   183  	apiClient := api.NewClientFromHTTP(httpClient)
   184  
   185  	if opts.AutoMergeDisable {
   186  		err := disableAutoMerge(httpClient, baseRepo, pr.ID)
   187  		if err != nil {
   188  			return err
   189  		}
   190  		if isTerminal {
   191  			fmt.Fprintf(opts.IO.ErrOut, "%s Auto-merge disabled for pull request #%d\n", cs.SuccessIconWithColor(cs.Green), pr.Number)
   192  		}
   193  		return nil
   194  	}
   195  
   196  	if opts.SelectorArg == "" && len(pr.Commits.Nodes) > 0 {
   197  		if localBranchLastCommit, err := git.LastCommit(); err == nil {
   198  			if localBranchLastCommit.Sha != pr.Commits.Nodes[len(pr.Commits.Nodes)-1].Commit.OID {
   199  				fmt.Fprintf(opts.IO.ErrOut,
   200  					"%s Pull request #%d (%s) has diverged from local branch\n", cs.Yellow("!"), pr.Number, pr.Title)
   201  			}
   202  		}
   203  	}
   204  
   205  	isPRAlreadyMerged := pr.State == "MERGED"
   206  	if reason := blockedReason(pr.MergeStateStatus, opts.UseAdmin); !opts.AutoMergeEnable && !isPRAlreadyMerged && reason != "" {
   207  		fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is not mergeable: %s.\n", cs.FailureIcon(), pr.Number, reason)
   208  		fmt.Fprintf(opts.IO.ErrOut, "To have the pull request merged after all the requirements have been met, add the `--auto` flag.\n")
   209  		if !opts.UseAdmin && allowsAdminOverride(pr.MergeStateStatus) {
   210  			// TODO: show this flag only to repo admins
   211  			fmt.Fprintf(opts.IO.ErrOut, "To use administrator privileges to immediately merge the pull request, add the `--admin` flag.\n")
   212  		}
   213  		return cmdutil.SilentError
   214  	}
   215  
   216  	deleteBranch := opts.DeleteBranch
   217  	crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner()
   218  	autoMerge := opts.AutoMergeEnable && !isImmediatelyMergeable(pr.MergeStateStatus)
   219  
   220  	if !isPRAlreadyMerged {
   221  		payload := mergePayload{
   222  			repo:          baseRepo,
   223  			pullRequestID: pr.ID,
   224  			method:        opts.MergeMethod,
   225  			auto:          autoMerge,
   226  			commitBody:    opts.Body,
   227  			setCommitBody: opts.BodySet,
   228  		}
   229  
   230  		if opts.InteractiveMode {
   231  			r, err := api.GitHubRepo(apiClient, baseRepo)
   232  			if err != nil {
   233  				return err
   234  			}
   235  			payload.method, err = mergeMethodSurvey(r)
   236  			if err != nil {
   237  				return err
   238  			}
   239  			deleteBranch, err = deleteBranchSurvey(opts, crossRepoPR)
   240  			if err != nil {
   241  				return err
   242  			}
   243  
   244  			allowEditMsg := payload.method != PullRequestMergeMethodRebase
   245  
   246  			action, err := confirmSurvey(allowEditMsg)
   247  			if err != nil {
   248  				return fmt.Errorf("unable to confirm: %w", err)
   249  			}
   250  
   251  			if action == shared.EditCommitMessageAction {
   252  				if !payload.setCommitBody {
   253  					payload.commitBody, err = getMergeText(httpClient, baseRepo, pr.ID, payload.method)
   254  					if err != nil {
   255  						return err
   256  					}
   257  				}
   258  
   259  				payload.commitBody, err = opts.Editor.Edit("*.md", payload.commitBody)
   260  				if err != nil {
   261  					return err
   262  				}
   263  				payload.setCommitBody = true
   264  
   265  				action, err = confirmSurvey(false)
   266  				if err != nil {
   267  					return fmt.Errorf("unable to confirm: %w", err)
   268  				}
   269  			}
   270  			if action == shared.CancelAction {
   271  				fmt.Fprintln(opts.IO.ErrOut, "Cancelled.")
   272  				return cmdutil.CancelError
   273  			}
   274  		}
   275  
   276  		err = mergePullRequest(httpClient, payload)
   277  		if err != nil {
   278  			return err
   279  		}
   280  
   281  		if isTerminal {
   282  			if payload.auto {
   283  				method := ""
   284  				switch payload.method {
   285  				case PullRequestMergeMethodRebase:
   286  					method = " via rebase"
   287  				case PullRequestMergeMethodSquash:
   288  					method = " via squash"
   289  				}
   290  				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)
   291  			} else {
   292  				action := "Merged"
   293  				switch payload.method {
   294  				case PullRequestMergeMethodRebase:
   295  					action = "Rebased and merged"
   296  				case PullRequestMergeMethodSquash:
   297  					action = "Squashed and merged"
   298  				}
   299  				fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.SuccessIconWithColor(cs.Magenta), action, pr.Number, pr.Title)
   300  			}
   301  		}
   302  	} else if !opts.IsDeleteBranchIndicated && opts.InteractiveMode && !crossRepoPR && !opts.AutoMergeEnable {
   303  		err := prompt.SurveyAskOne(&survey.Confirm{
   304  			Message: fmt.Sprintf("Pull request #%d was already merged. Delete the branch locally?", pr.Number),
   305  			Default: false,
   306  		}, &deleteBranch)
   307  		if err != nil {
   308  			return fmt.Errorf("could not prompt: %w", err)
   309  		}
   310  	} else if crossRepoPR {
   311  		fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d was already merged\n", cs.WarningIcon(), pr.Number)
   312  	}
   313  
   314  	if !deleteBranch || crossRepoPR || autoMerge {
   315  		return nil
   316  	}
   317  
   318  	branchSwitchString := ""
   319  
   320  	if opts.CanDeleteLocalBranch {
   321  		currentBranch, err := opts.Branch()
   322  		if err != nil {
   323  			return err
   324  		}
   325  
   326  		var branchToSwitchTo string
   327  		if currentBranch == pr.HeadRefName {
   328  			branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo)
   329  			if err != nil {
   330  				return err
   331  			}
   332  			err = git.CheckoutBranch(branchToSwitchTo)
   333  			if err != nil {
   334  				return err
   335  			}
   336  		}
   337  
   338  		localBranchExists := git.HasLocalBranch(pr.HeadRefName)
   339  		if localBranchExists {
   340  			err = git.DeleteLocalBranch(pr.HeadRefName)
   341  			if err != nil {
   342  				err = fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err)
   343  				return err
   344  			}
   345  		}
   346  
   347  		if branchToSwitchTo != "" {
   348  			branchSwitchString = fmt.Sprintf(" and switched to branch %s", cs.Cyan(branchToSwitchTo))
   349  		}
   350  	}
   351  
   352  	if !isPRAlreadyMerged {
   353  		err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName)
   354  		var httpErr api.HTTPError
   355  		// The ref might have already been deleted by GitHub
   356  		if err != nil && (!errors.As(err, &httpErr) || httpErr.StatusCode != 422) {
   357  			err = fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err)
   358  			return err
   359  		}
   360  	}
   361  
   362  	if isTerminal {
   363  		fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", cs.SuccessIconWithColor(cs.Red), cs.Cyan(pr.HeadRefName), branchSwitchString)
   364  	}
   365  
   366  	return nil
   367  }
   368  
   369  func mergeMethodSurvey(baseRepo *api.Repository) (PullRequestMergeMethod, error) {
   370  	type mergeOption struct {
   371  		title  string
   372  		method PullRequestMergeMethod
   373  	}
   374  
   375  	var mergeOpts []mergeOption
   376  	if baseRepo.MergeCommitAllowed {
   377  		opt := mergeOption{title: "Create a merge commit", method: PullRequestMergeMethodMerge}
   378  		mergeOpts = append(mergeOpts, opt)
   379  	}
   380  	if baseRepo.RebaseMergeAllowed {
   381  		opt := mergeOption{title: "Rebase and merge", method: PullRequestMergeMethodRebase}
   382  		mergeOpts = append(mergeOpts, opt)
   383  	}
   384  	if baseRepo.SquashMergeAllowed {
   385  		opt := mergeOption{title: "Squash and merge", method: PullRequestMergeMethodSquash}
   386  		mergeOpts = append(mergeOpts, opt)
   387  	}
   388  
   389  	var surveyOpts []string
   390  	for _, v := range mergeOpts {
   391  		surveyOpts = append(surveyOpts, v.title)
   392  	}
   393  
   394  	mergeQuestion := &survey.Select{
   395  		Message: "What merge method would you like to use?",
   396  		Options: surveyOpts,
   397  	}
   398  
   399  	var result int
   400  	err := prompt.SurveyAskOne(mergeQuestion, &result)
   401  	return mergeOpts[result].method, err
   402  }
   403  
   404  func deleteBranchSurvey(opts *MergeOptions, crossRepoPR bool) (bool, error) {
   405  	if !crossRepoPR && !opts.IsDeleteBranchIndicated {
   406  		var message string
   407  		if opts.CanDeleteLocalBranch {
   408  			message = "Delete the branch locally and on GitHub?"
   409  		} else {
   410  			message = "Delete the branch on GitHub?"
   411  		}
   412  
   413  		var result bool
   414  		submit := &survey.Confirm{
   415  			Message: message,
   416  			Default: false,
   417  		}
   418  		err := prompt.SurveyAskOne(submit, &result)
   419  		return result, err
   420  	}
   421  
   422  	return opts.DeleteBranch, nil
   423  }
   424  
   425  func confirmSurvey(allowEditMsg bool) (shared.Action, error) {
   426  	const (
   427  		submitLabel        = "Submit"
   428  		editCommitMsgLabel = "Edit commit message"
   429  		cancelLabel        = "Cancel"
   430  	)
   431  
   432  	options := []string{submitLabel}
   433  	if allowEditMsg {
   434  		options = append(options, editCommitMsgLabel)
   435  	}
   436  	options = append(options, cancelLabel)
   437  
   438  	var result string
   439  	submit := &survey.Select{
   440  		Message: "What's next?",
   441  		Options: options,
   442  	}
   443  	err := prompt.SurveyAskOne(submit, &result)
   444  	if err != nil {
   445  		return shared.CancelAction, fmt.Errorf("could not prompt: %w", err)
   446  	}
   447  
   448  	switch result {
   449  	case submitLabel:
   450  		return shared.SubmitAction, nil
   451  	case editCommitMsgLabel:
   452  		return shared.EditCommitMessageAction, nil
   453  	default:
   454  		return shared.CancelAction, nil
   455  	}
   456  }
   457  
   458  type userEditor struct {
   459  	io     *iostreams.IOStreams
   460  	config func() (config.Config, error)
   461  }
   462  
   463  func (e *userEditor) Edit(filename, startingText string) (string, error) {
   464  	editorCommand, err := cmdutil.DetermineEditor(e.config)
   465  	if err != nil {
   466  		return "", err
   467  	}
   468  
   469  	return surveyext.Edit(editorCommand, filename, startingText, e.io.In, e.io.Out, e.io.ErrOut, nil)
   470  }
   471  
   472  // blockedReason translates various MergeStateStatus GraphQL values into human-readable reason
   473  func blockedReason(status string, useAdmin bool) string {
   474  	switch status {
   475  	case "BLOCKED":
   476  		if useAdmin {
   477  			return ""
   478  		}
   479  		return "the base branch policy prohibits the merge"
   480  	case "BEHIND":
   481  		if useAdmin {
   482  			return ""
   483  		}
   484  		return "the head branch is not up to date with the base branch"
   485  	case "DIRTY":
   486  		return "the merge commit cannot be cleanly created"
   487  	default:
   488  		return ""
   489  	}
   490  }
   491  
   492  func allowsAdminOverride(status string) bool {
   493  	switch status {
   494  	case "BLOCKED", "BEHIND":
   495  		return true
   496  	default:
   497  		return false
   498  	}
   499  }
   500  
   501  func isImmediatelyMergeable(status string) bool {
   502  	switch status {
   503  	case "CLEAN", "HAS_HOOKS", "UNSTABLE":
   504  		return true
   505  	default:
   506  		return false
   507  	}
   508  }