Skip to content

Commit eeeb73a

Browse files
cmbroseadonovan
andauthored
Repo name suggestions for cs create (cli#5108)
Co-authored-by: Alan Donovan <alan@alandonovan.net>
1 parent f0b60e3 commit eeeb73a

File tree

6 files changed

+257
-2
lines changed

6 files changed

+257
-2
lines changed

internal/codespaces/api/api.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"encoding/json"
3333
"errors"
3434
"fmt"
35+
"io"
3536
"io/ioutil"
3637
"net/http"
3738
"net/url"
@@ -477,6 +478,84 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc
477478
return response.Machines, nil
478479
}
479480

481+
// RepoSearchParameters are the optional parameters for searching for repositories.
482+
type RepoSearchParameters struct {
483+
// The maximum number of repos to return. At most 100 repos are returned even if this value is greater than 100.
484+
MaxRepos int
485+
// 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.
486+
Sort string
487+
}
488+
489+
// GetCodespaceRepoSuggestions searches for and returns repo names based on the provided search text.
490+
func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, parameters RepoSearchParameters) ([]string, error) {
491+
reqURL := fmt.Sprintf("%s/search/repositories", a.githubAPI)
492+
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
493+
if err != nil {
494+
return nil, fmt.Errorf("error creating request: %w", err)
495+
}
496+
497+
parts := strings.SplitN(partialSearch, "/", 2)
498+
499+
var nameSearch string
500+
if len(parts) == 2 {
501+
user := parts[0]
502+
repo := parts[1]
503+
nameSearch = fmt.Sprintf("%s user:%s", repo, user)
504+
} else {
505+
/*
506+
* This results in searching for the text within the owner or the name. It's possible to
507+
* do an owner search and then look up some repos for those owners, but that adds a
508+
* good amount of latency to the fetch which slows down showing the suggestions.
509+
*/
510+
nameSearch = partialSearch
511+
}
512+
513+
queryStr := fmt.Sprintf("%s in:name", nameSearch)
514+
515+
q := req.URL.Query()
516+
q.Add("q", queryStr)
517+
518+
if len(parameters.Sort) > 0 {
519+
q.Add("sort", parameters.Sort)
520+
}
521+
522+
if parameters.MaxRepos > 0 {
523+
q.Add("per_page", strconv.Itoa(parameters.MaxRepos))
524+
}
525+
526+
req.URL.RawQuery = q.Encode()
527+
528+
a.setHeaders(req)
529+
resp, err := a.do(ctx, req, "/search/repositories/*")
530+
if err != nil {
531+
return nil, fmt.Errorf("error searching repositories: %w", err)
532+
}
533+
defer resp.Body.Close()
534+
535+
if resp.StatusCode != http.StatusOK {
536+
return nil, api.HandleHTTPError(resp)
537+
}
538+
539+
b, err := io.ReadAll(resp.Body)
540+
if err != nil {
541+
return nil, fmt.Errorf("error reading response body: %w", err)
542+
}
543+
544+
var response struct {
545+
Items []*Repository `json:"items"`
546+
}
547+
if err := json.Unmarshal(b, &response); err != nil {
548+
return nil, fmt.Errorf("error unmarshaling response: %w", err)
549+
}
550+
551+
repoNames := make([]string, len(response.Items))
552+
for i, repo := range response.Items {
553+
repoNames[i] = repo.FullName
554+
}
555+
556+
return repoNames, nil
557+
}
558+
480559
// CreateCodespaceParams are the required parameters for provisioning a Codespace.
481560
type CreateCodespaceParams struct {
482561
RepositoryID int

internal/codespaces/api/api_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,102 @@ func TestListCodespaces_unlimited(t *testing.T) {
115115
}
116116
}
117117

118+
func TestGetRepoSuggestions(t *testing.T) {
119+
tests := []struct {
120+
searchText string // The input search string
121+
queryText string // The wanted query string (based off searchText)
122+
sort string // (Optional) The RepoSearchParameters.Sort param
123+
maxRepos string // (Optional) The RepoSearchParameters.MaxRepos param
124+
}{
125+
{
126+
searchText: "test",
127+
queryText: "test",
128+
},
129+
{
130+
searchText: "org/repo",
131+
queryText: "repo user:org",
132+
},
133+
{
134+
searchText: "org/repo/extra",
135+
queryText: "repo/extra user:org",
136+
},
137+
{
138+
searchText: "test",
139+
queryText: "test",
140+
sort: "stars",
141+
maxRepos: "1000",
142+
},
143+
}
144+
145+
for _, tt := range tests {
146+
runRepoSearchTest(t, tt.searchText, tt.queryText, tt.sort, tt.maxRepos)
147+
}
148+
}
149+
150+
func createFakeSearchReposServer(t *testing.T, wantSearchText string, wantSort string, wantPerPage string, responseRepos []*Repository) *httptest.Server {
151+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152+
if r.URL.Path != "/search/repositories" {
153+
t.Error("Incorrect path")
154+
return
155+
}
156+
157+
query := r.URL.Query()
158+
got := fmt.Sprintf("q=%q sort=%s per_page=%s", query.Get("q"), query.Get("sort"), query.Get("per_page"))
159+
want := fmt.Sprintf("q=%q sort=%s per_page=%s", wantSearchText+" in:name", wantSort, wantPerPage)
160+
if got != want {
161+
t.Errorf("for query, got %s, want %s", got, want)
162+
return
163+
}
164+
165+
response := struct {
166+
Items []*Repository `json:"items"`
167+
}{
168+
responseRepos,
169+
}
170+
171+
data, _ := json.Marshal(response)
172+
w.Write(data)
173+
}))
174+
}
175+
176+
func runRepoSearchTest(t *testing.T, searchText, wantQueryText, wantSort, wantMaxRepos string) {
177+
wantRepoNames := []string{"repo1", "repo2"}
178+
179+
apiResponseRepositories := make([]*Repository, 0)
180+
for _, name := range wantRepoNames {
181+
apiResponseRepositories = append(apiResponseRepositories, &Repository{FullName: name})
182+
}
183+
184+
svr := createFakeSearchReposServer(t, wantQueryText, wantSort, wantMaxRepos, apiResponseRepositories)
185+
defer svr.Close()
186+
187+
api := API{
188+
githubAPI: svr.URL,
189+
client: &http.Client{},
190+
}
191+
192+
ctx := context.Background()
193+
194+
searchParameters := RepoSearchParameters{}
195+
if len(wantSort) > 0 {
196+
searchParameters.Sort = wantSort
197+
}
198+
if len(wantMaxRepos) > 0 {
199+
searchParameters.MaxRepos, _ = strconv.Atoi(wantMaxRepos)
200+
}
201+
202+
gotRepoNames, err := api.GetCodespaceRepoSuggestions(ctx, searchText, searchParameters)
203+
if err != nil {
204+
t.Fatal(err)
205+
}
206+
207+
gotNamesStr := fmt.Sprintf("%v", gotRepoNames)
208+
wantNamesStr := fmt.Sprintf("%v", wantRepoNames)
209+
if gotNamesStr != wantNamesStr {
210+
t.Fatalf("got repo names %s, want %s", gotNamesStr, wantNamesStr)
211+
}
212+
}
213+
118214
func TestRetries(t *testing.T) {
119215
var callCount int
120216
csName := "test_codespace"

pkg/cmd/codespace/common.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ type apiClient interface {
6767
GetCodespaceRegionLocation(ctx context.Context) (string, error)
6868
GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error)
6969
GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
70+
GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error)
7071
}
7172

7273
var errNoCodespaces = errors.New("you have no codespaces")

pkg/cmd/codespace/create.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,14 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
6060
}
6161
questions := []*survey.Question{
6262
{
63-
Name: "repository",
64-
Prompt: &survey.Input{Message: "Repository:"},
63+
Name: "repository",
64+
Prompt: &survey.Input{
65+
Message: "Repository:",
66+
Help: "Search for repos by name. To search within an org or user, or to see private repos, enter at least ':user/'.",
67+
Suggest: func(toComplete string) []string {
68+
return getRepoSuggestions(ctx, a.apiClient, toComplete)
69+
},
70+
},
6571
Validate: survey.Required,
6672
},
6773
{
@@ -265,6 +271,21 @@ func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machin
265271
return selectedMachine.Name, nil
266272
}
267273

274+
func getRepoSuggestions(ctx context.Context, apiClient apiClient, partialSearch string) []string {
275+
searchParams := api.RepoSearchParameters{
276+
// The prompt shows 7 items so 7 effectively turns off scrolling which is similar behavior to other clients
277+
MaxRepos: 7,
278+
Sort: "repo",
279+
}
280+
281+
repos, err := apiClient.GetCodespaceRepoSuggestions(ctx, partialSearch, searchParams)
282+
if err != nil {
283+
return nil
284+
}
285+
286+
return repos
287+
}
288+
268289
// buildDisplayName returns display name to be used in the machine survey prompt.
269290
func buildDisplayName(displayName string, prebuildAvailability string) string {
270291
prebuildText := ""

pkg/cmd/codespace/create_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ func TestApp_Create(t *testing.T) {
5555
Name: "monalisa-dotfiles-abcd1234",
5656
}, nil
5757
},
58+
GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) {
59+
return nil, nil // We can't ask for suggestions without a terminal.
60+
},
5861
},
5962
},
6063
opts: createOptions{

pkg/cmd/codespace/mock_api.go

Lines changed: 55 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)