Repo name suggestions for cs create (#5108)
Co-authored-by: Alan Donovan <alan@alandonovan.net>
This commit is contained in:
parent
f0b60e3530
commit
eeeb73a3e3
6 changed files with 257 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 := ""
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue