diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index f56a59ce2..4a94c05a2 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -248,20 +248,26 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co } func createRun(opts *CreateOptions) error { - fromScratch := opts.Source == "" - if opts.Interactive { - selected, err := opts.Prompter.Select("What would you like to do?", "", []string{ + answer, err := opts.Prompter.Select("What would you like to do?", "", []string{ "Create a new repository on GitHub from scratch", + "Create a new repository on GitHub from a template repository", "Push an existing local repository to GitHub", }) if err != nil { return err } - fromScratch = selected == 0 + switch answer { + case 0: + return createFromScratch(opts) + case 1: + return createFromTemplate(opts) + case 2: + return createFromLocal(opts) + } } - if fromScratch { + if opts.Source == "" { return createFromScratch(opts) } return createFromLocal(opts) @@ -404,6 +410,102 @@ func createFromScratch(opts *CreateOptions) error { } else if err := cloneWithRetry(opts, remoteURL, templateRepoMainBranch); err != nil { return err } + + } + + return nil +} + +// create new repo on remote host from template repo +func createFromTemplate(opts *CreateOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + host, _ := cfg.Authentication().DefaultHost() + + opts.Name, opts.Description, opts.Visibility, err = interactiveRepoInfo(httpClient, host, opts.Prompter, "") + if err != nil { + return err + } + + if !strings.Contains(opts.Name, "/") { + username, _, err := userAndOrgs(httpClient, host) + if err != nil { + return err + } + opts.Name = fmt.Sprintf("%s/%s", username, opts.Name) + } + repoToCreate, err := ghrepo.FromFullName(opts.Name) + if err != nil { + return fmt.Errorf("argument error: %w", err) + } + + templateRepo, err := interactiveRepoTemplate(httpClient, host, repoToCreate.RepoOwner(), opts.Prompter) + if err != nil { + return err + } + input := repoCreateInput{ + Name: repoToCreate.RepoName(), + Visibility: opts.Visibility, + OwnerLogin: repoToCreate.RepoOwner(), + TeamSlug: opts.Team, + Description: opts.Description, + HomepageURL: opts.Homepage, + HasIssuesEnabled: !opts.DisableIssues, + HasWikiEnabled: !opts.DisableWiki, + GitIgnoreTemplate: opts.GitIgnoreTemplate, + LicenseTemplate: opts.LicenseTemplate, + IncludeAllBranches: opts.IncludeAllBranches, + InitReadme: opts.AddReadme, + TemplateRepositoryID: templateRepo.ID, + } + templateRepoMainBranch := templateRepo.DefaultBranchRef.Name + + targetRepo := shared.NormalizeRepoName(opts.Name) + if idx := strings.IndexRune(opts.Name, '/'); idx > 0 { + targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:]) + } + confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true) + if err != nil { + return err + } else if !confirmed { + return cmdutil.CancelError + } + + repo, err := repoCreate(httpClient, repoToCreate.RepoHost(), input) + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, + "%s Created repository %s on GitHub\n", + cs.SuccessIconWithColor(cs.Green), + ghrepo.FullName(repo)) + + opts.Clone, err = opts.Prompter.Confirm("Clone the new repository locally?", true) + if err != nil { + return err + } + + if opts.Clone { + protocol, err := cfg.GetOrDefault(repo.RepoHost(), "git_protocol") + if err != nil { + return err + } + + remoteURL := ghrepo.FormatRemoteURL(repo, protocol) + + if err := cloneWithRetry(opts, remoteURL, templateRepoMainBranch); err != nil { + return err + } } return nil @@ -678,6 +780,27 @@ func localInit(gitClient *git.Client, remoteURL, path string) error { return nil } +func interactiveRepoTemplate(client *http.Client, hostname, owner string, prompter iprompter) (*api.Repository, error) { + templateRepos, err := listTemplateRepositories(client, hostname, owner) + if err != nil { + return nil, err + } + if len(templateRepos) == 0 { + return nil, fmt.Errorf("%s has no template repositories", owner) + } + + var templates []string + for _, repo := range templateRepos { + templates = append(templates, repo.Name) + } + + selected, err := prompter.Select("Choose a template repository", "", templates) + if err != nil { + return nil, err + } + return &templateRepos[selected], nil +} + func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompter) (string, error) { confirmed, err := prompter.Confirm("Would you like to add a .gitignore?", false) if err != nil { diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index b0d21460e..58e897628 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -510,6 +510,131 @@ func Test_createRun(t *testing.T) { }, wantStdout: "āœ“ Created repository OWNER/REPO on GitHub\nāœ“ Added remote https://github.com/OWNER/REPO.git\nāœ“ Pushed commits to https://github.com/OWNER/REPO.git\n", }, + { + name: "interactive create from a template repository", + opts: &CreateOptions{Interactive: true}, + tty: true, + promptStubs: func(p *prompter.PrompterMock) { + p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) { + switch message { + case `This will create "OWNER/REPO" as a private repository on GitHub. Continue?`: + return defaultValue, nil + case "Clone the new repository locally?": + return defaultValue, nil + default: + return false, fmt.Errorf("unexpected confirm prompt: %s", message) + } + } + p.InputFunc = func(message, defaultValue string) (string, error) { + switch message { + case "Repository name": + return "REPO", nil + case "Description": + return "my new repo", nil + default: + return "", fmt.Errorf("unexpected input prompt: %s", message) + } + } + p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { + switch message { + case "Repository owner": + return prompter.IndexFor(options, "OWNER") + case "Choose a template repository": + return prompter.IndexFor(options, "REPO") + case "What would you like to do?": + return prompter.IndexFor(options, "Create a new repository on GitHub from a template repository") + case "Visibility": + return prompter.IndexFor(options, "Private") + default: + return 0, fmt.Errorf("unexpected select prompt: %s", message) + } + } + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"OWNER","organizations":{"nodes": []}}}}`)) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"OWNER","organizations":{"nodes": []}}}}`)) + reg.Register( + httpmock.GraphQL(`query RepositoryList\b`), + httpmock.FileResponse("./fixtures/repoTempList.json")) + reg.Register( + httpmock.REST("GET", "users/OWNER"), + httpmock.StringResponse(`{"login":"OWNER","type":"User"}`)) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"id":"OWNER"}}}`)) + reg.Register( + httpmock.GraphQL(`mutation CloneTemplateRepository\b`), + httpmock.StringResponse(` + { + "data": { + "cloneTemplateRepository": { + "repository": { + "id": "REPOID", + "name": "REPO", + "owner": {"login":"OWNER"}, + "url": "https://github.com/OWNER/REPO" + } + } + } + }`)) + }, + execStubs: func(cs *run.CommandStubber) { + cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 0, "") + }, + wantStdout: "āœ“ Created repository OWNER/REPO on GitHub\n", + }, + { + name: "interactive create from template repo but there are no template repos", + opts: &CreateOptions{Interactive: true}, + tty: true, + promptStubs: func(p *prompter.PrompterMock) { + p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) { + switch message { + default: + return false, fmt.Errorf("unexpected confirm prompt: %s", message) + } + } + p.InputFunc = func(message, defaultValue string) (string, error) { + switch message { + case "Repository name": + return "REPO", nil + case "Description": + return "my new repo", nil + default: + return "", fmt.Errorf("unexpected input prompt: %s", message) + } + } + p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { + switch message { + case "What would you like to do?": + return prompter.IndexFor(options, "Create a new repository on GitHub from a template repository") + case "Visibility": + return prompter.IndexFor(options, "Private") + default: + return 0, fmt.Errorf("unexpected select prompt: %s", message) + } + } + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"OWNER","organizations":{"nodes": []}}}}`)) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"OWNER","organizations":{"nodes": []}}}}`)) + reg.Register( + httpmock.GraphQL(`query RepositoryList\b`), + httpmock.StringResponse(`{"data":{"repositoryOwner":{"login":"OWNER","repositories":{"nodes":[]},"totalCount":0,"pageInfo":{"hasNextPage":false,"endCursor":""}}}}`)) + }, + execStubs: func(cs *run.CommandStubber) {}, + wantStdout: "", + wantErr: true, + errMsg: "OWNER has no template repositories", + }, { name: "noninteractive create from scratch", opts: &CreateOptions{ diff --git a/pkg/cmd/repo/create/fixtures/repoTempList.json b/pkg/cmd/repo/create/fixtures/repoTempList.json new file mode 100644 index 000000000..9fbe46c51 --- /dev/null +++ b/pkg/cmd/repo/create/fixtures/repoTempList.json @@ -0,0 +1,25 @@ +{ + "data": { + "repositoryOwner": { + "login": "OWNER", + "repositories": { + "nodes": [ + { + "id": "REPOID", + "name": "REPO", + "isTemplate": true, + "pushedAt": "2021-02-19T06:34:58Z", + "defaultBranchRef": { + "name": "main" + } + } + ] + }, + "totalCount": 0, + "pageInfo": { + "hasNextPage": false, + "endCursor": "" + } + } + } +} \ No newline at end of file diff --git a/pkg/cmd/repo/create/http.go b/pkg/cmd/repo/create/http.go index 8142f8133..86024a254 100644 --- a/pkg/cmd/repo/create/http.go +++ b/pkg/cmd/repo/create/http.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/cli/cli/v2/api" + "github.com/shurcooL/githubv4" ) // repoCreateInput is input parameters for the repoCreate method @@ -264,6 +265,73 @@ func resolveOrganizationTeam(client *api.Client, hostname, orgName, teamSlug str return &response, err } +func listTemplateRepositories(client *http.Client, hostname, owner string) ([]api.Repository, error) { + ownerConnection := "repositoryOwner(login: $owner)" + + variables := map[string]interface{}{ + "perPage": githubv4.Int(100), + "owner": githubv4.String(owner), + } + inputs := []string{"$perPage:Int!", "$endCursor:String", "$owner:String!"} + + type result struct { + RepositoryOwner struct { + Login string + Repositories struct { + Nodes []api.Repository + TotalCount int + PageInfo struct { + HasNextPage bool + EndCursor string + } + } + } + } + + query := fmt.Sprintf(`query RepositoryList(%s) { + %s { + login + repositories(first: $perPage, after: $endCursor, ownerAffiliations: OWNER, orderBy: { field: PUSHED_AT, direction: DESC }) { + nodes{ + id + name + isTemplate + defaultBranchRef { + name + } + } + totalCount + pageInfo{hasNextPage,endCursor} + } + } + }`, strings.Join(inputs, ","), ownerConnection) + + apiClient := api.NewClientFromHTTP(client) + var templateRepositories []api.Repository + for { + var res result + err := apiClient.GraphQL(hostname, query, variables, &res) + if err != nil { + return nil, err + } + + owner := res.RepositoryOwner + + for _, repo := range owner.Repositories.Nodes { + if repo.IsTemplate { + templateRepositories = append(templateRepositories, repo) + } + } + + if !owner.Repositories.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(owner.Repositories.PageInfo.EndCursor) + } + + return templateRepositories, nil +} + // listGitIgnoreTemplates uses API v3 here because gitignore template isn't supported by GraphQL yet. func listGitIgnoreTemplates(httpClient *http.Client, hostname string) ([]string, error) { var gitIgnoreTemplates []string