Add top level search command and search repos sub command (#5172)
This commit is contained in:
parent
4a41fec2ed
commit
e0045f26b9
16 changed files with 1588 additions and 28 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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{}),
|
||||
|
|
|
|||
203
pkg/cmd/search/repos/repos.go
Normal file
203
pkg/cmd/search/repos/repos.go
Normal 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
|
||||
}
|
||||
295
pkg/cmd/search/repos/repos_test.go
Normal file
295
pkg/cmd/search/repos/repos_test.go
Normal 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
20
pkg/cmd/search/search.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
111
pkg/search/query.go
Normal 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
135
pkg/search/query_test.go
Normal 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
120
pkg/search/result.go
Normal 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
46
pkg/search/result_test.go
Normal 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
184
pkg/search/searcher.go
Normal 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
116
pkg/search/searcher_mock.go
Normal 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
198
pkg/search/searcher_test.go
Normal 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
29
pkg/text/convert.go
Normal 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
61
pkg/text/convert_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue