Create repositories from templates in interactive mode (#7769)
This commit is contained in:
parent
776fdf5bb2
commit
7d6fba0d7d
4 changed files with 346 additions and 5 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
25
pkg/cmd/repo/create/fixtures/repoTempList.json
Normal file
25
pkg/cmd/repo/create/fixtures/repoTempList.json
Normal 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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue