cli/command/pr_create.go
Mislav Marohnić d75faab85a Creating a PR now always prioritizes an existing fork as a push target
Before: the default push target for the current branch in `pr create`
was the first repository found among git remotes that has write access.

Now: the default push target is the fork the base repo, if said fork
exists and has write access, falling back to old behavior otherwise.

This change in the default is to facilitate contributions to projects
that have a hard requirement that all pull requests (even those opened
by people with write access to that project) come from forks.
2020-03-19 16:04:23 +01:00

313 lines
8.4 KiB
Go

package command
import (
"errors"
"fmt"
"net/url"
"time"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type defaults struct {
Title string
Body string
}
func computeDefaults(baseRef, headRef string) (defaults, error) {
commits, err := git.Commits(baseRef, headRef)
if err != nil {
return defaults{}, err
}
out := defaults{}
if len(commits) == 1 {
out.Title = commits[0].Title
body, err := git.CommitBody(commits[0].Sha)
if err != nil {
return defaults{}, err
}
out.Body = body
} else {
out.Title = utils.Humanize(headRef)
body := ""
for _, c := range commits {
body += fmt.Sprintf("- %s\n", c.Title)
}
out.Body = body
}
return out, nil
}
func prCreate(cmd *cobra.Command, _ []string) error {
ctx := contextForCommand(cmd)
remotes, err := ctx.Remotes()
if err != nil {
return err
}
client, err := apiClientForContext(ctx)
if err != nil {
return fmt.Errorf("could not initialize API client: %w", err)
}
baseRepoOverride, _ := cmd.Flags().GetString("repo")
repoContext, err := context.ResolveRemotesToRepos(remotes, client, baseRepoOverride)
if err != nil {
return err
}
baseRepo, err := repoContext.BaseRepo()
if err != nil {
return fmt.Errorf("could not determine base repository: %w", err)
}
headBranch, err := ctx.Branch()
if err != nil {
return fmt.Errorf("could not determine the current branch: %w", err)
}
headRepo, headRepoErr := repoContext.HeadRepo()
baseBranch, err := cmd.Flags().GetString("base")
if err != nil {
return err
}
if baseBranch == "" {
baseBranch = baseRepo.DefaultBranchRef.Name
}
if headBranch == baseBranch && headRepo != nil && ghrepo.IsSame(baseRepo, headRepo) {
return fmt.Errorf("must be on a branch named differently than %q", baseBranch)
}
if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change"))
}
title, err := cmd.Flags().GetString("title")
if err != nil {
return fmt.Errorf("could not parse title: %w", err)
}
body, err := cmd.Flags().GetString("body")
if err != nil {
return fmt.Errorf("could not parse body: %w", err)
}
defs, defaultsErr := computeDefaults(baseBranch, headBranch)
isWeb, err := cmd.Flags().GetBool("web")
if err != nil {
return fmt.Errorf("could not parse web: %q", err)
}
autofill, err := cmd.Flags().GetBool("fill")
if err != nil {
return fmt.Errorf("could not parse fill: %q", err)
}
action := SubmitAction
if isWeb {
action = PreviewAction
if (title == "" || body == "") && defaultsErr != nil {
return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr)
}
} else if autofill {
if defaultsErr != nil {
return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr)
}
title = defs.Title
body = defs.Body
}
if !isWeb {
headBranchLabel := headBranch
if headRepo != nil && !ghrepo.IsSame(baseRepo, headRepo) {
headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
}
existingPR, err := api.PullRequestForBranch(client, baseRepo, headBranchLabel)
var notFound *api.NotFoundError
if err != nil && !errors.As(err, &notFound) {
return fmt.Errorf("error checking for existing pull request: %w", err)
}
if err == nil {
return fmt.Errorf("a pull request for branch %q already exists:\n%s", headBranchLabel, existingPR.URL)
}
}
if !isWeb && !autofill {
fmt.Fprintf(colorableErr(cmd), "\nCreating pull request for %s into %s in %s\n\n",
utils.Cyan(headBranch),
utils.Cyan(baseBranch),
ghrepo.FullName(baseRepo))
if (title == "" || body == "") && defaultsErr != nil {
fmt.Fprintf(colorableErr(cmd), "%s warning: could not compute title or body defaults: %s\n", utils.Yellow("!"), defaultsErr)
}
}
// TODO: only drop into interactive mode if stdin & stdout are a tty
if !isWeb && !autofill && (title == "" || body == "") {
var templateFiles []string
if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests
templateFiles = githubtemplate.Find(rootDir, "PULL_REQUEST_TEMPLATE")
}
tb, err := titleBodySurvey(cmd, title, body, defs, templateFiles)
if err != nil {
return fmt.Errorf("could not collect title and/or body: %w", err)
}
action = tb.Action
if action == CancelAction {
fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.")
return nil
}
if title == "" {
title = tb.Title
}
if body == "" {
body = tb.Body
}
}
if action == SubmitAction && title == "" {
return errors.New("pull request title must not be blank")
}
isDraft, err := cmd.Flags().GetBool("draft")
if err != nil {
return fmt.Errorf("could not parse draft: %w", err)
}
if isDraft && isWeb {
return errors.New("the --draft flag is not supported with --web")
}
didForkRepo := false
if headRepoErr != nil {
if baseRepo.IsPrivate {
return fmt.Errorf("cannot fork private repository '%s'", ghrepo.FullName(baseRepo))
}
headRepo, err = api.ForkRepo(client, baseRepo)
if err != nil {
return fmt.Errorf("error forking repo: %w", err)
}
didForkRepo = true
}
headBranchLabel := headBranch
if !ghrepo.IsSame(baseRepo, headRepo) {
headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
}
headRemote, err := repoContext.RemoteForRepo(headRepo)
// There are two cases when an existing remote for the head repo will be
// missing:
// 1. the head repo was just created by auto-forking;
// 2. an existing fork was discovered by quering the API.
//
// In either case, we want to add the head repo as a new git remote so we
// can push to it.
if err != nil {
// TODO: support non-HTTPS git remote URLs
headRepoURL := fmt.Sprintf("https://github.com/%s.git", ghrepo.FullName(headRepo))
// TODO: prevent clashes with another remote of a same name
gitRemote, err := git.AddRemote("fork", headRepoURL, "")
if err != nil {
return fmt.Errorf("error adding remote: %w", err)
}
headRemote = &context.Remote{
Remote: gitRemote,
Owner: headRepo.RepoOwner(),
Repo: headRepo.RepoName(),
}
}
pushTries := 0
maxPushTries := 3
for {
// TODO: respect existing upstream configuration of the current branch
if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", headBranch)); err != nil {
if didForkRepo && pushTries < maxPushTries {
pushTries++
// first wait 2 seconds after forking, then 4s, then 6s
waitSeconds := 2 * pushTries
fmt.Fprintf(cmd.ErrOrStderr(), "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second"))
time.Sleep(time.Duration(waitSeconds) * time.Second)
continue
}
return err
}
break
}
if action == SubmitAction {
params := map[string]interface{}{
"title": title,
"body": body,
"draft": isDraft,
"baseRefName": baseBranch,
"headRefName": headBranchLabel,
}
pr, err := api.CreatePullRequest(client, baseRepo, params)
if err != nil {
return fmt.Errorf("failed to create pull request: %w", err)
}
fmt.Fprintln(cmd.OutOrStdout(), pr.URL)
} else if action == PreviewAction {
openURL := generateCompareURL(baseRepo, baseBranch, headBranchLabel, title, body)
// TODO could exceed max url length for explorer
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL))
return utils.OpenInBrowser(openURL)
} else {
panic("Unreachable state")
}
return nil
}
func generateCompareURL(r ghrepo.Interface, base, head, title, body string) string {
u := fmt.Sprintf(
"https://github.com/%s/compare/%s...%s?expand=1",
ghrepo.FullName(r),
base,
head,
)
if title != "" {
u += "&title=" + url.QueryEscape(title)
}
if body != "" {
u += "&body=" + url.QueryEscape(body)
}
return u
}
var prCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a pull request",
RunE: prCreate,
}
func init() {
prCreateCmd.Flags().BoolP("draft", "d", false,
"Mark pull request as a draft")
prCreateCmd.Flags().StringP("title", "t", "",
"Supply a title. Will prompt for one otherwise.")
prCreateCmd.Flags().StringP("body", "b", "",
"Supply a body. Will prompt for one otherwise.")
prCreateCmd.Flags().StringP("base", "B", "",
"The branch into which you want your code merged")
prCreateCmd.Flags().BoolP("web", "w", false, "Open the web browser to create a pull request")
prCreateCmd.Flags().BoolP("fill", "f", false, "Do not prompt for title/body and just use commit info")
}