From 6eaab2562fe2b9ce27daad250cccf3b3667c28c1 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Mon, 24 Feb 2020 14:23:34 -0600 Subject: [PATCH] add repo fork command --- api/queries_repo.go | 59 +++++++++++++--- command/repo.go | 164 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 11 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 9d0c4f00d..16060abc5 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -12,9 +12,10 @@ import ( // Repository contains information about a GitHub repo type Repository struct { - ID string - Name string - Owner RepositoryOwner + ID string + Name string + CloneURL string + Owner RepositoryOwner IsPrivate bool HasIssuesEnabled bool @@ -59,6 +60,46 @@ func (r Repository) ViewerCanPush() bool { } } +func RepoExistsOnGitHub(client *Client, repo ghrepo.Interface) (bool, error) { + query := ` + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id + } + } + ` + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "name": repo.RepoName(), + } + + result := struct { + Repository Repository + }{} + err := client.GraphQL(query, variables, &result) + + if err == nil { + // we found it. + return true, nil + } + + // we didn't find it, but need to determine if we hit an error or it just doesn't exist. + graphqlError, isGraphQLError := err.(*GraphQLErrorResponse) + if isGraphQLError { + tolerated := true + for _, ge := range graphqlError.Errors { + if ge.Type != "NOT_FOUND" { + tolerated = false + } + } + if tolerated { + err = nil + } + } + + return false, err +} + // GitHubRepo looks up the node ID of a named repository func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { query := ` @@ -190,9 +231,10 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e // repositoryV3 is the repository result from GitHub API v3 type repositoryV3 struct { - NodeID string - Name string - Owner struct { + NodeID string + Name string + CloneURL string `json:"clone_url"` + Owner struct { Login string } } @@ -208,8 +250,9 @@ func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { } return &Repository{ - ID: result.NodeID, - Name: result.Name, + ID: result.NodeID, + Name: result.Name, + CloneURL: result.CloneURL, Owner: RepositoryOwner{ Login: result.Owner.Login, }, diff --git a/command/repo.go b/command/repo.go index 484ef609c..61cc2fc8a 100644 --- a/command/repo.go +++ b/command/repo.go @@ -2,12 +2,16 @@ package command import ( "fmt" + "net/url" "os" "strings" + "github.com/cli/cli/api" "github.com/cli/cli/git" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/utils" + + "github.com/AlecAivazis/survey/v2" "github.com/spf13/cobra" ) @@ -15,6 +19,10 @@ func init() { RootCmd.AddCommand(repoCmd) repoCmd.AddCommand(repoCloneCmd) repoCmd.AddCommand(repoViewCmd) + repoCmd.AddCommand(repoForkCmd) + + repoForkCmd.Flags().BoolP("yes", "y", false, "Run non-interactively, saying yes to prompts") + repoForkCmd.Flags().BoolP("no", "n", false, "Run non-interactively, saying no to prompts") } var repoCmd = &cobra.Command{ @@ -37,9 +45,18 @@ To pass 'git clone' options, separate them with '--'.`, RunE: repoClone, } +var repoForkCmd = &cobra.Command{ + Use: "fork []", + Short: "Create a fork of a repository.", + Long: `Create a fork of a repository. + +With no argument, creates a fork of the current repository. Otherwise, forks the specified repository.`, + RunE: repoFork, +} + var repoViewCmd = &cobra.Command{ - Use: "view []", - Short: "View a repository in the browser", + Use: "view []", + Short: "View a repository in the browser.", Long: `View a GitHub repository in the browser. With no argument, the repository for the current directory is opened.`, @@ -63,6 +80,147 @@ func repoClone(cmd *cobra.Command, args []string) error { return utils.PrepareCmd(cloneCmd).Run() } +func isURL(arg string) bool { + return strings.HasPrefix(arg, "http:/") || strings.HasPrefix(arg, "https:/") +} + +func repoFork(cmd *cobra.Command, args []string) error { + ctx := contextForCommand(cmd) + + forceYes, err := cmd.Flags().GetBool("yes") + if err != nil { + return err + } + forceNo, err := cmd.Flags().GetBool("no") + if err != nil { + return err + } + + apiClient, err := apiClientForContext(ctx) + if err != nil { + return fmt.Errorf("unable to create client: %w", err) + } + + var toFork ghrepo.Interface + inParent := false // whether or not we're forking the repo we're currently "in" + if len(args) == 0 { + baseRepo, err := determineBaseRepo(cmd, ctx) + if err != nil { + return fmt.Errorf("unable to determine base repository: %w", err) + } + inParent = true + toFork = baseRepo + } else { + repoArg := args[0] + + if isURL(repoArg) { + parsedURL, err := url.Parse(repoArg) + if err != nil { + return fmt.Errorf("did not understand argument: %w", err) + } + + toFork, err = ghrepo.FromURL(parsedURL) + if err != nil { + return fmt.Errorf("did not understand argument: %w", err) + } + + } else { + toFork = ghrepo.FromFullName(repoArg) + if toFork.RepoName() == "" || toFork.RepoOwner() == "" { + return fmt.Errorf("could not parse owner or repo name from %s", repoArg) + } + } + } + + out := colorableOut(cmd) + fmt.Fprintf(out, "Forking %s...\n", utils.Cyan(ghrepo.FullName(toFork))) + + authLogin, err := ctx.AuthLogin() + if err != nil { + return fmt.Errorf("could not determine current username: %w", err) + } + + possibleFork := ghrepo.New(authLogin, toFork.RepoName()) + exists, err := api.RepoExistsOnGitHub(apiClient, possibleFork) + if err != nil { + return fmt.Errorf("problem with API request: %w", err) + } + + if exists { + return fmt.Errorf("%s %s", utils.Cyan(ghrepo.FullName(possibleFork)), utils.Red("already exists!")) + } + + forkedRepo, err := api.ForkRepo(apiClient, toFork) + if err != nil { + return fmt.Errorf("failed to fork: %w", err) + } + + fmt.Fprintf(out, "%s %s %s!\n", + utils.Cyan(ghrepo.FullName(toFork)), + utils.Green("successfully forked to"), + utils.Cyan(ghrepo.FullName(forkedRepo))) + + if forceNo { + return nil + } + + if inParent { + if !forceYes { + remoteDesired := forceYes + if !forceYes { + prompt := &survey.Confirm{ + Message: "Would you like to add a remote for the new fork?", + } + err = survey.AskOne(prompt, &remoteDesired) + if err != nil { + return fmt.Errorf("failed to prompt: %w", err) + } + } + if remoteDesired { + _, err := git.AddRemote("fork", forkedRepo.CloneURL, "") + if err != nil { + return fmt.Errorf("failed to add remote: %w", err) + } + + fetchCmd := git.GitCommand("fetch", "fork") + fetchCmd.Stdin = os.Stdin + fetchCmd.Stdout = os.Stdout + fetchCmd.Stderr = os.Stderr + err = utils.PrepareCmd(fetchCmd).Run() + if err != nil { + return fmt.Errorf("failed to fetch new remote: %w", err) + } + + fmt.Fprintf(out, "%s %s\n", utils.Green("remote added at "), utils.Cyan("fork")) + } + } + } else { + cloneDesired := forceYes + if !forceYes { + prompt := &survey.Confirm{ + Message: "Would you like to clone the new fork?", + } + err = survey.AskOne(prompt, &cloneDesired) + if err != nil { + return fmt.Errorf("failed to prompt: %w", err) + } + + } + if cloneDesired { + cloneCmd := git.GitCommand("clone", forkedRepo.CloneURL) + cloneCmd.Stdin = os.Stdin + cloneCmd.Stdout = os.Stdout + cloneCmd.Stderr = os.Stderr + err = utils.PrepareCmd(cloneCmd).Run() + if err != nil { + return fmt.Errorf("failed to clone fork: %w", err) + } + } + } + + return nil +} + func repoView(cmd *cobra.Command, args []string) error { ctx := contextForCommand(cmd) @@ -75,7 +233,7 @@ func repoView(cmd *cobra.Command, args []string) error { openURL = fmt.Sprintf("https://github.com/%s", ghrepo.FullName(baseRepo)) } else { repoArg := args[0] - if strings.HasPrefix(repoArg, "http:/") || strings.HasPrefix(repoArg, "https:/") { + if isURL(repoArg) { openURL = repoArg } else { openURL = fmt.Sprintf("https://github.com/%s", repoArg)