Skip to content

Commit ee6fc0f

Browse files
authored
Add search issues and search pull requests commands (cli#5334)
1 parent 3a94920 commit ee6fc0f

File tree

16 files changed

+1493
-55
lines changed

16 files changed

+1493
-55
lines changed

pkg/cmd/search/issues/issues.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package issues
2+
3+
import (
4+
"github.com/MakeNowJust/heredoc"
5+
"github.com/cli/cli/v2/pkg/cmd/search/shared"
6+
"github.com/cli/cli/v2/pkg/cmdutil"
7+
"github.com/cli/cli/v2/pkg/search"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobra.Command {
12+
var includePrs bool
13+
var locked bool
14+
var noAssignee, noLabel, noMilestone, noProject bool
15+
var order string
16+
var sort string
17+
opts := &shared.IssuesOptions{
18+
Browser: f.Browser,
19+
Entity: shared.Issues,
20+
IO: f.IOStreams,
21+
Query: search.Query{Kind: search.KindIssues,
22+
Qualifiers: search.Qualifiers{Type: "issue"}},
23+
}
24+
25+
cmd := &cobra.Command{
26+
Use: "issues [<query>]",
27+
Short: "Search for issues",
28+
Long: heredoc.Doc(`
29+
Search for issues on GitHub.
30+
31+
The command supports constructing queries using the GitHub search syntax,
32+
using the parameter and qualifier flags, or a combination of the two.
33+
34+
GitHub search syntax is documented at:
35+
<https://docs.github.com/search-github/searching-on-github/searching-issues-and-pull-requests>
36+
`),
37+
Example: heredoc.Doc(`
38+
# search issues matching set of keywords "readme" and "typo"
39+
$ gh search issues readme typo
40+
41+
# search issues matching phrase "broken feature"
42+
$ gh search issues "broken feature"
43+
44+
# search issues and pull requests in cli organization
45+
$ gh search issues --include-prs --owner=cli
46+
47+
# search open issues assigned to yourself
48+
$ gh search issues --assignee=@me --state=open
49+
50+
# search issues with numerous comments
51+
$ gh search issues --comments=">100"
52+
`),
53+
RunE: func(c *cobra.Command, args []string) error {
54+
if len(args) == 0 && c.Flags().NFlag() == 0 {
55+
return cmdutil.FlagErrorf("specify search keywords or flags")
56+
}
57+
if opts.Query.Limit < 1 || opts.Query.Limit > shared.SearchMaxResults {
58+
return cmdutil.FlagErrorf("`--limit` must be between 1 and 1000")
59+
}
60+
if includePrs {
61+
opts.Entity = shared.Both
62+
opts.Query.Qualifiers.Type = ""
63+
}
64+
if c.Flags().Changed("order") {
65+
opts.Query.Order = order
66+
}
67+
if c.Flags().Changed("sort") {
68+
opts.Query.Sort = sort
69+
}
70+
if c.Flags().Changed("locked") {
71+
if locked {
72+
opts.Query.Qualifiers.Is = append(opts.Query.Qualifiers.Is, "locked")
73+
} else {
74+
opts.Query.Qualifiers.Is = append(opts.Query.Qualifiers.Is, "unlocked")
75+
}
76+
}
77+
if c.Flags().Changed("no-assignee") && noAssignee {
78+
opts.Query.Qualifiers.No = append(opts.Query.Qualifiers.No, "assignee")
79+
}
80+
if c.Flags().Changed("no-label") && noLabel {
81+
opts.Query.Qualifiers.No = append(opts.Query.Qualifiers.No, "label")
82+
}
83+
if c.Flags().Changed("no-milestone") && noMilestone {
84+
opts.Query.Qualifiers.No = append(opts.Query.Qualifiers.No, "milestone")
85+
}
86+
if c.Flags().Changed("no-project") && noProject {
87+
opts.Query.Qualifiers.No = append(opts.Query.Qualifiers.No, "project")
88+
}
89+
opts.Query.Keywords = args
90+
if runF != nil {
91+
return runF(opts)
92+
}
93+
var err error
94+
opts.Searcher, err = shared.Searcher(f)
95+
if err != nil {
96+
return err
97+
}
98+
return shared.SearchIssues(opts)
99+
},
100+
}
101+
102+
// Output flags
103+
cmdutil.AddJSONFlags(cmd, &opts.Exporter, search.IssueFields)
104+
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the search query in the web browser")
105+
106+
// Query parameter flags
107+
cmd.Flags().IntVarP(&opts.Query.Limit, "limit", "L", 30, "Maximum number of results to fetch")
108+
cmdutil.StringEnumFlag(cmd, &order, "order", "", "desc", []string{"asc", "desc"}, "Order of results returned, ignored unless '--sort' flag is specified")
109+
cmdutil.StringEnumFlag(cmd, &sort, "sort", "", "best-match",
110+
[]string{
111+
"comments",
112+
"created",
113+
"interactions",
114+
"reactions",
115+
"reactions-+1",
116+
"reactions--1",
117+
"reactions-heart",
118+
"reactions-smile",
119+
"reactions-tada",
120+
"reactions-thinking_face",
121+
"updated",
122+
}, "Sort fetched results")
123+
124+
// Query qualifier flags
125+
cmd.Flags().BoolVar(&includePrs, "include-prs", false, "Include pull requests in results")
126+
cmdutil.NilBoolFlag(cmd, &opts.Query.Qualifiers.Archived, "archived", "", "Restrict search to archived repositories")
127+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Assignee, "assignee", "", "Filter by assignee")
128+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Author, "author", "", "Filter by author")
129+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Closed, "closed", "", "Filter on closed at `date`")
130+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Commenter, "commenter", "", "Filter based on comments by `user`")
131+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Comments, "comments", "", "Filter on `number` of comments")
132+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Created, "created", "", "Filter based on created at `date`")
133+
cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.In, "match", "", nil, []string{"title", "body", "comments"}, "Restrict search to specific field of issue")
134+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Interactions, "interactions", "", "Filter on `number` of reactions and comments")
135+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Involves, "involves", "", "Filter based on involvement of `user`")
136+
cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", nil, []string{"public", "private", "internal"}, "Filter based on repository visibility")
137+
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Label, "label", nil, "Filter on label")
138+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Language, "language", "", "Filter based on the coding language")
139+
cmd.Flags().BoolVar(&locked, "locked", false, "Filter on locked conversation status")
140+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Mentions, "mentions", "", "Filter based on `user` mentions")
141+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Milestone, "milestone", "", "Filter by milestone `title`")
142+
cmd.Flags().BoolVar(&noAssignee, "no-assignee", false, "Filter on missing assignee")
143+
cmd.Flags().BoolVar(&noLabel, "no-label", false, "Filter on missing label")
144+
cmd.Flags().BoolVar(&noMilestone, "no-milestone", false, "Filter on missing milestone")
145+
cmd.Flags().BoolVar(&noProject, "no-project", false, "Filter on missing project")
146+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Org, "owner", "", "Filter on owner")
147+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Project, "project", "", "Filter on project board `number`")
148+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Reactions, "reactions", "", "Filter on `number` of reactions")
149+
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Repo, "repo", nil, "Filter on repository")
150+
cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.State, "state", "", "", []string{"open", "closed"}, "Filter based on state")
151+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Team, "team-mentions", "", "Filter based on team mentions")
152+
cmd.Flags().StringVar(&opts.Query.Qualifiers.Updated, "updated", "", "Filter on last updated at `date`")
153+
154+
return cmd
155+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package issues
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
"github.com/cli/cli/v2/pkg/cmd/search/shared"
8+
"github.com/cli/cli/v2/pkg/cmdutil"
9+
"github.com/cli/cli/v2/pkg/iostreams"
10+
"github.com/cli/cli/v2/pkg/search"
11+
"github.com/google/shlex"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func TestNewCmdIssues(t *testing.T) {
16+
var trueBool = true
17+
tests := []struct {
18+
name string
19+
input string
20+
output shared.IssuesOptions
21+
wantErr bool
22+
errMsg string
23+
}{
24+
{
25+
name: "no arguments",
26+
input: "",
27+
wantErr: true,
28+
errMsg: "specify search keywords or flags",
29+
},
30+
{
31+
name: "keyword arguments",
32+
input: "some search terms",
33+
output: shared.IssuesOptions{
34+
Query: search.Query{
35+
Keywords: []string{"some", "search", "terms"},
36+
Kind: "issues",
37+
Limit: 30,
38+
Qualifiers: search.Qualifiers{Type: "issue"},
39+
},
40+
},
41+
},
42+
{
43+
name: "web flag",
44+
input: "--web",
45+
output: shared.IssuesOptions{
46+
Query: search.Query{
47+
Keywords: []string{},
48+
Kind: "issues",
49+
Limit: 30,
50+
Qualifiers: search.Qualifiers{Type: "issue"},
51+
},
52+
WebMode: true,
53+
},
54+
},
55+
{
56+
name: "limit flag",
57+
input: "--limit 10",
58+
output: shared.IssuesOptions{
59+
Query: search.Query{
60+
Keywords: []string{},
61+
Kind: "issues",
62+
Limit: 10,
63+
Qualifiers: search.Qualifiers{Type: "issue"},
64+
},
65+
},
66+
},
67+
{
68+
name: "invalid limit flag",
69+
input: "--limit 1001",
70+
wantErr: true,
71+
errMsg: "`--limit` must be between 1 and 1000",
72+
},
73+
{
74+
name: "order flag",
75+
input: "--order asc",
76+
output: shared.IssuesOptions{
77+
Query: search.Query{
78+
Keywords: []string{},
79+
Kind: "issues",
80+
Limit: 30,
81+
Order: "asc",
82+
Qualifiers: search.Qualifiers{Type: "issue"},
83+
},
84+
},
85+
},
86+
{
87+
name: "invalid order flag",
88+
input: "--order invalid",
89+
wantErr: true,
90+
errMsg: "invalid argument \"invalid\" for \"--order\" flag: valid values are {asc|desc}",
91+
},
92+
{
93+
name: "include-prs flag",
94+
input: "--include-prs",
95+
output: shared.IssuesOptions{
96+
Query: search.Query{
97+
Keywords: []string{},
98+
Kind: "issues",
99+
Limit: 30,
100+
Qualifiers: search.Qualifiers{Type: ""},
101+
},
102+
},
103+
},
104+
{
105+
name: "qualifier flags",
106+
input: `
107+
--archived
108+
--assignee=assignee
109+
--author=author
110+
--closed=closed
111+
--commenter=commenter
112+
--created=created
113+
--match=title,body
114+
--language=language
115+
--locked
116+
--mentions=mentions
117+
--no-label
118+
--repo=owner/repo
119+
--updated=updated
120+
--visibility=public
121+
`,
122+
output: shared.IssuesOptions{
123+
Query: search.Query{
124+
Keywords: []string{},
125+
Kind: "issues",
126+
Limit: 30,
127+
Qualifiers: search.Qualifiers{
128+
Archived: &trueBool,
129+
Assignee: "assignee",
130+
Author: "author",
131+
Closed: "closed",
132+
Commenter: "commenter",
133+
Created: "created",
134+
In: []string{"title", "body"},
135+
Is: []string{"public", "locked"},
136+
Language: "language",
137+
Mentions: "mentions",
138+
No: []string{"label"},
139+
Repo: []string{"owner/repo"},
140+
Type: "issue",
141+
Updated: "updated",
142+
},
143+
},
144+
},
145+
},
146+
}
147+
for _, tt := range tests {
148+
t.Run(tt.name, func(t *testing.T) {
149+
io, _, _, _ := iostreams.Test()
150+
f := &cmdutil.Factory{
151+
IOStreams: io,
152+
}
153+
argv, err := shlex.Split(tt.input)
154+
assert.NoError(t, err)
155+
var gotOpts *shared.IssuesOptions
156+
cmd := NewCmdIssues(f, func(opts *shared.IssuesOptions) error {
157+
gotOpts = opts
158+
return nil
159+
})
160+
cmd.SetArgs(argv)
161+
cmd.SetIn(&bytes.Buffer{})
162+
cmd.SetOut(&bytes.Buffer{})
163+
cmd.SetErr(&bytes.Buffer{})
164+
165+
_, err = cmd.ExecuteC()
166+
if tt.wantErr {
167+
assert.EqualError(t, err, tt.errMsg)
168+
return
169+
}
170+
171+
assert.NoError(t, err)
172+
assert.Equal(t, tt.output.Query, gotOpts.Query)
173+
assert.Equal(t, tt.output.WebMode, gotOpts.WebMode)
174+
})
175+
}
176+
}

0 commit comments

Comments
 (0)