Add search issues and search pull requests commands (#5334)
This commit is contained in:
parent
3a949203c4
commit
ee6fc0f9b7
16 changed files with 1493 additions and 55 deletions
155
pkg/cmd/search/issues/issues.go
Normal file
155
pkg/cmd/search/issues/issues.go
Normal 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
|
||||
}
|
||||
176
pkg/cmd/search/issues/issues_test.go
Normal file
176
pkg/cmd/search/issues/issues_test.go
Normal 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
168
pkg/cmd/search/prs/prs.go
Normal 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
|
||||
}
|
||||
161
pkg/cmd/search/prs/prs_test.go
Normal file
161
pkg/cmd/search/prs/prs_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
165
pkg/cmd/search/shared/shared.go
Normal file
165
pkg/cmd/search/shared/shared.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
220
pkg/cmd/search/shared/shared_test.go
Normal file
220
pkg/cmd/search/shared/shared_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue