Add search issues and search pull requests commands (#5334)

This commit is contained in:
Sam Coe 2022-03-27 22:53:03 +03:00 committed by GitHub
parent 3a949203c4
commit ee6fc0f9b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1493 additions and 55 deletions

View file

@ -0,0 +1,155 @@
package issues
import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/pkg/cmd/search/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/search"
"github.com/spf13/cobra"
)
func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobra.Command {
var includePrs bool
var locked bool
var noAssignee, noLabel, noMilestone, noProject bool
var order string
var sort string
opts := &shared.IssuesOptions{
Browser: f.Browser,
Entity: shared.Issues,
IO: f.IOStreams,
Query: search.Query{Kind: search.KindIssues,
Qualifiers: search.Qualifiers{Type: "issue"}},
}
cmd := &cobra.Command{
Use: "issues [<query>]",
Short: "Search for issues",
Long: heredoc.Doc(`
Search for issues 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-issues-and-pull-requests>
`),
Example: heredoc.Doc(`
# search issues matching set of keywords "readme" and "typo"
$ gh search issues readme typo
# search issues matching phrase "broken feature"
$ gh search issues "broken feature"
# search issues and pull requests in cli organization
$ gh search issues --include-prs --owner=cli
# search open issues assigned to yourself
$ gh search issues --assignee=@me --state=open
# search issues with numerous comments
$ gh search issues --comments=">100"
`),
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 > shared.SearchMaxResults {
return cmdutil.FlagErrorf("`--limit` must be between 1 and 1000")
}
if includePrs {
opts.Entity = shared.Both
opts.Query.Qualifiers.Type = ""
}
if c.Flags().Changed("order") {
opts.Query.Order = order
}
if c.Flags().Changed("sort") {
opts.Query.Sort = sort
}
if c.Flags().Changed("locked") {
if locked {
opts.Query.Qualifiers.Is = append(opts.Query.Qualifiers.Is, "locked")
} else {
opts.Query.Qualifiers.Is = append(opts.Query.Qualifiers.Is, "unlocked")
}
}
if c.Flags().Changed("no-assignee") && noAssignee {
opts.Query.Qualifiers.No = append(opts.Query.Qualifiers.No, "assignee")
}
if c.Flags().Changed("no-label") && noLabel {
opts.Query.Qualifiers.No = append(opts.Query.Qualifiers.No, "label")
}
if c.Flags().Changed("no-milestone") && noMilestone {
opts.Query.Qualifiers.No = append(opts.Query.Qualifiers.No, "milestone")
}
if c.Flags().Changed("no-project") && noProject {
opts.Query.Qualifiers.No = append(opts.Query.Qualifiers.No, "project")
}
opts.Query.Keywords = args
if runF != nil {
return runF(opts)
}
var err error
opts.Searcher, err = shared.Searcher(f)
if err != nil {
return err
}
return shared.SearchIssues(opts)
},
}
// Output flags
cmdutil.AddJSONFlags(cmd, &opts.Exporter, search.IssueFields)
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 results to fetch")
cmdutil.StringEnumFlag(cmd, &order, "order", "", "desc", []string{"asc", "desc"}, "Order of results returned, ignored unless '--sort' flag is specified")
cmdutil.StringEnumFlag(cmd, &sort, "sort", "", "best-match",
[]string{
"comments",
"created",
"interactions",
"reactions",
"reactions-+1",
"reactions--1",
"reactions-heart",
"reactions-smile",
"reactions-tada",
"reactions-thinking_face",
"updated",
}, "Sort fetched results")
// Query qualifier flags
cmd.Flags().BoolVar(&includePrs, "include-prs", false, "Include pull requests in results")
cmdutil.NilBoolFlag(cmd, &opts.Query.Qualifiers.Archived, "archived", "", "Restrict search to archived repositories")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Assignee, "assignee", "", "Filter by assignee")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Author, "author", "", "Filter by author")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Closed, "closed", "", "Filter on closed at `date`")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Commenter, "commenter", "", "Filter based on comments by `user`")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Comments, "comments", "", "Filter on `number` of comments")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Created, "created", "", "Filter based on created at `date`")
cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.In, "match", "", nil, []string{"title", "body", "comments"}, "Restrict search to specific field of issue")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Interactions, "interactions", "", "Filter on `number` of reactions and comments")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Involves, "involves", "", "Filter based on involvement of `user`")
cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", nil, []string{"public", "private", "internal"}, "Filter based on repository visibility")
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Label, "label", nil, "Filter on label")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Language, "language", "", "Filter based on the coding language")
cmd.Flags().BoolVar(&locked, "locked", false, "Filter on locked conversation status")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Mentions, "mentions", "", "Filter based on `user` mentions")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Milestone, "milestone", "", "Filter by milestone `title`")
cmd.Flags().BoolVar(&noAssignee, "no-assignee", false, "Filter on missing assignee")
cmd.Flags().BoolVar(&noLabel, "no-label", false, "Filter on missing label")
cmd.Flags().BoolVar(&noMilestone, "no-milestone", false, "Filter on missing milestone")
cmd.Flags().BoolVar(&noProject, "no-project", false, "Filter on missing project")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Org, "owner", "", "Filter on owner")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Project, "project", "", "Filter on project board `number`")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Reactions, "reactions", "", "Filter on `number` of reactions")
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Repo, "repo", nil, "Filter on repository")
cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.State, "state", "", "", []string{"open", "closed"}, "Filter based on state")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Team, "team-mentions", "", "Filter based on team mentions")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Updated, "updated", "", "Filter on last updated at `date`")
return cmd
}

View file

@ -0,0 +1,176 @@
package issues
import (
"bytes"
"testing"
"github.com/cli/cli/v2/pkg/cmd/search/shared"
"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 TestNewCmdIssues(t *testing.T) {
var trueBool = true
tests := []struct {
name string
input string
output shared.IssuesOptions
wantErr bool
errMsg string
}{
{
name: "no arguments",
input: "",
wantErr: true,
errMsg: "specify search keywords or flags",
},
{
name: "keyword arguments",
input: "some search terms",
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{"some", "search", "terms"},
Kind: "issues",
Limit: 30,
Qualifiers: search.Qualifiers{Type: "issue"},
},
},
},
{
name: "web flag",
input: "--web",
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{},
Kind: "issues",
Limit: 30,
Qualifiers: search.Qualifiers{Type: "issue"},
},
WebMode: true,
},
},
{
name: "limit flag",
input: "--limit 10",
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{},
Kind: "issues",
Limit: 10,
Qualifiers: search.Qualifiers{Type: "issue"},
},
},
},
{
name: "invalid limit flag",
input: "--limit 1001",
wantErr: true,
errMsg: "`--limit` must be between 1 and 1000",
},
{
name: "order flag",
input: "--order asc",
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{},
Kind: "issues",
Limit: 30,
Order: "asc",
Qualifiers: search.Qualifiers{Type: "issue"},
},
},
},
{
name: "invalid order flag",
input: "--order invalid",
wantErr: true,
errMsg: "invalid argument \"invalid\" for \"--order\" flag: valid values are {asc|desc}",
},
{
name: "include-prs flag",
input: "--include-prs",
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{},
Kind: "issues",
Limit: 30,
Qualifiers: search.Qualifiers{Type: ""},
},
},
},
{
name: "qualifier flags",
input: `
--archived
--assignee=assignee
--author=author
--closed=closed
--commenter=commenter
--created=created
--match=title,body
--language=language
--locked
--mentions=mentions
--no-label
--repo=owner/repo
--updated=updated
--visibility=public
`,
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{},
Kind: "issues",
Limit: 30,
Qualifiers: search.Qualifiers{
Archived: &trueBool,
Assignee: "assignee",
Author: "author",
Closed: "closed",
Commenter: "commenter",
Created: "created",
In: []string{"title", "body"},
Is: []string{"public", "locked"},
Language: "language",
Mentions: "mentions",
No: []string{"label"},
Repo: []string{"owner/repo"},
Type: "issue",
Updated: "updated",
},
},
},
},
}
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 *shared.IssuesOptions
cmd := NewCmdIssues(f, func(opts *shared.IssuesOptions) 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)
})
}
}

168
pkg/cmd/search/prs/prs.go Normal file
View file

@ -0,0 +1,168 @@
package prs
import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/pkg/cmd/search/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/search"
"github.com/spf13/cobra"
)
func NewCmdPrs(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobra.Command {
var locked bool
var merged bool
var noAssignee, noLabel, noMilestone, noProject bool
var order string
var sort string
opts := &shared.IssuesOptions{
Browser: f.Browser,
Entity: shared.Issues,
IO: f.IOStreams,
Query: search.Query{Kind: search.KindIssues,
Qualifiers: search.Qualifiers{Type: "pr"}},
}
cmd := &cobra.Command{
Use: "prs [<query>]",
Short: "Search for pull requests",
Long: heredoc.Doc(`
Search for pull requests 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-issues-and-pull-requests>
`),
Example: heredoc.Doc(`
# search pull requests matching set of keywords "fix" and "bug"
$ gh search prs fix bug
# search draft pull requests in cli repository
$ gh search prs --repo=cli/cli --draft
# search open pull requests requesting your review
$ gh search prs --review-requested=@me --state=open
# search merged pull requests assigned to yourself
$ gh search prs --assignee=@me --merged
# search pull requests with numerous reactions
$ gh search prs --reactions=">100"
`),
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 > shared.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
}
if c.Flags().Changed("locked") && locked {
if locked {
opts.Query.Qualifiers.Is = append(opts.Query.Qualifiers.Is, "locked")
} else {
opts.Query.Qualifiers.Is = append(opts.Query.Qualifiers.Is, "unlocked")
}
}
if c.Flags().Changed("merged") {
if merged {
opts.Query.Qualifiers.Is = append(opts.Query.Qualifiers.Is, "merged")
} else {
opts.Query.Qualifiers.Is = append(opts.Query.Qualifiers.Is, "unmerged")
}
}
if c.Flags().Changed("no-assignee") && noAssignee {
opts.Query.Qualifiers.No = append(opts.Query.Qualifiers.No, "assignee")
}
if c.Flags().Changed("no-label") && noLabel {
opts.Query.Qualifiers.No = append(opts.Query.Qualifiers.No, "label")
}
if c.Flags().Changed("no-milestone") && noMilestone {
opts.Query.Qualifiers.No = append(opts.Query.Qualifiers.No, "milestone")
}
if c.Flags().Changed("no-project") && noProject {
opts.Query.Qualifiers.No = append(opts.Query.Qualifiers.No, "project")
}
opts.Query.Keywords = args
if runF != nil {
return runF(opts)
}
var err error
opts.Searcher, err = shared.Searcher(f)
if err != nil {
return err
}
return shared.SearchIssues(opts)
},
}
// Output flags
cmdutil.AddJSONFlags(cmd, &opts.Exporter, search.IssueFields)
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 results to fetch")
cmdutil.StringEnumFlag(cmd, &order, "order", "", "desc", []string{"asc", "desc"}, "Order of results returned, ignored unless '--sort' flag is specified")
cmdutil.StringEnumFlag(cmd, &sort, "sort", "", "best-match",
[]string{
"comments",
"reactions",
"reactions-+1",
"reactions--1",
"reactions-smile",
"reactions-thinking_face",
"reactions-heart",
"reactions-tada",
"interactions",
"created",
"updated",
}, "Sort fetched results")
// Issue query qualifier flags
cmdutil.NilBoolFlag(cmd, &opts.Query.Qualifiers.Archived, "archived", "", "Restrict search to archived repositories")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Assignee, "assignee", "", "Filter by assignee")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Author, "author", "", "Filter by author")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Closed, "closed", "", "Filter on closed at `date`")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Commenter, "commenter", "", "Filter based on comments by `user`")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Comments, "comments", "", "Filter on `number` of comments")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Created, "created", "", "Filter based on created at `date`")
cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.In, "match", "", nil, []string{"title", "body", "comments"}, "Restrict search to specific field of issue")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Interactions, "interactions", "", "Filter on `number` of reactions and comments")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Involves, "involves", "", "Filter based on involvement of `user`")
cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", nil, []string{"public", "private", "internal"}, "Filter based on repository visibility")
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Label, "label", nil, "Filter on label")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Language, "language", "", "Filter based on the coding language")
cmd.Flags().BoolVar(&locked, "locked", false, "Filter on locked conversation status")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Mentions, "mentions", "", "Filter based on `user` mentions")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Milestone, "milestone", "", "Filter by milestone `title`")
cmd.Flags().BoolVar(&noAssignee, "no-assignee", false, "Filter on missing assignee")
cmd.Flags().BoolVar(&noLabel, "no-label", false, "Filter on missing label")
cmd.Flags().BoolVar(&noMilestone, "no-milestone", false, "Filter on missing milestone")
cmd.Flags().BoolVar(&noProject, "no-project", false, "Filter on missing project")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Org, "owner", "", "Filter on owner")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Project, "project", "", "Filter on project board `number`")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Reactions, "reactions", "", "Filter on `number` of reactions")
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Repo, "repo", nil, "Filter on repository")
cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.State, "state", "", "", []string{"open", "closed"}, "Filter based on state")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Team, "team-mentions", "", "Filter based on team mentions")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Updated, "updated", "", "Filter on last updated at `date`")
// Pull request query qualifier flags
cmd.Flags().StringVarP(&opts.Query.Qualifiers.Base, "base", "B", "", "Filter on base branch name")
cmdutil.NilBoolFlag(cmd, &opts.Query.Qualifiers.Draft, "draft", "", "Filter based on draft state")
cmd.Flags().StringVarP(&opts.Query.Qualifiers.Head, "head", "H", "", "Filter on head branch name")
cmd.Flags().StringVar(&opts.Query.Qualifiers.Merged, "merged-at", "", "Filter on merged at `date`")
cmd.Flags().BoolVar(&merged, "merged", false, "Filter based on merged state")
cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.Review, "review", "", "", []string{"none", "required", "approved", "changes_requested"}, "Filter based on review status")
cmd.Flags().StringVar(&opts.Query.Qualifiers.ReviewRequested, "review-requested", "", "Filter on `user` requested to review")
cmd.Flags().StringVar(&opts.Query.Qualifiers.ReviewedBy, "reviewed-by", "", "Filter on `user` who reviewed")
cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.Status, "checks", "", "", []string{"pending", "success", "failure"}, "Filter based on status of the checks")
return cmd
}

View file

@ -0,0 +1,161 @@
package prs
import (
"bytes"
"testing"
"github.com/cli/cli/v2/pkg/cmd/search/shared"
"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 TestNewCmdPrs(t *testing.T) {
var trueBool = true
tests := []struct {
name string
input string
output shared.IssuesOptions
wantErr bool
errMsg string
}{
{
name: "no arguments",
input: "",
wantErr: true,
errMsg: "specify search keywords or flags",
},
{
name: "keyword arguments",
input: "some search terms",
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{"some", "search", "terms"},
Kind: "issues",
Limit: 30,
Qualifiers: search.Qualifiers{Type: "pr"},
},
},
},
{
name: "web flag",
input: "--web",
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{},
Kind: "issues",
Limit: 30,
Qualifiers: search.Qualifiers{Type: "pr"},
},
WebMode: true,
},
},
{
name: "limit flag",
input: "--limit 10",
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{},
Kind: "issues",
Limit: 10,
Qualifiers: search.Qualifiers{Type: "pr"},
},
},
},
{
name: "invalid limit flag",
input: "--limit 1001",
wantErr: true,
errMsg: "`--limit` must be between 1 and 1000",
},
{
name: "order flag",
input: "--order asc",
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{},
Kind: "issues",
Limit: 30,
Order: "asc",
Qualifiers: search.Qualifiers{Type: "pr"},
},
},
},
{
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
--assignee=assignee
--author=author
--closed=closed
--commenter=commenter
--created=created
--match=title,body
--language=language
--locked
--merged
--no-milestone
--updated=updated
--visibility=public
`,
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{},
Kind: "issues",
Limit: 30,
Qualifiers: search.Qualifiers{
Archived: &trueBool,
Assignee: "assignee",
Author: "author",
Closed: "closed",
Commenter: "commenter",
Created: "created",
In: []string{"title", "body"},
Is: []string{"public", "locked", "merged"},
Language: "language",
No: []string{"milestone"},
Type: "pr",
Updated: "updated",
},
},
},
},
}
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 *shared.IssuesOptions
cmd := NewCmdPrs(f, func(opts *shared.IssuesOptions) 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)
})
}
}

View file

@ -6,6 +6,7 @@ import (
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/pkg/cmd/search/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/search"
@ -14,12 +15,6 @@ import (
"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
@ -48,7 +43,7 @@ func NewCmdRepos(f *cmdutil.Factory, runF func(*ReposOptions) error) *cobra.Comm
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
<https://docs.github.com/search-github/searching-on-github/searching-for-repositories>
`),
Example: heredoc.Doc(`
# search repositories matching set of keywords "cli" and "shell"
@ -70,7 +65,7 @@ func NewCmdRepos(f *cmdutil.Factory, runF func(*ReposOptions) error) *cobra.Comm
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 {
if opts.Query.Limit < 1 || opts.Query.Limit > shared.SearchMaxResults {
return cmdutil.FlagErrorf("`--limit` must be between 1 and 1000")
}
if c.Flags().Changed("order") {
@ -84,7 +79,7 @@ func NewCmdRepos(f *cmdutil.Factory, runF func(*ReposOptions) error) *cobra.Comm
return runF(opts)
}
var err error
opts.Searcher, err = searcher(f)
opts.Searcher, err = shared.Searcher(f)
if err != nil {
return err
}
@ -118,7 +113,7 @@ func NewCmdRepos(f *cmdutil.Factory, runF func(*ReposOptions) error) *cobra.Comm
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")
cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", nil, []string{"public", "private", "internal"}, "Filter based on visibility")
return cmd
}
@ -185,19 +180,3 @@ func displayResults(io *iostreams.IOStreams, results search.RepositoriesResult)
}
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

@ -110,7 +110,7 @@ func TestNewCmdRepos(t *testing.T) {
Stars: "6",
Topic: []string{"topic"},
Topics: "7",
Is: "public",
Is: []string{"public"},
},
},
},
@ -147,7 +147,7 @@ func TestNewCmdRepos(t *testing.T) {
}
}
func Test_ReposRun(t *testing.T) {
func TestReposRun(t *testing.T) {
var query = search.Query{
Keywords: []string{"cli"},
Kind: "repositories",

View file

@ -4,6 +4,8 @@ import (
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
searchIssuesCmd "github.com/cli/cli/v2/pkg/cmd/search/issues"
searchPrsCmd "github.com/cli/cli/v2/pkg/cmd/search/prs"
searchReposCmd "github.com/cli/cli/v2/pkg/cmd/search/repos"
)
@ -14,6 +16,8 @@ func NewCmdSearch(f *cmdutil.Factory) *cobra.Command {
Long: "Search across all of GitHub.",
}
cmd.AddCommand(searchIssuesCmd.NewCmdIssues(f, nil))
cmd.AddCommand(searchPrsCmd.NewCmdPrs(f, nil))
cmd.AddCommand(searchReposCmd.NewCmdRepos(f, nil))
return cmd

View file

@ -0,0 +1,165 @@
package shared
import (
"fmt"
"strconv"
"strings"
"time"
"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"
)
type EntityType int
const (
// Limitation of GitHub search see:
// https://docs.github.com/en/rest/reference/search
SearchMaxResults = 1000
Both EntityType = iota
Issues
PullRequests
)
type IssuesOptions struct {
Browser cmdutil.Browser
Entity EntityType
Exporter cmdutil.Exporter
IO *iostreams.IOStreams
Query search.Query
Searcher search.Searcher
WebMode bool
}
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
}
func SearchIssues(opts *IssuesOptions) 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.Issues(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 displayIssueResults(io, opts.Entity, result)
}
func displayIssueResults(io *iostreams.IOStreams, et EntityType, results search.IssuesResult) error {
cs := io.ColorScheme()
tp := utils.NewTablePrinter(io)
for _, issue := range results.Items {
if et == Both {
kind := "issue"
if issue.IsPullRequest() {
kind = "pr"
}
tp.AddField(kind, nil, nil)
}
comp := strings.Split(issue.RepositoryURL, "/")
name := comp[len(comp)-2:]
tp.AddField(strings.Join(name, "/"), nil, nil)
issueNum := strconv.Itoa(issue.Number)
if tp.IsTTY() {
issueNum = "#" + issueNum
}
tp.AddField(issueNum, nil, cs.ColorFromString(colorForIssueState(issue.State)))
if !tp.IsTTY() {
tp.AddField(issue.State, nil, nil)
}
tp.AddField(text.ReplaceExcessiveWhitespace(issue.Title), nil, nil)
tp.AddField(listIssueLabels(&issue, cs, tp.IsTTY()), nil, nil)
now := time.Now()
ago := now.Sub(issue.UpdatedAt)
if tp.IsTTY() {
tp.AddField(utils.FuzzyAgo(ago), nil, cs.Gray)
} else {
tp.AddField(issue.UpdatedAt.String(), nil, nil)
}
tp.EndRow()
}
if io.IsStdoutTTY() {
var header string
if len(results.Items) == 0 {
switch et {
case Both:
header = "No issues or pull requests matched your search\n"
case Issues:
header = "No issues matched your search\n"
case PullRequests:
header = "No pull requests matched your search\n"
}
} else {
switch et {
case Both:
header = fmt.Sprintf("Showing %d of %d issues and pull requests\n\n", len(results.Items), results.Total)
case Issues:
header = fmt.Sprintf("Showing %d of %d issues\n\n", len(results.Items), results.Total)
case PullRequests:
header = fmt.Sprintf("Showing %d of %d pull requests\n\n", len(results.Items), results.Total)
}
}
fmt.Fprintf(io.Out, "\n%s", header)
}
return tp.Render()
}
func listIssueLabels(issue *search.Issue, cs *iostreams.ColorScheme, colorize bool) string {
if len(issue.Labels) == 0 {
return ""
}
labelNames := make([]string, 0, len(issue.Labels))
for _, label := range issue.Labels {
if colorize {
labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name))
} else {
labelNames = append(labelNames, label.Name)
}
}
return strings.Join(labelNames, ", ")
}
func colorForIssueState(state string) string {
switch state {
case "open":
return "green"
case "closed":
return "red"
case "merged":
return "magenta"
default:
return ""
}
}

View file

@ -0,0 +1,220 @@
package shared
import (
"fmt"
"testing"
"time"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/factory"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/search"
"github.com/stretchr/testify/assert"
)
func TestSearcher(t *testing.T) {
f := factory.New("1")
f.Config = func() (config.Config, error) {
return config.NewBlankConfig(), nil
}
_, err := Searcher(f)
assert.NoError(t, err)
}
func TestSearchIssues(t *testing.T) {
query := search.Query{
Keywords: []string{"keyword"},
Kind: "issues",
Limit: 30,
Qualifiers: search.Qualifiers{
Language: "go",
Type: "issue",
Is: []string{"public", "locked"},
},
}
var updatedAt = time.Date(2021, 2, 28, 12, 30, 0, 0, time.UTC)
tests := []struct {
errMsg string
name string
opts *IssuesOptions
tty bool
wantErr bool
wantStderr string
wantStdout string
}{
{
name: "displays results tty",
opts: &IssuesOptions{
Entity: Issues,
Query: query,
Searcher: &search.SearcherMock{
IssuesFunc: func(query search.Query) (search.IssuesResult, error) {
return search.IssuesResult{
IncompleteResults: false,
Items: []search.Issue{
{RepositoryURL: "github.com/test/cli", Number: 123, State: "open", Title: "something broken", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt},
{RepositoryURL: "github.com/what/what", Number: 456, State: "closed", Title: "feature request", Labels: []search.Label{{Name: "enhancement"}}, UpdatedAt: updatedAt},
{RepositoryURL: "github.com/blah/test", Number: 789, State: "open", Title: "some title", UpdatedAt: updatedAt},
},
Total: 300,
}, nil
},
},
},
tty: true,
wantStdout: "\nShowing 3 of 300 issues\n\ntest/cli #123 something broken bug, p1 about 1 year ago\nwhat/what #456 feature request enhancement about 1 year ago\nblah/test #789 some title about 1 year ago\n",
},
{
name: "displays issues and pull requests tty",
opts: &IssuesOptions{
Entity: Both,
Query: query,
Searcher: &search.SearcherMock{
IssuesFunc: func(query search.Query) (search.IssuesResult, error) {
return search.IssuesResult{
IncompleteResults: false,
Items: []search.Issue{
{RepositoryURL: "github.com/test/cli", Number: 123, State: "open", Title: "bug", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt},
{RepositoryURL: "github.com/what/what", Number: 456, State: "open", Title: "fix bug", Labels: []search.Label{{Name: "fix"}}, PullRequestLinks: search.PullRequestLinks{URL: "someurl"}, UpdatedAt: updatedAt},
},
Total: 300,
}, nil
},
},
},
tty: true,
wantStdout: "\nShowing 2 of 300 issues and pull requests\n\nissue test/cli #123 bug bug, p1 about 1 year ago\npr what/what #456 fix bug fix about 1 year ago\n",
},
{
name: "displays no results tty",
opts: &IssuesOptions{
Entity: Issues,
Query: query,
Searcher: &search.SearcherMock{
IssuesFunc: func(query search.Query) (search.IssuesResult, error) {
return search.IssuesResult{}, nil
},
},
},
tty: true,
wantStdout: "\nNo issues matched your search\n",
},
{
name: "displays results notty",
opts: &IssuesOptions{
Entity: Issues,
Query: query,
Searcher: &search.SearcherMock{
IssuesFunc: func(query search.Query) (search.IssuesResult, error) {
return search.IssuesResult{
IncompleteResults: false,
Items: []search.Issue{
{RepositoryURL: "github.com/test/cli", Number: 123, State: "open", Title: "something broken", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt},
{RepositoryURL: "github.com/what/what", Number: 456, State: "closed", Title: "feature request", Labels: []search.Label{{Name: "enhancement"}}, UpdatedAt: updatedAt},
{RepositoryURL: "github.com/blah/test", Number: 789, State: "open", Title: "some title", UpdatedAt: updatedAt},
},
Total: 300,
}, nil
},
},
},
wantStdout: "test/cli\t123\topen\tsomething broken\tbug, p1\t2021-02-28 12:30:00 +0000 UTC\nwhat/what\t456\tclosed\tfeature request\tenhancement\t2021-02-28 12:30:00 +0000 UTC\nblah/test\t789\topen\tsome title\t\t2021-02-28 12:30:00 +0000 UTC\n",
},
{
name: "displays issues and pull requests notty",
opts: &IssuesOptions{
Entity: Both,
Query: query,
Searcher: &search.SearcherMock{
IssuesFunc: func(query search.Query) (search.IssuesResult, error) {
return search.IssuesResult{
IncompleteResults: false,
Items: []search.Issue{
{RepositoryURL: "github.com/test/cli", Number: 123, State: "open", Title: "bug", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt},
{RepositoryURL: "github.com/what/what", Number: 456, State: "open", Title: "fix bug", Labels: []search.Label{{Name: "fix"}}, PullRequestLinks: search.PullRequestLinks{URL: "someurl"}, UpdatedAt: updatedAt},
},
Total: 300,
}, nil
},
},
},
wantStdout: "issue\ttest/cli\t123\topen\tbug\tbug, p1\t2021-02-28 12:30:00 +0000 UTC\npr\twhat/what\t456\topen\tfix bug\tfix\t2021-02-28 12:30:00 +0000 UTC\n",
},
{
name: "displays no results notty",
opts: &IssuesOptions{
Entity: Issues,
Query: query,
Searcher: &search.SearcherMock{
IssuesFunc: func(query search.Query) (search.IssuesResult, error) {
return search.IssuesResult{}, nil
},
},
},
},
{
name: "displays search error",
opts: &IssuesOptions{
Entity: Issues,
Query: query,
Searcher: &search.SearcherMock{
IssuesFunc: func(query search.Query) (search.IssuesResult, error) {
return search.IssuesResult{}, fmt.Errorf("error with query")
},
},
},
errMsg: "error with query",
wantErr: true,
},
{
name: "opens browser for web mode tty",
opts: &IssuesOptions{
Browser: &cmdutil.TestBrowser{},
Entity: Issues,
Query: query,
Searcher: &search.SearcherMock{
URLFunc: func(query search.Query) string {
return "https://github.com/search?type=issues&q=cli"
},
},
WebMode: true,
},
tty: true,
wantStderr: "Opening github.com/search in your browser.\n",
},
{
name: "opens browser for web mode notty",
opts: &IssuesOptions{
Browser: &cmdutil.TestBrowser{},
Entity: Issues,
Query: query,
Searcher: &search.SearcherMock{
URLFunc: func(query search.Query) string {
return "https://github.com/search?type=issues&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 := SearchIssues(tt.opts)
if tt.wantErr {
assert.EqualError(t, err, tt.errMsg)
return
} else if err != nil {
t.Fatalf("SearchIssues unexpected error: %v", err)
}
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}

View file

@ -11,6 +11,7 @@ import (
const (
KindRepositories = "repositories"
KindIssues = "issues"
)
type Query struct {
@ -25,22 +26,48 @@ type Query struct {
type Qualifiers struct {
Archived *bool
Assignee string
Author string
Base string
Closed string
Commenter string
Comments string
Created string
Draft *bool
Followers string
Fork string
Forks string
GoodFirstIssues string
Head string
HelpWantedIssues string
In []string
Is string
Interactions string
Involves string
Is []string
Label []string
Language string
License []string
Mentions string
Merged string
Milestone string
No []string
Org string
Project string
Pushed string
Reactions string
Repo []string
Review string
ReviewRequested string
ReviewedBy string
Size string
Stars string
State string
Status string
Team string
Topic []string
Topics string
Type string
Updated string
}
func (q Query) String() string {

View file

@ -35,7 +35,7 @@ func TestQueryString(t *testing.T) {
Stars: "6",
Topic: []string{"topic"},
Topics: "7",
Is: "public",
Is: []string{"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",
@ -89,7 +89,7 @@ func TestQualifiersMap(t *testing.T) {
Stars: "6",
Topic: []string{"topic"},
Topics: "7",
Is: "public",
Is: []string{"public"},
},
out: map[string][]string{
"archived": {"true"},

View file

@ -32,16 +32,43 @@ var RepositoryFields = []string{
"size",
"stargazersCount",
"updatedAt",
"url",
"visibility",
"watchersCount",
}
var IssueFields = []string{
"assignees",
"author",
"authorAssociation",
"body",
"closedAt",
"commentsCount",
"createdAt",
"id",
"isLocked",
"isPullRequest",
"labels",
"number",
"repository",
"state",
"title",
"updatedAt",
"url",
}
type RepositoriesResult struct {
IncompleteResults bool `json:"incomplete_results"`
Items []Repository `json:"items"`
Total int `json:"total_count"`
}
type IssuesResult struct {
IncompleteResults bool `json:"incomplete_results"`
Items []Issue `json:"items"`
Total int `json:"total_count"`
}
type Repository struct {
CreatedAt time.Time `json:"created_at"`
DefaultBranch string `json:"default_branch"`
@ -54,7 +81,7 @@ type Repository struct {
HasProjects bool `json:"has_projects"`
HasWiki bool `json:"has_wiki"`
Homepage string `json:"homepage"`
ID int64 `json:"id"`
ID string `json:"node_id"`
IsArchived bool `json:"archived"`
IsDisabled bool `json:"disabled"`
IsFork bool `json:"fork"`
@ -68,24 +95,56 @@ type Repository struct {
PushedAt time.Time `json:"pushed_at"`
Size int `json:"size"`
StargazersCount int `json:"stargazers_count"`
URL string `json:"html_url"`
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"`
Key string `json:"key"`
Name string `json:"name"`
URL string `json:"url"`
}
type User struct {
GravatarID string `json:"gravatar_id"`
ID int64 `json:"id"`
ID string `json:"node_id"`
Login string `json:"login"`
SiteAdmin bool `json:"site_admin"`
Type string `json:"type"`
URL string `json:"html_url"`
}
type Issue struct {
Assignees []User `json:"assignees"`
Author User `json:"user"`
AuthorAssociation string `json:"author_association"`
Body string `json:"body"`
ClosedAt time.Time `json:"closed_at"`
CommentsCount int `json:"comments"`
CreatedAt time.Time `json:"created_at"`
ID string `json:"node_id"`
Labels []Label `json:"labels"`
IsLocked bool `json:"locked"`
Number int `json:"number"`
PullRequestLinks PullRequestLinks `json:"pull_request"`
RepositoryURL string `json:"repository_url"`
State string `json:"state"`
Title string `json:"title"`
URL string `json:"html_url"`
UpdatedAt time.Time `json:"updated_at"`
}
type PullRequestLinks struct {
URL string `json:"html_url"`
}
type Label struct {
Color string `json:"color"`
Description string `json:"description"`
ID string `json:"node_id"`
Name string `json:"name"`
}
func (repo Repository) ExportData(fields []string) map[string]interface{} {
@ -104,6 +163,59 @@ func (repo Repository) ExportData(fields []string) map[string]interface{} {
"id": repo.Owner.ID,
"login": repo.Owner.Login,
"type": repo.Owner.Type,
"url": repo.Owner.URL,
}
default:
sf := fieldByName(v, f)
data[f] = sf.Interface()
}
}
return data
}
func (issue Issue) IsPullRequest() bool {
return issue.PullRequestLinks.URL != ""
}
func (issue Issue) ExportData(fields []string) map[string]interface{} {
v := reflect.ValueOf(issue)
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "assignees":
assignees := make([]interface{}, 0, len(issue.Assignees))
for _, assignee := range issue.Assignees {
assignees = append(assignees, map[string]interface{}{
"id": assignee.ID,
"login": assignee.Login,
"type": assignee.Type,
})
}
data[f] = assignees
case "author":
data[f] = map[string]interface{}{
"id": issue.Author.ID,
"login": issue.Author.Login,
"type": issue.Author.Type,
}
case "isPullRequest":
data[f] = issue.IsPullRequest()
case "labels":
labels := make([]interface{}, 0, len(issue.Labels))
for _, label := range issue.Labels {
labels = append(labels, map[string]interface{}{
"color": label.Color,
"description": label.Description,
"id": label.ID,
"name": label.Name,
})
}
data[f] = labels
case "repository":
comp := strings.Split(issue.RepositoryURL, "/")
nameWithOwner := strings.Join(comp[len(comp)-2:], "/")
data[f] = map[string]interface{}{
"nameWithOwner": nameWithOwner,
}
default:
sf := fieldByName(v, f)

View file

@ -44,3 +44,37 @@ func TestRepositoryExportData(t *testing.T) {
})
}
}
func TestIssueExportData(t *testing.T) {
var updatedAt = time.Date(2021, 2, 28, 12, 30, 0, 0, time.UTC)
tests := []struct {
name string
fields []string
issue Issue
output string
}{
{
name: "exports requested fields",
fields: []string{"assignees", "body", "commentsCount", "labels", "isLocked", "title", "updatedAt"},
issue: Issue{
Assignees: []User{{Login: "test"}},
Body: "body",
CommentsCount: 1,
Labels: []Label{{Name: "label1"}, {Name: "label2"}},
IsLocked: true,
Title: "title",
UpdatedAt: updatedAt,
},
output: `{"assignees":[{"id":"","login":"test","type":""}],"body":"body","commentsCount":1,"isLocked":true,"labels":[{"color":"","description":"","id":"","name":"label1"},{"color":"","description":"","id":"","name":"label2"}],"title":"title","updatedAt":"2021-02-28T12:30:00Z"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
exported := tt.issue.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()))
})
}
}

View file

@ -26,6 +26,7 @@ var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
//go:generate moq -rm -out searcher_mock.go . Searcher
type Searcher interface {
Repositories(Query) (RepositoriesResult, error)
Issues(Query) (IssuesResult, error)
URL(Query) string
}
@ -79,6 +80,30 @@ func (s searcher) Repositories(query Query) (RepositoriesResult, error) {
return result, nil
}
func (s searcher) Issues(query Query) (IssuesResult, error) {
result := IssuesResult{}
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 := IssuesResult{}
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{}

View file

@ -17,6 +17,9 @@ var _ Searcher = &SearcherMock{}
//
// // make and configure a mocked Searcher
// mockedSearcher := &SearcherMock{
// IssuesFunc: func(query Query) (IssuesResult, error) {
// panic("mock out the Issues method")
// },
// RepositoriesFunc: func(query Query) (RepositoriesResult, error) {
// panic("mock out the Repositories method")
// },
@ -30,6 +33,9 @@ var _ Searcher = &SearcherMock{}
//
// }
type SearcherMock struct {
// IssuesFunc mocks the Issues method.
IssuesFunc func(query Query) (IssuesResult, error)
// RepositoriesFunc mocks the Repositories method.
RepositoriesFunc func(query Query) (RepositoriesResult, error)
@ -38,6 +44,11 @@ type SearcherMock struct {
// calls tracks calls to the methods.
calls struct {
// Issues holds details about calls to the Issues method.
Issues []struct {
// Query is the query argument value.
Query Query
}
// Repositories holds details about calls to the Repositories method.
Repositories []struct {
// Query is the query argument value.
@ -49,10 +60,42 @@ type SearcherMock struct {
Query Query
}
}
lockIssues sync.RWMutex
lockRepositories sync.RWMutex
lockURL sync.RWMutex
}
// Issues calls IssuesFunc.
func (mock *SearcherMock) Issues(query Query) (IssuesResult, error) {
if mock.IssuesFunc == nil {
panic("SearcherMock.IssuesFunc: method is nil but Searcher.Issues was just called")
}
callInfo := struct {
Query Query
}{
Query: query,
}
mock.lockIssues.Lock()
mock.calls.Issues = append(mock.calls.Issues, callInfo)
mock.lockIssues.Unlock()
return mock.IssuesFunc(query)
}
// IssuesCalls gets all the calls that were made to Issues.
// Check the length with:
// len(mockedSearcher.IssuesCalls())
func (mock *SearcherMock) IssuesCalls() []struct {
Query Query
} {
var calls []struct {
Query Query
}
mock.lockIssues.RLock()
calls = mock.calls.Issues
mock.lockIssues.RUnlock()
return calls
}
// Repositories calls RepositoriesFunc.
func (mock *SearcherMock) Repositories(query Query) (RepositoriesResult, error) {
if mock.RepositoriesFunc == nil {

View file

@ -10,24 +10,24 @@ import (
"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) {
query := Query{
Keywords: []string{"keyword"},
Kind: "repositories",
Limit: 30,
Order: "desc",
Sort: "stars",
Qualifiers: Qualifiers{
Stars: ">=5",
Topic: []string{"topic"},
},
}
values := url.Values{
"page": []string{"1"},
"per_page": []string{"30"},
"order": []string{"stars"},
"sort": []string{"desc"},
"order": []string{"desc"},
"sort": []string{"stars"},
"q": []string{"keyword stars:>=5 topic:topic"},
}
@ -99,8 +99,8 @@ func TestSearcherRepositories(t *testing.T) {
secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{
"page": []string{"2"},
"per_page": []string{"29"},
"order": []string{"stars"},
"sort": []string{"desc"},
"order": []string{"desc"},
"sort": []string{"stars"},
"q": []string{"keyword stars:>=5 topic:topic"},
},
)
@ -167,7 +167,176 @@ func TestSearcherRepositories(t *testing.T) {
}
}
func TestSearcherIssues(t *testing.T) {
query := Query{
Keywords: []string{"keyword"},
Kind: "issues",
Limit: 30,
Order: "desc",
Sort: "comments",
Qualifiers: Qualifiers{
Language: "go",
Is: []string{"public", "locked"},
},
}
values := url.Values{
"page": []string{"1"},
"per_page": []string{"30"},
"order": []string{"desc"},
"sort": []string{"comments"},
"q": []string{"keyword is:locked is:public language:go"},
}
tests := []struct {
name string
host string
query Query
result IssuesResult
wantErr bool
errMsg string
httpStubs func(*httpmock.Registry)
}{
{
name: "searches issues",
query: query,
result: IssuesResult{
IncompleteResults: false,
Items: []Issue{{Number: 1234}},
Total: 1,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "search/issues", values),
httpmock.JSONResponse(IssuesResult{
IncompleteResults: false,
Items: []Issue{{Number: 1234}},
Total: 1,
}),
)
},
},
{
name: "searches issues for enterprise host",
host: "enterprise.com",
query: query,
result: IssuesResult{
IncompleteResults: false,
Items: []Issue{{Number: 1234}},
Total: 1,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "api/v3/search/issues", values),
httpmock.JSONResponse(IssuesResult{
IncompleteResults: false,
Items: []Issue{{Number: 1234}},
Total: 1,
}),
)
},
},
{
name: "paginates results",
query: query,
result: IssuesResult{
IncompleteResults: false,
Items: []Issue{{Number: 1234}, {Number: 5678}},
Total: 2,
},
httpStubs: func(reg *httpmock.Registry) {
firstReq := httpmock.QueryMatcher("GET", "search/issues", values)
firstRes := httpmock.JSONResponse(IssuesResult{
IncompleteResults: false,
Items: []Issue{{Number: 1234}},
Total: 2,
},
)
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/issues?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
secondReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{
"page": []string{"2"},
"per_page": []string{"29"},
"order": []string{"desc"},
"sort": []string{"comments"},
"q": []string{"keyword is:locked is:public language:go"},
},
)
secondRes := httpmock.JSONResponse(IssuesResult{
IncompleteResults: false,
Items: []Issue{{Number: 5678}},
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 is:locked is:public language:go".
"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/issues", 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.Issues(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) {
query := Query{
Keywords: []string{"keyword"},
Kind: "repositories",
Limit: 30,
Order: "desc",
Sort: "stars",
Qualifiers: Qualifiers{
Stars: ">=5",
Topic: []string{"topic"},
},
}
tests := []struct {
name string
host string
@ -177,13 +346,13 @@ func TestSearcherURL(t *testing.T) {
{
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",
url: "https://github.com/search?order=desc&q=keyword+stars%3A%3E%3D5+topic%3Atopic&sort=stars&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",
url: "https://enterprise.com/search?order=desc&q=keyword+stars%3A%3E%3D5+topic%3Atopic&sort=stars&type=repositories",
},
}
for _, tt := range tests {