Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prompt for owner when interactively creating repos #6578

Merged
merged 2 commits into from Dec 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 24 additions & 0 deletions 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 {
Expand All @@ -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 {
Expand Down
67 changes: 61 additions & 6 deletions pkg/cmd/repo/create/create.go
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"os/exec"
"path/filepath"
"sort"
"strings"

"github.com/MakeNowJust/heredoc"
Expand Down Expand Up @@ -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
}
Expand All @@ -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:])
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discovered this bug while testing. The confirmation prompt was mangling target names w/ org prefixes.

}
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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
83 changes: 83 additions & 0 deletions pkg/cmd/repo/create/create_test.go
Expand Up @@ -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"]`))
Expand All @@ -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},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(`
Expand Down Expand Up @@ -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(`
Expand Down
6 changes: 6 additions & 0 deletions pkg/cmd/repo/create/http.go
Expand Up @@ -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)
}