From e2a825effb6fa616639769a7d21655d386b6a885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 22 Jan 2020 10:40:06 +0100 Subject: [PATCH] Auto-fork on `pr create` if no pushable target found --- api/queries_repo.go | 38 ++++++++++++++++++++++++++++++--- command/pr_create.go | 51 +++++++++++++++++++++++++++++++++++++------- git/remote.go | 35 ++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 11 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 955af9a4d..b77dc2d34 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -14,9 +14,7 @@ import ( type Repository struct { ID string Name string - Owner struct { - Login string - } + Owner RepositoryOwner IsPrivate bool HasIssuesEnabled bool @@ -31,6 +29,11 @@ type Repository struct { Parent *Repository } +// RepositoryOwner is the owner of a GitHub repository +type RepositoryOwner struct { + Login string +} + // RepoOwner is the login name of the owner func (r Repository) RepoOwner() string { return r.Owner.Login @@ -182,3 +185,32 @@ func RepoNetwork(client *Client, repos []Repo) (RepoNetworkResult, error) { } return result, nil } + +// repositoryV3 is the repository result from GitHub API v3 +type repositoryV3 struct { + NodeID string + Name string + Owner struct { + Login string + } +} + +// ForkRepo forks the repository on GitHub and returns the new repository +func ForkRepo(client *Client, repo Repo) (*Repository, error) { + path := fmt.Sprintf("repos/%s/%s/forks", repo.RepoOwner(), repo.RepoName()) + body := bytes.NewBufferString(`{}`) + result := repositoryV3{} + err := client.REST("POST", path, body, &result) + if err != nil { + return nil, err + } + + return &Repository{ + ID: result.NodeID, + Name: result.Name, + Owner: RepositoryOwner{ + Login: result.Owner.Login, + }, + ViewerPermission: "WRITE", + }, nil +} diff --git a/command/pr_create.go b/command/pr_create.go index b53dd15d0..4f8a2a70a 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -5,6 +5,7 @@ import ( "net/url" "sort" "strings" + "time" "github.com/github/gh-cli/api" "github.com/github/gh-cli/context" @@ -51,27 +52,61 @@ func prCreate(cmd *cobra.Command, _ []string) error { baseBranch = baseRepo.DefaultBranchRef.Name } + didForkRepo := false + var headRemote *context.Remote headRepo, err := repoContext.HeadRepo() if err != nil { - // TODO: auto-fork repository and add new git remote - return errors.Wrap(err, "could not determine the head repository") + if baseRepo.IsPrivate { + return fmt.Errorf("cannot write to private repository '%s/%s'", baseRepo.RepoOwner(), baseRepo.RepoName()) + } + headRepo, err = api.ForkRepo(client, baseRepo) + if err != nil { + return fmt.Errorf("error forking repo: %w", err) + } + didForkRepo = true + // TODO: support non-HTTPS git remote URLs + baseRepoURL := fmt.Sprintf("https://github.com/%s/%s.git", baseRepo.RepoOwner(), baseRepo.RepoName()) + headRepoURL := fmt.Sprintf("https://github.com/%s/%s.git", headRepo.RepoOwner(), headRepo.RepoName()) + // TODO: figure out what to name the new git remote + gitRemote, err := git.AddRemote("fork", baseRepoURL, headRepoURL) + if err != nil { + return fmt.Errorf("error adding remote: %w", err) + } + headRemote = &context.Remote{ + Remote: gitRemote, + Owner: headRepo.RepoOwner(), + Repo: headRepo.RepoName(), + } } if headBranch == baseBranch && isSameRepo(baseRepo, headRepo) { return fmt.Errorf("must be on a branch named differently than %q", baseBranch) } - headRemote, err := repoContext.RemoteForRepo(headRepo) - if err != nil { - return errors.Wrap(err, "git remote not found for head repository") + if headRemote == nil { + headRemote, err = repoContext.RemoteForRepo(headRepo) + if err != nil { + return errors.Wrap(err, "git remote not found for head repository") + } } if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change")) } - // TODO: respect existing upstream configuration of the current branch - if err = git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", headBranch)); err != nil { - return err + 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 + time.Sleep(time.Duration(2*pushTries) * time.Second) + continue + } + return err + } + break } isWeb, err := cmd.Flags().GetBool("web") diff --git a/git/remote.go b/git/remote.go index ba29049c2..9bb24146f 100644 --- a/git/remote.go +++ b/git/remote.go @@ -2,8 +2,11 @@ package git import ( "net/url" + "os/exec" "regexp" "strings" + + "github.com/github/gh-cli/utils" ) var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`) @@ -67,3 +70,35 @@ func parseRemotes(gitRemotes []string) (remotes RemoteSet) { } return } + +// AddRemote adds a new git remote. The initURL is the remote URL with which the +// automatic fetch is made and finalURL, if non-blank, is set as the remote URL +// after the fetch. +func AddRemote(name, initURL, finalURL string) (*Remote, error) { + addCmd := exec.Command("git", "remote", "add", "-f", name, initURL) + err := utils.PrepareCmd(addCmd).Run() + if err != nil { + return nil, err + } + + if finalURL == "" { + finalURL = initURL + } else { + setCmd := exec.Command("git", "remote", "set-url", name, finalURL) + err := utils.PrepareCmd(setCmd).Run() + if err != nil { + return nil, err + } + } + + finalURLParsed, err := url.Parse(initURL) + if err != nil { + return nil, err + } + + return &Remote{ + Name: name, + FetchURL: finalURLParsed, + PushURL: finalURLParsed, + }, nil +}