From c34054bdc89f41825868ec7dd71c4dc526fd5080 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 24 Jul 2020 10:50:24 -0500 Subject: [PATCH] isolate repo create command --- api/queries_org.go | 1 + api/queries_repo.go | 69 +------- api/queries_repo_test.go | 39 ----- command/repo.go | 179 +------------------- command/repo_test.go | 235 ++------------------------ command/root.go | 2 + pkg/cmd/repo/clone/clone_test.go | 18 +- pkg/cmd/repo/create/create.go | 190 +++++++++++++++++++++ pkg/cmd/repo/create/create_test.go | 256 +++++++++++++++++++++++++++++ pkg/cmd/repo/create/http.go | 92 +++++++++++ pkg/cmd/repo/create/http_test.go | 48 ++++++ pkg/prompt/prompt.go | 11 ++ test/helpers.go | 13 ++ 13 files changed, 635 insertions(+), 518 deletions(-) create mode 100644 pkg/cmd/repo/create/create.go create mode 100644 pkg/cmd/repo/create/create_test.go create mode 100644 pkg/cmd/repo/create/http.go create mode 100644 pkg/cmd/repo/create/http_test.go create mode 100644 pkg/prompt/prompt.go diff --git a/api/queries_org.go b/api/queries_org.go index b91c2da78..3f8ee60a6 100644 --- a/api/queries_org.go +++ b/api/queries_org.go @@ -7,6 +7,7 @@ import ( "github.com/shurcooL/githubv4" ) +// TODO clean up // using API v3 here because the equivalent in GraphQL needs `read:org` scope func resolveOrganization(client *Client, orgName string) (string, error) { var response struct { diff --git a/api/queries_repo.go b/api/queries_repo.go index e2ae274ed..06d4bfdab 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -109,7 +109,7 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { return nil, err } - return initRepoHostname(&result.Repository, repo.RepoHost()), nil + return InitRepoHostname(&result.Repository, repo.RepoHost()), nil } func RepoDefaultBranch(client *Client, repo ghrepo.Interface) (string, error) { @@ -250,7 +250,7 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e if err := decoder.Decode(&repo); err != nil { return result, err } - result.Repositories = append(result.Repositories, initRepoHostname(&repo, hostname)) + result.Repositories = append(result.Repositories, InitRepoHostname(&repo, hostname)) } else { return result, fmt.Errorf("unknown GraphQL result key %q", name) } @@ -258,7 +258,7 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e return result, nil } -func initRepoHostname(repo *Repository, hostname string) *Repository { +func InitRepoHostname(repo *Repository, hostname string) *Repository { repo.hostname = hostname if repo.Parent != nil { repo.Parent.hostname = hostname @@ -338,72 +338,11 @@ func RepoFindFork(client *Client, repo ghrepo.Interface) (*Repository, error) { // `affiliations` condition, to guard against versions of GitHub with a // faulty `affiliations` implementation if len(forks) > 0 && forks[0].ViewerCanPush() { - return initRepoHostname(&forks[0], repo.RepoHost()), nil + return InitRepoHostname(&forks[0], repo.RepoHost()), nil } return nil, &NotFoundError{errors.New("no fork found")} } -// RepoCreateInput represents input parameters for RepoCreate -type RepoCreateInput struct { - Name string `json:"name"` - Visibility string `json:"visibility"` - HomepageURL string `json:"homepageUrl,omitempty"` - Description string `json:"description,omitempty"` - - OwnerID string `json:"ownerId,omitempty"` - TeamID string `json:"teamId,omitempty"` - - HasIssuesEnabled bool `json:"hasIssuesEnabled"` - HasWikiEnabled bool `json:"hasWikiEnabled"` -} - -// RepoCreate creates a new GitHub repository -func RepoCreate(client *Client, input RepoCreateInput) (*Repository, error) { - var response struct { - CreateRepository struct { - Repository Repository - } - } - - if input.TeamID != "" { - orgID, teamID, err := resolveOrganizationTeam(client, input.OwnerID, input.TeamID) - if err != nil { - return nil, err - } - input.TeamID = teamID - input.OwnerID = orgID - } else if input.OwnerID != "" { - orgID, err := resolveOrganization(client, input.OwnerID) - if err != nil { - return nil, err - } - input.OwnerID = orgID - } - - variables := map[string]interface{}{ - "input": input, - } - - err := client.GraphQL(` - mutation RepositoryCreate($input: CreateRepositoryInput!) { - createRepository(input: $input) { - repository { - id - name - owner { login } - url - } - } - } - `, variables, &response) - if err != nil { - return nil, err - } - - // FIXME: support Enterprise hosts - return initRepoHostname(&response.CreateRepository.Repository, "github.com"), nil -} - type RepoMetadataResult struct { AssignableUsers []RepoAssignee Labels []RepoLabel diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 58e4e061c..ad0e36864 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -1,51 +1,12 @@ package api import ( - "bytes" - "encoding/json" - "io/ioutil" "testing" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/httpmock" ) -func Test_RepoCreate(t *testing.T) { - http := &httpmock.Registry{} - client := NewClient(ReplaceTripper(http)) - - http.StubResponse(200, bytes.NewBufferString(`{}`)) - - input := RepoCreateInput{ - Description: "roasted chesnuts", - HomepageURL: "http://example.com", - } - - _, err := RepoCreate(client, input) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(http.Requests) != 1 { - t.Fatalf("expected 1 HTTP request, seen %d", len(http.Requests)) - } - - var reqBody struct { - Query string - Variables struct { - Input map[string]interface{} - } - } - - bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) - _ = json.Unmarshal(bodyBytes, &reqBody) - if description := reqBody.Variables.Input["description"].(string); description != "roasted chesnuts" { - t.Errorf("expected description to be %q, got %q", "roasted chesnuts", description) - } - if homepage := reqBody.Variables.Input["homepageUrl"].(string); homepage != "http://example.com" { - t.Errorf("expected homepageUrl to be %q, got %q", "http://example.com", homepage) - } -} func Test_RepoMetadata(t *testing.T) { http := &httpmock.Registry{} client := NewClient(ReplaceTripper(http)) diff --git a/command/repo.go b/command/repo.go index cca74d8fb..c5a3934f6 100644 --- a/command/repo.go +++ b/command/repo.go @@ -4,30 +4,20 @@ import ( "errors" "fmt" "net/url" - "os" - "path" "strings" "time" - "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" "github.com/cli/cli/git" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/utils" "github.com/spf13/cobra" ) func init() { - repoCmd.AddCommand(repoCreateCmd) - repoCreateCmd.Flags().StringP("description", "d", "", "Description of repository") - repoCreateCmd.Flags().StringP("homepage", "h", "", "Repository home page URL") - repoCreateCmd.Flags().StringP("team", "t", "", "The name of the organization team to be granted access") - repoCreateCmd.Flags().Bool("enable-issues", true, "Enable issues in the new repository") - repoCreateCmd.Flags().Bool("enable-wiki", true, "Enable wiki in the new repository") - repoCreateCmd.Flags().Bool("public", false, "Make the new repository public (default: private)") - repoCmd.AddCommand(repoForkCmd) repoForkCmd.Flags().String("clone", "prompt", "Clone fork: {true|false|prompt}") repoForkCmd.Flags().String("remote", "prompt", "Add remote for fork: {true|false|prompt}") @@ -55,26 +45,6 @@ A repository can be supplied as an argument in any of the following formats: - by URL, e.g. "https://github.com/OWNER/REPO"`}, } -var repoCreateCmd = &cobra.Command{ - Use: "create []", - Short: "Create a new repository", - Long: `Create a new GitHub repository.`, - Example: heredoc.Doc(` - # create a repository under your account using the current directory name - $ gh repo create - - # create a repository with a specific name - $ gh repo create my-project - - # create a repository in an organization - $ gh repo create cli/my-project - `), - Annotations: map[string]string{"help:arguments": `A repository can be supplied as an argument in any of the following formats: -- -- by URL, e.g. "https://github.com/OWNER/REPO"`}, - RunE: repoCreate, -} - var repoForkCmd = &cobra.Command{ Use: "fork []", Short: "Create a fork of a repository", @@ -105,141 +75,6 @@ var repoCreditsCmd = &cobra.Command{ Hidden: true, } -func repoCreate(cmd *cobra.Command, args []string) error { - projectDir, projectDirErr := git.ToplevelDir() - - orgName := "" - teamSlug, err := cmd.Flags().GetString("team") - if err != nil { - return err - } - - var name string - if len(args) > 0 { - name = args[0] - if strings.Contains(name, "/") { - newRepo, err := ghrepo.FromFullName(name) - if err != nil { - return fmt.Errorf("argument error: %w", err) - } - orgName = newRepo.RepoOwner() - name = newRepo.RepoName() - } - } else { - if projectDirErr != nil { - return projectDirErr - } - name = path.Base(projectDir) - } - - isPublic, err := cmd.Flags().GetBool("public") - if err != nil { - return err - } - hasIssuesEnabled, err := cmd.Flags().GetBool("enable-issues") - if err != nil { - return err - } - hasWikiEnabled, err := cmd.Flags().GetBool("enable-wiki") - if err != nil { - return err - } - description, err := cmd.Flags().GetString("description") - if err != nil { - return err - } - homepage, err := cmd.Flags().GetString("homepage") - if err != nil { - return err - } - - // TODO: move this into constant within `api` - visibility := "PRIVATE" - if isPublic { - visibility = "PUBLIC" - } - - input := api.RepoCreateInput{ - Name: name, - Visibility: visibility, - OwnerID: orgName, - TeamID: teamSlug, - Description: description, - HomepageURL: homepage, - HasIssuesEnabled: hasIssuesEnabled, - HasWikiEnabled: hasWikiEnabled, - } - - ctx := contextForCommand(cmd) - client, err := apiClientForContext(ctx) - if err != nil { - return err - } - - repo, err := api.RepoCreate(client, input) - if err != nil { - return err - } - - out := cmd.OutOrStdout() - greenCheck := utils.Green("✓") - isTTY := false - if outFile, isFile := out.(*os.File); isFile { - isTTY = utils.IsTerminal(outFile) - if isTTY { - // FIXME: duplicates colorableOut - out = utils.NewColorable(outFile) - } - } - - if isTTY { - fmt.Fprintf(out, "%s Created repository %s on GitHub\n", greenCheck, ghrepo.FullName(repo)) - } else { - fmt.Fprintln(out, repo.URL) - } - - remoteURL := formatRemoteURL(cmd, repo) - - if projectDirErr == nil { - _, err = git.AddRemote("origin", remoteURL) - if err != nil { - return err - } - if isTTY { - fmt.Fprintf(out, "%s Added remote %s\n", greenCheck, remoteURL) - } - } else if isTTY { - doSetup := false - err := Confirm(fmt.Sprintf("Create a local project directory for %s?", ghrepo.FullName(repo)), &doSetup) - if err != nil { - return err - } - - if doSetup { - path := repo.Name - - gitInit := git.GitCommand("init", path) - gitInit.Stdout = os.Stdout - gitInit.Stderr = os.Stderr - err = run.PrepareCmd(gitInit).Run() - if err != nil { - return err - } - gitRemoteAdd := git.GitCommand("-C", path, "remote", "add", "origin", remoteURL) - gitRemoteAdd.Stdout = os.Stdout - gitRemoteAdd.Stderr = os.Stderr - err = run.PrepareCmd(gitRemoteAdd).Run() - if err != nil { - return err - } - - fmt.Fprintf(out, "%s Initialized repository in './%s/'\n", greenCheck, path) - } - } - - return nil -} - var Since = func(t time.Time) time.Duration { return time.Since(t) } @@ -371,7 +206,7 @@ func repoFork(cmd *cobra.Command, args []string) error { remoteDesired := remotePref == "true" if remotePref == "prompt" { - err = Confirm("Would you like to add a remote for the fork?", &remoteDesired) + err = prompt.Confirm("Would you like to add a remote for the fork?", &remoteDesired) if err != nil { return fmt.Errorf("failed to prompt: %w", err) } @@ -409,7 +244,7 @@ func repoFork(cmd *cobra.Command, args []string) error { } else { cloneDesired := clonePref == "true" if clonePref == "prompt" { - err = Confirm("Would you like to clone the fork?", &cloneDesired) + err = prompt.Confirm("Would you like to clone the fork?", &cloneDesired) if err != nil { return fmt.Errorf("failed to prompt: %w", err) } @@ -447,14 +282,6 @@ func repoFork(cmd *cobra.Command, args []string) error { return nil } -var Confirm = func(prompt string, result *bool) error { - p := &survey.Confirm{ - Message: prompt, - Default: true, - } - return survey.AskOne(p, result) -} - func repoCredits(cmd *cobra.Command, args []string) error { return credits(cmd, args) } diff --git a/command/repo_test.go b/command/repo_test.go index 50375dbcd..18e6ae304 100644 --- a/command/repo_test.go +++ b/command/repo_test.go @@ -1,8 +1,6 @@ package command import ( - "encoding/json" - "io/ioutil" "os/exec" "regexp" "strings" @@ -13,7 +11,7 @@ import ( "github.com/cli/cli/context" "github.com/cli/cli/internal/run" - "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/test" "github.com/cli/cli/utils" "github.com/stretchr/testify/assert" @@ -296,12 +294,12 @@ func TestRepoFork_outside_survey_yes(t *testing.T) { cs.Stub("") // git clone cs.Stub("") // git remote add - oldConfirm := Confirm - Confirm = func(_ string, result *bool) error { + oldConfirm := prompt.Confirm + prompt.Confirm = func(_ string, result *bool) error { *result = true return nil } - defer func() { Confirm = oldConfirm }() + defer func() { prompt.Confirm = oldConfirm }() output, err := RunCommand("repo fork OWNER/REPO") if err != nil { @@ -331,12 +329,12 @@ func TestRepoFork_outside_survey_no(t *testing.T) { return &test.OutputStub{} })() - oldConfirm := Confirm - Confirm = func(_ string, result *bool) error { + oldConfirm := prompt.Confirm + prompt.Confirm = func(_ string, result *bool) error { *result = false return nil } - defer func() { Confirm = oldConfirm }() + defer func() { prompt.Confirm = oldConfirm }() output, err := RunCommand("repo fork OWNER/REPO") if err != nil { @@ -369,12 +367,12 @@ func TestRepoFork_in_parent_survey_yes(t *testing.T) { return &test.OutputStub{} })() - oldConfirm := Confirm - Confirm = func(_ string, result *bool) error { + oldConfirm := prompt.Confirm + prompt.Confirm = func(_ string, result *bool) error { *result = true return nil } - defer func() { Confirm = oldConfirm }() + defer func() { prompt.Confirm = oldConfirm }() output, err := RunCommand("repo fork") if err != nil { @@ -413,12 +411,12 @@ func TestRepoFork_in_parent_survey_no(t *testing.T) { return &test.OutputStub{} })() - oldConfirm := Confirm - Confirm = func(_ string, result *bool) error { + oldConfirm := prompt.Confirm + prompt.Confirm = func(_ string, result *bool) error { *result = false return nil } - defer func() { Confirm = oldConfirm }() + defer func() { prompt.Confirm = oldConfirm }() output, err := RunCommand("repo fork") if err != nil { @@ -435,210 +433,3 @@ func TestRepoFork_in_parent_survey_no(t *testing.T) { return } } - -func TestRepoCreate(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - initContext = func() context.Context { - return ctx - } - - http := initFakeHTTP() - http.Register( - httpmock.GraphQL(`mutation RepositoryCreate\b`), - httpmock.StringResponse(` - { "data": { "createRepository": { - "repository": { - "id": "REPOID", - "url": "https://github.com/OWNER/REPO", - "name": "REPO", - "owner": { - "login": "OWNER" - } - } - } } }`)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("repo create REPO") - if err != nil { - t.Errorf("error running command `repo create`: %v", err) - } - - eq(t, output.String(), "https://github.com/OWNER/REPO\n") - eq(t, output.Stderr(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - eq(t, strings.Join(seenCmd.Args, " "), "git remote add -f origin https://github.com/OWNER/REPO.git") - - var reqBody struct { - Query string - Variables struct { - Input map[string]interface{} - } - } - - if len(http.Requests) != 1 { - t.Fatalf("expected 1 HTTP request, got %d", len(http.Requests)) - } - - bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) - _ = json.Unmarshal(bodyBytes, &reqBody) - if repoName := reqBody.Variables.Input["name"].(string); repoName != "REPO" { - t.Errorf("expected %q, got %q", "REPO", repoName) - } - if repoVisibility := reqBody.Variables.Input["visibility"].(string); repoVisibility != "PRIVATE" { - t.Errorf("expected %q, got %q", "PRIVATE", repoVisibility) - } - if _, ownerSet := reqBody.Variables.Input["ownerId"]; ownerSet { - t.Error("expected ownerId not to be set") - } -} - -func TestRepoCreate_org(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - initContext = func() context.Context { - return ctx - } - - http := initFakeHTTP() - http.Register( - httpmock.REST("GET", "users/ORG"), - httpmock.StringResponse(` - { "node_id": "ORGID" - }`)) - http.Register( - httpmock.GraphQL(`mutation RepositoryCreate\b`), - httpmock.StringResponse(` - { "data": { "createRepository": { - "repository": { - "id": "REPOID", - "url": "https://github.com/ORG/REPO", - "name": "REPO", - "owner": { - "login": "ORG" - } - } - } } }`)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("repo create ORG/REPO") - if err != nil { - t.Errorf("error running command `repo create`: %v", err) - } - - eq(t, output.String(), "https://github.com/ORG/REPO\n") - eq(t, output.Stderr(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - eq(t, strings.Join(seenCmd.Args, " "), "git remote add -f origin https://github.com/ORG/REPO.git") - - var reqBody struct { - Query string - Variables struct { - Input map[string]interface{} - } - } - - if len(http.Requests) != 2 { - t.Fatalf("expected 2 HTTP requests, got %d", len(http.Requests)) - } - - eq(t, http.Requests[0].URL.Path, "/users/ORG") - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - _ = json.Unmarshal(bodyBytes, &reqBody) - if orgID := reqBody.Variables.Input["ownerId"].(string); orgID != "ORGID" { - t.Errorf("expected %q, got %q", "ORGID", orgID) - } - if _, teamSet := reqBody.Variables.Input["teamId"]; teamSet { - t.Error("expected teamId not to be set") - } -} - -func TestRepoCreate_orgWithTeam(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - initContext = func() context.Context { - return ctx - } - - http := initFakeHTTP() - http.Register( - httpmock.REST("GET", "orgs/ORG/teams/monkeys"), - httpmock.StringResponse(` - { "node_id": "TEAMID", - "organization": { "node_id": "ORGID" } - }`)) - http.Register( - httpmock.GraphQL(`mutation RepositoryCreate\b`), - httpmock.StringResponse(` - { "data": { "createRepository": { - "repository": { - "id": "REPOID", - "url": "https://github.com/ORG/REPO", - "name": "REPO", - "owner": { - "login": "ORG" - } - } - } } }`)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("repo create ORG/REPO --team monkeys") - if err != nil { - t.Errorf("error running command `repo create`: %v", err) - } - - eq(t, output.String(), "https://github.com/ORG/REPO\n") - eq(t, output.Stderr(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - eq(t, strings.Join(seenCmd.Args, " "), "git remote add -f origin https://github.com/ORG/REPO.git") - - var reqBody struct { - Query string - Variables struct { - Input map[string]interface{} - } - } - - if len(http.Requests) != 2 { - t.Fatalf("expected 2 HTTP requests, got %d", len(http.Requests)) - } - - eq(t, http.Requests[0].URL.Path, "/orgs/ORG/teams/monkeys") - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - _ = json.Unmarshal(bodyBytes, &reqBody) - if orgID := reqBody.Variables.Input["ownerId"].(string); orgID != "ORGID" { - t.Errorf("expected %q, got %q", "ORGID", orgID) - } - if teamID := reqBody.Variables.Input["teamId"].(string); teamID != "TEAMID" { - t.Errorf("expected %q, got %q", "TEAMID", teamID) - } -} diff --git a/command/root.go b/command/root.go index fdd1c8d11..046946797 100644 --- a/command/root.go +++ b/command/root.go @@ -22,6 +22,7 @@ import ( apiCmd "github.com/cli/cli/pkg/cmd/api" gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create" repoCloneCmd "github.com/cli/cli/pkg/cmd/repo/clone" + repoCreateCmd "github.com/cli/cli/pkg/cmd/repo/create" repoViewCmd "github.com/cli/cli/pkg/cmd/repo/view" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -148,6 +149,7 @@ func init() { RootCmd.AddCommand(repoCmd) repoCmd.AddCommand(repoViewCmd.NewCmdView(&repoResolvingCmdFactory, nil)) repoCmd.AddCommand(repoCloneCmd.NewCmdClone(cmdFactory, nil)) + repoCmd.AddCommand(repoCreateCmd.NewCmdCreate(cmdFactory, nil)) } // RootCmd is the entry point of command-line execution diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go index 0ceb17050..670573a87 100644 --- a/pkg/cmd/repo/clone/clone_test.go +++ b/pkg/cmd/repo/clone/clone_test.go @@ -1,7 +1,6 @@ package clone import ( - "bytes" "net/http" "strings" "testing" @@ -15,20 +14,7 @@ import ( "github.com/stretchr/testify/assert" ) -// TODO copypasta from command package -type cmdOut struct { - outBuf, errBuf *bytes.Buffer -} - -func (c cmdOut) String() string { - return c.outBuf.String() -} - -func (c cmdOut) Stderr() string { - return c.errBuf.String() -} - -func runCloneCommand(httpClient *http.Client, cli string) (*cmdOut, error) { +func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) { io, stdin, stdout, stderr := iostreams.Test() fac := &cmdutil.Factory{ IOStreams: io, @@ -59,7 +45,7 @@ func runCloneCommand(httpClient *http.Client, cli string) (*cmdOut, error) { return nil, err } - return &cmdOut{stdout, stderr}, nil + return &test.CmdOut{stdout, stderr}, nil } func Test_RepoClone(t *testing.T) { diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go new file mode 100644 index 000000000..6d86c6388 --- /dev/null +++ b/pkg/cmd/repo/create/create.go @@ -0,0 +1,190 @@ +package create + +import ( + "fmt" + "net/http" + "path" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type CreateOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + + Name string + Description string + Homepage string + Team string + EnableIssues bool + EnableWiki bool + Public bool +} + +func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "create []", + Short: "Create a new repository", + Long: `Create a new GitHub repository.`, + Args: cobra.MaximumNArgs(1), + Example: heredoc.Doc(` + # create a repository under your account using the current directory name + $ gh repo create + + # create a repository with a specific name + $ gh repo create my-project + + # create a repository in an organization + $ gh repo create cli/my-project + `), + Annotations: map[string]string{"help:arguments": `A repository can be supplied as an argument in any of the following formats: +- +- by URL, e.g. "https://github.com/OWNER/REPO"`}, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.Name = args[0] + } + + if runF != nil { + return runF(opts) + } + + return createRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Description, "description", "d", "", "Description of repository") + cmd.Flags().StringVarP(&opts.Homepage, "homepage", "h", "", "Repository home page URL") + cmd.Flags().StringVarP(&opts.Team, "team", "t", "", "The name of the organization team to be granted access") + cmd.Flags().BoolVar(&opts.EnableIssues, "enable-issues", true, "Enable issues in the new repository") + cmd.Flags().BoolVar(&opts.EnableWiki, "enable-wiki", true, "Enable wiki in the new repository") + cmd.Flags().BoolVar(&opts.Public, "public", false, "Make the new repository public (default: private)") + + return cmd +} + +func createRun(opts *CreateOptions) error { + projectDir, projectDirErr := git.ToplevelDir() + + orgName := "" + name := opts.Name + + if name != "" { + if strings.Contains(name, "/") { + newRepo, err := ghrepo.FromFullName(name) + if err != nil { + return fmt.Errorf("argument error: %w", err) + } + orgName = newRepo.RepoOwner() + name = newRepo.RepoName() + } + } else { + if projectDirErr != nil { + return projectDirErr + } + name = path.Base(projectDir) + } + + visibility := "PRIVATE" + if opts.Public { + visibility = "PUBLIC" + } + + input := repoCreateInput{ + Name: name, + Visibility: visibility, + OwnerID: orgName, + TeamID: opts.Team, + Description: opts.Description, + HomepageURL: opts.Homepage, + HasIssuesEnabled: opts.EnableIssues, + HasWikiEnabled: opts.EnableWiki, + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + repo, err := repoCreate(httpClient, input) + if err != nil { + return err + } + + stderr := opts.IO.ErrOut + stdout := opts.IO.Out + greenCheck := utils.Green("✓") + isTTY := opts.IO.IsStdoutTTY() + + if isTTY { + fmt.Fprintf(stderr, "%s Created repository %s on GitHub\n", greenCheck, ghrepo.FullName(repo)) + } else { + fmt.Fprintln(stdout, repo.URL) + } + + // TODO This is overly wordy and I'd like to streamline this. + cfg, err := opts.Config() + if err != nil { + return err + } + protocol, err := cfg.Get("", "git_protocol") + if err != nil { + return err + } + remoteURL := ghrepo.FormatRemoteURL(repo, protocol) + + if projectDirErr == nil { + _, err = git.AddRemote("origin", remoteURL) + if err != nil { + return err + } + if isTTY { + fmt.Fprintf(stderr, "%s Added remote %s\n", greenCheck, remoteURL) + } + } else if isTTY { + doSetup := false + err := prompt.Confirm(fmt.Sprintf("Create a local project directory for %s?", ghrepo.FullName(repo)), &doSetup) + if err != nil { + return err + } + + if doSetup { + path := repo.Name + + gitInit := git.GitCommand("init", path) + gitInit.Stdout = stdout + gitInit.Stderr = stderr + err = run.PrepareCmd(gitInit).Run() + if err != nil { + return err + } + gitRemoteAdd := git.GitCommand("-C", path, "remote", "add", "origin", remoteURL) + gitRemoteAdd.Stdout = stdout + gitRemoteAdd.Stderr = stderr + err = run.PrepareCmd(gitRemoteAdd).Run() + if err != nil { + return err + } + + fmt.Fprintf(stderr, "%s Initialized repository in './%s/'\n", greenCheck, path) + } + } + return nil +} diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go new file mode 100644 index 000000000..8bca0c6a9 --- /dev/null +++ b/pkg/cmd/repo/create/create_test.go @@ -0,0 +1,256 @@ +package create + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os/exec" + "strings" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func runCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) { + io, stdin, stdout, stderr := iostreams.Test() + fac := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return httpClient, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + } + + fmt.Printf("DEBUG %#v\n", "HI") + cmd := NewCmdCreate(fac, nil) + fmt.Printf("DEBUG %#v\n", "BYE") + + // TODO STUPID HACK + // cobra aggressively adds help to all commands. since we're not running through the root command + // (which manages help when running for real) and since create has a '-h' flag (for homepage), + // cobra blows up when it tried to add a help flag and -h is already in use. This hack adds a + // dummy help flag with a random shorthand to get around this. + cmd.Flags().BoolP("help", "x", false, "") + + argv, err := shlex.Split(cli) + cmd.SetArgs(argv) + + cmd.SetIn(stdin) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + + if err != nil { + panic(err) + } + + _, err = cmd.ExecuteC() + + if err != nil { + return nil, err + } + + return &test.CmdOut{stdout, stderr}, nil +} + +func TestRepoCreate(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`mutation RepositoryCreate\b`), + httpmock.StringResponse(` + { "data": { "createRepository": { + "repository": { + "id": "REPOID", + "url": "https://github.com/OWNER/REPO", + "name": "REPO", + "owner": { + "login": "OWNER" + } + } + } } }`)) + + httpClient := &http.Client{Transport: reg} + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(httpClient, "REPO") + if err != nil { + t.Errorf("error running command `repo create`: %v", err) + } + + assert.Equal(t, "https://github.com/OWNER/REPO\n", output.String()) + assert.Equal(t, "", output.Stderr()) + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + assert.Equal(t, "git remote add -f origin https://github.com/OWNER/REPO.git", strings.Join(seenCmd.Args, " ")) + + var reqBody struct { + Query string + Variables struct { + Input map[string]interface{} + } + } + + if len(reg.Requests) != 1 { + t.Fatalf("expected 1 HTTP request, got %d", len(reg.Requests)) + } + + bodyBytes, _ := ioutil.ReadAll(reg.Requests[0].Body) + _ = json.Unmarshal(bodyBytes, &reqBody) + if repoName := reqBody.Variables.Input["name"].(string); repoName != "REPO" { + t.Errorf("expected %q, got %q", "REPO", repoName) + } + if repoVisibility := reqBody.Variables.Input["visibility"].(string); repoVisibility != "PRIVATE" { + t.Errorf("expected %q, got %q", "PRIVATE", repoVisibility) + } + if _, ownerSet := reqBody.Variables.Input["ownerId"]; ownerSet { + t.Error("expected ownerId not to be set") + } +} + +func TestRepoCreate_org(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "users/ORG"), + httpmock.StringResponse(` + { "node_id": "ORGID" + }`)) + reg.Register( + httpmock.GraphQL(`mutation RepositoryCreate\b`), + httpmock.StringResponse(` + { "data": { "createRepository": { + "repository": { + "id": "REPOID", + "url": "https://github.com/ORG/REPO", + "name": "REPO", + "owner": { + "login": "ORG" + } + } + } } }`)) + httpClient := &http.Client{Transport: reg} + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(httpClient, "ORG/REPO") + if err != nil { + t.Errorf("error running command `repo create`: %v", err) + } + + assert.Equal(t, "https://github.com/ORG/REPO\n", output.String()) + assert.Equal(t, "", output.Stderr()) + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + assert.Equal(t, "git remote add -f origin https://github.com/ORG/REPO.git", strings.Join(seenCmd.Args, " ")) + + var reqBody struct { + Query string + Variables struct { + Input map[string]interface{} + } + } + + if len(reg.Requests) != 2 { + t.Fatalf("expected 2 HTTP requests, got %d", len(reg.Requests)) + } + + assert.Equal(t, "/users/ORG", reg.Requests[0].URL.Path) + + bodyBytes, _ := ioutil.ReadAll(reg.Requests[1].Body) + _ = json.Unmarshal(bodyBytes, &reqBody) + if orgID := reqBody.Variables.Input["ownerId"].(string); orgID != "ORGID" { + t.Errorf("expected %q, got %q", "ORGID", orgID) + } + if _, teamSet := reqBody.Variables.Input["teamId"]; teamSet { + t.Error("expected teamId not to be set") + } +} + +func TestRepoCreate_orgWithTeam(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "orgs/ORG/teams/monkeys"), + httpmock.StringResponse(` + { "node_id": "TEAMID", + "organization": { "node_id": "ORGID" } + }`)) + reg.Register( + httpmock.GraphQL(`mutation RepositoryCreate\b`), + httpmock.StringResponse(` + { "data": { "createRepository": { + "repository": { + "id": "REPOID", + "url": "https://github.com/ORG/REPO", + "name": "REPO", + "owner": { + "login": "ORG" + } + } + } } }`)) + httpClient := &http.Client{Transport: reg} + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(httpClient, "ORG/REPO --team monkeys") + if err != nil { + t.Errorf("error running command `repo create`: %v", err) + } + + assert.Equal(t, "https://github.com/ORG/REPO\n", output.String()) + assert.Equal(t, "", output.Stderr()) + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + assert.Equal(t, "git remote add -f origin https://github.com/ORG/REPO.git", strings.Join(seenCmd.Args, " ")) + + var reqBody struct { + Query string + Variables struct { + Input map[string]interface{} + } + } + + if len(reg.Requests) != 2 { + t.Fatalf("expected 2 HTTP requests, got %d", len(reg.Requests)) + } + + assert.Equal(t, "/orgs/ORG/teams/monkeys", reg.Requests[0].URL.Path) + + bodyBytes, _ := ioutil.ReadAll(reg.Requests[1].Body) + _ = json.Unmarshal(bodyBytes, &reqBody) + if orgID := reqBody.Variables.Input["ownerId"].(string); orgID != "ORGID" { + t.Errorf("expected %q, got %q", "ORGID", orgID) + } + if teamID := reqBody.Variables.Input["teamId"].(string); teamID != "TEAMID" { + t.Errorf("expected %q, got %q", "TEAMID", teamID) + } +} diff --git a/pkg/cmd/repo/create/http.go b/pkg/cmd/repo/create/http.go new file mode 100644 index 000000000..3d79ea99d --- /dev/null +++ b/pkg/cmd/repo/create/http.go @@ -0,0 +1,92 @@ +package create + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" +) + +// repoCreateInput represents input parameters for repoCreate +type repoCreateInput struct { + Name string `json:"name"` + Visibility string `json:"visibility"` + HomepageURL string `json:"homepageUrl,omitempty"` + Description string `json:"description,omitempty"` + + OwnerID string `json:"ownerId,omitempty"` + TeamID string `json:"teamId,omitempty"` + + HasIssuesEnabled bool `json:"hasIssuesEnabled"` + HasWikiEnabled bool `json:"hasWikiEnabled"` +} + +// repoCreate creates a new GitHub repository +func repoCreate(client *http.Client, input repoCreateInput) (*api.Repository, error) { + apiClient := api.NewClientFromHTTP(client) + + var response struct { + CreateRepository struct { + Repository api.Repository + } + } + + if input.TeamID != "" { + orgID, teamID, err := resolveOrganizationTeam(apiClient, input.OwnerID, input.TeamID) + if err != nil { + return nil, err + } + input.TeamID = teamID + input.OwnerID = orgID + } else if input.OwnerID != "" { + orgID, err := resolveOrganization(apiClient, input.OwnerID) + if err != nil { + return nil, err + } + input.OwnerID = orgID + } + + variables := map[string]interface{}{ + "input": input, + } + + err := apiClient.GraphQL(` + mutation RepositoryCreate($input: CreateRepositoryInput!) { + createRepository(input: $input) { + repository { + id + name + owner { login } + url + } + } + } + `, variables, &response) + if err != nil { + return nil, err + } + + // FIXME: support Enterprise hosts + return api.InitRepoHostname(&response.CreateRepository.Repository, "github.com"), nil +} + +// using API v3 here because the equivalent in GraphQL needs `read:org` scope +func resolveOrganization(client *api.Client, orgName string) (string, error) { + var response struct { + NodeID string `json:"node_id"` + } + err := client.REST("GET", fmt.Sprintf("users/%s", orgName), nil, &response) + return response.NodeID, err +} + +// using API v3 here because the equivalent in GraphQL needs `read:org` scope +func resolveOrganizationTeam(client *api.Client, orgName, teamSlug string) (string, string, error) { + var response struct { + NodeID string `json:"node_id"` + Organization struct { + NodeID string `json:"node_id"` + } + } + err := client.REST("GET", fmt.Sprintf("orgs/%s/teams/%s", orgName, teamSlug), nil, &response) + return response.Organization.NodeID, response.NodeID, err +} diff --git a/pkg/cmd/repo/create/http_test.go b/pkg/cmd/repo/create/http_test.go new file mode 100644 index 000000000..fb0fc6364 --- /dev/null +++ b/pkg/cmd/repo/create/http_test.go @@ -0,0 +1,48 @@ +package create + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "testing" + + "github.com/cli/cli/api" + "github.com/cli/cli/pkg/httpmock" +) + +func Test_RepoCreate(t *testing.T) { + reg := &httpmock.Registry{} + httpClient := api.NewHTTPClient(api.ReplaceTripper(reg)) + + reg.StubResponse(200, bytes.NewBufferString(`{}`)) + + input := repoCreateInput{ + Description: "roasted chesnuts", + HomepageURL: "http://example.com", + } + + _, err := repoCreate(httpClient, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(reg.Requests) != 1 { + t.Fatalf("expected 1 HTTP request, seen %d", len(reg.Requests)) + } + + var reqBody struct { + Query string + Variables struct { + Input map[string]interface{} + } + } + + bodyBytes, _ := ioutil.ReadAll(reg.Requests[0].Body) + _ = json.Unmarshal(bodyBytes, &reqBody) + if description := reqBody.Variables.Input["description"].(string); description != "roasted chesnuts" { + t.Errorf("expected description to be %q, got %q", "roasted chesnuts", description) + } + if homepage := reqBody.Variables.Input["homepageUrl"].(string); homepage != "http://example.com" { + t.Errorf("expected homepageUrl to be %q, got %q", "http://example.com", homepage) + } +} diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go new file mode 100644 index 000000000..03bb194fa --- /dev/null +++ b/pkg/prompt/prompt.go @@ -0,0 +1,11 @@ +package prompt + +import "github.com/AlecAivazis/survey/v2" + +var Confirm = func(prompt string, result *bool) error { + p := &survey.Confirm{ + Message: prompt, + Default: true, + } + return survey.AskOne(p, result) +} diff --git a/test/helpers.go b/test/helpers.go index c2d4e2f37..b10e87a85 100644 --- a/test/helpers.go +++ b/test/helpers.go @@ -11,6 +11,19 @@ import ( "github.com/cli/cli/internal/run" ) +// TODO copypasta from command package +type CmdOut struct { + OutBuf, ErrBuf *bytes.Buffer +} + +func (c CmdOut) String() string { + return c.OutBuf.String() +} + +func (c CmdOut) Stderr() string { + return c.ErrBuf.String() +} + // OutputStub implements a simple utils.Runnable type OutputStub struct { Out []byte