diff --git a/api/queries_issue.go b/api/queries_issue.go index bd3f15555..0bca3bdcd 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -453,6 +453,113 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e return &resp.Repository.Issue, nil } +func IssueSearch(client *Client, repo ghrepo.Interface, searchQuery string, limit int) (*IssuesAndTotalCount, error) { + query := + `query IssueSearch($repoName: String!, $owner: String!, $type: SearchType!, $first: Int, $after: String, $searchQuery: String!) { + repository(name: $repoName, owner: $owner) { + hasIssuesEnabled + } + search(type: $type, first: $first, after: $after, query: $searchQuery) { + issueCount + edges { + node { + ... on Issue { + repository { + hasIssuesEnabled + } + number + title + updatedAt + state + labels(first: 100) { + nodes { + name + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + }` + + type response struct { + Repository struct { + HasIssuesEnabled bool + } + Search struct { + IssueCount int + Edges []struct { + Node struct { + Number int + Title string + State string + UpdatedAt time.Time + Labels Labels + } + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } + } + + searchQuery = fmt.Sprintf("is:issue repo:%s/%s %s", repo.RepoOwner(), repo.RepoName(), searchQuery) + + perPage := min(limit, 100) + + variables := map[string]interface{}{ + "repoName": repo.RepoName(), + "owner": repo.RepoOwner(), + "type": "ISSUE", + "first": perPage, + "searchQuery": searchQuery, + } + + ic := IssuesAndTotalCount{} + +loop: + for { + var resp response + err := client.GraphQL(repo.RepoHost(), query, variables, &resp) + if err != nil { + return nil, err + } + + if !resp.Repository.HasIssuesEnabled { + return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo)) + } + + ic.TotalCount = resp.Search.IssueCount + + for _, i := range resp.Search.Edges { + ic.Issues = append(ic.Issues, Issue{ + Number: i.Node.Number, + Title: i.Node.Title, + State: i.Node.State, + UpdatedAt: i.Node.UpdatedAt, + Labels: i.Node.Labels, + }) + if len(ic.Issues) == limit { + break loop + } + } + + if resp.Search.PageInfo.HasNextPage { + variables["after"] = resp.Search.PageInfo.EndCursor + variables["first"] = min(perPage, limit-len(resp.Search.Edges)) + } else { + break + } + } + + return &ic, nil +} + func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error { var mutation struct { CloseIssue struct { diff --git a/pkg/cmd/issue/list/fixtures/issueList.json b/pkg/cmd/issue/list/fixtures/issueList.json index 1f878f7a5..8af96da1a 100644 --- a/pkg/cmd/issue/list/fixtures/issueList.json +++ b/pkg/cmd/issue/list/fixtures/issueList.json @@ -6,49 +6,49 @@ "totalCount": 3, "nodes": [ { - "number": 1, - "title": "number won", - "url": "https://wow.com", - "updatedAt": "2011-01-26T19:01:12Z", - "labels": { - "nodes": [ - { - "name": "label" - } - ], - "totalCount": 1 - } + "number": 1, + "title": "number won", + "url": "https://wow.com", + "updatedAt": "2011-01-26T19:01:12Z", + "labels": { + "nodes": [ + { + "name": "label" + } + ], + "totalCount": 1 + } }, { - "number": 2, - "title": "number too", - "url": "https://wow.com", - "updatedAt": "2011-01-26T19:01:12Z", - "labels": { - "nodes": [ - { - "name": "label" - } - ], - "totalCount": 1 - } + "number": 2, + "title": "number too", + "url": "https://wow.com", + "updatedAt": "2011-01-26T19:01:12Z", + "labels": { + "nodes": [ + { + "name": "label" + } + ], + "totalCount": 1 + } }, { - "number": 4, - "title": "number fore", - "url": "https://wow.com", - "updatedAt": "2011-01-26T19:01:12Z", - "labels": { - "nodes": [ - { - "name": "label" - } - ], - "totalCount": 1 - } + "number": 4, + "title": "number fore", + "url": "https://wow.com", + "updatedAt": "2011-01-26T19:01:12Z", + "labels": { + "nodes": [ + { + "name": "label" + } + ], + "totalCount": 1 + } } ] } } } -} +} \ No newline at end of file diff --git a/pkg/cmd/issue/list/fixtures/issueSearch.json b/pkg/cmd/issue/list/fixtures/issueSearch.json new file mode 100644 index 000000000..997dc0ce9 --- /dev/null +++ b/pkg/cmd/issue/list/fixtures/issueSearch.json @@ -0,0 +1,60 @@ +{ + "data": { + "repository": { + "hasIssuesEnabled": true + }, + "search": { + "issueCount": 3, + "edges": [ + { + "node": { + "number": 1, + "title": "number won", + "url": "https://wow.com", + "updatedAt": "2011-01-26T19:01:12Z", + "labels": { + "nodes": [ + { + "name": "label" + } + ], + "totalCount": 1 + } + } + }, + { + "node": { + "number": 2, + "title": "number too", + "url": "https://wow.com", + "updatedAt": "2011-01-26T19:01:12Z", + "labels": { + "nodes": [ + { + "name": "label" + } + ], + "totalCount": 1 + } + } + }, + { + "node": { + "number": 4, + "title": "number fore", + "url": "https://wow.com", + "updatedAt": "2011-01-26T19:01:12Z", + "labels": { + "nodes": [ + { + "name": "label" + } + ], + "totalCount": 1 + } + } + } + ] + } + } +} \ No newline at end of file diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index c28d048dc..7d26c1dea 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -32,6 +32,7 @@ type ListOptions struct { Author string Mention string Milestone string + Search string } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { @@ -50,6 +51,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman $ gh issue list -a @me $ gh issue list --web $ gh issue list --milestone 'MVP' + $ gh issue list --search "error no:assignee sort:created-asc" `), Args: cmdutil.NoArgsQuoteReminder, RunE: func(cmd *cobra.Command, args []string) error { @@ -75,7 +77,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Author, "author", "A", "", "Filter by author") cmd.Flags().StringVar(&opts.Mention, "mention", "", "Filter by mention") cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Filter by milestone `number` or `title`") - + cmd.Flags().StringVarP(&opts.Search, "search", "S", "", "Search issues with filter") return cmd } @@ -107,29 +109,44 @@ func listRun(opts *ListOptions) error { return err } + filterOptions := prShared.FilterOptions{ + Entity: "issue", + State: opts.State, + Assignee: filterAssignee, + Labels: opts.Labels, + Author: filterAuthor, + Mention: filterMention, + Milestone: opts.Milestone, + Search: opts.Search, + } + if opts.WebMode { issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues") - openURL, err := prShared.ListURLWithQuery(issueListURL, prShared.FilterOptions{ - Entity: "issue", - State: opts.State, - Assignee: filterAssignee, - Labels: opts.Labels, - Author: filterAuthor, - Mention: filterMention, - Milestone: opts.Milestone, - }) + openURL, err := prShared.ListURLWithQuery(issueListURL, filterOptions) if err != nil { return err } + if isTerminal { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } return utils.OpenInBrowser(openURL) } - listResult, err := api.IssueList(apiClient, baseRepo, opts.State, opts.Labels, filterAssignee, opts.LimitResults, filterAuthor, filterMention, opts.Milestone) - if err != nil { - return err + searchQuery := prShared.IssueSearchBuild(filterOptions) + + var listResult *api.IssuesAndTotalCount + + if opts.Search != "" { + listResult, err = api.IssueSearch(apiClient, baseRepo, searchQuery, opts.LimitResults) + if err != nil { + return err + } + } else { + listResult, err = api.IssueList(apiClient, baseRepo, opts.State, opts.Labels, filterAssignee, opts.LimitResults, filterAuthor, filterMention, opts.Milestone) + if err != nil { + return err + } } err = opts.IO.StartPager() diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index b40701023..4ba4db800 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -58,6 +58,7 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err ErrBuf: stderr, }, err } + func TestIssueList_nontty(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) @@ -296,3 +297,52 @@ func TestIssueList_milestoneByNumber(t *testing.T) { t.Fatalf("error running issue list: %v", err) } } + +func TestIssueList_Search_tty(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query IssueSearch\b`), + httpmock.FileResponse("./fixtures/issueSearch.json")) + + output, err := runCommand(http, true, "--search \"auth bug\"") + if err != nil { + t.Errorf("error running command `issue list`: %v", err) + } + + out := output.String() + timeRE := regexp.MustCompile(`\d+ years`) + out = timeRE.ReplaceAllString(out, "X years") + + assert.Equal(t, heredoc.Doc(` + + Showing 3 of 3 open issues in OWNER/REPO + + #1 number won (label) about X years ago + #2 number too (label) about X years ago + #4 number fore (label) about X years ago + `), out) +} + +func TestIssueList_Search_web(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`https://github\.com`, 0, "", func(args []string) { + url := strings.ReplaceAll(args[len(args)-1], "^", "") + assert.Equal(t, "https://github.com/OWNER/REPO/issues?q=is%3Aissue+assignee%3Apeter+label%3Abug+label%3Adocs+author%3Ajohn+mentions%3Afrank+milestone%3Av1.1+transfer", url) + }) + + output, err := runCommand(http, true, "--web -a peter -A john -l bug -l docs -L 10 -s all --mention frank --milestone v1.1 --search transfer") + if err != nil { + t.Errorf("error running command `issue list` with `--web` flag: %v", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/issues in your browser.\n", output.Stderr()) + +} diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 722a981d7..c123fc363 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -149,6 +149,7 @@ type FilterOptions struct { BaseBranch string Mention string Milestone string + Search string } func ListURLWithQuery(listURL string, options FilterOptions) (string, error) { @@ -156,6 +157,16 @@ func ListURLWithQuery(listURL string, options FilterOptions) (string, error) { if err != nil { return "", err } + query := IssueSearchBuild(options) + + q := u.Query() + q.Set("q", strings.TrimSuffix(query, " ")) + u.RawQuery = q.Encode() + return u.String(), nil +} + +func IssueSearchBuild(options FilterOptions) string { + query := fmt.Sprintf("is:%s ", options.Entity) if options.State != "all" { query += fmt.Sprintf("is:%s ", options.State) @@ -178,10 +189,11 @@ func ListURLWithQuery(listURL string, options FilterOptions) (string, error) { if options.Milestone != "" { query += fmt.Sprintf("milestone:%s ", quoteValueForQuery(options.Milestone)) } - q := u.Query() - q.Set("q", strings.TrimSuffix(query, " ")) - u.RawQuery = q.Encode() - return u.String(), nil + if options.Search != "" { + query += options.Search + } + + return query } func quoteValueForQuery(v string) string {