From 5da8301d5d6490c2082e228398e884db41e52bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Sat, 27 Feb 2021 16:51:45 +0100 Subject: [PATCH] Enable filtering `repo list` by coding language --- pkg/cmd/repo/list/fixtures/repoSearch.json | 37 ++++++ pkg/cmd/repo/list/http.go | 100 ++++++++++++++- pkg/cmd/repo/list/http_test.go | 141 +++++++++++++++++++++ pkg/cmd/repo/list/list.go | 21 +-- pkg/cmd/repo/list/list_test.go | 19 +++ 5 files changed, 308 insertions(+), 10 deletions(-) create mode 100644 pkg/cmd/repo/list/fixtures/repoSearch.json create mode 100644 pkg/cmd/repo/list/http_test.go diff --git a/pkg/cmd/repo/list/fixtures/repoSearch.json b/pkg/cmd/repo/list/fixtures/repoSearch.json new file mode 100644 index 000000000..eecae8ac4 --- /dev/null +++ b/pkg/cmd/repo/list/fixtures/repoSearch.json @@ -0,0 +1,37 @@ +{ + "data": { + "search": { + "repositoryCount": 3, + "nodes": [ + { + "nameWithOwner": "octocat/hello-world", + "description": "My first repository", + "isFork": false, + "isPrivate": false, + "isArchived": false, + "pushedAt": "2021-02-19T06:34:58Z" + }, + { + "nameWithOwner": "octocat/cli", + "description": "GitHub CLI", + "isFork": true, + "isPrivate": false, + "isArchived": false, + "pushedAt": "2021-02-19T06:06:06Z" + }, + { + "nameWithOwner": "octocat/testing", + "description": null, + "isFork": false, + "isPrivate": true, + "isArchived": false, + "pushedAt": "2021-02-11T22:32:05Z" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "" + } + } + } +} diff --git a/pkg/cmd/repo/list/http.go b/pkg/cmd/repo/list/http.go index 97bf584fb..7aefeaf9d 100644 --- a/pkg/cmd/repo/list/http.go +++ b/pkg/cmd/repo/list/http.go @@ -2,6 +2,7 @@ package list import ( "context" + "fmt" "net/http" "reflect" "strings" @@ -45,7 +46,18 @@ type RepositoryList struct { TotalCount int } +type FilterOptions struct { + Visibility string // private, public + Fork bool + Source bool + Language string +} + func listRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) { + if filter.Language != "" { + return searchRepos(client, hostname, limit, owner, filter) + } + perPage := limit if perPage > 100 { perPage = 100 @@ -97,11 +109,11 @@ func listRepos(client *http.Client, hostname string, limit int, owner string, fi }, }) + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) listResult := RepositoryList{} pagination: for { result := reflect.New(query) - gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) err := gql.QueryNamed(context.Background(), "RepositoryList", result.Interface(), variables) if err != nil { return nil, err @@ -126,3 +138,89 @@ pagination: return &listResult, nil } + +func searchRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) { + type query struct { + Search struct { + RepositoryCount int + Nodes []struct { + Repository Repository `graphql:"...on Repository"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"search(type: REPOSITORY, query: $query, first: $perPage, after: $endCursor)"` + } + + perPage := limit + if perPage > 100 { + perPage = 100 + } + + variables := map[string]interface{}{ + "query": githubv4.String(searchQuery(owner, filter)), + "perPage": githubv4.Int(perPage), + "endCursor": (*githubv4.String)(nil), + } + + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) + listResult := RepositoryList{} +pagination: + for { + var result query + err := gql.QueryNamed(context.Background(), "RepositoryListSearch", &result, variables) + if err != nil { + return nil, err + } + + listResult.TotalCount = result.Search.RepositoryCount + for _, node := range result.Search.Nodes { + if listResult.Owner == "" { + idx := strings.IndexRune(node.Repository.NameWithOwner, '/') + listResult.Owner = node.Repository.NameWithOwner[:idx] + } + listResult.Repositories = append(listResult.Repositories, node.Repository) + if len(listResult.Repositories) >= limit { + break pagination + } + } + + if !result.Search.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(result.Search.PageInfo.EndCursor) + } + + return &listResult, nil +} + +func searchQuery(owner string, filter FilterOptions) string { + queryParts := []string{"sort:updated-desc"} + if owner == "" { + queryParts = append(queryParts, "user:@me") + } else { + queryParts = append(queryParts, "user:"+owner) + } + + if filter.Fork { + queryParts = append(queryParts, "fork:only") + } else if filter.Source { + queryParts = append(queryParts, "fork:false") + } else { + queryParts = append(queryParts, "fork:true") + } + + if filter.Language != "" { + queryParts = append(queryParts, fmt.Sprintf("language:%q", filter.Language)) + } + + switch filter.Visibility { + case "public": + queryParts = append(queryParts, "is:public") + case "private": + queryParts = append(queryParts, "is:private") + } + + return strings.Join(queryParts, " ") +} diff --git a/pkg/cmd/repo/list/http_test.go b/pkg/cmd/repo/list/http_test.go new file mode 100644 index 000000000..8c6b8d5f4 --- /dev/null +++ b/pkg/cmd/repo/list/http_test.go @@ -0,0 +1,141 @@ +package list + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "os" + "testing" + + "github.com/cli/cli/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_listReposWithLanguage(t *testing.T) { + reg := httpmock.Registry{} + defer reg.Verify(t) + + var searchData struct { + Query string + Variables map[string]interface{} + } + reg.Register( + httpmock.GraphQL(`query RepositoryListSearch\b`), + func(req *http.Request) (*http.Response, error) { + jsonData, err := ioutil.ReadAll(req.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(jsonData, &searchData) + if err != nil { + return nil, err + } + + respBody, err := os.Open("./fixtures/repoSearch.json") + if err != nil { + return nil, err + } + + return &http.Response{ + StatusCode: 200, + Request: req, + Body: respBody, + }, nil + }, + ) + + client := http.Client{Transport: ®} + res, err := listRepos(&client, "github.com", 10, "", FilterOptions{ + Language: "go", + }) + require.NoError(t, err) + + assert.Equal(t, 3, res.TotalCount) + assert.Equal(t, "octocat", res.Owner) + assert.Equal(t, "octocat/hello-world", res.Repositories[0].NameWithOwner) + + assert.Equal(t, float64(10), searchData.Variables["perPage"]) + assert.Equal(t, `sort:updated-desc user:@me fork:true language:"go"`, searchData.Variables["query"]) +} + +func Test_searchQuery(t *testing.T) { + type args struct { + owner string + filter FilterOptions + } + tests := []struct { + name string + args args + want string + }{ + { + name: "blank", + want: "sort:updated-desc user:@me fork:true", + }, + { + name: "in org", + args: args{ + owner: "cli", + }, + want: "sort:updated-desc user:cli fork:true", + }, + { + name: "only public", + args: args{ + owner: "", + filter: FilterOptions{ + Visibility: "public", + }, + }, + want: "sort:updated-desc user:@me fork:true is:public", + }, + { + name: "only private", + args: args{ + owner: "", + filter: FilterOptions{ + Visibility: "private", + }, + }, + want: "sort:updated-desc user:@me fork:true is:private", + }, + { + name: "only forks", + args: args{ + owner: "", + filter: FilterOptions{ + Fork: true, + }, + }, + want: "sort:updated-desc user:@me fork:only", + }, + { + name: "no forks", + args: args{ + owner: "", + filter: FilterOptions{ + Source: true, + }, + }, + want: "sort:updated-desc user:@me fork:false", + }, + { + name: "with language", + args: args{ + owner: "", + filter: FilterOptions{ + Language: "ruby", + }, + }, + want: "sort:updated-desc user:@me fork:true language:\"ruby\"", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := searchQuery(tt.args.owner, tt.args.filter); got != tt.want { + t.Errorf("searchQuery() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 158b12ce8..39b330a04 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -13,12 +13,6 @@ import ( "github.com/spf13/cobra" ) -type FilterOptions struct { - Visibility string // private, public - Fork bool - Source bool -} - type ListOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams @@ -29,6 +23,7 @@ type ListOptions struct { Visibility string Fork bool Source bool + Language string Now func() time.Time } @@ -83,6 +78,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().BoolVar(&flagPublic, "public", false, "Show only public repositories") cmd.Flags().BoolVar(&opts.Source, "source", false, "Show only non-forks") cmd.Flags().BoolVar(&opts.Fork, "fork", false, "Show only forks") + cmd.Flags().StringVarP(&opts.Language, "language", "l", "", "Filter by primary coding language") return cmd } @@ -97,6 +93,7 @@ func listRun(opts *ListOptions) error { Visibility: opts.Visibility, Fork: opts.Fork, Source: opts.Source, + Language: opts.Language, } listResult, err := listRepos(httpClient, ghinstance.OverridableDefault(), opts.Limit, opts.Owner, filter) @@ -132,7 +129,7 @@ func listRun(opts *ListOptions) error { } if opts.IO.IsStdoutTTY() { - hasFilters := filter.Visibility != "" || filter.Fork || filter.Source + hasFilters := filter.Visibility != "" || filter.Fork || filter.Source || filter.Language != "" title := listHeader(listResult.Owner, len(listResult.Repositories), listResult.TotalCount, hasFilters) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) } @@ -144,9 +141,15 @@ func listHeader(owner string, matchCount, totalMatchCount int, hasFilters bool) if totalMatchCount == 0 { if hasFilters { return "No results match your search" + } else if owner != "" { + return "There are no repositories in @" + owner } - return "There are no repositories in @" + owner + return "No results" } - return fmt.Sprintf("Showing %d of %d repositories in @%s", matchCount, totalMatchCount, owner) + var matchStr string + if hasFilters { + matchStr = " that match your search" + } + return fmt.Sprintf("Showing %d of %d repositories in @%s%s", matchCount, totalMatchCount, owner, matchStr) } diff --git a/pkg/cmd/repo/list/list_test.go b/pkg/cmd/repo/list/list_test.go index 7c7cbf938..86ccdf420 100644 --- a/pkg/cmd/repo/list/list_test.go +++ b/pkg/cmd/repo/list/list_test.go @@ -33,6 +33,7 @@ func TestNewCmdList(t *testing.T) { Visibility: "", Fork: false, Source: false, + Language: "", }, }, { @@ -44,6 +45,7 @@ func TestNewCmdList(t *testing.T) { Visibility: "", Fork: false, Source: false, + Language: "", }, }, { @@ -55,6 +57,7 @@ func TestNewCmdList(t *testing.T) { Visibility: "", Fork: false, Source: false, + Language: "", }, }, { @@ -66,6 +69,7 @@ func TestNewCmdList(t *testing.T) { Visibility: "public", Fork: false, Source: false, + Language: "", }, }, { @@ -77,6 +81,7 @@ func TestNewCmdList(t *testing.T) { Visibility: "private", Fork: false, Source: false, + Language: "", }, }, { @@ -88,6 +93,7 @@ func TestNewCmdList(t *testing.T) { Visibility: "", Fork: true, Source: false, + Language: "", }, }, { @@ -99,6 +105,19 @@ func TestNewCmdList(t *testing.T) { Visibility: "", Fork: false, Source: true, + Language: "", + }, + }, + { + name: "with language", + cli: "-l go", + wants: ListOptions{ + Limit: 30, + Owner: "", + Visibility: "", + Fork: false, + Source: false, + Language: "go", }, }, {