Repo name suggestions for cs create (#5108)

Co-authored-by: Alan Donovan <alan@alandonovan.net>
This commit is contained in:
Caleb Brose 2022-01-31 04:20:49 -06:00 committed by GitHub
parent f0b60e3530
commit eeeb73a3e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 257 additions and 2 deletions

View file

@ -32,6 +32,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
@ -477,6 +478,84 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc
return response.Machines, nil
}
// RepoSearchParameters are the optional parameters for searching for repositories.
type RepoSearchParameters struct {
// The maximum number of repos to return. At most 100 repos are returned even if this value is greater than 100.
MaxRepos int
// The sort order for returned repos. Possible values are 'stars', 'forks', 'help-wanted-issues', or 'updated'. If empty the API's default ordering is used.
Sort string
}
// GetCodespaceRepoSuggestions searches for and returns repo names based on the provided search text.
func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, parameters RepoSearchParameters) ([]string, error) {
reqURL := fmt.Sprintf("%s/search/repositories", a.githubAPI)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
parts := strings.SplitN(partialSearch, "/", 2)
var nameSearch string
if len(parts) == 2 {
user := parts[0]
repo := parts[1]
nameSearch = fmt.Sprintf("%s user:%s", repo, user)
} else {
/*
* This results in searching for the text within the owner or the name. It's possible to
* do an owner search and then look up some repos for those owners, but that adds a
* good amount of latency to the fetch which slows down showing the suggestions.
*/
nameSearch = partialSearch
}
queryStr := fmt.Sprintf("%s in:name", nameSearch)
q := req.URL.Query()
q.Add("q", queryStr)
if len(parameters.Sort) > 0 {
q.Add("sort", parameters.Sort)
}
if parameters.MaxRepos > 0 {
q.Add("per_page", strconv.Itoa(parameters.MaxRepos))
}
req.URL.RawQuery = q.Encode()
a.setHeaders(req)
resp, err := a.do(ctx, req, "/search/repositories/*")
if err != nil {
return nil, fmt.Errorf("error searching repositories: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response struct {
Items []*Repository `json:"items"`
}
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
repoNames := make([]string, len(response.Items))
for i, repo := range response.Items {
repoNames[i] = repo.FullName
}
return repoNames, nil
}
// CreateCodespaceParams are the required parameters for provisioning a Codespace.
type CreateCodespaceParams struct {
RepositoryID int

View file

@ -115,6 +115,102 @@ func TestListCodespaces_unlimited(t *testing.T) {
}
}
func TestGetRepoSuggestions(t *testing.T) {
tests := []struct {
searchText string // The input search string
queryText string // The wanted query string (based off searchText)
sort string // (Optional) The RepoSearchParameters.Sort param
maxRepos string // (Optional) The RepoSearchParameters.MaxRepos param
}{
{
searchText: "test",
queryText: "test",
},
{
searchText: "org/repo",
queryText: "repo user:org",
},
{
searchText: "org/repo/extra",
queryText: "repo/extra user:org",
},
{
searchText: "test",
queryText: "test",
sort: "stars",
maxRepos: "1000",
},
}
for _, tt := range tests {
runRepoSearchTest(t, tt.searchText, tt.queryText, tt.sort, tt.maxRepos)
}
}
func createFakeSearchReposServer(t *testing.T, wantSearchText string, wantSort string, wantPerPage string, responseRepos []*Repository) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/repositories" {
t.Error("Incorrect path")
return
}
query := r.URL.Query()
got := fmt.Sprintf("q=%q sort=%s per_page=%s", query.Get("q"), query.Get("sort"), query.Get("per_page"))
want := fmt.Sprintf("q=%q sort=%s per_page=%s", wantSearchText+" in:name", wantSort, wantPerPage)
if got != want {
t.Errorf("for query, got %s, want %s", got, want)
return
}
response := struct {
Items []*Repository `json:"items"`
}{
responseRepos,
}
data, _ := json.Marshal(response)
w.Write(data)
}))
}
func runRepoSearchTest(t *testing.T, searchText, wantQueryText, wantSort, wantMaxRepos string) {
wantRepoNames := []string{"repo1", "repo2"}
apiResponseRepositories := make([]*Repository, 0)
for _, name := range wantRepoNames {
apiResponseRepositories = append(apiResponseRepositories, &Repository{FullName: name})
}
svr := createFakeSearchReposServer(t, wantQueryText, wantSort, wantMaxRepos, apiResponseRepositories)
defer svr.Close()
api := API{
githubAPI: svr.URL,
client: &http.Client{},
}
ctx := context.Background()
searchParameters := RepoSearchParameters{}
if len(wantSort) > 0 {
searchParameters.Sort = wantSort
}
if len(wantMaxRepos) > 0 {
searchParameters.MaxRepos, _ = strconv.Atoi(wantMaxRepos)
}
gotRepoNames, err := api.GetCodespaceRepoSuggestions(ctx, searchText, searchParameters)
if err != nil {
t.Fatal(err)
}
gotNamesStr := fmt.Sprintf("%v", gotRepoNames)
wantNamesStr := fmt.Sprintf("%v", wantRepoNames)
if gotNamesStr != wantNamesStr {
t.Fatalf("got repo names %s, want %s", gotNamesStr, wantNamesStr)
}
}
func TestRetries(t *testing.T) {
var callCount int
csName := "test_codespace"

View file

@ -67,6 +67,7 @@ type apiClient interface {
GetCodespaceRegionLocation(ctx context.Context) (string, error)
GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error)
GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error)
}
var errNoCodespaces = errors.New("you have no codespaces")

View file

@ -60,8 +60,14 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
}
questions := []*survey.Question{
{
Name: "repository",
Prompt: &survey.Input{Message: "Repository:"},
Name: "repository",
Prompt: &survey.Input{
Message: "Repository:",
Help: "Search for repos by name. To search within an org or user, or to see private repos, enter at least ':user/'.",
Suggest: func(toComplete string) []string {
return getRepoSuggestions(ctx, a.apiClient, toComplete)
},
},
Validate: survey.Required,
},
{
@ -265,6 +271,21 @@ func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machin
return selectedMachine.Name, nil
}
func getRepoSuggestions(ctx context.Context, apiClient apiClient, partialSearch string) []string {
searchParams := api.RepoSearchParameters{
// The prompt shows 7 items so 7 effectively turns off scrolling which is similar behavior to other clients
MaxRepos: 7,
Sort: "repo",
}
repos, err := apiClient.GetCodespaceRepoSuggestions(ctx, partialSearch, searchParams)
if err != nil {
return nil
}
return repos
}
// buildDisplayName returns display name to be used in the machine survey prompt.
func buildDisplayName(displayName string, prebuildAvailability string) string {
prebuildText := ""

View file

@ -55,6 +55,9 @@ func TestApp_Create(t *testing.T) {
Name: "monalisa-dotfiles-abcd1234",
}, nil
},
GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) {
return nil, nil // We can't ask for suggestions without a terminal.
},
},
},
opts: createOptions{

View file

@ -31,6 +31,9 @@ import (
// GetCodespaceRegionLocationFunc: func(ctx context.Context) (string, error) {
// panic("mock out the GetCodespaceRegionLocation method")
// },
// GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) {
// panic("mock out the GetCodespaceRepoSuggestions method")
// },
// GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) {
// panic("mock out the GetCodespaceRepositoryContents method")
// },
@ -74,6 +77,9 @@ type apiClientMock struct {
// GetCodespaceRegionLocationFunc mocks the GetCodespaceRegionLocation method.
GetCodespaceRegionLocationFunc func(ctx context.Context) (string, error)
// GetCodespaceRepoSuggestionsFunc mocks the GetCodespaceRepoSuggestions method.
GetCodespaceRepoSuggestionsFunc func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error)
// GetCodespaceRepositoryContentsFunc mocks the GetCodespaceRepositoryContents method.
GetCodespaceRepositoryContentsFunc func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
@ -132,6 +138,15 @@ type apiClientMock struct {
// Ctx is the ctx argument value.
Ctx context.Context
}
// GetCodespaceRepoSuggestions holds details about calls to the GetCodespaceRepoSuggestions method.
GetCodespaceRepoSuggestions []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// PartialSearch is the partialSearch argument value.
PartialSearch string
// Params is the params argument value.
Params api.RepoSearchParameters
}
// GetCodespaceRepositoryContents holds details about calls to the GetCodespaceRepositoryContents method.
GetCodespaceRepositoryContents []struct {
// Ctx is the ctx argument value.
@ -191,6 +206,7 @@ type apiClientMock struct {
lockDeleteCodespace sync.RWMutex
lockGetCodespace sync.RWMutex
lockGetCodespaceRegionLocation sync.RWMutex
lockGetCodespaceRepoSuggestions sync.RWMutex
lockGetCodespaceRepositoryContents sync.RWMutex
lockGetCodespacesMachines sync.RWMutex
lockGetRepository sync.RWMutex
@ -375,6 +391,45 @@ func (mock *apiClientMock) GetCodespaceRegionLocationCalls() []struct {
return calls
}
// GetCodespaceRepoSuggestions calls GetCodespaceRepoSuggestionsFunc.
func (mock *apiClientMock) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) {
if mock.GetCodespaceRepoSuggestionsFunc == nil {
panic("apiClientMock.GetCodespaceRepoSuggestionsFunc: method is nil but apiClient.GetCodespaceRepoSuggestions was just called")
}
callInfo := struct {
Ctx context.Context
PartialSearch string
Params api.RepoSearchParameters
}{
Ctx: ctx,
PartialSearch: partialSearch,
Params: params,
}
mock.lockGetCodespaceRepoSuggestions.Lock()
mock.calls.GetCodespaceRepoSuggestions = append(mock.calls.GetCodespaceRepoSuggestions, callInfo)
mock.lockGetCodespaceRepoSuggestions.Unlock()
return mock.GetCodespaceRepoSuggestionsFunc(ctx, partialSearch, params)
}
// GetCodespaceRepoSuggestionsCalls gets all the calls that were made to GetCodespaceRepoSuggestions.
// Check the length with:
// len(mockedapiClient.GetCodespaceRepoSuggestionsCalls())
func (mock *apiClientMock) GetCodespaceRepoSuggestionsCalls() []struct {
Ctx context.Context
PartialSearch string
Params api.RepoSearchParameters
} {
var calls []struct {
Ctx context.Context
PartialSearch string
Params api.RepoSearchParameters
}
mock.lockGetCodespaceRepoSuggestions.RLock()
calls = mock.calls.GetCodespaceRepoSuggestions
mock.lockGetCodespaceRepoSuggestions.RUnlock()
return calls
}
// GetCodespaceRepositoryContents calls GetCodespaceRepositoryContentsFunc.
func (mock *apiClientMock) GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) {
if mock.GetCodespaceRepositoryContentsFunc == nil {