diff --git a/api/queries_user.go b/api/queries_user.go index 3822f2090..cf0121a8b 100644 --- a/api/queries_user.go +++ b/api/queries_user.go @@ -1,5 +1,9 @@ package api +type Organization struct { + Login string +} + func CurrentLoginName(client *Client, hostname string) (string, error) { var query struct { Viewer struct { @@ -10,6 +14,26 @@ func CurrentLoginName(client *Client, hostname string) (string, error) { return query.Viewer.Login, err } +func CurrentLoginNameAndOrgs(client *Client, hostname string) (string, []string, error) { + var query struct { + Viewer struct { + Login string + Organizations struct { + Nodes []Organization + } `graphql:"organizations(first: 100)"` + } + } + err := client.Query(hostname, "UserCurrent", &query, nil) + if err != nil { + return "", nil, err + } + orgNames := []string{} + for _, org := range query.Viewer.Organizations.Nodes { + orgNames = append(orgNames, org.Login) + } + return query.Viewer.Login, orgNames, err +} + func CurrentUserID(client *Client, hostname string) (string, error) { var query struct { Viewer struct { diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 8e1ae0b94..085857dc1 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -7,6 +7,7 @@ import ( "net/http" "os/exec" "path/filepath" + "sort" "strings" "github.com/MakeNowJust/heredoc" @@ -273,7 +274,7 @@ func createFromScratch(opts *CreateOptions) error { host, _ := cfg.DefaultHost() if opts.Interactive { - opts.Name, opts.Description, opts.Visibility, err = interactiveRepoInfo(opts.Prompter, "") + opts.Name, opts.Description, opts.Visibility, err = interactiveRepoInfo(httpClient, host, opts.Prompter, "") if err != nil { return err } @@ -291,8 +292,8 @@ func createFromScratch(opts *CreateOptions) error { } targetRepo := shared.NormalizeRepoName(opts.Name) - if idx := strings.IndexRune(targetRepo, '/'); idx > 0 { - targetRepo = targetRepo[0:idx+1] + shared.NormalizeRepoName(targetRepo[idx+1:]) + 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 { @@ -467,7 +468,7 @@ func createFromLocal(opts *CreateOptions) error { } if opts.Interactive { - opts.Name, opts.Description, opts.Visibility, err = interactiveRepoInfo(opts.Prompter, filepath.Base(absPath)) + opts.Name, opts.Description, opts.Visibility, err = interactiveRepoInfo(httpClient, host, opts.Prompter, filepath.Base(absPath)) if err != nil { return err } @@ -699,11 +700,14 @@ func interactiveLicense(client *http.Client, hostname string, prompter iprompter } // name, description, and visibility -func interactiveRepoInfo(prompter iprompter, defaultName string) (string, string, string, error) { - name, err := prompter.Input("Repository name", defaultName) +func interactiveRepoInfo(client *http.Client, hostname string, prompter iprompter, defaultName string) (string, string, string, error) { + name, owner, err := interactiveRepoNameAndOwner(client, hostname, prompter, defaultName) if err != nil { return "", "", "", err } + if owner != "" { + name = fmt.Sprintf("%s/%s", owner, name) + } description, err := prompter.Input("Description", defaultName) if err != nil { @@ -719,6 +723,57 @@ func interactiveRepoInfo(prompter iprompter, defaultName string) (string, string return name, description, strings.ToUpper(visibilityOptions[selected]), nil } +func interactiveRepoNameAndOwner(client *http.Client, hostname string, prompter iprompter, defaultName string) (string, string, error) { + name, err := prompter.Input("Repository name", defaultName) + if err != nil { + return "", "", err + } + + name, owner, err := splitNameAndOwner(name) + if err != nil { + return "", "", err + } + if owner != "" { + // User supplied an explicit owner prefix. + return name, owner, nil + } + + username, orgs, err := userAndOrgs(client, hostname) + if err != nil { + return "", "", err + } + if len(orgs) == 0 { + // User doesn't belong to any orgs. + // Leave the owner blank to indicate a personal repo. + return name, "", nil + } + + owners := append(orgs, username) + sort.Strings(owners) + selected, err := prompter.Select("Repository owner", username, owners) + if err != nil { + return "", "", err + } + + owner = owners[selected] + if owner == username { + // Leave the owner blank to indicate a personal repo. + return name, "", nil + } + return name, owner, nil +} + +func splitNameAndOwner(name string) (string, string, error) { + if !strings.Contains(name, "/") { + return name, "", nil + } + repo, err := ghrepo.FromFullName(name) + if err != nil { + return "", "", fmt.Errorf("argument error: %w", err) + } + return repo.RepoName(), repo.RepoOwner(), nil +} + func cloneGitClient(c *git.Client) *git.Client { return &git.Client{ GhPath: c.GhPath, diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 636f447c1..4ba48990b 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -231,6 +231,9 @@ func Test_createRun(t *testing.T) { } }, httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"someuser","organizations":{"nodes": []}}}}`)) reg.Register( httpmock.REST("GET", "gitignore/templates"), httpmock.StringResponse(`["Actionscript","Android","AppceleratorTitanium","Autotools","Bancha","C","C++","Go"]`)) @@ -246,6 +249,75 @@ func Test_createRun(t *testing.T) { cs.Register(`git clone https://github.com/OWNER/REPO.git`, 0, "") }, }, + { + name: "interactive create from scratch but with prompted owner", + opts: &CreateOptions{Interactive: true}, + tty: true, + wantStdout: "✓ Created repository org1/REPO on GitHub\n", + promptStubs: func(p *prompter.PrompterMock) { + p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) { + switch message { + case "Would you like to add a README file?": + return false, nil + case "Would you like to add a .gitignore?": + return false, nil + case "Would you like to add a license?": + return false, nil + case `This will create "org1/REPO" as a private repository on GitHub. Continue?`: + return true, nil + case "Clone the new repository locally?": + return false, 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, "org1") + case "What would you like to do?": + return prompter.IndexFor(options, "Create a new repository on GitHub from scratch") + 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":"someuser","organizations":{"nodes": [{"login": "org1"}, {"login": "org2"}]}}}}`)) + reg.Register( + httpmock.REST("GET", "users/org1"), + httpmock.StringResponse(`{"login":"org1","type":"Organization"}`)) + reg.Register( + httpmock.GraphQL(`mutation RepositoryCreate\b`), + httpmock.StringResponse(` + { + "data": { + "createRepository": { + "repository": { + "id": "REPOID", + "name": "REPO", + "owner": {"login":"org1"}, + "url": "https://github.com/org1/REPO" + } + } + } + }`)) + }, + }, { name: "interactive create from scratch but cancel before submit", opts: &CreateOptions{Interactive: true}, @@ -286,6 +358,11 @@ func Test_createRun(t *testing.T) { } } }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"someuser","organizations":{"nodes": []}}}}`)) + }, wantStdout: "", wantErr: true, errMsg: "CancelError", @@ -327,6 +404,9 @@ func Test_createRun(t *testing.T) { } }, httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"someuser","organizations":{"nodes": []}}}}`)) reg.Register( httpmock.GraphQL(`mutation RepositoryCreate\b`), httpmock.StringResponse(` @@ -390,6 +470,9 @@ func Test_createRun(t *testing.T) { } }, httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"someuser","organizations":{"nodes": []}}}}`)) reg.Register( httpmock.GraphQL(`mutation RepositoryCreate\b`), httpmock.StringResponse(` diff --git a/pkg/cmd/repo/create/http.go b/pkg/cmd/repo/create/http.go index a5cc0ae28..dc8e708be 100644 --- a/pkg/cmd/repo/create/http.go +++ b/pkg/cmd/repo/create/http.go @@ -257,3 +257,9 @@ func listLicenseTemplates(httpClient *http.Client, hostname string) ([]api.Licen } return licenseTemplates, nil } + +// Returns the current username and any orgs that user is a member of. +func userAndOrgs(httpClient *http.Client, hostname string) (string, []string, error) { + client := api.NewClientFromHTTP(httpClient) + return api.CurrentLoginNameAndOrgs(client, hostname) +}