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, ¬Found) { 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?$")