8000 Remove `Internal` from `gh repo create` prompt when owner is not an org by jtmcg · Pull Request #9465 · cli/cli · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Remove Internal from gh repo create prompt when owner is not an org #9465

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

Merged
merged 6 commits into from
Aug 22, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion pkg/cmd/repo/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,7 @@ func interactiveRepoInfo(client *http.Client, hostname string, prompter iprompte
return "", "", "", err
}

visibilityOptions := []string{"Public", "Private", "Internal"}
visibilityOptions := getRepoVisibilityOptions(owner)
selected, err := prompter.Select("Visibility", "Public", visibilityOptions)
if err != nil {
return "", "", "", err
Expand All @@ -864,6 +864,15 @@ func interactiveRepoInfo(client *http.Client, hostname string, prompter iprompte
return name, description, strings.ToUpper(visibilityOptions[selected]), nil
}

func getRepoVisibilityOptions(owner string) []string {
visibilityOptions := []string{"Public", "Private"}
// orgs can also create internal repos
if owner != "" {
visibilityOptions = append(visibilityOptions, "Internal")
}
return visibilityOptions
}

func interactiveRepoNameAndOwner(client *http.Client, hostname string, prompter iprompter, defaultName string) (string, string, error) {
name, err := prompter.Input("Repository name", defaultName)
if err != nil {
Expand Down
24 changes: 24 additions & 0 deletions pkg/cmd/repo/create/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -866,3 +866,27 @@ func Test_createRun(t *testing.T) {
})
}
}

func Test_getRepoVisibilityOptions(t *testing.T) {
tests := []struct {
name string
owner string
want []string
}{
{
name: "user repo",
owner: "",
want: []string{"Public", "Private"},
},
{
name: "org repo",
owner: "fooOrg",
want: []string{"Public", "Private", "Internal"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, getRepoVisibilityOptions(tt.owner))
})
}
}
5 changes: 5 additions & 0 deletions pkg/cmd/repo/create/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ func repoCreate(client *http.Client, hostname string, input repoCreateInput) (*a
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 {
Expand Down
49 changes: 49 additions & 0 deletions pkg/cmd/repo/create/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func Test_repoCreate(t *testing.T) {
input repoCreateInput
stubs func(t *testing.T, r *httpmock.Registry)
wantErr bool
errMsg string
wantRepo string
}{
{
Expand Down Expand Up @@ -681,6 +682,51 @@ func Test_repoCreate(t *testing.T) {
},
wantRepo: "https://github.com/snacks-inc/crisps",
},
{
name: "create personal repository but try to set it as 'internal'",
hostname: "github.com",
input: repoCreateInput{
Name: "winter-foods",
Description: "roasted chestnuts",
HomepageURL: "http://example.com",
Visibility: "internal",
OwnerLogin: "OWNER",
},
wantErr: true,
errMsg: "internal repositories can only be created within an organization",
stubs: func(t *testing.T, r *httpmock.Registry) {
r.Register(
httpmock.REST("GET", "users/OWNER"),
httpmock.StringResponse(`{ "node_id": "1234", "type": "Not-Org" }`))
r.Exclude(
t,
httpmock.GraphQL(`mutation RepositoryCreate\b`),
)
},
},
{
name: "create personal repository with README but try to set it as 'internal'",
hostname: "github.com",
input: repoCreateInput{
Name: "winter-foods",
Description: "roasted chestnuts",
HomepageURL: "http://example.com",
Visibility: "internal",
OwnerLogin: "OWNER",
InitReadme: true,
},
wantErr: true,
errMsg: "internal repositories can only be created within an organization",
stubs: func(t *testing.T, r *httpmock.Registry) {
r.Register(
httpmock.REST("GET", "users/OWNER"),
httpmock.StringResponse(`{ "node_id": "1234", "type": "Not-Org" }`))
r.Exclude(
t,
httpmock.REST("POST", "user/repos"),
)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -691,6 +737,9 @@ func Test_repoCreate(t *testing.T) {
r, err := repoCreate(httpClient, tt.hostname, tt.input)
if tt.wantErr {
assert.Error(t, err)
if tt.errMsg != "" {
assert.ErrorContains(t, err, tt.errMsg)
}
return
} else {
assert.NoError(t, err)
Expand Down
18 changes: 17 additions & 1 deletion pkg/httpmock/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"fmt"
"net/http"
"sync"
"testing"

"github.com/stretchr/testify/assert"
)

// Replace http.Client transport layer with registry so all requests get
Expand All @@ -25,6 +28,18 @@ func (r *Registry) Register(m Matcher, resp Responder) {
})
}

func (r *Registry) Exclude(t *testing.T, m Matcher) {
excludedStub := &Stub{
Matcher: m,
Responder: func(req *http.Request) (*http.Response, error) {
assert.FailNowf(t, "Exclude error", "API called when excluded: %v", req.URL)
return nil, nil
},
exclude: true,
}
r.stubs = append(r.stubs, excludedStub)
}

Comment on lines +31 to +42
Copy link
Member

Choose a reason for hiding this comment

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

How readily can this new capability be reasoned in context of httpmock?

This is the question I mentally keep coming back to reading and re-reading through this updated registry code. Depending how accurate I am below, maybe some added documentation and/or renaming makes sense:

  • Tests that Register stubs are ensuring that specific HTTP requests are being made for REST or GraphQL APIs when the test calls Verify after being deferred. It in turn iterates over all the stubs registered, finds whether any stubs have not been matched, and errors appropriately.

  • Exclude now changes this dynamic by including stubs that cause the test to fail if encountered. Less of a Exclude and more of a FailIf scenario in my mind.

  • Lastly, I don't know if exclude field is actually needed if an Excludeed stub will be marked as matched in RoundTrip:

func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
var stub *Stub
r.mu.Lock()
for _, s := range r.stubs {
if s.matched || !s.Matcher(req) {
continue
}
// TODO: reinstate this check once the legacy layer has been cleaned up
// if stub != nil {
// r.mu.Unlock()
// return nil, fmt.Errorf("more than 1 stub matched %v", req)
// }
stub = s
break // TODO: remove
}
if stub != nil {
stub.matched = true
}

Copy link
Contributor Author
@jtmcg jtmcg Aug 20, 2024

Choose a reason for hiding this comment

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

Lastly, I don't know if exclude field is actually needed if an Excludeed stub will be marked as matched in RoundTrip

Unfortunately this is required because Verify will complain for any unmatched stubs.

$ go test
--- FAIL: Test_repoCreate (0.00s)
    --- FAIL: Test_repoCreate/create_personal_repository_but_try_to_set_it_as_'internal' (0.00s)
        http_test.go:737: 1 unmatched HTTP stubs
    --- FAIL: Test_repoCreate/create_personal_repository_with_README_but_try_to_set_it_as_'internal' (0.00s)
        http_test.go:737: 1 unmatched HTTP stubs
FAIL
exit status 1
FAIL    github.com/cli/cli/v2/pkg/cmd/repo/create       3.247s

There's another implementation I tried that adds an excludedStubs []stub key/value to the Registry struct, but I tinkered with that for a long time and the looping and exiting of the tests weren't very user friendly. There ended up being extra log dump to parse for the Exclusion failure that was bad UX and the exclude field is where I landed.

Exclude now changes this dynamic by including stubs that cause the test to fail if encountered. Less of a Exclude and more of a FailIf scenario in my mind

First, names are hard 😅 I'd love to figure out what best to name this 📛

That said, I think Exclude isn't bad because technically we're calling registry.Exclude when we use this - it's just shortened to r.Exclude in many of these tests (it can be confusing because we're shortening the name of the registry. Shortening the names of receivers and variables in golang is one of the things I take exception to in Golang practices for precisely this reason). So really our interface is:

type Registry interface {
    Register(Matcher, Responder)
    Exclude(*testing.T, Matcher)
    Verify(Testing)
    RoundTrip(*http.Request)
}

Taken all together, you either "register" and api call "exclude" an api calls.

I'm not sure if FailIf fits that well. I'm open to other suggestions.

Copy link
Member
@andyfeller andyfeller Aug 21, 2024

Choose a reason for hiding this comment

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

First, names are hard 😅

Yes they are!

Putting aside whether to change the name of this or not, I primarily wanted to make sure I was following what this intended.

Because there is little to no documentation around the httpmock package, it causes the reader to read the code and infer the intention rather than clearly calling out what it does with documentation. So I'm making sure I understand its use first 👍

My question about whether the exclude field was needed or not was because it seemed like the excluded stubs were still marked as matched, but I trust you; just unclear how that 1 additional field changes anything assuming everything is marked matched.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, gotcha. Yeah, your understanding is correct, but I'll add one piece that I think will help solidify this

Tests that Register stubs are ensuring that specific HTTP requests are being made for REST or GraphQL APIs when the test calls Verify after being deferred. It in turn iterates over all the stubs registered, finds whether any stubs have not been matched, and errors appropriately.

When a match is found (and before anything errors), it also runs the provided callback, called Responder, on the stub. I've leveraged this so that the callback is hardcoded for the Exclude method so that when the match is found the callback that is invoked results in a failing test. That way I can avoid relying on errors to communicate that an excluded endpoint was called. This is important because testing for errors is a common pattern throughout the code (see wantErr)

type Testing interface {
Errorf(string, ...interface{})
Helper()
Expand All @@ -33,7 +48,7 @@ type Testing interface {
func (r *Registry) Verify(t Testing) {
n := 0
for _, s := range r.stubs {
if !s.matched {
if !s.matched && !s.exclude {
n++
}
}
Expand Down Expand Up @@ -62,6 +77,7 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
stub = s
break // TODO: remove
}

if stub != nil {
stub.matched = true
}
Expand Down
1 change: 1 addition & 0 deletions pkg/httpmock/stub.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Stub struct {
matched bool
Matcher Matcher
Responder Responder
exclude bool
}

func MatchAny(*http.Request) bool {
Expand Down
Loading
0