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