Two bugs introduced in #13009 found during acceptance testing: 1. `pr create --assignee @copilot` sent the literal `@copilot` to the API because NewIssueState only ran MeReplacer on assignees, not CopilotReplacer. Fixed by switching to SpecialAssigneeReplacer (which handles both @me and @copilot) like issue create already does. 2. The replaceActorsForAssignable mutation requires the [bot] suffix on bot actor logins (e.g. `copilot-swe-agent[bot]`), unlike requestReviewsByLogin which has a separate botLogins field. Added the suffix in ReplaceActorsForAssignableByLogin for CopilotAssigneeLogin. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1328 lines
45 KiB
Go
1328 lines
45 KiB
Go
package create
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"github.com/cenkalti/backoff/v4"
|
|
"github.com/cli/cli/v2/api"
|
|
ghContext "github.com/cli/cli/v2/context"
|
|
"github.com/cli/cli/v2/git"
|
|
"github.com/cli/cli/v2/internal/browser"
|
|
fd "github.com/cli/cli/v2/internal/featuredetection"
|
|
"github.com/cli/cli/v2/internal/gh"
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
"github.com/cli/cli/v2/internal/prompter"
|
|
"github.com/cli/cli/v2/internal/text"
|
|
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
|
"github.com/cli/cli/v2/pkg/iostreams"
|
|
"github.com/cli/cli/v2/pkg/markdown"
|
|
o "github.com/cli/cli/v2/pkg/option"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type CreateOptions struct {
|
|
// This struct stores user input and factory functions
|
|
Detector fd.Detector
|
|
HttpClient func() (*http.Client, error)
|
|
GitClient *git.Client
|
|
Config func() (gh.Config, error)
|
|
IO *iostreams.IOStreams
|
|
Remotes func() (ghContext.Remotes, error)
|
|
Branch func() (string, error)
|
|
Browser browser.Browser
|
|
Prompter shared.Prompt
|
|
Finder shared.PRFinder
|
|
TitledEditSurvey func(string, string) (string, string, error)
|
|
|
|
TitleProvided bool
|
|
BodyProvided bool
|
|
|
|
RootDirOverride string
|
|
RepoOverride string
|
|
|
|
Autofill bool
|
|
FillVerbose bool
|
|
FillFirst bool
|
|
EditorMode bool
|
|
WebMode bool
|
|
RecoverFile string
|
|
|
|
IsDraft bool
|
|
Title string
|
|
Body string
|
|
BaseBranch string
|
|
HeadBranch string
|
|
|
|
Reviewers []string
|
|
Assignees []string
|
|
Labels []string
|
|
Projects []string
|
|
Milestone string
|
|
|
|
MaintainerCanModify bool
|
|
Template string
|
|
|
|
DryRun bool
|
|
}
|
|
|
|
// creationRefs is an interface that provides the necessary information for creating a pull request in the API.
|
|
// Upcasting to concrete implementations can provide further context on other operations (forking and pushing).
|
|
type creationRefs interface {
|
|
// QualifiedHeadRef returns a stringified form of the head ref, varying depending
|
|
// on whether the head ref is in the same repository as the base ref. If they are
|
|
// the same repository, we return the branch name only. If they are different repositories,
|
|
// we return the owner and branch name in the form <owner>:<branch>.
|
|
QualifiedHeadRef() string
|
|
// UnqualifiedHeadRef returns a head ref in the form of the branch name only.
|
|
UnqualifiedHeadRef() string
|
|
//BaseRef returns the base branch name.
|
|
BaseRef() string
|
|
|
|
// While the only thing really required from an api.Repository is the repository ID, changing that
|
|
// would require changing the API function signatures, and the refactor that introduced this refs
|
|
// type is already large enough.
|
|
BaseRepo() *api.Repository
|
|
}
|
|
|
|
type baseRefs struct {
|
|
baseRepo *api.Repository
|
|
baseBranchName string
|
|
}
|
|
|
|
func (r baseRefs) BaseRef() string {
|
|
return r.baseBranchName
|
|
}
|
|
|
|
func (r baseRefs) BaseRepo() *api.Repository {
|
|
return r.baseRepo
|
|
}
|
|
|
|
// skipPushRefs indicate to handlePush that no pushing is required.
|
|
type skipPushRefs struct {
|
|
baseRefs
|
|
|
|
qualifiedHeadRef shared.QualifiedHeadRef
|
|
}
|
|
|
|
func (r skipPushRefs) QualifiedHeadRef() string {
|
|
return r.qualifiedHeadRef.String()
|
|
}
|
|
|
|
func (r skipPushRefs) UnqualifiedHeadRef() string {
|
|
return r.qualifiedHeadRef.BranchName()
|
|
}
|
|
|
|
// pushableRefs indicate to handlePush that pushing is required,
|
|
// and provide further information (HeadRepo) on where that push
|
|
// should go.
|
|
type pushableRefs struct {
|
|
baseRefs
|
|
|
|
headRepo ghrepo.Interface
|
|
headBranchName string
|
|
}
|
|
|
|
func (r pushableRefs) QualifiedHeadRef() string {
|
|
if ghrepo.IsSame(r.headRepo, r.baseRepo) {
|
|
return r.headBranchName
|
|
}
|
|
return fmt.Sprintf("%s:%s", r.headRepo.RepoOwner(), r.headBranchName)
|
|
}
|
|
|
|
func (r pushableRefs) UnqualifiedHeadRef() string {
|
|
return r.headBranchName
|
|
}
|
|
|
|
func (r pushableRefs) HeadRepo() ghrepo.Interface {
|
|
return r.headRepo
|
|
}
|
|
|
|
// forkableRefs indicate to handlePush that forking is required before
|
|
// pushing. The expectation is that after forking, this is converted to
|
|
// pushableRefs. We could go very OOP and have a Fork method on this
|
|
// struct that returns a pushableRefs but then we'd need to embed an API client
|
|
// and it just seems nice that it is a simple bag of data.
|
|
type forkableRefs struct {
|
|
baseRefs
|
|
|
|
qualifiedHeadRef shared.QualifiedHeadRef
|
|
}
|
|
|
|
func (r forkableRefs) QualifiedHeadRef() string {
|
|
return r.qualifiedHeadRef.String()
|
|
}
|
|
|
|
func (r forkableRefs) UnqualifiedHeadRef() string {
|
|
return r.qualifiedHeadRef.BranchName()
|
|
}
|
|
|
|
// isSameRef checks if the head and base refs point to the same ref in the same repository.
|
|
// For cross-repository PRs (e.g., from a fork), the qualified head ref will contain
|
|
// an owner prefix (owner:branch), so even if branch names match, they refer to different repos.
|
|
func isSameRef(refs creationRefs) bool {
|
|
if strings.Contains(refs.QualifiedHeadRef(), ":") {
|
|
return false
|
|
}
|
|
return refs.UnqualifiedHeadRef() == refs.BaseRef()
|
|
}
|
|
|
|
// CreateContext stores contextual data about the creation process and is for building up enough
|
|
// data to create a pull request.
|
|
type CreateContext struct {
|
|
ResolvedRemotes *ghContext.ResolvedRemotes
|
|
PRRefs creationRefs
|
|
// BaseTrackingBranch is perhaps a slightly leaky abstraction in the presence
|
|
// of PRRefs, but a huge amount of refactoring was done to introduce that struct,
|
|
// and this is a small price to pay for the convenience of not having to do a lot
|
|
// more design.
|
|
BaseTrackingBranch string
|
|
Client *api.Client
|
|
GitClient *git.Client
|
|
}
|
|
|
|
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
|
|
opts := &CreateOptions{
|
|
IO: f.IOStreams,
|
|
HttpClient: f.HttpClient,
|
|
GitClient: f.GitClient,
|
|
Config: f.Config,
|
|
Remotes: f.Remotes,
|
|
Branch: f.Branch,
|
|
Browser: f.Browser,
|
|
Prompter: f.Prompter,
|
|
TitledEditSurvey: shared.TitledEditSurvey(&shared.UserEditor{Config: f.Config, IO: f.IOStreams}),
|
|
}
|
|
|
|
var bodyFile string
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "create",
|
|
Short: "Create a pull request",
|
|
Long: heredoc.Docf(`
|
|
Create a pull request on GitHub.
|
|
|
|
Upon success, the URL of the created pull request will be printed.
|
|
|
|
When the current branch isn't fully pushed to a git remote, a prompt will ask where
|
|
to push the branch and offer an option to fork the base repository. Any fork created this
|
|
way will only have the default branch of the upstream repository. Use %[1]s--head%[1]s to
|
|
explicitly skip any forking or pushing behavior.
|
|
|
|
%[1]s--head%[1]s supports %[1]s<user>:<branch>%[1]s syntax to select a head repo owned by %[1]s<user>%[1]s.
|
|
Using an organization as the %[1]s<user>%[1]s is currently not supported.
|
|
For more information, see <https://github.com/cli/cli/issues/10093>
|
|
|
|
A prompt will also ask for the title and the body of the pull request. Use %[1]s--title%[1]s and
|
|
%[1]s--body%[1]s to skip this, or use %[1]s--fill%[1]s to autofill these values from git commits.
|
|
It's important to notice that if the %[1]s--title%[1]s and/or %[1]s--body%[1]s are also provided
|
|
alongside %[1]s--fill%[1]s, the values specified by %[1]s--title%[1]s and/or %[1]s--body%[1]s will
|
|
take precedence and overwrite any autofilled content.
|
|
|
|
The base branch for the created PR can be specified using the %[1]s--base%[1]s flag. If not provided,
|
|
the value of %[1]sgh-merge-base%[1]s git branch config will be used. If not configured, the repository's
|
|
default branch will be used. Run %[1]sgit config branch.{current}.gh-merge-base {base}%[1]s to configure
|
|
the current branch to use the specified merge base.
|
|
|
|
Link an issue to the pull request by referencing the issue in the body of the pull
|
|
request. If the body text mentions %[1]sFixes #123%[1]s or %[1]sCloses #123%[1]s, the referenced issue
|
|
will automatically get closed when the pull request gets merged.
|
|
|
|
By default, users with write access to the base repository can push new commits to the
|
|
head branch of the pull request. Disable this with %[1]s--no-maintainer-edit%[1]s.
|
|
|
|
Adding a pull request to projects requires authorization with the %[1]sproject%[1]s scope.
|
|
To authorize, run %[1]sgh auth refresh -s project%[1]s.
|
|
`, "`"),
|
|
Example: heredoc.Doc(`
|
|
$ gh pr create --title "The bug is fixed" --body "Everything works again"
|
|
$ gh pr create --reviewer monalisa,hubot --reviewer myorg/team-name
|
|
$ gh pr create --project "Roadmap"
|
|
$ gh pr create --base develop --head monalisa:feature
|
|
$ gh pr create --template "pull_request_template.md"
|
|
`),
|
|
Args: cmdutil.NoArgsQuoteReminder,
|
|
Aliases: []string{"new"},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
opts.Finder = shared.NewFinder(f)
|
|
|
|
opts.TitleProvided = cmd.Flags().Changed("title")
|
|
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
|
|
// Workaround: Due to the way this command is implemented, we need to manually check GH_REPO.
|
|
// Commands should use the standard BaseRepoOverride functionality to handle this behavior instead.
|
|
if opts.RepoOverride == "" {
|
|
opts.RepoOverride = os.Getenv("GH_REPO")
|
|
}
|
|
|
|
noMaintainerEdit, _ := cmd.Flags().GetBool("no-maintainer-edit")
|
|
opts.MaintainerCanModify = !noMaintainerEdit
|
|
|
|
if !opts.IO.CanPrompt() && opts.RecoverFile != "" {
|
|
return cmdutil.FlagErrorf("`--recover` only supported when running interactively")
|
|
}
|
|
|
|
if opts.IsDraft && opts.WebMode {
|
|
return cmdutil.FlagErrorf("the `--draft` flag is not supported with `--web`")
|
|
}
|
|
|
|
if len(opts.Reviewers) > 0 && opts.WebMode {
|
|
return cmdutil.FlagErrorf("the `--reviewer` flag is not supported with `--web`")
|
|
}
|
|
|
|
if cmd.Flags().Changed("no-maintainer-edit") && opts.WebMode {
|
|
return cmdutil.FlagErrorf("the `--no-maintainer-edit` flag is not supported with `--web`")
|
|
}
|
|
|
|
if opts.Autofill && opts.FillFirst {
|
|
return cmdutil.FlagErrorf("`--fill` is not supported with `--fill-first`")
|
|
}
|
|
|
|
if opts.FillVerbose && opts.FillFirst {
|
|
return cmdutil.FlagErrorf("`--fill-verbose` is not supported with `--fill-first`")
|
|
}
|
|
|
|
if opts.FillVerbose && opts.Autofill {
|
|
return cmdutil.FlagErrorf("`--fill-verbose` is not supported with `--fill`")
|
|
}
|
|
|
|
if err := cmdutil.MutuallyExclusive(
|
|
"specify only one of `--editor` or `--web`",
|
|
opts.EditorMode,
|
|
opts.WebMode,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
var err error
|
|
opts.EditorMode, err = shared.InitEditorMode(f, opts.EditorMode, opts.WebMode, opts.IO.CanPrompt())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
opts.BodyProvided = cmd.Flags().Changed("body")
|
|
if bodyFile != "" {
|
|
b, err := cmdutil.ReadFile(bodyFile, opts.IO.In)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts.Body = string(b)
|
|
opts.BodyProvided = true
|
|
}
|
|
|
|
if opts.Template != "" && opts.BodyProvided {
|
|
return cmdutil.FlagErrorf("`--template` is not supported when using `--body` or `--body-file`")
|
|
}
|
|
|
|
if !opts.IO.CanPrompt() && !opts.WebMode && !(opts.FillVerbose || opts.Autofill || opts.FillFirst) && (!opts.TitleProvided || !opts.BodyProvided) {
|
|
return cmdutil.FlagErrorf("must provide `--title` and `--body` (or `--fill` or `fill-first` or `--fillverbose`) when not running interactively")
|
|
}
|
|
|
|
if opts.DryRun && opts.WebMode {
|
|
return cmdutil.FlagErrorf("`--dry-run` is not supported when using `--web`")
|
|
}
|
|
|
|
if runF != nil {
|
|
return runF(opts)
|
|
}
|
|
return createRun(opts)
|
|
},
|
|
}
|
|
|
|
fl := cmd.Flags()
|
|
fl.BoolVarP(&opts.IsDraft, "draft", "d", false, "Mark pull request as a draft")
|
|
fl.StringVarP(&opts.Title, "title", "t", "", "Title for the pull request")
|
|
fl.StringVarP(&opts.Body, "body", "b", "", "Body for the pull request")
|
|
fl.StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
|
|
fl.StringVarP(&opts.BaseBranch, "base", "B", "", "The `branch` into which you want your code merged")
|
|
fl.StringVarP(&opts.HeadBranch, "head", "H", "", "The `branch` that contains commits for your pull request (default [current branch])")
|
|
fl.BoolVarP(&opts.EditorMode, "editor", "e", false, "Skip prompts and open the text editor to write the title and body in. The first line is the title and the remaining text is the body.")
|
|
fl.BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to create a pull request")
|
|
fl.BoolVarP(&opts.FillVerbose, "fill-verbose", "", false, "Use commits msg+body for description")
|
|
fl.BoolVarP(&opts.Autofill, "fill", "f", false, "Use commit info for title and body")
|
|
fl.BoolVar(&opts.FillFirst, "fill-first", false, "Use first commit info for title and body")
|
|
fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people or teams by their `handle`")
|
|
fl.StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.")
|
|
fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`")
|
|
fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `title`")
|
|
fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`")
|
|
fl.Bool("no-maintainer-edit", false, "Disable maintainer's ability to modify pull request")
|
|
fl.StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create")
|
|
fl.StringVarP(&opts.Template, "template", "T", "", "Template `file` to use as starting body text")
|
|
fl.BoolVar(&opts.DryRun, "dry-run", false, "Print details instead of creating the PR. May still push git changes.")
|
|
|
|
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head")
|
|
|
|
_ = cmd.RegisterFlagCompletionFunc("reviewer", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
|
results, err := requestableReviewersForCompletion(opts)
|
|
if err != nil {
|
|
return nil, cobra.ShellCompDirectiveError
|
|
}
|
|
return results, cobra.ShellCompDirectiveNoFileComp
|
|
})
|
|
|
|
return cmd
|
|
}
|
|
|
|
func createRun(opts *CreateOptions) error {
|
|
ctx, err := NewCreateContext(opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if isSameRef(ctx.PRRefs) {
|
|
return fmt.Errorf("head branch %q is the same as base branch %q, cannot create a pull request", ctx.PRRefs.UnqualifiedHeadRef(), ctx.PRRefs.BaseRef())
|
|
}
|
|
|
|
httpClient, err := opts.HttpClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// TODO projectsV1Deprecation
|
|
// Remove this section as we should no longer need to detect
|
|
if opts.Detector == nil {
|
|
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
|
|
opts.Detector = fd.NewDetector(cachedClient, ctx.PRRefs.BaseRepo().RepoHost())
|
|
}
|
|
|
|
projectsV1Support := opts.Detector.ProjectsV1()
|
|
|
|
client := ctx.Client
|
|
|
|
// Detect ApiActorsSupported feature to determine if we can use search-based
|
|
// reviewer selection (github.com) or need to use legacy ID-based selection (GHES)
|
|
issueFeatures, err := opts.Detector.IssueFeatures()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var reviewerSearchFunc func(string) prompter.MultiSelectSearchResult
|
|
var assigneeSearchFunc func(string) prompter.MultiSelectSearchResult
|
|
if issueFeatures.ApiActorsSupported {
|
|
reviewerSearchFunc = func(query string) prompter.MultiSelectSearchResult {
|
|
candidates, moreResults, err := api.SuggestedReviewerActorsForRepo(client, ctx.PRRefs.BaseRepo(), query)
|
|
if err != nil {
|
|
return prompter.MultiSelectSearchResult{Err: err}
|
|
}
|
|
keys := make([]string, len(candidates))
|
|
labels := make([]string, len(candidates))
|
|
for i, c := range candidates {
|
|
keys[i] = c.Login()
|
|
labels[i] = c.DisplayName()
|
|
}
|
|
return prompter.MultiSelectSearchResult{Keys: keys, Labels: labels, MoreResults: moreResults}
|
|
}
|
|
assigneeSearchFunc = shared.RepoAssigneeSearchFunc(client, ctx.PRRefs.BaseRepo())
|
|
}
|
|
|
|
state, err := NewIssueState(*ctx, *opts, issueFeatures.ApiActorsSupported)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// TODO ApiActorsSupported
|
|
if issueFeatures.ApiActorsSupported {
|
|
state.ApiActorsSupported = true
|
|
}
|
|
|
|
var openURL string
|
|
|
|
if opts.WebMode {
|
|
if !(opts.Autofill || opts.FillFirst) {
|
|
state.Title = opts.Title
|
|
state.Body = opts.Body
|
|
}
|
|
if opts.Template != "" {
|
|
state.Template = opts.Template
|
|
}
|
|
err = handlePush(*opts, *ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
openURL, err = generateCompareURL(*ctx, *state, projectsV1Support)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !shared.ValidURL(openURL) {
|
|
err = fmt.Errorf("cannot open in browser: maximum URL length exceeded")
|
|
return err
|
|
}
|
|
return previewPR(*opts, openURL)
|
|
}
|
|
|
|
if opts.TitleProvided {
|
|
state.Title = opts.Title
|
|
}
|
|
|
|
if opts.BodyProvided {
|
|
state.Body = opts.Body
|
|
}
|
|
|
|
existingPR, _, err := opts.Finder.Find(shared.FindOptions{
|
|
Selector: ctx.PRRefs.QualifiedHeadRef(),
|
|
BaseBranch: ctx.PRRefs.BaseRef(),
|
|
States: []string{"OPEN"},
|
|
Fields: []string{"url"},
|
|
})
|
|
var notFound *shared.NotFoundError
|
|
if err != nil && !errors.As(err, ¬Found) {
|
|
return fmt.Errorf("error checking for existing pull request: %w", err)
|
|
}
|
|
if err == nil {
|
|
return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s",
|
|
ctx.PRRefs.QualifiedHeadRef(), ctx.PRRefs.BaseRef(), existingPR.URL)
|
|
}
|
|
|
|
message := "\nCreating pull request for %s into %s in %s\n\n"
|
|
if state.Draft {
|
|
message = "\nCreating draft pull request for %s into %s in %s\n\n"
|
|
}
|
|
if opts.DryRun {
|
|
message = "\nDry Running pull request for %s into %s in %s\n\n"
|
|
}
|
|
|
|
cs := opts.IO.ColorScheme()
|
|
|
|
if opts.IO.CanPrompt() {
|
|
fmt.Fprintf(opts.IO.ErrOut, message,
|
|
cs.Cyan(ctx.PRRefs.QualifiedHeadRef()),
|
|
cs.Cyan(ctx.PRRefs.BaseRef()),
|
|
ghrepo.FullName(ctx.PRRefs.BaseRepo()))
|
|
}
|
|
|
|
if !opts.EditorMode && (opts.FillVerbose || opts.Autofill || opts.FillFirst || (opts.TitleProvided && opts.BodyProvided)) {
|
|
err = handlePush(*opts, *ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return submitPR(*opts, *ctx, *state, projectsV1Support)
|
|
}
|
|
|
|
if opts.RecoverFile != "" {
|
|
err = shared.FillFromJSON(opts.IO, opts.RecoverFile, state)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to recover input: %w", err)
|
|
}
|
|
}
|
|
|
|
action := shared.SubmitAction
|
|
if opts.IsDraft {
|
|
action = shared.SubmitDraftAction
|
|
}
|
|
|
|
tpl := shared.NewTemplateManager(client.HTTP(), ctx.PRRefs.BaseRepo(), opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true)
|
|
|
|
if opts.EditorMode {
|
|
if opts.Template != "" {
|
|
var template shared.Template
|
|
template, err = tpl.Select(opts.Template)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if state.Title == "" {
|
|
state.Title = template.Title()
|
|
}
|
|
state.Body = string(template.Body())
|
|
}
|
|
|
|
state.Title, state.Body, err = opts.TitledEditSurvey(state.Title, state.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if state.Title == "" {
|
|
err = fmt.Errorf("title can't be blank")
|
|
return err
|
|
}
|
|
} else {
|
|
|
|
if !opts.TitleProvided {
|
|
err = shared.TitleSurvey(opts.Prompter, opts.IO, state)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
defer shared.PreserveInput(opts.IO, state, &err)()
|
|
|
|
if !opts.BodyProvided {
|
|
templateContent := ""
|
|
if opts.RecoverFile == "" {
|
|
var template shared.Template
|
|
|
|
if opts.Template != "" {
|
|
template, err = tpl.Select(opts.Template)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
template, err = tpl.Choose()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if template != nil {
|
|
templateContent = string(template.Body())
|
|
}
|
|
}
|
|
|
|
err = shared.BodySurvey(opts.Prompter, state, templateContent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
openURL, err = generateCompareURL(*ctx, *state, projectsV1Support)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
allowPreview := !state.HasMetadata() && shared.ValidURL(openURL) && !opts.DryRun
|
|
allowMetadata := ctx.PRRefs.BaseRepo().ViewerCanTriage()
|
|
action, err = shared.ConfirmPRSubmission(opts.Prompter, allowPreview, allowMetadata, state.Draft)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to confirm: %w", err)
|
|
}
|
|
|
|
if action == shared.MetadataAction {
|
|
fetcher := &shared.MetadataFetcher{
|
|
IO: opts.IO,
|
|
APIClient: client,
|
|
Repo: ctx.PRRefs.BaseRepo(),
|
|
State: state,
|
|
}
|
|
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, projectsV1Support, reviewerSearchFunc, assigneeSearchFunc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
action, err = shared.ConfirmPRSubmission(opts.Prompter, !state.HasMetadata() && !opts.DryRun, false, state.Draft)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
if action == shared.CancelAction {
|
|
fmt.Fprintln(opts.IO.ErrOut, "Discarding.")
|
|
err = cmdutil.CancelError
|
|
return err
|
|
}
|
|
|
|
err = handlePush(*opts, *ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if action == shared.PreviewAction {
|
|
return previewPR(*opts, openURL)
|
|
}
|
|
|
|
if action == shared.SubmitDraftAction {
|
|
state.Draft = true
|
|
return submitPR(*opts, *ctx, *state, projectsV1Support)
|
|
}
|
|
|
|
if action == shared.SubmitAction {
|
|
return submitPR(*opts, *ctx, *state, projectsV1Support)
|
|
}
|
|
|
|
err = errors.New("expected to cancel, preview, or submit")
|
|
return err
|
|
}
|
|
|
|
var regexPattern = regexp.MustCompile(`(?m)^`)
|
|
|
|
func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, useFirstCommit bool, addBody bool) error {
|
|
commits, err := ctx.GitClient.Commits(context.Background(), ctx.BaseTrackingBranch, ctx.PRRefs.UnqualifiedHeadRef())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(commits) == 1 || useFirstCommit {
|
|
state.Title = commits[len(commits)-1].Title
|
|
state.Body = commits[len(commits)-1].Body
|
|
} else {
|
|
state.Title = humanize(ctx.PRRefs.UnqualifiedHeadRef())
|
|
var body strings.Builder
|
|
for i := len(commits) - 1; i >= 0; i-- {
|
|
fmt.Fprintf(&body, "- **%s**\n", commits[i].Title)
|
|
if addBody {
|
|
x := regexPattern.ReplaceAllString(commits[i].Body, " ")
|
|
fmt.Fprintf(&body, "%s", x)
|
|
|
|
if i > 0 {
|
|
fmt.Fprintln(&body)
|
|
fmt.Fprintln(&body)
|
|
}
|
|
}
|
|
}
|
|
state.Body = body.String()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func NewIssueState(ctx CreateContext, opts CreateOptions, apiActorsSupported bool) (*shared.IssueMetadataState, error) {
|
|
var milestoneTitles []string
|
|
if opts.Milestone != "" {
|
|
milestoneTitles = []string{opts.Milestone}
|
|
}
|
|
|
|
assigneeReplacer := shared.NewSpecialAssigneeReplacer(ctx.Client, ctx.PRRefs.BaseRepo().RepoHost(), apiActorsSupported, !opts.WebMode)
|
|
assignees, err := assigneeReplacer.ReplaceSlice(opts.Assignees)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
copilotReplacer := shared.NewCopilotReviewerReplacer()
|
|
reviewers := copilotReplacer.ReplaceSlice(opts.Reviewers)
|
|
|
|
state := &shared.IssueMetadataState{
|
|
Type: shared.PRMetadata,
|
|
Reviewers: reviewers,
|
|
Assignees: assignees,
|
|
Labels: opts.Labels,
|
|
ProjectTitles: opts.Projects,
|
|
Milestones: milestoneTitles,
|
|
Draft: opts.IsDraft,
|
|
}
|
|
|
|
if opts.FillVerbose || opts.Autofill || opts.FillFirst || !opts.TitleProvided || !opts.BodyProvided {
|
|
err := initDefaultTitleBody(ctx, state, opts.FillFirst, opts.FillVerbose)
|
|
if err != nil && (opts.FillVerbose || opts.Autofill || opts.FillFirst) {
|
|
return nil, fmt.Errorf("could not compute title or body defaults: %w", err)
|
|
}
|
|
}
|
|
|
|
return state, nil
|
|
}
|
|
|
|
func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
|
|
httpClient, err := opts.HttpClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client := api.NewClientFromHTTP(httpClient)
|
|
|
|
remotes, err := getRemotes(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resolvedRemotes, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var baseRepo *api.Repository
|
|
if br, err := resolvedRemotes.BaseRepo(opts.IO); err == nil {
|
|
if r, ok := br.(*api.Repository); ok {
|
|
baseRepo = r
|
|
} else {
|
|
// TODO: if RepoNetwork is going to be requested anyway in `repoContext.HeadRepos()`,
|
|
// consider piggybacking on that result instead of performing a separate lookup
|
|
baseRepo, err = api.GitHubRepo(client, br)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
} else {
|
|
return nil, err
|
|
}
|
|
|
|
// This closure provides an easy way to instantiate a CreateContext with everything other than
|
|
// the refs. This probably indicates that CreateContext could do with some rework, but the refactor
|
|
// to introduce PRRefs is already large enough.
|
|
var newCreateContext = func(refs creationRefs) *CreateContext {
|
|
baseTrackingBranch := refs.BaseRef()
|
|
|
|
// The baseTrackingBranch is used later for a command like:
|
|
// `git commit upstream/main feature` in order to create a PR message showing the commits
|
|
// between these two refs. I'm not really sure what is expected to happen if we don't have a remote,
|
|
// which seems like it would be possible with a command `gh pr create --repo owner/repo-that-is-not-a-remote`.
|
|
// In that case, we might just have a mess? In any case, this is what the old code did, so I don't want to change
|
|
// it as part of an already large refactor.
|
|
baseRemote, _ := resolvedRemotes.RemoteForRepo(baseRepo)
|
|
if baseRemote != nil {
|
|
baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseTrackingBranch)
|
|
}
|
|
|
|
return &CreateContext{
|
|
ResolvedRemotes: resolvedRemotes,
|
|
Client: client,
|
|
GitClient: opts.GitClient,
|
|
PRRefs: refs,
|
|
BaseTrackingBranch: baseTrackingBranch,
|
|
}
|
|
}
|
|
|
|
// If the user provided a head branch we're going to use that without any interrogation
|
|
// of git. The value can take the form of <branch> or <user>:<branch>. In the former case, the
|
|
// PR base and head repos are the same. In the latter case we don't know the head repo
|
|
// (though we could look it up in the API) but fortunately we don't need to because the API
|
|
// will resolve this for us when we create the pull request. This is possible because
|
|
// users can only have a single fork in their namespace, and organizations don't work at all with this ref format.
|
|
//
|
|
// Note that providing the head branch in this way indicates that we shouldn't push the branch,
|
|
// and we indicate that via the returned type as well.
|
|
if opts.HeadBranch != "" {
|
|
qualifiedHeadRef, err := shared.ParseQualifiedHeadRef(opts.HeadBranch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
branchConfig, err := opts.GitClient.ReadBranchConfig(context.Background(), qualifiedHeadRef.BranchName())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
baseBranch := opts.BaseBranch
|
|
if baseBranch == "" {
|
|
baseBranch = branchConfig.MergeBase
|
|
}
|
|
if baseBranch == "" {
|
|
baseBranch = baseRepo.DefaultBranchRef.Name
|
|
}
|
|
|
|
return newCreateContext(skipPushRefs{
|
|
qualifiedHeadRef: qualifiedHeadRef,
|
|
baseRefs: baseRefs{
|
|
baseRepo: baseRepo,
|
|
baseBranchName: baseBranch,
|
|
},
|
|
}), nil
|
|
}
|
|
|
|
if ucc, err := opts.GitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 {
|
|
fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change"))
|
|
}
|
|
|
|
// If the user didn't provide a head branch then we're gettin' real. We're going to interrogate git
|
|
// and try to create refs that are pushable.
|
|
currentBranch, err := opts.Branch()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not determine the current branch: %w", err)
|
|
}
|
|
|
|
branchConfig, err := opts.GitClient.ReadBranchConfig(context.Background(), currentBranch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
baseBranch := opts.BaseBranch
|
|
if baseBranch == "" {
|
|
baseBranch = branchConfig.MergeBase
|
|
}
|
|
if baseBranch == "" {
|
|
baseBranch = baseRepo.DefaultBranchRef.Name
|
|
}
|
|
|
|
// First we check with the git information we have to see if we can figure out the default
|
|
// head repo and remote branch name.
|
|
defaultPRHead, err := shared.TryDetermineDefaultPRHead(
|
|
// We requested the branch config already, so let's cache that
|
|
shared.CachedBranchConfigGitConfigClient{
|
|
CachedBranchConfig: branchConfig,
|
|
GitConfigClient: opts.GitClient,
|
|
},
|
|
shared.NewRemoteToRepoResolver(opts.Remotes),
|
|
currentBranch,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The baseRefs are always going to be the same from now on. If I could make this immutable I would!
|
|
baseRefs := baseRefs{
|
|
baseRepo: baseRepo,
|
|
baseBranchName: baseBranch,
|
|
}
|
|
|
|
// If we were able to determine a head repo, then let's check that the remote tracking ref matches the SHA of
|
|
// HEAD. If it does, then we don't need to push, otherwise we'll need to ask the user to tell us where to push.
|
|
if headRepo, present := defaultPRHead.Repo.Value(); present {
|
|
// We may not find a remote because the git branch config may have a URL rather than a remote name.
|
|
// Ideally, we would return a sentinel error from RemoteForRepo that we could compare to, but the
|
|
// refactor that introduced this code was already large enough.
|
|
headRemote, _ := resolvedRemotes.RemoteForRepo(headRepo)
|
|
if headRemote != nil {
|
|
resolvedRefs, _ := opts.GitClient.ShowRefs(
|
|
context.Background(),
|
|
[]string{
|
|
"HEAD",
|
|
fmt.Sprintf("refs/remotes/%s/%s", headRemote.Name, defaultPRHead.BranchName),
|
|
},
|
|
)
|
|
|
|
// Two refs returned means we can compare HEAD to the remote tracking branch.
|
|
// If we had a matching ref, then we can skip pushing.
|
|
refsMatch := len(resolvedRefs) == 2 && resolvedRefs[0].Hash == resolvedRefs[1].Hash
|
|
if refsMatch {
|
|
qualifiedHeadRef := shared.NewQualifiedHeadRefWithoutOwner(defaultPRHead.BranchName)
|
|
if headRepo.RepoOwner() != baseRepo.RepoOwner() {
|
|
qualifiedHeadRef = shared.NewQualifiedHeadRef(headRepo.RepoOwner(), defaultPRHead.BranchName)
|
|
}
|
|
|
|
return newCreateContext(skipPushRefs{
|
|
qualifiedHeadRef: qualifiedHeadRef,
|
|
baseRefs: baseRefs,
|
|
}), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we didn't determine that the git indicated repo had the correct ref, we'll take a look at the other
|
|
// remotes and see whether any of them have the same SHA as HEAD. Now, at this point, you might be asking yourself:
|
|
// "Why didn't we collect all the SHAs with a single ShowRefs command above, for use in both cases?"
|
|
// ...
|
|
// That's because the code below has a bug that I've ported from the old code, in order to preserve the existing
|
|
// behaviour, and to limit the scope of an already large refactor. The intention of the original code was to loop
|
|
// over all the returned refs. However, as it turns out, our implementation of ShowRefs doesn't do that correctly.
|
|
// Since it provides the --verify flag, git will return the SHAs for refs up until it hits a ref that doesn't exist,
|
|
// at which point it bails out.
|
|
//
|
|
// Imagine you have a remotes "upstream" and "origin", and you have pushed your branch "feature" to "origin". Since
|
|
// the order of remotes is always guaranteed "upstream", "github", "origin", and then everything else unstably sorted,
|
|
// we will never get a SHA for origin, as refs/remotes/upstream/feature doesn't exist.
|
|
//
|
|
// Furthermore, when you really think about it, this code is a bit eager. What happens if you have the same SHA on
|
|
// remotes "origin" and "colleague", this will always offer origin. If it were "colleague-a" and "colleague-b", no
|
|
// order would be guaranteed between different invocations of pr create, because the order of remotes after "origin"
|
|
// is unstable sorted.
|
|
//
|
|
// All that said, this has been the behaviour for a long, long time, and I do not want to make other behavioural changes
|
|
// in what is mostly a refactor.
|
|
refsToLookup := []string{"HEAD"}
|
|
for _, remote := range remotes {
|
|
refsToLookup = append(refsToLookup, fmt.Sprintf("refs/remotes/%s/%s", remote.Name, currentBranch))
|
|
}
|
|
|
|
// Ignoring the error in this case is allowed because we may get refs and an error (see: --verify flag above).
|
|
// Ideally there would be a typed error to allow us to distinguish between an execution error and some refs
|
|
// not existing. However, this is too much to take on in an already large refactor.
|
|
refs, _ := opts.GitClient.ShowRefs(context.Background(), refsToLookup)
|
|
if len(refs) > 1 {
|
|
headRef := refs[0]
|
|
var firstMatchingRef o.Option[git.RemoteTrackingRef]
|
|
// Loop over all the refs, trying to find one that matches the SHA of HEAD.
|
|
for _, r := range refs[1:] {
|
|
if r.Hash == headRef.Hash {
|
|
remoteTrackingRef, err := git.ParseRemoteTrackingRef(r.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
firstMatchingRef = o.Some(remoteTrackingRef)
|
|
break
|
|
}
|
|
}
|
|
|
|
// If we found a matching ref, then we don't need to push.
|
|
if ref, present := firstMatchingRef.Value(); present {
|
|
remote, err := remotes.FindByName(ref.Remote)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
qualifiedHeadRef := shared.NewQualifiedHeadRefWithoutOwner(ref.Branch)
|
|
if baseRepo.RepoOwner() != remote.RepoOwner() {
|
|
qualifiedHeadRef = shared.NewQualifiedHeadRef(remote.RepoOwner(), ref.Branch)
|
|
}
|
|
|
|
return newCreateContext(skipPushRefs{
|
|
qualifiedHeadRef: qualifiedHeadRef,
|
|
baseRefs: baseRefs,
|
|
}), nil
|
|
}
|
|
}
|
|
|
|
// If we haven't got a repo by now, and we can't prompt then it's game over.
|
|
if !opts.IO.CanPrompt() {
|
|
fmt.Fprintln(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag")
|
|
return nil, cmdutil.SilentError
|
|
}
|
|
|
|
// Otherwise, hooray, prompting!
|
|
|
|
// First, we're going to look at our remotes and decide whether there are any repos we can push to.
|
|
pushableRepos, err := resolvedRemotes.HeadRepos()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If we couldn't find any pushable repos, then find forks of the base repo.
|
|
if len(pushableRepos) == 0 {
|
|
pushableRepos, err = api.RepoFindForks(client, baseRepo, 3)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hasOwnFork := false
|
|
var pushOptions []string
|
|
for _, r := range pushableRepos {
|
|
pushOptions = append(pushOptions, ghrepo.FullName(r))
|
|
if r.RepoOwner() == currentLogin {
|
|
hasOwnFork = true
|
|
}
|
|
}
|
|
|
|
if !hasOwnFork {
|
|
pushOptions = append(pushOptions, fmt.Sprintf("Create a fork of %s", ghrepo.FullName(baseRepo)))
|
|
}
|
|
pushOptions = append(pushOptions, "Skip pushing the branch")
|
|
pushOptions = append(pushOptions, "Cancel")
|
|
|
|
selectedOption, err := opts.Prompter.Select(fmt.Sprintf("Where should we push the '%s' branch?", currentBranch), "", pushOptions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if selectedOption < len(pushableRepos) {
|
|
// A repository has been selected to push to.
|
|
return newCreateContext(pushableRefs{
|
|
headRepo: pushableRepos[selectedOption],
|
|
headBranchName: currentBranch,
|
|
baseRefs: baseRefs,
|
|
}), nil
|
|
} else if pushOptions[selectedOption] == "Skip pushing the branch" {
|
|
// We're going to skip pushing the branch altogether, meaning, use whatever SHA is already pushed.
|
|
// It's not exactly clear what repo the user expects to use here for the HEAD, and maybe we should
|
|
// make that clear in the UX somehow, but in the old implementation as far as I can tell, this
|
|
// always meant "use the base repo".
|
|
return newCreateContext(skipPushRefs{
|
|
qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner(currentBranch),
|
|
baseRefs: baseRefs,
|
|
}), nil
|
|
} else if pushOptions[selectedOption] == "Cancel" {
|
|
return nil, cmdutil.CancelError
|
|
} else {
|
|
// A fork should be created.
|
|
return newCreateContext(forkableRefs{
|
|
qualifiedHeadRef: shared.NewQualifiedHeadRef(currentLogin, currentBranch),
|
|
baseRefs: baseRefs,
|
|
}), nil
|
|
}
|
|
}
|
|
|
|
func getRemotes(opts *CreateOptions) (ghContext.Remotes, error) {
|
|
// TODO: consider obtaining remotes from GitClient instead
|
|
remotes, err := opts.Remotes()
|
|
if err != nil {
|
|
// When a repo override value is given, ignore errors when fetching git remotes
|
|
// to support using this command outside of git repos.
|
|
if opts.RepoOverride == "" {
|
|
return nil, err
|
|
}
|
|
}
|
|
return remotes, nil
|
|
}
|
|
|
|
func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState, projectV1Support gh.ProjectsV1Support) error {
|
|
client := ctx.Client
|
|
|
|
params := map[string]interface{}{
|
|
"title": state.Title,
|
|
"body": state.Body,
|
|
"draft": state.Draft,
|
|
"baseRefName": ctx.PRRefs.BaseRef(),
|
|
"headRefName": ctx.PRRefs.QualifiedHeadRef(),
|
|
"maintainerCanModify": opts.MaintainerCanModify,
|
|
}
|
|
|
|
if params["title"] == "" {
|
|
return errors.New("pull request title must not be blank")
|
|
}
|
|
|
|
err := shared.AddMetadataToIssueParams(client, ctx.PRRefs.BaseRepo(), params, &state, projectV1Support)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.DryRun {
|
|
if opts.IO.IsStdoutTTY() {
|
|
return renderPullRequestTTY(opts.IO, params, &state)
|
|
} else {
|
|
return renderPullRequestPlain(opts.IO.Out, params, &state)
|
|
}
|
|
}
|
|
|
|
opts.IO.StartProgressIndicator()
|
|
pr, err := api.CreatePullRequest(client, ctx.PRRefs.BaseRepo(), params)
|
|
opts.IO.StopProgressIndicator()
|
|
if pr != nil {
|
|
fmt.Fprintln(opts.IO.Out, pr.URL)
|
|
}
|
|
if err != nil {
|
|
if pr != nil {
|
|
return fmt.Errorf("pull request update failed: %w", err)
|
|
}
|
|
return fmt.Errorf("pull request create failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func renderPullRequestPlain(w io.Writer, params map[string]interface{}, state *shared.IssueMetadataState) error {
|
|
fmt.Fprint(w, "Would have created a Pull Request with:\n")
|
|
fmt.Fprintf(w, "title:\t%s\n", params["title"])
|
|
fmt.Fprintf(w, "draft:\t%t\n", params["draft"])
|
|
fmt.Fprintf(w, "base:\t%s\n", params["baseRefName"])
|
|
fmt.Fprintf(w, "head:\t%s\n", params["headRefName"])
|
|
if len(state.Labels) != 0 {
|
|
fmt.Fprintf(w, "labels:\t%v\n", strings.Join(state.Labels, ", "))
|
|
}
|
|
if len(state.Reviewers) != 0 {
|
|
fmt.Fprintf(w, "reviewers:\t%v\n", strings.Join(state.Reviewers, ", "))
|
|
}
|
|
if len(state.Assignees) != 0 {
|
|
fmt.Fprintf(w, "assignees:\t%v\n", strings.Join(state.Assignees, ", "))
|
|
}
|
|
if len(state.Milestones) != 0 {
|
|
fmt.Fprintf(w, "milestones:\t%v\n", strings.Join(state.Milestones, ", "))
|
|
}
|
|
if len(state.ProjectTitles) != 0 {
|
|
fmt.Fprintf(w, "projects:\t%v\n", strings.Join(state.ProjectTitles, ", "))
|
|
}
|
|
fmt.Fprintf(w, "maintainerCanModify:\t%t\n", params["maintainerCanModify"])
|
|
fmt.Fprint(w, "body:\n")
|
|
if len(params["body"].(string)) != 0 {
|
|
fmt.Fprintln(w, params["body"])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func renderPullRequestTTY(io *iostreams.IOStreams, params map[string]interface{}, state *shared.IssueMetadataState) error {
|
|
cs := io.ColorScheme()
|
|
out := io.Out
|
|
|
|
fmt.Fprint(out, "Would have created a Pull Request with:\n")
|
|
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Title"), params["title"].(string))
|
|
fmt.Fprintf(out, "%s: %t\n", cs.Bold("Draft"), params["draft"])
|
|
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Base"), params["baseRefName"])
|
|
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Head"), params["headRefName"])
|
|
if len(state.Labels) != 0 {
|
|
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Labels"), strings.Join(state.Labels, ", "))
|
|
}
|
|
if len(state.Reviewers) != 0 {
|
|
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Reviewers"), strings.Join(state.Reviewers, ", "))
|
|
}
|
|
if len(state.Assignees) != 0 {
|
|
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Assignees"), strings.Join(state.Assignees, ", "))
|
|
}
|
|
if len(state.Milestones) != 0 {
|
|
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Milestones"), strings.Join(state.Milestones, ", "))
|
|
}
|
|
if len(state.ProjectTitles) != 0 {
|
|
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Projects"), strings.Join(state.ProjectTitles, ", "))
|
|
}
|
|
fmt.Fprintf(out, "%s: %t\n", cs.Bold("MaintainerCanModify"), params["maintainerCanModify"])
|
|
|
|
fmt.Fprintf(out, "%s\n", cs.Bold("Body:"))
|
|
// Body
|
|
var md string
|
|
var err error
|
|
if len(params["body"].(string)) == 0 {
|
|
md = fmt.Sprintf("%s\n", cs.Muted("No description provided"))
|
|
} else {
|
|
md, err = markdown.Render(params["body"].(string),
|
|
markdown.WithTheme(io.TerminalTheme()),
|
|
markdown.WithWrap(io.TerminalWidth()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Fprintf(out, "%s", md)
|
|
|
|
return nil
|
|
}
|
|
|
|
func previewPR(opts CreateOptions, openURL string) error {
|
|
if opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() {
|
|
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL))
|
|
}
|
|
return opts.Browser.Browse(openURL)
|
|
}
|
|
|
|
func handlePush(opts CreateOptions, ctx CreateContext) error {
|
|
refs := ctx.PRRefs
|
|
forkableRefs, requiresFork := refs.(forkableRefs)
|
|
if requiresFork {
|
|
opts.IO.StartProgressIndicator()
|
|
forkedRepo, err := api.ForkRepo(ctx.Client, forkableRefs.BaseRepo(), "", "", true)
|
|
opts.IO.StopProgressIndicator()
|
|
if err != nil {
|
|
return fmt.Errorf("error forking repo: %w", err)
|
|
}
|
|
|
|
refs = pushableRefs{
|
|
headRepo: forkedRepo,
|
|
headBranchName: forkableRefs.qualifiedHeadRef.BranchName(),
|
|
baseRefs: baseRefs{
|
|
baseRepo: forkableRefs.baseRepo,
|
|
baseBranchName: forkableRefs.baseBranchName,
|
|
},
|
|
}
|
|
}
|
|
|
|
// We may have upcast to pushableRefs on fork, or we may have been passed an instance
|
|
// already. But if we haven't, then there's nothing more to do.
|
|
pushableRefs, ok := refs.(pushableRefs)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// There are two cases when an existing remote for the head repo will be
|
|
// missing (and an error will be returned):
|
|
// 1. the head repo was just created by auto-forking;
|
|
// 2. an existing fork was discovered by querying the API.
|
|
// In either case, we want to add the head repo as a new git remote so we
|
|
// can push to it. We will try to add the head repo as the "origin" remote
|
|
// and fallback to the "fork" remote if it is unavailable. Also, if the
|
|
// base repo is the "origin" remote we will rename it "upstream".
|
|
headRemote, _ := ctx.ResolvedRemotes.RemoteForRepo(pushableRefs.HeadRepo())
|
|
if headRemote == nil {
|
|
cfg, err := opts.Config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
remotes, err := opts.Remotes()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cloneProtocol := cfg.GitProtocol(pushableRefs.HeadRepo().RepoHost()).Value
|
|
headRepoURL := ghrepo.FormatRemoteURL(pushableRefs.HeadRepo(), cloneProtocol)
|
|
gitClient := ctx.GitClient
|
|
origin, _ := remotes.FindByName("origin")
|
|
upstreamName := "upstream"
|
|
upstream, _ := remotes.FindByName(upstreamName)
|
|
remoteName := "origin"
|
|
|
|
if origin != nil {
|
|
remoteName = "fork"
|
|
}
|
|
|
|
if origin != nil && upstream == nil && ghrepo.IsSame(origin, pushableRefs.BaseRepo()) {
|
|
renameCmd, err := gitClient.Command(context.Background(), "remote", "rename", "origin", upstreamName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err = renameCmd.Output(); err != nil {
|
|
return fmt.Errorf("error renaming origin remote: %w", err)
|
|
}
|
|
remoteName = "origin"
|
|
fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(pushableRefs.BaseRepo()), upstreamName)
|
|
}
|
|
|
|
gitRemote, err := gitClient.AddRemote(context.Background(), remoteName, headRepoURL, []string{})
|
|
if err != nil {
|
|
return fmt.Errorf("error adding remote: %w", err)
|
|
}
|
|
|
|
fmt.Fprintf(opts.IO.ErrOut, "Added %s as remote %q\n", ghrepo.FullName(pushableRefs.HeadRepo()), remoteName)
|
|
|
|
// Only mark `upstream` remote as default if `gh pr create` created the remote.
|
|
if requiresFork {
|
|
err := gitClient.SetRemoteResolution(context.Background(), upstreamName, "base")
|
|
if err != nil {
|
|
return fmt.Errorf("error setting upstream as default: %w", err)
|
|
}
|
|
|
|
if opts.IO.IsStdoutTTY() {
|
|
cs := opts.IO.ColorScheme()
|
|
fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(pushableRefs.HeadRepo())))
|
|
}
|
|
}
|
|
|
|
headRemote = &ghContext.Remote{
|
|
Remote: gitRemote,
|
|
Repo: pushableRefs.HeadRepo(),
|
|
}
|
|
}
|
|
|
|
// automatically push the branch if it hasn't been pushed anywhere yet
|
|
pushBranch := func() error {
|
|
w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "")
|
|
defer w.Flush()
|
|
ref := fmt.Sprintf("HEAD:refs/heads/%s", ctx.PRRefs.UnqualifiedHeadRef())
|
|
bo := backoff.NewConstantBackOff(2 * time.Second)
|
|
root := context.Background()
|
|
return backoff.Retry(func() error {
|
|
if err := ctx.GitClient.Push(root, headRemote.Name, ref, git.WithStderr(w)); err != nil {
|
|
// Only retry if we have forked the repo else the push should succeed the first time.
|
|
if requiresFork {
|
|
fmt.Fprintf(opts.IO.ErrOut, "waiting 2 seconds before retrying...\n")
|
|
return err
|
|
}
|
|
return backoff.Permanent(err)
|
|
}
|
|
return nil
|
|
}, backoff.WithContext(backoff.WithMaxRetries(bo, 3), root))
|
|
}
|
|
|
|
return pushBranch()
|
|
}
|
|
|
|
func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState, projectsV1Support gh.ProjectsV1Support) (string, error) {
|
|
u := ghrepo.GenerateRepoURL(
|
|
ctx.PRRefs.BaseRepo(),
|
|
"compare/%s...%s?expand=1",
|
|
url.PathEscape(ctx.PRRefs.BaseRef()), url.PathEscape(ctx.PRRefs.QualifiedHeadRef()))
|
|
url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state, projectsV1Support)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return url, nil
|
|
}
|
|
|
|
// Humanize returns a copy of the string s that replaces all instance of '-' and '_' with spaces.
|
|
func humanize(s string) string {
|
|
replace := "_-"
|
|
h := func(r rune) rune {
|
|
if strings.ContainsRune(replace, r) {
|
|
return ' '
|
|
}
|
|
return r
|
|
}
|
|
return strings.Map(h, s)
|
|
}
|
|
|
|
func requestableReviewersForCompletion(opts *CreateOptions) ([]string, error) {
|
|
httpClient, err := opts.HttpClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
remotes, err := getRemotes(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repoContext, err := ghContext.ResolveRemotesToRepos(remotes, api.NewClientFromHTTP(httpClient), opts.RepoOverride)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
baseRepo, err := repoContext.BaseRepo(opts.IO)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return shared.RequestableReviewersForCompletion(httpClient, baseRepo)
|
|
}
|
|
|
|
var gitPushRegexp = regexp.MustCompile("^remote: (Create a pull request.*by visiting|[[:space:]]*https://.*/pull/new/).*\n?$")
|