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 }