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