Skip to content

Commit 5da8301

Browse files
committed
Enable filtering repo list by coding language
1 parent f75144d commit 5da8301

File tree

5 files changed

+308
-10
lines changed

5 files changed

+308
-10
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"data": {
3+
"search": {
4+
"repositoryCount": 3,
5+
"nodes": [
6+
{
7+
"nameWithOwner": "octocat/hello-world",
8+
"description": "My first repository",
9+
"isFork": false,
10+
"isPrivate": false,
11+
"isArchived": false,
12+
"pushedAt": "2021-02-19T06:34:58Z"
13+
},
14+
{
15+
"nameWithOwner": "octocat/cli",
16+
"description": "GitHub CLI",
17+
"isFork": true,
18+
"isPrivate": false,
19+
"isArchived": false,
20+
"pushedAt": "2021-02-19T06:06:06Z"
21+
},
22+
{
23+
"nameWithOwner": "octocat/testing",
24+
"description": null,
25+
"isFork": false,
26+
"isPrivate": true,
27+
"isArchived": false,
28+
"pushedAt": "2021-02-11T22:32:05Z"
29+
}
30+
],
31+
"pageInfo": {
32+
"hasNextPage": false,
33+
"endCursor": ""
34+
}
35+
}
36+
}
37+
}

pkg/cmd/repo/list/http.go

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package list
22

33
import (
44
"context"
5+
"fmt"
56
"net/http"
67
"reflect"
78
"strings"
@@ -45,7 +46,18 @@ type RepositoryList struct {
4546
TotalCount int
4647
}
4748

49+
type FilterOptions struct {
50+
Visibility string // private, public
51+
Fork bool
52+
Source bool
53+
Language string
54+
}
55+
4856
func listRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) {
57+
if filter.Language != "" {
58+
return searchRepos(client, hostname, limit, owner, filter)
59+
}
60+
4961
perPage := limit
5062
if perPage > 100 {
5163
perPage = 100
@@ -97,11 +109,11 @@ func listRepos(client *http.Client, hostname string, limit int, owner string, fi
97109
},
98110
})
99111

112+
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client)
100113
listResult := RepositoryList{}
101114
pagination:
102115
for {
103116
result := reflect.New(query)
104-
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client)
105117
err := gql.QueryNamed(context.Background(), "RepositoryList", result.Interface(), variables)
106118
if err != nil {
107119
return nil, err
@@ -126,3 +138,89 @@ pagination:
126138

127139
return &listResult, nil
128140
}
141+
142+
func searchRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) {
143+
type query struct {
144+
Search struct {
145+
RepositoryCount int
146+
Nodes []struct {
147+
Repository Repository `graphql:"...on Repository"`
148+
}
149+
PageInfo struct {
150+
HasNextPage bool
151+
EndCursor string
152+
}
153+
} `graphql:"search(type: REPOSITORY, query: $query, first: $perPage, after: $endCursor)"`
154+
}
155+
156+
perPage := limit
157+
if perPage > 100 {
158+
perPage = 100
159+
}
160+
161+
variables := map[string]interface{}{
162+
"query": githubv4.String(searchQuery(owner, filter)),
163+
"perPage": githubv4.Int(perPage),
164+
"endCursor": (*githubv4.String)(nil),
165+
}
166+
167+
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client)
168+
listResult := RepositoryList{}
169+
pagination:
170+
for {
171+
var result query
172+
err := gql.QueryNamed(context.Background(), "RepositoryListSearch", &result, variables)
173+
if err != nil {
174+
return nil, err
175+
}
176+
177+
listResult.TotalCount = result.Search.RepositoryCount
178+
for _, node := range result.Search.Nodes {
179+
if listResult.Owner == "" {
180+
idx := strings.IndexRune(node.Repository.NameWithOwner, '/')
181+
listResult.Owner = node.Repository.NameWithOwner[:idx]
182+
}
183+
listResult.Repositories = append(listResult.Repositories, node.Repository)
184+
if len(listResult.Repositories) >= limit {
185+
break pagination
186+
}
187+
}
188+
189+
if !result.Search.PageInfo.HasNextPage {
190+
break
191+
}
192+
variables["endCursor"] = githubv4.String(result.Search.PageInfo.EndCursor)
193+
}
194+
195+
return &listResult, nil
196+
}
197+
198+
func searchQuery(owner string, filter FilterOptions) string {
199+
queryParts := []string{"sort:updated-desc"}
200+
if owner == "" {
201+
queryParts = append(queryParts, "user:@me")
202+
} else {
203+
queryParts = append(queryParts, "user:"+owner)
204+
}
205+
206+
if filter.Fork {
207+
queryParts = append(queryParts, "fork:only")
208+
} else if filter.Source {
209+
queryParts = append(queryParts, "fork:false")
210+
} else {
211+
queryParts = append(queryParts, "fork:true")
212+
}
213+
214+
if filter.Language != "" {
215+
queryParts = append(queryParts, fmt.Sprintf("language:%q", filter.Language))
216+
}
217+
218+
switch filter.Visibility {
219+
case "public":
220+
queryParts = append(queryParts, "is:public")
221+
case "private":
222+
queryParts = append(queryParts, "is:private")
223+
}
224+
225+
return strings.Join(queryParts, " ")
226+
}

pkg/cmd/repo/list/http_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package list
2+
3+
import (
4+
"encoding/json"
5+
"io/ioutil"
6+
"net/http"
7+
"os"
8+
"testing"
9+
10+
"github.com/cli/cli/pkg/httpmock"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func Test_listReposWithLanguage(t *testing.T) {
16+
reg := httpmock.Registry{}
17+
defer reg.Verify(t)
18+
19+
var searchData struct {
20+
Query string
21+
Variables map[string]interface{}
22+
}
23+
reg.Register(
24+
httpmock.GraphQL(`query RepositoryListSearch\b`),
25+
func(req *http.Request) (*http.Response, error) {
26+
jsonData, err := ioutil.ReadAll(req.Body)
27+
if err != nil {
28+
return nil, err
29+
}
30+
err = json.Unmarshal(jsonData, &searchData)
31+
if err != nil {
32+
return nil, err
33+
}
34+
35+
respBody, err := os.Open("./fixtures/repoSearch.json")
36+
if err != nil {
37+
return nil, err
38+
}
39+
40+
return &http.Response{
41+
StatusCode: 200,
42+
Request: req,
43+
Body: respBody,
44+
}, nil
45+
},
46+
)
47+
48+
client := http.Client{Transport: &reg}
49+
res, err := listRepos(&client, "github.com", 10, "", FilterOptions{
50+
Language: "go",
51+
})
52+
require.NoError(t, err)
53+
54+
assert.Equal(t, 3, res.TotalCount)
55+
assert.Equal(t, "octocat", res.Owner)
56+
assert.Equal(t, "octocat/hello-world", res.Repositories[0].NameWithOwner)
57+
58+
assert.Equal(t, float64(10), searchData.Variables["perPage"])
59+
assert.Equal(t, `sort:updated-desc user:@me fork:true language:"go"`, searchData.Variables["query"])
60+
}
61+
62+
func Test_searchQuery(t *testing.T) {
63+
type args struct {
64+
owner string
65+
filter FilterOptions
66+
}
67+
tests := []struct {
68+
name string
69+
args args
70+
want string
71+
}{
72+
{
73+
name: "blank",
74+
want: "sort:updated-desc user:@me fork:true",
75+
},
76+
{
77+
name: "in org",
78+
args: args{
79+
owner: "cli",
80+
},
81+
want: "sort:updated-desc user:cli fork:true",
82+
},
83+
{
84+
name: "only public",
85+
args: args{
86+
owner: "",
87+
filter: FilterOptions{
88+
Visibility: "public",
89+
},
90+
},
91+
want: "sort:updated-desc user:@me fork:true is:public",
92+
},
93+
{
94+
name: "only private",
95+
args: args{
96+
owner: "",
97+
filter: FilterOptions{
98+
Visibility: "private",
99+
},
100+
},
101+
want: "sort:updated-desc user:@me fork:true is:private",
102+
},
103+
{
104+
name: "only forks",
105+
args: args{
106+
owner: "",
107+
filter: FilterOptions{
108+
Fork: true,
109+
},
110+
},
111+
want: "sort:updated-desc user:@me fork:only",
112+
},
113+
{
114+
name: "no forks",
115+
args: args{
116+
owner: "",
117+
filter: FilterOptions{
118+
Source: true,
119+
},
120+
},
121+
want: "sort:updated-desc user:@me fork:false",
122+
},
123+
{
124+
name: "with language",
125+
args: args{
126+
owner: "",
127+
filter: FilterOptions{
128+
Language: "ruby",
129+
},
130+
},
131+
want: "sort:updated-desc user:@me fork:true language:\"ruby\"",
132+
},
133+
}
134+
for _, tt := range tests {
135+
t.Run(tt.name, func(t *testing.T) {
136+
if got := searchQuery(tt.args.owner, tt.args.filter); got != tt.want {
137+
t.Errorf("searchQuery() = %q, want %q", got, tt.want)
138+
}
139+
})
140+
}
141+
}

pkg/cmd/repo/list/list.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,6 @@ import (
1313
"github.com/spf13/cobra"
1414
)
1515

16-
type FilterOptions struct {
17-
Visibility string // private, public
18-
Fork bool
19-
Source bool
20-
}
21-
2216
type ListOptions struct {
2317
HttpClient func() (*http.Client, error)
2418
IO *iostreams.IOStreams
@@ -29,6 +23,7 @@ type ListOptions struct {
2923
Visibility string
3024
Fork bool
3125
Source bool
26+
Language string
3227

3328
Now func() time.Time
3429
}
@@ -83,6 +78,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
8378
cmd.Flags().BoolVar(&flagPublic, "public", false, "Show only public repositories")
8479
cmd.Flags().BoolVar(&opts.Source, "source", false, "Show only non-forks")
8580
cmd.Flags().BoolVar(&opts.Fork, "fork", false, "Show only forks")
81+
cmd.Flags().StringVarP(&opts.Language, "language", "l", "", "Filter by primary coding language")
8682

8783
return cmd
8884
}
@@ -97,6 +93,7 @@ func listRun(opts *ListOptions) error {
9793
Visibility: opts.Visibility,
9894
Fork: opts.Fork,
9995
Source: opts.Source,
96+
Language: opts.Language,
10097
}
10198

10299
listResult, err := listRepos(httpClient, ghinstance.OverridableDefault(), opts.Limit, opts.Owner, filter)
@@ -132,7 +129,7 @@ func listRun(opts *ListOptions) error {
132129
}
133130

134131
if opts.IO.IsStdoutTTY() {
135-
hasFilters := filter.Visibility != "" || filter.Fork || filter.Source
132+
hasFilters := filter.Visibility != "" || filter.Fork || filter.Source || filter.Language != ""
136133
title := listHeader(listResult.Owner, len(listResult.Repositories), listResult.TotalCount, hasFilters)
137134
fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title)
138135
}
@@ -144,9 +141,15 @@ func listHeader(owner string, matchCount, totalMatchCount int, hasFilters bool)
144141
if totalMatchCount == 0 {
145142
if hasFilters {
146143
return "No results match your search"
144+
} else if owner != "" {
145+
return "There are no repositories in @" + owner
147146
}
148-
return "There are no repositories in @" + owner
147+
return "No results"
149148
}
150149

151-
return fmt.Sprintf("Showing %d of %d repositories in @%s", matchCount, totalMatchCount, owner)
150+
var matchStr string
151+
if hasFilters {
152+
matchStr = " that match your search"
153+
}
154+
return fmt.Sprintf("Showing %d of %d repositories in @%s%s", matchCount, totalMatchCount, owner, matchStr)
152155
}

0 commit comments

Comments
 (0)