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