github.com/andrewhsu/cli/v2@v2.0.1-0.20210910131313-d4b4061f5b89/pkg/cmd/pr/create/create.go (about)

     1  package create
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"net/http"
     7  	"net/url"
     8  	"regexp"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/AlecAivazis/survey/v2"
    13  	"github.com/MakeNowJust/heredoc"
    14  	"github.com/andrewhsu/cli/v2/api"
    15  	"github.com/andrewhsu/cli/v2/context"
    16  	"github.com/andrewhsu/cli/v2/git"
    17  	"github.com/andrewhsu/cli/v2/internal/config"
    18  	"github.com/andrewhsu/cli/v2/internal/ghrepo"
    19  	"github.com/andrewhsu/cli/v2/pkg/cmd/pr/shared"
    20  	"github.com/andrewhsu/cli/v2/pkg/cmdutil"
    21  	"github.com/andrewhsu/cli/v2/pkg/iostreams"
    22  	"github.com/andrewhsu/cli/v2/pkg/prompt"
    23  	"github.com/andrewhsu/cli/v2/utils"
    24  	"github.com/spf13/cobra"
    25  )
    26  
    27  type browser interface {
    28  	Browse(string) error
    29  }
    30  
    31  type CreateOptions struct {
    32  	// This struct stores user input and factory functions
    33  	HttpClient func() (*http.Client, error)
    34  	Config     func() (config.Config, error)
    35  	IO         *iostreams.IOStreams
    36  	Remotes    func() (context.Remotes, error)
    37  	Branch     func() (string, error)
    38  	Browser    browser
    39  	Finder     shared.PRFinder
    40  
    41  	TitleProvided bool
    42  	BodyProvided  bool
    43  
    44  	RootDirOverride string
    45  	RepoOverride    string
    46  
    47  	Autofill    bool
    48  	WebMode     bool
    49  	RecoverFile string
    50  
    51  	IsDraft    bool
    52  	Title      string
    53  	Body       string
    54  	BaseBranch string
    55  	HeadBranch string
    56  
    57  	Reviewers []string
    58  	Assignees []string
    59  	Labels    []string
    60  	Projects  []string
    61  	Milestone string
    62  
    63  	MaintainerCanModify bool
    64  }
    65  
    66  type CreateContext struct {
    67  	// This struct stores contextual data about the creation process and is for building up enough
    68  	// data to create a pull request
    69  	RepoContext        *context.ResolvedRemotes
    70  	BaseRepo           *api.Repository
    71  	HeadRepo           ghrepo.Interface
    72  	BaseTrackingBranch string
    73  	BaseBranch         string
    74  	HeadBranch         string
    75  	HeadBranchLabel    string
    76  	HeadRemote         *context.Remote
    77  	IsPushEnabled      bool
    78  	Client             *api.Client
    79  }
    80  
    81  func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
    82  	opts := &CreateOptions{
    83  		IO:         f.IOStreams,
    84  		HttpClient: f.HttpClient,
    85  		Config:     f.Config,
    86  		Remotes:    f.Remotes,
    87  		Branch:     f.Branch,
    88  		Browser:    f.Browser,
    89  	}
    90  
    91  	var bodyFile string
    92  
    93  	cmd := &cobra.Command{
    94  		Use:   "create",
    95  		Short: "Create a pull request",
    96  		Long: heredoc.Docf(`
    97  			Create a pull request on GitHub.
    98  
    99  			When the current branch isn't fully pushed to a git remote, a prompt will ask where
   100  			to push the branch and offer an option to fork the base repository. Use %[1]s--head%[1]s to
   101  			explicitly skip any forking or pushing behavior.
   102  
   103  			A prompt will also ask for the title and the body of the pull request. Use %[1]s--title%[1]s
   104  			and %[1]s--body%[1]s to skip this, or use %[1]s--fill%[1]s to autofill these values from git commits.
   105  
   106  			Link an issue to the pull request by referencing the issue in the body of the pull
   107  			request. If the body text mentions %[1]sFixes #123%[1]s or %[1]sCloses #123%[1]s, the referenced issue
   108  			will automatically get closed when the pull request gets merged.
   109  
   110  			By default, users with write access to the base repository can push new commits to the
   111  			head branch of the pull request. Disable this with %[1]s--no-maintainer-edit%[1]s.
   112  		`, "`"),
   113  		Example: heredoc.Doc(`
   114  			$ gh pr create --title "The bug is fixed" --body "Everything works again"
   115  			$ gh pr create --reviewer monalisa,hubot  --reviewer myorg/team-name
   116  			$ gh pr create --project "Roadmap"
   117  			$ gh pr create --base develop --head monalisa:feature
   118  		`),
   119  		Args: cmdutil.NoArgsQuoteReminder,
   120  		RunE: func(cmd *cobra.Command, args []string) error {
   121  			opts.Finder = shared.NewFinder(f)
   122  
   123  			opts.TitleProvided = cmd.Flags().Changed("title")
   124  			opts.RepoOverride, _ = cmd.Flags().GetString("repo")
   125  			noMaintainerEdit, _ := cmd.Flags().GetBool("no-maintainer-edit")
   126  			opts.MaintainerCanModify = !noMaintainerEdit
   127  
   128  			if !opts.IO.CanPrompt() && opts.RecoverFile != "" {
   129  				return &cmdutil.FlagError{Err: errors.New("`--recover` only supported when running interactively")}
   130  			}
   131  
   132  			if !opts.IO.CanPrompt() && !opts.WebMode && !opts.TitleProvided && !opts.Autofill {
   133  				return &cmdutil.FlagError{Err: errors.New("`--title` or `--fill` required when not running interactively")}
   134  			}
   135  
   136  			if opts.IsDraft && opts.WebMode {
   137  				return errors.New("the `--draft` flag is not supported with `--web`")
   138  			}
   139  			if len(opts.Reviewers) > 0 && opts.WebMode {
   140  				return errors.New("the `--reviewer` flag is not supported with `--web`")
   141  			}
   142  			if cmd.Flags().Changed("no-maintainer-edit") && opts.WebMode {
   143  				return errors.New("the `--no-maintainer-edit` flag is not supported with `--web`")
   144  			}
   145  
   146  			opts.BodyProvided = cmd.Flags().Changed("body")
   147  			if bodyFile != "" {
   148  				b, err := cmdutil.ReadFile(bodyFile, opts.IO.In)
   149  				if err != nil {
   150  					return err
   151  				}
   152  				opts.Body = string(b)
   153  				opts.BodyProvided = true
   154  			}
   155  
   156  			if runF != nil {
   157  				return runF(opts)
   158  			}
   159  			return createRun(opts)
   160  		},
   161  	}
   162  
   163  	fl := cmd.Flags()
   164  	fl.BoolVarP(&opts.IsDraft, "draft", "d", false, "Mark pull request as a draft")
   165  	fl.StringVarP(&opts.Title, "title", "t", "", "Title for the pull request")
   166  	fl.StringVarP(&opts.Body, "body", "b", "", "Body for the pull request")
   167  	fl.StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file`")
   168  	fl.StringVarP(&opts.BaseBranch, "base", "B", "", "The `branch` into which you want your code merged")
   169  	fl.StringVarP(&opts.HeadBranch, "head", "H", "", "The `branch` that contains commits for your pull request (default: current branch)")
   170  	fl.BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to create a pull request")
   171  	fl.BoolVarP(&opts.Autofill, "fill", "f", false, "Do not prompt for title/body and just use commit info")
   172  	fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people or teams by their `handle`")
   173  	fl.StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.")
   174  	fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`")
   175  	fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `name`")
   176  	fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`")
   177  	fl.Bool("no-maintainer-edit", false, "Disable maintainer's ability to modify pull request")
   178  	fl.StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create")
   179  
   180  	return cmd
   181  }
   182  
   183  func createRun(opts *CreateOptions) (err error) {
   184  	ctx, err := NewCreateContext(opts)
   185  	if err != nil {
   186  		return
   187  	}
   188  
   189  	client := ctx.Client
   190  
   191  	state, err := NewIssueState(*ctx, *opts)
   192  	if err != nil {
   193  		return
   194  	}
   195  
   196  	var openURL string
   197  
   198  	if opts.WebMode {
   199  		if !opts.Autofill {
   200  			state.Title = opts.Title
   201  			state.Body = opts.Body
   202  		}
   203  		err = handlePush(*opts, *ctx)
   204  		if err != nil {
   205  			return
   206  		}
   207  		openURL, err = generateCompareURL(*ctx, *state)
   208  		if err != nil {
   209  			return
   210  		}
   211  		if !utils.ValidURL(openURL) {
   212  			err = fmt.Errorf("cannot open in browser: maximum URL length exceeded")
   213  			return
   214  		}
   215  		return previewPR(*opts, openURL)
   216  	}
   217  
   218  	if opts.TitleProvided {
   219  		state.Title = opts.Title
   220  	}
   221  
   222  	if opts.BodyProvided {
   223  		state.Body = opts.Body
   224  	}
   225  
   226  	existingPR, _, err := opts.Finder.Find(shared.FindOptions{
   227  		Selector:   ctx.HeadBranchLabel,
   228  		BaseBranch: ctx.BaseBranch,
   229  		States:     []string{"OPEN"},
   230  		Fields:     []string{"url"},
   231  	})
   232  	var notFound *shared.NotFoundError
   233  	if err != nil && !errors.As(err, &notFound) {
   234  		return fmt.Errorf("error checking for existing pull request: %w", err)
   235  	}
   236  	if err == nil {
   237  		return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s",
   238  			ctx.HeadBranchLabel, ctx.BaseBranch, existingPR.URL)
   239  	}
   240  
   241  	message := "\nCreating pull request for %s into %s in %s\n\n"
   242  	if state.Draft {
   243  		message = "\nCreating draft pull request for %s into %s in %s\n\n"
   244  	}
   245  
   246  	cs := opts.IO.ColorScheme()
   247  
   248  	if opts.IO.CanPrompt() {
   249  		fmt.Fprintf(opts.IO.ErrOut, message,
   250  			cs.Cyan(ctx.HeadBranchLabel),
   251  			cs.Cyan(ctx.BaseBranch),
   252  			ghrepo.FullName(ctx.BaseRepo))
   253  	}
   254  
   255  	if opts.Autofill || (opts.TitleProvided && opts.BodyProvided) {
   256  		err = handlePush(*opts, *ctx)
   257  		if err != nil {
   258  			return
   259  		}
   260  		return submitPR(*opts, *ctx, *state)
   261  	}
   262  
   263  	if opts.RecoverFile != "" {
   264  		err = shared.FillFromJSON(opts.IO, opts.RecoverFile, state)
   265  		if err != nil {
   266  			return fmt.Errorf("failed to recover input: %w", err)
   267  		}
   268  	}
   269  
   270  	if !opts.TitleProvided {
   271  		err = shared.TitleSurvey(state)
   272  		if err != nil {
   273  			return
   274  		}
   275  	}
   276  
   277  	editorCommand, err := cmdutil.DetermineEditor(opts.Config)
   278  	if err != nil {
   279  		return
   280  	}
   281  
   282  	defer shared.PreserveInput(opts.IO, state, &err)()
   283  
   284  	if !opts.BodyProvided {
   285  		templateContent := ""
   286  		if opts.RecoverFile == "" {
   287  			tpl := shared.NewTemplateManager(client.HTTP(), ctx.BaseRepo, opts.RootDirOverride, opts.RepoOverride == "", true)
   288  			var template shared.Template
   289  			template, err = tpl.Choose()
   290  			if err != nil {
   291  				return
   292  			}
   293  
   294  			if template != nil {
   295  				templateContent = string(template.Body())
   296  			} else {
   297  				templateContent = string(tpl.LegacyBody())
   298  			}
   299  		}
   300  
   301  		err = shared.BodySurvey(state, templateContent, editorCommand)
   302  		if err != nil {
   303  			return
   304  		}
   305  	}
   306  
   307  	openURL, err = generateCompareURL(*ctx, *state)
   308  	if err != nil {
   309  		return
   310  	}
   311  
   312  	allowPreview := !state.HasMetadata() && utils.ValidURL(openURL)
   313  	allowMetadata := ctx.BaseRepo.ViewerCanTriage()
   314  	action, err := shared.ConfirmSubmission(allowPreview, allowMetadata)
   315  	if err != nil {
   316  		return fmt.Errorf("unable to confirm: %w", err)
   317  	}
   318  
   319  	if action == shared.MetadataAction {
   320  		fetcher := &shared.MetadataFetcher{
   321  			IO:        opts.IO,
   322  			APIClient: client,
   323  			Repo:      ctx.BaseRepo,
   324  			State:     state,
   325  		}
   326  		err = shared.MetadataSurvey(opts.IO, ctx.BaseRepo, fetcher, state)
   327  		if err != nil {
   328  			return
   329  		}
   330  
   331  		action, err = shared.ConfirmSubmission(!state.HasMetadata(), false)
   332  		if err != nil {
   333  			return
   334  		}
   335  	}
   336  
   337  	if action == shared.CancelAction {
   338  		fmt.Fprintln(opts.IO.ErrOut, "Discarding.")
   339  		err = cmdutil.CancelError
   340  		return
   341  	}
   342  
   343  	err = handlePush(*opts, *ctx)
   344  	if err != nil {
   345  		return
   346  	}
   347  
   348  	if action == shared.PreviewAction {
   349  		return previewPR(*opts, openURL)
   350  	}
   351  
   352  	if action == shared.SubmitAction {
   353  		return submitPR(*opts, *ctx, *state)
   354  	}
   355  
   356  	err = errors.New("expected to cancel, preview, or submit")
   357  	return
   358  }
   359  
   360  func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) error {
   361  	baseRef := ctx.BaseTrackingBranch
   362  	headRef := ctx.HeadBranch
   363  
   364  	commits, err := git.Commits(baseRef, headRef)
   365  	if err != nil {
   366  		return err
   367  	}
   368  
   369  	if len(commits) == 1 {
   370  		state.Title = commits[0].Title
   371  		body, err := git.CommitBody(commits[0].Sha)
   372  		if err != nil {
   373  			return err
   374  		}
   375  		state.Body = body
   376  	} else {
   377  		state.Title = utils.Humanize(headRef)
   378  
   379  		var body strings.Builder
   380  		for i := len(commits) - 1; i >= 0; i-- {
   381  			fmt.Fprintf(&body, "- %s\n", commits[i].Title)
   382  		}
   383  		state.Body = body.String()
   384  	}
   385  
   386  	return nil
   387  }
   388  
   389  func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.TrackingRef {
   390  	refsForLookup := []string{"HEAD"}
   391  	var trackingRefs []git.TrackingRef
   392  
   393  	headBranchConfig := git.ReadBranchConfig(headBranch)
   394  	if headBranchConfig.RemoteName != "" {
   395  		tr := git.TrackingRef{
   396  			RemoteName: headBranchConfig.RemoteName,
   397  			BranchName: strings.TrimPrefix(headBranchConfig.MergeRef, "refs/heads/"),
   398  		}
   399  		trackingRefs = append(trackingRefs, tr)
   400  		refsForLookup = append(refsForLookup, tr.String())
   401  	}
   402  
   403  	for _, remote := range remotes {
   404  		tr := git.TrackingRef{
   405  			RemoteName: remote.Name,
   406  			BranchName: headBranch,
   407  		}
   408  		trackingRefs = append(trackingRefs, tr)
   409  		refsForLookup = append(refsForLookup, tr.String())
   410  	}
   411  
   412  	resolvedRefs, _ := git.ShowRefs(refsForLookup...)
   413  	if len(resolvedRefs) > 1 {
   414  		for _, r := range resolvedRefs[1:] {
   415  			if r.Hash != resolvedRefs[0].Hash {
   416  				continue
   417  			}
   418  			for _, tr := range trackingRefs {
   419  				if tr.String() != r.Name {
   420  					continue
   421  				}
   422  				return &tr
   423  			}
   424  		}
   425  	}
   426  
   427  	return nil
   428  }
   429  
   430  func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadataState, error) {
   431  	var milestoneTitles []string
   432  	if opts.Milestone != "" {
   433  		milestoneTitles = []string{opts.Milestone}
   434  	}
   435  
   436  	meReplacer := shared.NewMeReplacer(ctx.Client, ctx.BaseRepo.RepoHost())
   437  	assignees, err := meReplacer.ReplaceSlice(opts.Assignees)
   438  	if err != nil {
   439  		return nil, err
   440  	}
   441  
   442  	state := &shared.IssueMetadataState{
   443  		Type:       shared.PRMetadata,
   444  		Reviewers:  opts.Reviewers,
   445  		Assignees:  assignees,
   446  		Labels:     opts.Labels,
   447  		Projects:   opts.Projects,
   448  		Milestones: milestoneTitles,
   449  		Draft:      opts.IsDraft,
   450  	}
   451  
   452  	if opts.Autofill || !opts.TitleProvided || !opts.BodyProvided {
   453  		err := initDefaultTitleBody(ctx, state)
   454  		if err != nil && opts.Autofill {
   455  			return nil, fmt.Errorf("could not compute title or body defaults: %w", err)
   456  		}
   457  	}
   458  
   459  	return state, nil
   460  }
   461  
   462  func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
   463  	httpClient, err := opts.HttpClient()
   464  	if err != nil {
   465  		return nil, err
   466  	}
   467  	client := api.NewClientFromHTTP(httpClient)
   468  
   469  	remotes, err := opts.Remotes()
   470  	if err != nil {
   471  		return nil, err
   472  	}
   473  
   474  	repoContext, err := context.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
   475  	if err != nil {
   476  		return nil, err
   477  	}
   478  
   479  	var baseRepo *api.Repository
   480  	if br, err := repoContext.BaseRepo(opts.IO); err == nil {
   481  		if r, ok := br.(*api.Repository); ok {
   482  			baseRepo = r
   483  		} else {
   484  			// TODO: if RepoNetwork is going to be requested anyway in `repoContext.HeadRepos()`,
   485  			// consider piggybacking on that result instead of performing a separate lookup
   486  			baseRepo, err = api.GitHubRepo(client, br)
   487  			if err != nil {
   488  				return nil, err
   489  			}
   490  		}
   491  	} else {
   492  		return nil, fmt.Errorf("could not determine base repository: %w", err)
   493  	}
   494  
   495  	isPushEnabled := false
   496  	headBranch := opts.HeadBranch
   497  	headBranchLabel := opts.HeadBranch
   498  	if headBranch == "" {
   499  		headBranch, err = opts.Branch()
   500  		if err != nil {
   501  			return nil, fmt.Errorf("could not determine the current branch: %w", err)
   502  		}
   503  		headBranchLabel = headBranch
   504  		isPushEnabled = true
   505  	} else if idx := strings.IndexRune(headBranch, ':'); idx >= 0 {
   506  		headBranch = headBranch[idx+1:]
   507  	}
   508  
   509  	if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 {
   510  		fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change"))
   511  	}
   512  
   513  	var headRepo ghrepo.Interface
   514  	var headRemote *context.Remote
   515  
   516  	if isPushEnabled {
   517  		// determine whether the head branch is already pushed to a remote
   518  		if pushedTo := determineTrackingBranch(remotes, headBranch); pushedTo != nil {
   519  			isPushEnabled = false
   520  			if r, err := remotes.FindByName(pushedTo.RemoteName); err == nil {
   521  				headRepo = r
   522  				headRemote = r
   523  				headBranchLabel = pushedTo.BranchName
   524  				if !ghrepo.IsSame(baseRepo, headRepo) {
   525  					headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), pushedTo.BranchName)
   526  				}
   527  			}
   528  		}
   529  	}
   530  
   531  	// otherwise, ask the user for the head repository using info obtained from the API
   532  	if headRepo == nil && isPushEnabled && opts.IO.CanPrompt() {
   533  		pushableRepos, err := repoContext.HeadRepos()
   534  		if err != nil {
   535  			return nil, err
   536  		}
   537  
   538  		if len(pushableRepos) == 0 {
   539  			pushableRepos, err = api.RepoFindForks(client, baseRepo, 3)
   540  			if err != nil {
   541  				return nil, err
   542  			}
   543  		}
   544  
   545  		currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost())
   546  		if err != nil {
   547  			return nil, err
   548  		}
   549  
   550  		hasOwnFork := false
   551  		var pushOptions []string
   552  		for _, r := range pushableRepos {
   553  			pushOptions = append(pushOptions, ghrepo.FullName(r))
   554  			if r.RepoOwner() == currentLogin {
   555  				hasOwnFork = true
   556  			}
   557  		}
   558  
   559  		if !hasOwnFork {
   560  			pushOptions = append(pushOptions, "Create a fork of "+ghrepo.FullName(baseRepo))
   561  		}
   562  		pushOptions = append(pushOptions, "Skip pushing the branch")
   563  		pushOptions = append(pushOptions, "Cancel")
   564  
   565  		var selectedOption int
   566  		err = prompt.SurveyAskOne(&survey.Select{
   567  			Message: fmt.Sprintf("Where should we push the '%s' branch?", headBranch),
   568  			Options: pushOptions,
   569  		}, &selectedOption)
   570  		if err != nil {
   571  			return nil, err
   572  		}
   573  
   574  		if selectedOption < len(pushableRepos) {
   575  			headRepo = pushableRepos[selectedOption]
   576  			if !ghrepo.IsSame(baseRepo, headRepo) {
   577  				headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
   578  			}
   579  		} else if pushOptions[selectedOption] == "Skip pushing the branch" {
   580  			isPushEnabled = false
   581  		} else if pushOptions[selectedOption] == "Cancel" {
   582  			return nil, cmdutil.CancelError
   583  		} else {
   584  			// "Create a fork of ..."
   585  			if baseRepo.IsPrivate {
   586  				return nil, fmt.Errorf("cannot fork private repository %s", ghrepo.FullName(baseRepo))
   587  			}
   588  			headBranchLabel = fmt.Sprintf("%s:%s", currentLogin, headBranch)
   589  		}
   590  	}
   591  
   592  	if headRepo == nil && isPushEnabled && !opts.IO.CanPrompt() {
   593  		fmt.Fprintf(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag")
   594  		return nil, cmdutil.SilentError
   595  	}
   596  
   597  	baseBranch := opts.BaseBranch
   598  	if baseBranch == "" {
   599  		baseBranch = baseRepo.DefaultBranchRef.Name
   600  	}
   601  	if headBranch == baseBranch && headRepo != nil && ghrepo.IsSame(baseRepo, headRepo) {
   602  		return nil, fmt.Errorf("must be on a branch named differently than %q", baseBranch)
   603  	}
   604  
   605  	baseTrackingBranch := baseBranch
   606  	if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil {
   607  		baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch)
   608  	}
   609  
   610  	return &CreateContext{
   611  		BaseRepo:           baseRepo,
   612  		HeadRepo:           headRepo,
   613  		BaseBranch:         baseBranch,
   614  		BaseTrackingBranch: baseTrackingBranch,
   615  		HeadBranch:         headBranch,
   616  		HeadBranchLabel:    headBranchLabel,
   617  		HeadRemote:         headRemote,
   618  		IsPushEnabled:      isPushEnabled,
   619  		RepoContext:        repoContext,
   620  		Client:             client,
   621  	}, nil
   622  
   623  }
   624  
   625  func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState) error {
   626  	client := ctx.Client
   627  
   628  	params := map[string]interface{}{
   629  		"title":               state.Title,
   630  		"body":                state.Body,
   631  		"draft":               state.Draft,
   632  		"baseRefName":         ctx.BaseBranch,
   633  		"headRefName":         ctx.HeadBranchLabel,
   634  		"maintainerCanModify": opts.MaintainerCanModify,
   635  	}
   636  
   637  	if params["title"] == "" {
   638  		return errors.New("pull request title must not be blank")
   639  	}
   640  
   641  	err := shared.AddMetadataToIssueParams(client, ctx.BaseRepo, params, &state)
   642  	if err != nil {
   643  		return err
   644  	}
   645  
   646  	opts.IO.StartProgressIndicator()
   647  	pr, err := api.CreatePullRequest(client, ctx.BaseRepo, params)
   648  	opts.IO.StopProgressIndicator()
   649  	if pr != nil {
   650  		fmt.Fprintln(opts.IO.Out, pr.URL)
   651  	}
   652  	if err != nil {
   653  		if pr != nil {
   654  			return fmt.Errorf("pull request update failed: %w", err)
   655  		}
   656  		return fmt.Errorf("pull request create failed: %w", err)
   657  	}
   658  	return nil
   659  }
   660  
   661  func previewPR(opts CreateOptions, openURL string) error {
   662  	if opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() {
   663  		fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
   664  	}
   665  	return opts.Browser.Browse(openURL)
   666  
   667  }
   668  
   669  func handlePush(opts CreateOptions, ctx CreateContext) error {
   670  	didForkRepo := false
   671  	headRepo := ctx.HeadRepo
   672  	headRemote := ctx.HeadRemote
   673  	client := ctx.Client
   674  
   675  	var err error
   676  	// if a head repository could not be determined so far, automatically create
   677  	// one by forking the base repository
   678  	if headRepo == nil && ctx.IsPushEnabled {
   679  		opts.IO.StartProgressIndicator()
   680  		headRepo, err = api.ForkRepo(client, ctx.BaseRepo, "")
   681  		opts.IO.StopProgressIndicator()
   682  		if err != nil {
   683  			return fmt.Errorf("error forking repo: %w", err)
   684  		}
   685  		didForkRepo = true
   686  	}
   687  
   688  	if headRemote == nil && headRepo != nil {
   689  		headRemote, _ = ctx.RepoContext.RemoteForRepo(headRepo)
   690  	}
   691  
   692  	// There are two cases when an existing remote for the head repo will be
   693  	// missing:
   694  	// 1. the head repo was just created by auto-forking;
   695  	// 2. an existing fork was discovered by querying the API.
   696  	//
   697  	// In either case, we want to add the head repo as a new git remote so we
   698  	// can push to it.
   699  	if headRemote == nil && ctx.IsPushEnabled {
   700  		cfg, err := opts.Config()
   701  		if err != nil {
   702  			return err
   703  		}
   704  		cloneProtocol, _ := cfg.Get(headRepo.RepoHost(), "git_protocol")
   705  
   706  		headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol)
   707  
   708  		// TODO: prevent clashes with another remote of a same name
   709  		gitRemote, err := git.AddRemote("fork", headRepoURL)
   710  		if err != nil {
   711  			return fmt.Errorf("error adding remote: %w", err)
   712  		}
   713  		headRemote = &context.Remote{
   714  			Remote: gitRemote,
   715  			Repo:   headRepo,
   716  		}
   717  	}
   718  
   719  	// automatically push the branch if it hasn't been pushed anywhere yet
   720  	if ctx.IsPushEnabled {
   721  		pushBranch := func() error {
   722  			pushTries := 0
   723  			maxPushTries := 3
   724  			for {
   725  				r := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "")
   726  				defer r.Flush()
   727  				cmdErr := r
   728  				cmdOut := opts.IO.Out
   729  				if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", ctx.HeadBranch), cmdOut, cmdErr); err != nil {
   730  					if didForkRepo && pushTries < maxPushTries {
   731  						pushTries++
   732  						// first wait 2 seconds after forking, then 4s, then 6s
   733  						waitSeconds := 2 * pushTries
   734  						fmt.Fprintf(opts.IO.ErrOut, "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second"))
   735  						time.Sleep(time.Duration(waitSeconds) * time.Second)
   736  						continue
   737  					}
   738  					return err
   739  				}
   740  				break
   741  			}
   742  			return nil
   743  		}
   744  
   745  		err := pushBranch()
   746  		if err != nil {
   747  			return err
   748  		}
   749  	}
   750  
   751  	return nil
   752  }
   753  
   754  func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (string, error) {
   755  	u := ghrepo.GenerateRepoURL(
   756  		ctx.BaseRepo,
   757  		"compare/%s...%s?expand=1",
   758  		url.QueryEscape(ctx.BaseBranch), url.QueryEscape(ctx.HeadBranchLabel))
   759  	url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.BaseRepo, u, state)
   760  	if err != nil {
   761  		return "", err
   762  	}
   763  	return url, nil
   764  }
   765  
   766  var gitPushRegexp = regexp.MustCompile("^remote: (Create a pull request.*by visiting|[[:space:]]*https://.*/pull/new/).*\n?$")