Add top level search command and search repos sub command (#5172)

This commit is contained in:
Sam Coe 2022-03-09 14:24:27 +02:00 committed by GitHub
parent 4a41fec2ed
commit e0045f26b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1588 additions and 28 deletions

View file

@ -25,6 +25,7 @@ import (
repoCmd "github.com/cli/cli/v2/pkg/cmd/repo"
creditsCmd "github.com/cli/cli/v2/pkg/cmd/repo/credits"
runCmd "github.com/cli/cli/v2/pkg/cmd/run"
searchCmd "github.com/cli/cli/v2/pkg/cmd/search"
secretCmd "github.com/cli/cli/v2/pkg/cmd/secret"
sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key"
versionCmd "github.com/cli/cli/v2/pkg/cmd/version"
@ -79,6 +80,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
cmd.AddCommand(gpgKeyCmd.NewCmdGPGKey(f))
cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams))
cmd.AddCommand(extensionCmd.NewCmdExtension(f))
cmd.AddCommand(searchCmd.NewCmdSearch(f))
cmd.AddCommand(secretCmd.NewCmdSecret(f))
cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))
cmd.AddCommand(newCodespaceCmd(f))

View file

@ -108,24 +108,6 @@ func TestNewCmdList(t *testing.T) {
}
func TestListRun(t *testing.T) {
// helper to match mocked requests by their query params along with method and path
queryMatcher := func(method string, path string, query url.Values) httpmock.Matcher {
return func(req *http.Request) bool {
if !httpmock.REST(method, path)(req) {
return false
}
actualQuery := req.URL.Query()
for param := range query {
if !(actualQuery.Get(param) == query.Get(param)) {
return false
}
}
return true
}
}
tests := []struct {
name string
opts *ListOptions
@ -244,7 +226,7 @@ func TestListRun(t *testing.T) {
},
stubs: func(reg *httpmock.Registry) {
reg.Register(
queryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{
httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{
"branch": []string{"the-branch"},
}),
httpmock.JSONResponse(shared.RunsPayload{}),
@ -261,7 +243,7 @@ func TestListRun(t *testing.T) {
},
stubs: func(reg *httpmock.Registry) {
reg.Register(
queryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{
httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{
"actor": []string{"bak1an"},
}),
httpmock.JSONResponse(shared.RunsPayload{}),

View file

@ -0,0 +1,203 @@
package repos
import (
"fmt"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/search"
"github.com/cli/cli/v2/pkg/text"
"github.com/cli/cli/v2/utils"
"github.com/spf13/cobra"
)
const (
// Limitation of GitHub search see:
// https://docs.github.com/en/rest/reference/search
searchMaxResults = 1000
)
type ReposOptions struct {
Browser cmdutil.Browser
Exporter cmdutil.Exporter
IO *iostreams.IOStreams
Query search.Query
Searcher search.Searcher
WebMode bool
}
func NewCmdRepos(f *cmdutil.Factory, runF func(*ReposOptions) error) *cobra.Command {
var order string
var sort string
opts := &ReposOptions{
Browser: f.Browser,
IO: f.IOStreams,
Query: search.Query{Kind: search.KindRepositories},
}
cmd := &cobra.Command{
Use: "repos [<query>]",
Short: "Search for repositories",
Long: heredoc.Doc(`
Search for repositories on GitHub.
The command supports constructing queries using the GitHub search syntax,
using the parameter and qualifier flags, or a combination of the two.
GitHub search syntax is documented at:
https://docs.github.com/search-github/searching-on-github/searching-for-repositories
`),
Example: heredoc.Doc(`
# search repositories matching set of keywords "cli" and "shell"
$ gh search repos cli shell
# search repositories matching phrase "vim plugin"
$ gh search repos "vim plugin"
# search repositories public repos in the microsoft organization
$ gh search repos --owner=microsoft --visibility=public
# search repositories with a set of topics
$ gh search repos --topic=unix,terminal
# search repositories by coding language and number of good first issues
$ gh search repos --language=go --good-first-issues=">=10"
`),
RunE: func(c *cobra.Command, args []string) error {
if len(args) == 0 && c.Flags().NFlag() == 0 {
return cmdutil.FlagErrorf("specify search keywords or flags")
}
if opts.Query.Limit < 1 || opts.Query.Limit > searchMaxResults {
return cmdutil.FlagErrorf("`--limit` must be between 1 and 1000")
}
if c.Flags().Changed("order") {
opts.Query.Order = order
}
if c.Flags().Changed("sort") {
opts.Query.Sort = sort
}
opts.Query.Keywords = args
if runF != nil {
return runF(opts)
}
var err error
opts.Searcher, err = searcher(f)
if err != nil {
return err
}
return reposRun(opts)
},
}
// Output flags
cmdutil.AddJSONFlags(cmd, &opts.Exporter, search.RepositoryFields)
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the search query in the web browser")
// Query parameter flags
cmd.Flags().IntVarP(&opts.Query.Limit, "limit", "L", 30, "Maximum number of repositories to fetch")
cmdutil.StringEnumFlag(cmd, &order, "order", "", "desc", []string{"asc", "desc"}, "Order of repositories returned, ignored unless '--sort' flag is specified")
cmdutil.StringEnumFlag(cmd, &sort, "sort", "", "best-match", []string{"forks", "help-wanted-issues", "stars", "updated"}, "Sort fetched repositories")
// Query qualifier flags
cmdutil.NilBoolFlag(cmd, &opts.Query.Qualifiers.Archived, "archived", "", "Filter based on archive state")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Created, "created", "", "Filter based on created at `date`")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Followers, "followers", "", "Filter based on `number` of followers")
cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.Fork, "include-forks", "", "", []string{"false", "true", "only"}, "Include forks in fetched repositories")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Forks, "forks", "", "Filter on `number` of forks")
cmd.Flags().StringVar(&opts.Query.Qualifiers.GoodFirstIssues, "good-first-issues", "", "Filter on `number` of issues with the 'good first issue' label")
cmd.Flags().StringVar(&opts.Query.Qualifiers.HelpWantedIssues, "help-wanted-issues", "", "Filter on `number` of issues with the 'help wanted' label")
cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.In, "match", "", nil, []string{"name", "description", "readme"}, "Restrict search to specific field of repository")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Language, "language", "", "Filter based on the coding language")
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.License, "license", nil, "Filter based on license type")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Org, "owner", "", "Filter on owner")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Pushed, "updated", "", "Filter on last updated at `date`")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Size, "size", "", "Filter on a size range, in kilobytes")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Stars, "stars", "", "Filter on `number` of stars")
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Topic, "topic", nil, "Filter on topic")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Topics, "number-topics", "", "Filter on `number` of topics")
cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", "", []string{"public", "private", "internal"}, "Filter based on visibility")
return cmd
}
func reposRun(opts *ReposOptions) error {
io := opts.IO
if opts.WebMode {
url := opts.Searcher.URL(opts.Query)
if io.IsStdoutTTY() {
fmt.Fprintf(io.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(url))
}
return opts.Browser.Browse(url)
}
io.StartProgressIndicator()
result, err := opts.Searcher.Repositories(opts.Query)
io.StopProgressIndicator()
if err != nil {
return err
}
if err := io.StartPager(); err == nil {
defer io.StopPager()
} else {
fmt.Fprintf(io.ErrOut, "failed to start pager: %v\n", err)
}
if opts.Exporter != nil {
return opts.Exporter.Write(io, result.Items)
}
return displayResults(io, result)
}
func displayResults(io *iostreams.IOStreams, results search.RepositoriesResult) error {
cs := io.ColorScheme()
tp := utils.NewTablePrinter(io)
for _, repo := range results.Items {
tags := []string{repo.Visibility}
if repo.IsFork {
tags = append(tags, "fork")
}
if repo.IsArchived {
tags = append(tags, "archived")
}
info := strings.Join(tags, ", ")
infoColor := cs.Gray
if repo.IsPrivate {
infoColor = cs.Yellow
}
tp.AddField(repo.FullName, nil, cs.Bold)
description := repo.Description
tp.AddField(text.ReplaceExcessiveWhitespace(description), nil, nil)
tp.AddField(info, nil, infoColor)
if tp.IsTTY() {
tp.AddField(utils.FuzzyAgoAbbr(time.Now(), repo.UpdatedAt), nil, cs.Gray)
} else {
tp.AddField(repo.UpdatedAt.Format(time.RFC3339), nil, nil)
}
tp.EndRow()
}
if io.IsStdoutTTY() {
header := "No repositories matched your search\n"
if len(results.Items) > 0 {
header = fmt.Sprintf("Showing %d of %d repositories\n\n", len(results.Items), results.Total)
}
fmt.Fprintf(io.Out, "\n%s", header)
}
return tp.Render()
}
func searcher(f *cmdutil.Factory) (search.Searcher, error) {
cfg, err := f.Config()
if err != nil {
return nil, err
}
host, err := cfg.DefaultHost()
if err != nil {
return nil, err
}
client, err := f.HttpClient()
if err != nil {
return nil, err
}
return search.NewSearcher(client, host), nil
}

View file

@ -0,0 +1,295 @@
package repos
import (
"bytes"
"fmt"
"testing"
"time"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/search"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdRepos(t *testing.T) {
var trueBool = true
tests := []struct {
name string
input string
output ReposOptions
wantErr bool
errMsg string
}{
{
name: "no arguments",
input: "",
wantErr: true,
errMsg: "specify search keywords or flags",
},
{
name: "keyword arguments",
input: "some search terms",
output: ReposOptions{
Query: search.Query{Keywords: []string{"some", "search", "terms"}, Kind: "repositories", Limit: 30},
},
},
{
name: "web flag",
input: "--web",
output: ReposOptions{
Query: search.Query{Keywords: []string{}, Kind: "repositories", Limit: 30},
WebMode: true,
},
},
{
name: "limit flag",
input: "--limit 10",
output: ReposOptions{Query: search.Query{Keywords: []string{}, Kind: "repositories", Limit: 10}},
},
{
name: "invalid limit flag",
input: "--limit 1001",
wantErr: true,
errMsg: "`--limit` must be between 1 and 1000",
},
{
name: "order flag",
input: "--order asc",
output: ReposOptions{
Query: search.Query{Keywords: []string{}, Kind: "repositories", Limit: 30, Order: "asc"},
},
},
{
name: "invalid order flag",
input: "--order invalid",
wantErr: true,
errMsg: "invalid argument \"invalid\" for \"--order\" flag: valid values are {asc|desc}",
},
{
name: "qualifier flags",
input: `
--archived
--created=created
--followers=1
--include-forks=true
--forks=2
--good-first-issues=3
--help-wanted-issues=4
--match=description,readme
--language=language
--license=license
--owner=owner
--updated=updated
--size=5
--stars=6
--topic=topic
--number-topics=7
--visibility=public
`,
output: ReposOptions{
Query: search.Query{
Keywords: []string{},
Kind: "repositories",
Limit: 30,
Qualifiers: search.Qualifiers{
Archived: &trueBool,
Created: "created",
Followers: "1",
Fork: "true",
Forks: "2",
GoodFirstIssues: "3",
HelpWantedIssues: "4",
In: []string{"description", "readme"},
Language: "language",
License: []string{"license"},
Org: "owner",
Pushed: "updated",
Size: "5",
Stars: "6",
Topic: []string{"topic"},
Topics: "7",
Is: "public",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: io,
}
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var gotOpts *ReposOptions
cmd := NewCmdRepos(f, func(opts *ReposOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantErr {
assert.EqualError(t, err, tt.errMsg)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.output.Query, gotOpts.Query)
assert.Equal(t, tt.output.WebMode, gotOpts.WebMode)
})
}
}
func Test_ReposRun(t *testing.T) {
var query = search.Query{
Keywords: []string{"cli"},
Kind: "repositories",
Limit: 30,
Qualifiers: search.Qualifiers{
Stars: ">50",
Topic: []string{"golang"},
},
}
var updatedAt = time.Date(2021, 2, 28, 12, 30, 0, 0, time.UTC)
tests := []struct {
errMsg string
name string
opts *ReposOptions
tty bool
wantErr bool
wantStderr string
wantStdout string
}{
{
name: "displays results tty",
opts: &ReposOptions{
Query: query,
Searcher: &search.SearcherMock{
RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) {
return search.RepositoriesResult{
IncompleteResults: false,
Items: []search.Repository{
{FullName: "test/cli", Description: "of course", IsPrivate: true, IsArchived: true, UpdatedAt: updatedAt, Visibility: "private"},
{FullName: "test/cliing", Description: "wow", IsFork: true, UpdatedAt: updatedAt, Visibility: "public"},
{FullName: "cli/cli", Description: "so much", IsArchived: false, UpdatedAt: updatedAt, Visibility: "internal"},
},
Total: 300,
}, nil
},
},
},
tty: true,
wantStdout: "\nShowing 3 of 300 repositories\n\ntest/cli of course private, archived Feb 28, 2021\ntest/cliing wow public, fork Feb 28, 2021\ncli/cli so much internal Feb 28, 2021\n",
},
{
name: "displays no results tty",
opts: &ReposOptions{
Query: query,
Searcher: &search.SearcherMock{
RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) {
return search.RepositoriesResult{}, nil
},
},
},
tty: true,
wantStdout: "\nNo repositories matched your search\n",
},
{
name: "displays results notty",
opts: &ReposOptions{
Query: query,
Searcher: &search.SearcherMock{
RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) {
return search.RepositoriesResult{
IncompleteResults: false,
Items: []search.Repository{
{FullName: "test/cli", Description: "of course", IsPrivate: true, IsArchived: true, UpdatedAt: updatedAt, Visibility: "private"},
{FullName: "test/cliing", Description: "wow", IsFork: true, UpdatedAt: updatedAt, Visibility: "public"},
{FullName: "cli/cli", Description: "so much", IsArchived: false, UpdatedAt: updatedAt, Visibility: "internal"},
},
Total: 300,
}, nil
},
},
},
wantStdout: "test/cli\tof course\tprivate, archived\t2021-02-28T12:30:00Z\ntest/cliing\twow\tpublic, fork\t2021-02-28T12:30:00Z\ncli/cli\tso much\tinternal\t2021-02-28T12:30:00Z\n",
},
{
name: "displays no results notty",
opts: &ReposOptions{
Query: query,
Searcher: &search.SearcherMock{
RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) {
return search.RepositoriesResult{}, nil
},
},
},
},
{
name: "displays search error",
opts: &ReposOptions{
Query: query,
Searcher: &search.SearcherMock{
RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) {
return search.RepositoriesResult{}, fmt.Errorf("error with query")
},
},
},
errMsg: "error with query",
wantErr: true,
},
{
name: "opens browser for web mode tty",
opts: &ReposOptions{
Browser: &cmdutil.TestBrowser{},
Query: query,
Searcher: &search.SearcherMock{
URLFunc: func(query search.Query) string {
return "https://github.com/search?type=repositories&q=cli"
},
},
WebMode: true,
},
tty: true,
wantStderr: "Opening github.com/search in your browser.\n",
},
{
name: "opens browser for web mode notty",
opts: &ReposOptions{
Browser: &cmdutil.TestBrowser{},
Query: query,
Searcher: &search.SearcherMock{
URLFunc: func(query search.Query) string {
return "https://github.com/search?type=repositories&q=cli"
},
},
WebMode: true,
},
},
}
for _, tt := range tests {
io, _, stdout, stderr := iostreams.Test()
io.SetStdinTTY(tt.tty)
io.SetStdoutTTY(tt.tty)
io.SetStderrTTY(tt.tty)
tt.opts.IO = io
t.Run(tt.name, func(t *testing.T) {
err := reposRun(tt.opts)
if tt.wantErr {
assert.EqualError(t, err, tt.errMsg)
return
} else if err != nil {
t.Fatalf("reposRun unexpected error: %v", err)
}
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}

20
pkg/cmd/search/search.go Normal file
View file

@ -0,0 +1,20 @@
package search
import (
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
searchReposCmd "github.com/cli/cli/v2/pkg/cmd/search/repos"
)
func NewCmdSearch(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "search <command>",
Short: "Search for repositories, issues, pull requests and users",
Long: "Search across all of GitHub.",
}
cmd.AddCommand(searchReposCmd.NewCmdRepos(f, nil))
return cmd
}

View file

@ -34,6 +34,16 @@ func StringEnumFlag(cmd *cobra.Command, p *string, name, shorthand, defaultValue
return f
}
func StringSliceEnumFlag(cmd *cobra.Command, p *[]string, name, shorthand string, defaultValues, options []string, usage string) *pflag.Flag {
*p = defaultValues
val := &enumMultiValue{value: p, options: options}
f := cmd.Flags().VarPF(val, name, shorthand, fmt.Sprintf("%s: %s", usage, formatValuesForUsageDocs(options)))
_ = cmd.RegisterFlagCompletionFunc(name, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return options, cobra.ShellCompDirectiveNoFileComp
})
return f
}
func formatValuesForUsageDocs(values []string) string {
return fmt.Sprintf("{%s}", strings.Join(values, "|"))
}
@ -99,14 +109,7 @@ type enumValue struct {
}
func (e *enumValue) Set(value string) error {
found := false
for _, opt := range e.options {
if strings.EqualFold(opt, value) {
found = true
break
}
}
if !found {
if !isIncluded(value, e.options) {
return fmt.Errorf("valid values are %s", formatValuesForUsageDocs(e.options))
}
*e.string = value
@ -120,3 +123,39 @@ func (e *enumValue) String() string {
func (e *enumValue) Type() string {
return "string"
}
type enumMultiValue struct {
value *[]string
options []string
}
func (e *enumMultiValue) Set(value string) error {
items := strings.Split(value, ",")
for _, item := range items {
if !isIncluded(item, e.options) {
return fmt.Errorf("valid values are %s", formatValuesForUsageDocs(e.options))
}
}
*e.value = append(*e.value, items...)
return nil
}
func (e *enumMultiValue) String() string {
if len(*e.value) == 0 {
return ""
}
return fmt.Sprintf("{%s}", strings.Join(*e.value, ", "))
}
func (e *enumMultiValue) Type() string {
return "stringSlice"
}
func isIncluded(value string, opts []string) bool {
for _, opt := range opts {
if strings.EqualFold(opt, value) {
return true
}
}
return false
}

View file

@ -6,6 +6,7 @@ import (
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"regexp"
"strings"
@ -56,6 +57,24 @@ func GraphQL(q string) Matcher {
}
}
func QueryMatcher(method string, path string, query url.Values) Matcher {
return func(req *http.Request) bool {
if !REST(method, path)(req) {
return false
}
actualQuery := req.URL.Query()
for param := range query {
if !(actualQuery.Get(param) == query.Get(param)) {
return false
}
}
return true
}
}
func readBody(req *http.Request) ([]byte, error) {
bodyCopy := &bytes.Buffer{}
r := io.TeeReader(req.Body, bodyCopy)

111
pkg/search/query.go Normal file
View file

@ -0,0 +1,111 @@
package search
import (
"fmt"
"reflect"
"sort"
"strings"
"github.com/cli/cli/v2/pkg/text"
)
const (
KindRepositories = "repositories"
)
type Query struct {
Keywords []string
Kind string
Limit int
Order string
Page int
Qualifiers Qualifiers
Sort string
}
type Qualifiers struct {
Archived *bool
Created string
Followers string
Fork string
Forks string
GoodFirstIssues string
HelpWantedIssues string
In []string
Is string
Language string
License []string
Org string
Pushed string
Size string
Stars string
Topic []string
Topics string
}
func (q Query) String() string {
qualifiers := formatQualifiers(q.Qualifiers)
keywords := formatKeywords(q.Keywords)
all := append(keywords, qualifiers...)
return strings.Join(all, " ")
}
func (q Qualifiers) Map() map[string][]string {
m := map[string][]string{}
v := reflect.ValueOf(q)
t := reflect.TypeOf(q)
for i := 0; i < v.NumField(); i++ {
fieldName := t.Field(i).Name
key := text.CamelToKebab(fieldName)
typ := v.FieldByName(fieldName).Kind()
value := v.FieldByName(fieldName)
switch typ {
case reflect.Ptr:
if value.IsNil() {
continue
}
v := reflect.Indirect(value)
m[key] = []string{fmt.Sprintf("%v", v)}
case reflect.Slice:
if value.IsNil() {
continue
}
s := []string{}
for i := 0; i < value.Len(); i++ {
s = append(s, fmt.Sprintf("%v", value.Index(i)))
}
m[key] = s
default:
if value.IsZero() {
continue
}
m[key] = []string{fmt.Sprintf("%v", value)}
}
}
return m
}
func quote(s string) string {
if strings.ContainsAny(s, " \"\t\r\n") {
return fmt.Sprintf("%q", s)
}
return s
}
func formatQualifiers(qs Qualifiers) []string {
var all []string
for k, vs := range qs.Map() {
for _, v := range vs {
all = append(all, fmt.Sprintf("%s:%s", k, quote(v)))
}
}
sort.Strings(all)
return all
}
func formatKeywords(ks []string) []string {
for i, k := range ks {
ks[i] = quote(k)
}
return ks
}

135
pkg/search/query_test.go Normal file
View file

@ -0,0 +1,135 @@
package search
import (
"testing"
"github.com/stretchr/testify/assert"
)
var trueBool = true
func TestQueryString(t *testing.T) {
tests := []struct {
name string
query Query
out string
}{
{
name: "converts query to string",
query: Query{
Keywords: []string{"some", "keywords"},
Qualifiers: Qualifiers{
Archived: &trueBool,
Created: "created",
Followers: "1",
Fork: "true",
Forks: "2",
GoodFirstIssues: "3",
HelpWantedIssues: "4",
In: []string{"description", "readme"},
Language: "language",
License: []string{"license"},
Org: "org",
Pushed: "updated",
Size: "5",
Stars: "6",
Topic: []string{"topic"},
Topics: "7",
Is: "public",
},
},
out: "some keywords archived:true created:created followers:1 fork:true forks:2 good-first-issues:3 help-wanted-issues:4 in:description in:readme is:public language:language license:license org:org pushed:updated size:5 stars:6 topic:topic topics:7",
},
{
name: "quotes keywords",
query: Query{
Keywords: []string{"quote keywords"},
},
out: "\"quote keywords\"",
},
{
name: "quotes qualifiers",
query: Query{
Qualifiers: Qualifiers{
Topic: []string{"quote qualifier"},
},
},
out: "topic:\"quote qualifier\"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.out, tt.query.String())
})
}
}
func TestQualifiersMap(t *testing.T) {
tests := []struct {
name string
qualifiers Qualifiers
out map[string][]string
}{
{
name: "changes qualifiers to map",
qualifiers: Qualifiers{
Archived: &trueBool,
Created: "created",
Followers: "1",
Fork: "true",
Forks: "2",
GoodFirstIssues: "3",
HelpWantedIssues: "4",
In: []string{"readme"},
Language: "language",
License: []string{"license"},
Org: "org",
Pushed: "updated",
Size: "5",
Stars: "6",
Topic: []string{"topic"},
Topics: "7",
Is: "public",
},
out: map[string][]string{
"archived": {"true"},
"created": {"created"},
"followers": {"1"},
"fork": {"true"},
"forks": {"2"},
"good-first-issues": {"3"},
"help-wanted-issues": {"4"},
"in": {"readme"},
"is": {"public"},
"language": {"language"},
"license": {"license"},
"org": {"org"},
"pushed": {"updated"},
"size": {"5"},
"stars": {"6"},
"topic": {"topic"},
"topics": {"7"},
},
},
{
name: "excludes unset qualifiers from map",
qualifiers: Qualifiers{
Org: "org",
Pushed: "updated",
Size: "5",
Stars: "6",
},
out: map[string][]string{
"org": {"org"},
"pushed": {"updated"},
"size": {"5"},
"stars": {"6"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.out, tt.qualifiers.Map())
})
}
}

120
pkg/search/result.go Normal file
View file

@ -0,0 +1,120 @@
package search
import (
"reflect"
"strings"
"time"
)
var RepositoryFields = []string{
"createdAt",
"defaultBranch",
"description",
"forksCount",
"fullName",
"hasDownloads",
"hasIssues",
"hasPages",
"hasProjects",
"hasWiki",
"homepage",
"id",
"isArchived",
"isDisabled",
"isFork",
"isPrivate",
"language",
"license",
"name",
"openIssuesCount",
"owner",
"pushedAt",
"size",
"stargazersCount",
"updatedAt",
"visibility",
"watchersCount",
}
type RepositoriesResult struct {
IncompleteResults bool `json:"incomplete_results"`
Items []Repository `json:"items"`
Total int `json:"total_count"`
}
type Repository struct {
CreatedAt time.Time `json:"created_at"`
DefaultBranch string `json:"default_branch"`
Description string `json:"description"`
ForksCount int `json:"forks_count"`
FullName string `json:"full_name"`
HasDownloads bool `json:"has_downloads"`
HasIssues bool `json:"has_issues"`
HasPages bool `json:"has_pages"`
HasProjects bool `json:"has_projects"`
HasWiki bool `json:"has_wiki"`
Homepage string `json:"homepage"`
ID int64 `json:"id"`
IsArchived bool `json:"archived"`
IsDisabled bool `json:"disabled"`
IsFork bool `json:"fork"`
IsPrivate bool `json:"private"`
Language string `json:"language"`
License License `json:"license"`
MasterBranch string `json:"master_branch"`
Name string `json:"name"`
OpenIssuesCount int `json:"open_issues_count"`
Owner User `json:"owner"`
PushedAt time.Time `json:"pushed_at"`
Size int `json:"size"`
StargazersCount int `json:"stargazers_count"`
UpdatedAt time.Time `json:"updated_at"`
Visibility string `json:"visibility"`
WatchersCount int `json:"watchers_count"`
}
type License struct {
HTMLURL string `json:"html_url"`
Key string `json:"key"`
Name string `json:"name"`
URL string `json:"url"`
}
type User struct {
GravatarID string `json:"gravatar_id"`
ID int64 `json:"id"`
Login string `json:"login"`
SiteAdmin bool `json:"site_admin"`
Type string `json:"type"`
}
func (repo Repository) ExportData(fields []string) map[string]interface{} {
v := reflect.ValueOf(repo)
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "license":
data[f] = map[string]interface{}{
"key": repo.License.Key,
"name": repo.License.Name,
"url": repo.License.URL,
}
case "owner":
data[f] = map[string]interface{}{
"id": repo.Owner.ID,
"login": repo.Owner.Login,
"type": repo.Owner.Type,
}
default:
sf := fieldByName(v, f)
data[f] = sf.Interface()
}
}
return data
}
func fieldByName(v reflect.Value, field string) reflect.Value {
return v.FieldByNameFunc(func(s string) bool {
return strings.EqualFold(field, s)
})
}

46
pkg/search/result_test.go Normal file
View file

@ -0,0 +1,46 @@
package search
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepositoryExportData(t *testing.T) {
var createdAt = time.Date(2021, 2, 28, 12, 30, 0, 0, time.UTC)
tests := []struct {
name string
fields []string
repo Repository
output string
}{
{
name: "exports requested fields",
fields: []string{"createdAt", "description", "fullName", "isArchived", "isFork", "isPrivate", "pushedAt"},
repo: Repository{
CreatedAt: createdAt,
Description: "description",
FullName: "cli/cli",
IsArchived: true,
IsFork: false,
IsPrivate: false,
PushedAt: createdAt,
},
output: `{"createdAt":"2021-02-28T12:30:00Z","description":"description","fullName":"cli/cli","isArchived":true,"isFork":false,"isPrivate":false,"pushedAt":"2021-02-28T12:30:00Z"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
exported := tt.repo.ExportData(tt.fields)
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
require.NoError(t, enc.Encode(exported))
assert.Equal(t, tt.output, strings.TrimSpace(buf.String()))
})
}
}

184
pkg/search/searcher.go Normal file
View file

@ -0,0 +1,184 @@
package search
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/cli/cli/v2/internal/ghinstance"
)
const (
maxPerPage = 100
orderKey = "order"
sortKey = "sort"
)
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
var pageRE = regexp.MustCompile(`(\?|&)page=(\d*)`)
var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
//go:generate moq -rm -out searcher_mock.go . Searcher
type Searcher interface {
Repositories(Query) (RepositoriesResult, error)
URL(Query) string
}
type searcher struct {
client *http.Client
host string
}
type httpError struct {
Errors []httpErrorItem
Message string
RequestURL *url.URL
StatusCode int
}
type httpErrorItem struct {
Code string
Field string
Message string
Resource string
}
func NewSearcher(client *http.Client, host string) Searcher {
return &searcher{
client: client,
host: host,
}
}
func (s searcher) Repositories(query Query) (RepositoriesResult, error) {
result := RepositoriesResult{}
toRetrieve := query.Limit
var resp *http.Response
var err error
for toRetrieve > 0 {
query.Limit = min(toRetrieve, maxPerPage)
query.Page = nextPage(resp)
if query.Page == 0 {
break
}
page := RepositoriesResult{}
resp, err = s.search(query, &page)
if err != nil {
return result, err
}
result.IncompleteResults = page.IncompleteResults
result.Total = page.Total
result.Items = append(result.Items, page.Items...)
toRetrieve = toRetrieve - len(page.Items)
}
return result, nil
}
func (s searcher) search(query Query, result interface{}) (*http.Response, error) {
path := fmt.Sprintf("%ssearch/%s", ghinstance.RESTPrefix(s.host), query.Kind)
qs := url.Values{}
qs.Set("page", strconv.Itoa(query.Page))
qs.Set("per_page", strconv.Itoa(query.Limit))
qs.Set("q", query.String())
if query.Order != "" {
qs.Set(orderKey, query.Order)
}
if query.Sort != "" {
qs.Set(sortKey, query.Sort)
}
url := fmt.Sprintf("%s?%s", path, qs.Encode())
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return resp, handleHTTPError(resp)
}
decoder := json.NewDecoder(resp.Body)
err = decoder.Decode(result)
if err != nil {
return resp, err
}
return resp, nil
}
func (s searcher) URL(query Query) string {
path := fmt.Sprintf("https://%s/search", s.host)
qs := url.Values{}
qs.Set("type", query.Kind)
qs.Set("q", query.String())
if query.Order != "" {
qs.Set(orderKey, query.Order)
}
if query.Sort != "" {
qs.Set(sortKey, query.Sort)
}
url := fmt.Sprintf("%s?%s", path, qs.Encode())
return url
}
func (err httpError) Error() string {
if err.StatusCode != 422 || len(err.Errors) == 0 {
return fmt.Sprintf("HTTP %d: %s (%s)", err.StatusCode, err.Message, err.RequestURL)
}
query := strings.TrimSpace(err.RequestURL.Query().Get("q"))
return fmt.Sprintf("Invalid search query %q.\n%s", query, err.Errors[0].Message)
}
func handleHTTPError(resp *http.Response) error {
httpError := httpError{
RequestURL: resp.Request.URL,
StatusCode: resp.StatusCode,
}
if !jsonTypeRE.MatchString(resp.Header.Get("Content-Type")) {
httpError.Message = resp.Status
return httpError
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if err := json.Unmarshal(body, &httpError); err != nil {
return err
}
return httpError
}
func nextPage(resp *http.Response) (page int) {
if resp == nil {
return 1
}
for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) {
if !(len(m) > 2 && m[2] == "next") {
continue
}
p := pageRE.FindStringSubmatch(m[1])
if len(p) == 3 {
i, err := strconv.Atoi(p[2])
if err == nil {
return i
}
}
}
return 0
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

116
pkg/search/searcher_mock.go Normal file
View file

@ -0,0 +1,116 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package search
import (
"sync"
)
// Ensure, that SearcherMock does implement Searcher.
// If this is not the case, regenerate this file with moq.
var _ Searcher = &SearcherMock{}
// SearcherMock is a mock implementation of Searcher.
//
// func TestSomethingThatUsesSearcher(t *testing.T) {
//
// // make and configure a mocked Searcher
// mockedSearcher := &SearcherMock{
// RepositoriesFunc: func(query Query) (RepositoriesResult, error) {
// panic("mock out the Repositories method")
// },
// URLFunc: func(query Query) string {
// panic("mock out the URL method")
// },
// }
//
// // use mockedSearcher in code that requires Searcher
// // and then make assertions.
//
// }
type SearcherMock struct {
// RepositoriesFunc mocks the Repositories method.
RepositoriesFunc func(query Query) (RepositoriesResult, error)
// URLFunc mocks the URL method.
URLFunc func(query Query) string
// calls tracks calls to the methods.
calls struct {
// Repositories holds details about calls to the Repositories method.
Repositories []struct {
// Query is the query argument value.
Query Query
}
// URL holds details about calls to the URL method.
URL []struct {
// Query is the query argument value.
Query Query
}
}
lockRepositories sync.RWMutex
lockURL sync.RWMutex
}
// Repositories calls RepositoriesFunc.
func (mock *SearcherMock) Repositories(query Query) (RepositoriesResult, error) {
if mock.RepositoriesFunc == nil {
panic("SearcherMock.RepositoriesFunc: method is nil but Searcher.Repositories was just called")
}
callInfo := struct {
Query Query
}{
Query: query,
}
mock.lockRepositories.Lock()
mock.calls.Repositories = append(mock.calls.Repositories, callInfo)
mock.lockRepositories.Unlock()
return mock.RepositoriesFunc(query)
}
// RepositoriesCalls gets all the calls that were made to Repositories.
// Check the length with:
// len(mockedSearcher.RepositoriesCalls())
func (mock *SearcherMock) RepositoriesCalls() []struct {
Query Query
} {
var calls []struct {
Query Query
}
mock.lockRepositories.RLock()
calls = mock.calls.Repositories
mock.lockRepositories.RUnlock()
return calls
}
// URL calls URLFunc.
func (mock *SearcherMock) URL(query Query) string {
if mock.URLFunc == nil {
panic("SearcherMock.URLFunc: method is nil but Searcher.URL was just called")
}
callInfo := struct {
Query Query
}{
Query: query,
}
mock.lockURL.Lock()
mock.calls.URL = append(mock.calls.URL, callInfo)
mock.lockURL.Unlock()
return mock.URLFunc(query)
}
// URLCalls gets all the calls that were made to URL.
// Check the length with:
// len(mockedSearcher.URLCalls())
func (mock *SearcherMock) URLCalls() []struct {
Query Query
} {
var calls []struct {
Query Query
}
mock.lockURL.RLock()
calls = mock.calls.URL
mock.lockURL.RUnlock()
return calls
}

198
pkg/search/searcher_test.go Normal file
View file

@ -0,0 +1,198 @@
package search
import (
"net/http"
"net/url"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
)
var query = Query{
Keywords: []string{"keyword"},
Kind: "repositories",
Limit: 30,
Order: "stars",
Sort: "desc",
Qualifiers: Qualifiers{
Stars: ">=5",
Topic: []string{"topic"},
},
}
func TestSearcherRepositories(t *testing.T) {
values := url.Values{
"page": []string{"1"},
"per_page": []string{"30"},
"order": []string{"stars"},
"sort": []string{"desc"},
"q": []string{"keyword stars:>=5 topic:topic"},
}
tests := []struct {
name string
host string
query Query
result RepositoriesResult
wantErr bool
errMsg string
httpStubs func(*httpmock.Registry)
}{
{
name: "searches repositories",
query: query,
result: RepositoriesResult{
IncompleteResults: false,
Items: []Repository{{Name: "test"}},
Total: 1,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "search/repositories", values),
httpmock.JSONResponse(RepositoriesResult{
IncompleteResults: false,
Items: []Repository{{Name: "test"}},
Total: 1,
}),
)
},
},
{
name: "searches repositories for enterprise host",
host: "enterprise.com",
query: query,
result: RepositoriesResult{
IncompleteResults: false,
Items: []Repository{{Name: "test"}},
Total: 1,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "api/v3/search/repositories", values),
httpmock.JSONResponse(RepositoriesResult{
IncompleteResults: false,
Items: []Repository{{Name: "test"}},
Total: 1,
}),
)
},
},
{
name: "paginates results",
query: query,
result: RepositoriesResult{
IncompleteResults: false,
Items: []Repository{{Name: "test"}, {Name: "cli"}},
Total: 2,
},
httpStubs: func(reg *httpmock.Registry) {
firstReq := httpmock.QueryMatcher("GET", "search/repositories", values)
firstRes := httpmock.JSONResponse(RepositoriesResult{
IncompleteResults: false,
Items: []Repository{{Name: "test"}},
Total: 2,
},
)
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/repositories?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{
"page": []string{"2"},
"per_page": []string{"29"},
"order": []string{"stars"},
"sort": []string{"desc"},
"q": []string{"keyword stars:>=5 topic:topic"},
},
)
secondRes := httpmock.JSONResponse(RepositoriesResult{
IncompleteResults: false,
Items: []Repository{{Name: "cli"}},
Total: 2,
},
)
reg.Register(firstReq, firstRes)
reg.Register(secondReq, secondRes)
},
},
{
name: "handles search errors",
query: query,
wantErr: true,
errMsg: heredoc.Doc(`
Invalid search query "keyword stars:>=5 topic:topic".
"blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "search/repositories", values),
httpmock.WithHeader(
httpmock.StatusStringResponse(422,
`{
"message":"Validation Failed",
"errors":[
{
"message":"\"blah\" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.",
"resource":"Search",
"field":"q",
"code":"invalid"
}
],
"documentation_url":"https://docs.github.com/v3/search/"
}`,
), "Content-Type", "application/json"),
)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
if tt.httpStubs != nil {
tt.httpStubs(reg)
}
client := &http.Client{Transport: reg}
if tt.host == "" {
tt.host = "github.com"
}
searcher := NewSearcher(client, tt.host)
result, err := searcher.Repositories(tt.query)
if tt.wantErr {
assert.EqualError(t, err, tt.errMsg)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.result, result)
})
}
}
func TestSearcherURL(t *testing.T) {
tests := []struct {
name string
host string
query Query
url string
}{
{
name: "outputs encoded query url",
query: query,
url: "https://github.com/search?order=stars&q=keyword+stars%3A%3E%3D5+topic%3Atopic&sort=desc&type=repositories",
},
{
name: "supports enterprise hosts",
host: "enterprise.com",
query: query,
url: "https://enterprise.com/search?order=stars&q=keyword+stars%3A%3E%3D5+topic%3Atopic&sort=desc&type=repositories",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.host == "" {
tt.host = "github.com"
}
searcher := NewSearcher(nil, tt.host)
assert.Equal(t, tt.url, searcher.URL(tt.query))
})
}
}

29
pkg/text/convert.go Normal file
View file

@ -0,0 +1,29 @@
package text
import "unicode"
// Copied from: https://github.com/asaskevich/govalidator
func CamelToKebab(str string) string {
var output []rune
var segment []rune
for _, r := range str {
if !unicode.IsLower(r) && string(r) != "-" && !unicode.IsNumber(r) {
output = addSegment(output, segment)
segment = nil
}
segment = append(segment, unicode.ToLower(r))
}
output = addSegment(output, segment)
return string(output)
}
func addSegment(inrune, segment []rune) []rune {
if len(segment) == 0 {
return inrune
}
if len(inrune) != 0 {
inrune = append(inrune, '-')
}
inrune = append(inrune, segment...)
return inrune
}

61
pkg/text/convert_test.go Normal file
View file

@ -0,0 +1,61 @@
package text
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCamelToKebab(t *testing.T) {
tests := []struct {
name string
in string
out string
}{
{
name: "single lowercase word",
in: "test",
out: "test",
},
{
name: "multiple mixed words",
in: "testTestTest",
out: "test-test-test",
},
{
name: "multiple uppercase words",
in: "TestTest",
out: "test-test",
},
{
name: "multiple lowercase words",
in: "testtest",
out: "testtest",
},
{
name: "multiple mixed words with number",
in: "test2Test",
out: "test2-test",
},
{
name: "multiple lowercase words with number",
in: "test2test",
out: "test2test",
},
{
name: "multiple lowercase words with dash",
in: "test-test",
out: "test-test",
},
{
name: "multiple uppercase words with dash",
in: "Test-Test",
out: "test--test",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.out, CamelToKebab(tt.in))
})
}
}