Create repositories from templates in interactive mode (#7769)

This commit is contained in:
Jun Nishimura 2023-10-13 00:53:05 +09:00 committed by GitHub
parent 776fdf5bb2
commit 7d6fba0d7d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 346 additions and 5 deletions

View file

@ -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 {

View file

@ -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{

View file

@ -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": ""
}
}
}
}

View file

@ -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