github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/pr/create/create.go (about)

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