* Remove `Internal` from `gh repo create` prompt when owner is not an org Closes #9464 Internal repos only exist for organizations, so when a user selects their personal namespace to create a repo using `gh repo create`, `Internal` should not be an option in the `Visibility` prompt. This should avoid the additional quirk where if the user selects `Internal` while creating a personal repo and then proceeds to add any of the README, .gitignore, or LICENSE files prompted for later, the repo will not error and instead get created as a `Public` repo. This has the potential for a user to unknowingly leak sensitive info intended to go into a non-public repo. * Refactor prompter with test coverage By extracting the repo visibility options to its own function, getRepoVisibilityOptions, we're able to directly test the behavior introduced with this change. This breaks the testing pattern established here thus far, but may be a good example of the direction we should explore for a future refactor. * Add failing tests to check for error with internal vis in non-org repos There is a bug in the code, currently, where a user repo can attempt to be created as with `--internal` visibility flag when that is not an option for non-org repos. It fails at the API level if the --gitignore, --license, or --add-readme flags are not included, but silently falls back to Public visibility if one of them is included. Because this bug already existed, this commit adds the tests to ensure that both scenarios described above are captured accurately by the test suite. A fix for the latter scenario will be coming in a future commit * Add Exclude to httpmock registry and implement in Test_repoCreate Upon attempting to make the previous commit pass, I realized that it was actually impossible to test what I wanted to. The tests in the previous commit were behaving as expected given the bug that commit described, but upon attempting to implement a solution I realized that the tests were only testing the mocks and not the code functionality itself. Essentially, when the code to fix the bug was implemented, the tests were failing because the mocks required to test the buggy behavior were no longer being called. To make the tests pass, I'd have to rewrite them, but were I to remove the bug fix, the tests would no longer fail. This pointed me to a gap in our httpmocks - the ability to intentionally exclude api calls. The behavior I'm trying to test, here, is that we stop executing when a certain condition is met, and therefore won't make any subsequent api calls down the chain. This implements the Exclude method on the registry such that it will fail if an excluded api pattern is called. I have refactored the tests in Test_repoCreate to use the Exclude mock for testing. * Add error if user attempts to create repo with --internal flag This was previously failing at either the API if no other flags were included or falling back to creating a public repo if one of gitignore, license, or add-readme were included. * Add testing for error messages in gh repo create In the previous commits, we've introduced a new error when a user tries to create an Internal repo not owned by an organization. This adds tests to verify that the error we are getting is, in fact, the one associated with this use case and not some random error.
368 lines
10 KiB
Go
368 lines
10 KiB
Go
package create
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/cli/cli/v2/api"
|
|
"github.com/shurcooL/githubv4"
|
|
)
|
|
|
|
// repoCreateInput is input parameters for the repoCreate method
|
|
type repoCreateInput struct {
|
|
Name string
|
|
HomepageURL string
|
|
Description string
|
|
Visibility string
|
|
OwnerLogin string
|
|
TeamSlug string
|
|
TemplateRepositoryID string
|
|
HasIssuesEnabled bool
|
|
HasWikiEnabled bool
|
|
GitIgnoreTemplate string
|
|
LicenseTemplate string
|
|
IncludeAllBranches bool
|
|
InitReadme bool
|
|
}
|
|
|
|
// createRepositoryInputV3 is the payload for the repo create REST API
|
|
type createRepositoryInputV3 struct {
|
|
Name string `json:"name"`
|
|
HomepageURL string `json:"homepage,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
IsPrivate bool `json:"private"`
|
|
Visibility string `json:"visibility,omitempty"`
|
|
TeamID uint64 `json:"team_id,omitempty"`
|
|
HasIssuesEnabled bool `json:"has_issues"`
|
|
HasWikiEnabled bool `json:"has_wiki"`
|
|
GitIgnoreTemplate string `json:"gitignore_template,omitempty"`
|
|
LicenseTemplate string `json:"license_template,omitempty"`
|
|
InitReadme bool `json:"auto_init,omitempty"`
|
|
}
|
|
|
|
// createRepositoryInput is the payload for the repo create GraphQL mutation
|
|
type createRepositoryInput struct {
|
|
Name string `json:"name"`
|
|
HomepageURL string `json:"homepageUrl,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Visibility string `json:"visibility"`
|
|
OwnerID string `json:"ownerId,omitempty"`
|
|
TeamID string `json:"teamId,omitempty"`
|
|
HasIssuesEnabled bool `json:"hasIssuesEnabled"`
|
|
HasWikiEnabled bool `json:"hasWikiEnabled"`
|
|
}
|
|
|
|
// cloneTemplateRepositoryInput is the payload for creating a repo from a template using GraphQL
|
|
type cloneTemplateRepositoryInput struct {
|
|
Name string `json:"name"`
|
|
Visibility string `json:"visibility"`
|
|
Description string `json:"description,omitempty"`
|
|
OwnerID string `json:"ownerId"`
|
|
RepositoryID string `json:"repositoryId"`
|
|
IncludeAllBranches bool `json:"includeAllBranches"`
|
|
}
|
|
|
|
type updateRepositoryInput struct {
|
|
RepositoryID string `json:"repositoryId"`
|
|
HasWikiEnabled bool `json:"hasWikiEnabled"`
|
|
HasIssuesEnabled bool `json:"hasIssuesEnabled"`
|
|
HomepageURL string `json:"homepageUrl,omitempty"`
|
|
}
|
|
|
|
// repoCreate creates a new GitHub repository
|
|
func repoCreate(client *http.Client, hostname string, input repoCreateInput) (*api.Repository, error) {
|
|
isOrg := false
|
|
var ownerID string
|
|
var teamID string
|
|
var teamIDv3 uint64
|
|
|
|
apiClient := api.NewClientFromHTTP(client)
|
|
|
|
if input.TeamSlug != "" {
|
|
team, err := resolveOrganizationTeam(apiClient, hostname, input.OwnerLogin, input.TeamSlug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
teamIDv3 = team.ID
|
|
teamID = team.NodeID
|
|
ownerID = team.Organization.NodeID
|
|
isOrg = true
|
|
} else if input.OwnerLogin != "" {
|
|
owner, err := resolveOwner(apiClient, hostname, input.OwnerLogin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ownerID = owner.NodeID
|
|
isOrg = owner.IsOrganization()
|
|
}
|
|
|
|
isInternal := strings.ToLower(input.Visibility) == "internal"
|
|
if isInternal && !isOrg {
|
|
return nil, fmt.Errorf("internal repositories can only be created within an organization")
|
|
}
|
|
|
|
if input.TemplateRepositoryID != "" {
|
|
var response struct {
|
|
CloneTemplateRepository struct {
|
|
Repository api.Repository
|
|
}
|
|
}
|
|
|
|
if ownerID == "" {
|
|
var err error
|
|
ownerID, err = api.CurrentUserID(apiClient, hostname)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
variables := map[string]interface{}{
|
|
"input": cloneTemplateRepositoryInput{
|
|
Name: input.Name,
|
|
Description: input.Description,
|
|
Visibility: strings.ToUpper(input.Visibility),
|
|
OwnerID: ownerID,
|
|
RepositoryID: input.TemplateRepositoryID,
|
|
IncludeAllBranches: input.IncludeAllBranches,
|
|
},
|
|
}
|
|
|
|
err := apiClient.GraphQL(hostname, `
|
|
mutation CloneTemplateRepository($input: CloneTemplateRepositoryInput!) {
|
|
cloneTemplateRepository(input: $input) {
|
|
repository {
|
|
id
|
|
name
|
|
owner { login }
|
|
url
|
|
}
|
|
}
|
|
}
|
|
`, variables, &response)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !input.HasWikiEnabled || !input.HasIssuesEnabled || input.HomepageURL != "" {
|
|
updateVariables := map[string]interface{}{
|
|
"input": updateRepositoryInput{
|
|
RepositoryID: response.CloneTemplateRepository.Repository.ID,
|
|
HasWikiEnabled: input.HasWikiEnabled,
|
|
HasIssuesEnabled: input.HasIssuesEnabled,
|
|
HomepageURL: input.HomepageURL,
|
|
},
|
|
}
|
|
|
|
if err := apiClient.GraphQL(hostname, `
|
|
mutation UpdateRepository($input: UpdateRepositoryInput!) {
|
|
updateRepository(input: $input) {
|
|
repository {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
`, updateVariables, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return api.InitRepoHostname(&response.CloneTemplateRepository.Repository, hostname), nil
|
|
}
|
|
|
|
if input.GitIgnoreTemplate != "" || input.LicenseTemplate != "" || input.InitReadme {
|
|
inputv3 := createRepositoryInputV3{
|
|
Name: input.Name,
|
|
HomepageURL: input.HomepageURL,
|
|
Description: input.Description,
|
|
IsPrivate: strings.EqualFold(input.Visibility, "PRIVATE"),
|
|
TeamID: teamIDv3,
|
|
HasIssuesEnabled: input.HasIssuesEnabled,
|
|
HasWikiEnabled: input.HasWikiEnabled,
|
|
GitIgnoreTemplate: input.GitIgnoreTemplate,
|
|
LicenseTemplate: input.LicenseTemplate,
|
|
InitReadme: input.InitReadme,
|
|
}
|
|
|
|
path := "user/repos"
|
|
if isOrg {
|
|
path = fmt.Sprintf("orgs/%s/repos", input.OwnerLogin)
|
|
inputv3.Visibility = strings.ToLower(input.Visibility)
|
|
}
|
|
|
|
body := &bytes.Buffer{}
|
|
enc := json.NewEncoder(body)
|
|
if err := enc.Encode(inputv3); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
repo, err := api.CreateRepoTransformToV4(apiClient, hostname, "POST", path, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return repo, nil
|
|
}
|
|
|
|
var response struct {
|
|
CreateRepository struct {
|
|
Repository api.Repository
|
|
}
|
|
}
|
|
|
|
variables := map[string]interface{}{
|
|
"input": createRepositoryInput{
|
|
Name: input.Name,
|
|
Description: input.Description,
|
|
HomepageURL: input.HomepageURL,
|
|
Visibility: strings.ToUpper(input.Visibility),
|
|
OwnerID: ownerID,
|
|
TeamID: teamID,
|
|
HasIssuesEnabled: input.HasIssuesEnabled,
|
|
HasWikiEnabled: input.HasWikiEnabled,
|
|
},
|
|
}
|
|
|
|
err := apiClient.GraphQL(hostname, `
|
|
mutation RepositoryCreate($input: CreateRepositoryInput!) {
|
|
createRepository(input: $input) {
|
|
repository {
|
|
id
|
|
name
|
|
owner { login }
|
|
url
|
|
}
|
|
}
|
|
}
|
|
`, variables, &response)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return api.InitRepoHostname(&response.CreateRepository.Repository, hostname), nil
|
|
}
|
|
|
|
type ownerResponse struct {
|
|
NodeID string `json:"node_id"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
func (r *ownerResponse) IsOrganization() bool {
|
|
return r.Type == "Organization"
|
|
}
|
|
|
|
func resolveOwner(client *api.Client, hostname, orgName string) (*ownerResponse, error) {
|
|
var response ownerResponse
|
|
err := client.REST(hostname, "GET", fmt.Sprintf("users/%s", orgName), nil, &response)
|
|
return &response, err
|
|
}
|
|
|
|
type teamResponse struct {
|
|
ID uint64 `json:"id"`
|
|
NodeID string `json:"node_id"`
|
|
Organization struct {
|
|
NodeID string `json:"node_id"`
|
|
}
|
|
}
|
|
|
|
func resolveOrganizationTeam(client *api.Client, hostname, orgName, teamSlug string) (*teamResponse, error) {
|
|
var response teamResponse
|
|
err := client.REST(hostname, "GET", fmt.Sprintf("orgs/%s/teams/%s", orgName, teamSlug), nil, &response)
|
|
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
|
|
client := api.NewClientFromHTTP(httpClient)
|
|
err := client.REST(hostname, "GET", "gitignore/templates", nil, &gitIgnoreTemplates)
|
|
if err != nil {
|
|
return []string{}, err
|
|
}
|
|
return gitIgnoreTemplates, nil
|
|
}
|
|
|
|
// listLicenseTemplates uses API v3 here because license template isn't supported by GraphQL yet.
|
|
func listLicenseTemplates(httpClient *http.Client, hostname string) ([]api.License, error) {
|
|
var licenseTemplates []api.License
|
|
client := api.NewClientFromHTTP(httpClient)
|
|
err := client.REST(hostname, "GET", "licenses", nil, &licenseTemplates)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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)
|
|
}
|