diff --git a/pkg/cmd/search/issues/issues.go b/pkg/cmd/search/issues/issues.go new file mode 100644 index 000000000..e157c6b95 --- /dev/null +++ b/pkg/cmd/search/issues/issues.go @@ -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 []", + 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: + + `), + 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 +} diff --git a/pkg/cmd/search/issues/issues_test.go b/pkg/cmd/search/issues/issues_test.go new file mode 100644 index 000000000..e510a3c6d --- /dev/null +++ b/pkg/cmd/search/issues/issues_test.go @@ -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) + }) + } +} diff --git a/pkg/cmd/search/prs/prs.go b/pkg/cmd/search/prs/prs.go new file mode 100644 index 000000000..839c0fa16 --- /dev/null +++ b/pkg/cmd/search/prs/prs.go @@ -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 []", + 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: + + `), + 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 +} diff --git a/pkg/cmd/search/prs/prs_test.go b/pkg/cmd/search/prs/prs_test.go new file mode 100644 index 000000000..1862ca4a5 --- /dev/null +++ b/pkg/cmd/search/prs/prs_test.go @@ -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) + }) + } +} diff --git a/pkg/cmd/search/repos/repos.go b/pkg/cmd/search/repos/repos.go index 878599dc6..3d4488ce5 100644 --- a/pkg/cmd/search/repos/repos.go +++ b/pkg/cmd/search/repos/repos.go @@ -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 + `), 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 -} diff --git a/pkg/cmd/search/repos/repos_test.go b/pkg/cmd/search/repos/repos_test.go index 872171b01..8fc43f39d 100644 --- a/pkg/cmd/search/repos/repos_test.go +++ b/pkg/cmd/search/repos/repos_test.go @@ -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", diff --git a/pkg/cmd/search/search.go b/pkg/cmd/search/search.go index 5342175c8..494710be1 100644 --- a/pkg/cmd/search/search.go +++ b/pkg/cmd/search/search.go @@ -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 diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go new file mode 100644 index 000000000..64eac2f62 --- /dev/null +++ b/pkg/cmd/search/shared/shared.go @@ -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 "" + } +} diff --git a/pkg/cmd/search/shared/shared_test.go b/pkg/cmd/search/shared/shared_test.go new file mode 100644 index 000000000..7ee7f8c34 --- /dev/null +++ b/pkg/cmd/search/shared/shared_test.go @@ -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()) + }) + } +} diff --git a/pkg/search/query.go b/pkg/search/query.go index a0966ba93..865afb433 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -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 { diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go index 889acae76..3e1cfca20 100644 --- a/pkg/search/query_test.go +++ b/pkg/search/query_test.go @@ -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"}, diff --git a/pkg/search/result.go b/pkg/search/result.go index 99b3d2142..487eb7756 100644 --- a/pkg/search/result.go +++ b/pkg/search/result.go @@ -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) diff --git a/pkg/search/result_test.go b/pkg/search/result_test.go index 185cc4e36..15f00d98f 100644 --- a/pkg/search/result_test.go +++ b/pkg/search/result_test.go @@ -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())) + }) + } +} diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 39d2c09c5..778402621 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -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{} diff --git a/pkg/search/searcher_mock.go b/pkg/search/searcher_mock.go index 9d584867b..01ab3ff57 100644 --- a/pkg/search/searcher_mock.go +++ b/pkg/search/searcher_mock.go @@ -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 { diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index 9aadc32f1..99ea93479 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -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", `; 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 {