💅 cleanup switching to search mode in issue list

This commit is contained in:
Mislav Marohnić 2021-03-19 13:41:19 +01:00
parent f791bbdbcb
commit 80035aa686
5 changed files with 396 additions and 269 deletions

View file

@ -454,37 +454,20 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
}
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) {
query := fragments +
`query IssueSearch($repo: String!, $owner: String!, $type: SearchType!, $limit: Int, $after: String, $query: String!) {
repository(name: $repo, owner: $owner) {
hasIssuesEnabled
}
search(type: $type, first: $first, after: $after, query: $searchQuery) {
search(type: $type, last: $limit, after: $after, query: $query) {
issueCount
edges {
node {
... on Issue {
repository {
hasIssuesEnabled
}
number
title
updatedAt
state
labels(first: 100) {
nodes {
name
}
}
}
nodes { ...issue }
pageInfo {
hasNextPage
endCursor
}
}
pageInfo {
hasNextPage
endCursor
}
}
}`
}`
type response struct {
Repository struct {
@ -492,32 +475,23 @@ func IssueSearch(client *Client, repo ghrepo.Interface, searchQuery string, limi
}
Search struct {
IssueCount int
Edges []struct {
Node struct {
Number int
Title string
State string
UpdatedAt time.Time
Labels Labels
}
}
PageInfo struct {
Nodes []Issue
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
}
searchQuery = fmt.Sprintf("is:issue repo:%s/%s %s", repo.RepoOwner(), repo.RepoName(), searchQuery)
perPage := min(limit, 100)
searchQuery = fmt.Sprintf("repo:%s/%s %s", repo.RepoOwner(), repo.RepoName(), searchQuery)
variables := map[string]interface{}{
"repoName": repo.RepoName(),
"owner": repo.RepoOwner(),
"type": "ISSUE",
"first": perPage,
"searchQuery": searchQuery,
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"type": "ISSUE",
"limit": perPage,
"query": searchQuery,
}
ic := IssuesAndTotalCount{}
@ -536,25 +510,18 @@ loop:
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,
})
for _, issue := range resp.Search.Nodes {
ic.Issues = append(ic.Issues, issue)
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 {
if !resp.Search.PageInfo.HasNextPage {
break
}
variables["after"] = resp.Search.PageInfo.EndCursor
variables["perPage"] = min(perPage, limit-len(ic.Issues))
}
return &ic, nil

View file

@ -5,56 +5,50 @@
},
"search": {
"issueCount": 3,
"edges": [
"nodes": [
{
"node": {
"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
}
},
{
"node": {
"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
}
},
{
"node": {
"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
}
}
]
}
}
}
}

View file

@ -3,6 +3,7 @@ package list
import (
"fmt"
"net/http"
"strconv"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
@ -46,11 +47,11 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
Use: "list",
Short: "List and filter issues in this repository",
Example: heredoc.Doc(`
$ gh issue list -l "help wanted"
$ gh issue list -l "bug" -l "help wanted"
$ gh issue list -A monalisa
$ gh issue list -a @me
$ gh issue list --web
$ gh issue list --milestone 'MVP'
$ gh issue list --milestone "The big 1.0"
$ gh issue list --search "error no:assignee sort:created-asc"
`),
Args: cmdutil.NoArgsQuoteReminder,
@ -86,40 +87,25 @@ func listRun(opts *ListOptions) error {
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
isTerminal := opts.IO.IsStdoutTTY()
meReplacer := shared.NewMeReplacer(apiClient, baseRepo.RepoHost())
filterAssignee, err := meReplacer.Replace(opts.Assignee)
if err != nil {
return err
}
filterAuthor, err := meReplacer.Replace(opts.Author)
if err != nil {
return err
}
filterMention, err := meReplacer.Replace(opts.Mention)
if err != nil {
return err
}
filterOptions := prShared.FilterOptions{
Entity: "issue",
State: opts.State,
Assignee: filterAssignee,
Assignee: opts.Assignee,
Labels: opts.Labels,
Author: filterAuthor,
Mention: filterMention,
Author: opts.Author,
Mention: opts.Mention,
Milestone: opts.Milestone,
Search: opts.Search,
}
isTerminal := opts.IO.IsStdoutTTY()
if opts.WebMode {
issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues")
openURL, err := prShared.ListURLWithQuery(issueListURL, filterOptions)
@ -133,20 +119,9 @@ func listRun(opts *ListOptions) error {
return utils.OpenInBrowser(openURL)
}
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
}
listResult, err := issueList(httpClient, baseRepo, filterOptions, opts.LimitResults)
if err != nil {
return err
}
err = opts.IO.StartPager()
@ -156,7 +131,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()
if isTerminal {
hasFilters := opts.State != "open" || len(opts.Labels) > 0 || opts.Assignee != "" || opts.Author != "" || opts.Mention != "" || opts.Milestone != ""
hasFilters := opts.State != "open" || len(opts.Labels) > 0 || opts.Assignee != "" || opts.Author != "" || opts.Mention != "" || opts.Milestone != "" || opts.Search != ""
title := prShared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters)
fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title)
}
@ -165,3 +140,46 @@ func listRun(opts *ListOptions) error {
return nil
}
func issueList(client *http.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) {
apiClient := api.NewClientFromHTTP(client)
if filters.Search != "" {
if milestoneNumber, err := strconv.ParseInt(filters.Milestone, 10, 32); err == nil {
milestone, err := api.MilestoneByNumber(apiClient, repo, int32(milestoneNumber))
if err != nil {
return nil, err
}
filters.Milestone = milestone.Title
}
searchQuery := prShared.IssueSearchBuild(filters)
return api.IssueSearch(apiClient, repo, searchQuery, limit)
}
meReplacer := shared.NewMeReplacer(apiClient, repo.RepoHost())
filterAssignee, err := meReplacer.Replace(filters.Assignee)
if err != nil {
return nil, err
}
filterAuthor, err := meReplacer.Replace(filters.Author)
if err != nil {
return nil, err
}
filterMention, err := meReplacer.Replace(filters.Mention)
if err != nil {
return nil, err
}
return api.IssueList(
apiClient,
repo,
filters.State,
filters.Labels,
filterAssignee,
limit,
filterAuthor,
filterMention,
filters.Milestone,
)
}

View file

@ -2,7 +2,6 @@ package list
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"regexp"
@ -13,6 +12,7 @@ import (
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -148,31 +148,6 @@ No issues match your search in OWNER/REPO
`, output.String())
}
func TestIssueList_atMe(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "monalisa"} } }`))
http.Register(
httpmock.GraphQL(`query IssueList\b`),
httpmock.GraphQLQuery(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issues": { "nodes": [] }
} } }`, func(_ string, params map[string]interface{}) {
assert.Equal(t, "monalisa", params["assignee"].(string))
assert.Equal(t, "monalisa", params["author"].(string))
assert.Equal(t, "monalisa", params["mention"].(string))
}))
_, err := runCommand(http, true, "-a @me -A @me --mention @me")
if err != nil {
t.Errorf("error running command `issue list`: %v", err)
}
}
func TestIssueList_withInvalidLimitFlag(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
@ -184,36 +159,6 @@ func TestIssueList_withInvalidLimitFlag(t *testing.T) {
}
}
func TestIssueList_nullAssigneeLabels(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query IssueList\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issues": { "nodes": [] }
} } }`),
)
_, err := runCommand(http, true, "")
if err != nil {
t.Errorf("error running command `issue list`: %v", err)
}
bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body)
reqBody := struct {
Variables map[string]interface{}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
_, assigneeDeclared := reqBody.Variables["assignee"]
_, labelsDeclared := reqBody.Variables["labels"]
assert.Equal(t, false, assigneeDeclared)
assert.Equal(t, false, labelsDeclared)
}
func TestIssueList_disabledIssues(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
@ -253,78 +198,6 @@ func TestIssueList_web(t *testing.T) {
assert.Equal(t, "Opening github.com/OWNER/REPO/issues in your browser.\n", output.Stderr())
}
func TestIssueList_milestoneNotFound(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [{ "title":"1.x", "id": "MDk6TWlsZXN0b25lMTIzNDU=" }],
"pageInfo": { "hasNextPage": false }
} } } }
`))
_, err := runCommand(http, true, "--milestone NotFound")
if err == nil || err.Error() != `no milestone found with title "NotFound"` {
t.Errorf("error running command `issue list`: %v", err)
}
}
func TestIssueList_milestoneByNumber(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query RepositoryMilestoneByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestone": {
"id": "MDk6TWlsZXN0b25lMTIzNDU="
} } } }
`))
http.Register(
httpmock.GraphQL(`query IssueList\b`),
httpmock.GraphQLQuery(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issues": { "nodes": [] }
} } }`, func(_ string, params map[string]interface{}) {
assert.Equal(t, "12345", params["milestone"].(string)) // Database ID for the Milestone (see #1462)
}))
_, err := runCommand(http, true, "--milestone 13")
if err != nil {
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)
@ -344,5 +217,280 @@ func TestIssueList_Search_web(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, "Opening github.com/OWNER/REPO/issues in your browser.\n", output.Stderr())
}
func Test_issueList(t *testing.T) {
type args struct {
repo ghrepo.Interface
filters prShared.FilterOptions
limit int
}
tests := []struct {
name string
args args
httpStubs func(*httpmock.Registry)
wantErr bool
}{
{
name: "default",
args: args{
limit: 30,
repo: ghrepo.New("OWNER", "REPO"),
filters: prShared.FilterOptions{
Entity: "issue",
State: "open",
},
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query IssueList\b`),
httpmock.GraphQLQuery(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issues": { "nodes": [] }
} } }`, func(_ string, params map[string]interface{}) {
assert.Equal(t, map[string]interface{}{
"owner": "OWNER",
"repo": "REPO",
"limit": float64(30),
"states": []interface{}{"OPEN"},
}, params)
}))
},
},
{
name: "milestone by number",
args: args{
limit: 30,
repo: ghrepo.New("OWNER", "REPO"),
filters: prShared.FilterOptions{
Entity: "issue",
State: "open",
Milestone: "13",
},
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryMilestoneByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestone": {
"id": "MDk6TWlsZXN0b25lMTIzNDU="
} } } }
`))
reg.Register(
httpmock.GraphQL(`query IssueList\b`),
httpmock.GraphQLQuery(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issues": { "nodes": [] }
} } }`, func(_ string, params map[string]interface{}) {
assert.Equal(t, map[string]interface{}{
"owner": "OWNER",
"repo": "REPO",
"limit": float64(30),
"states": []interface{}{"OPEN"},
"milestone": "12345",
}, params)
}))
},
},
{
name: "milestone by number with search",
args: args{
limit: 30,
repo: ghrepo.New("OWNER", "REPO"),
filters: prShared.FilterOptions{
Entity: "issue",
State: "open",
Milestone: "13",
Search: "auth bug",
},
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryMilestoneByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestone": {
"title": "Big 1.0",
"id": "MDk6TWlsZXN0b25lMTIzNDU="
} } } }
`))
reg.Register(
httpmock.GraphQL(`query IssueSearch\b`),
httpmock.GraphQLQuery(`
{ "data": {
"repository": { "hasIssuesEnabled": true },
"search": {
"issueCount": 0,
"nodes": []
}
} }`, func(_ string, params map[string]interface{}) {
assert.Equal(t, map[string]interface{}{
"owner": "OWNER",
"repo": "REPO",
"limit": float64(30),
"query": "repo:OWNER/REPO is:issue is:open milestone:\"Big 1.0\" auth bug",
"type": "ISSUE",
}, params)
}))
},
},
{
name: "milestone by title with search",
args: args{
limit: 30,
repo: ghrepo.New("OWNER", "REPO"),
filters: prShared.FilterOptions{
Entity: "issue",
State: "open",
Milestone: "Big 1.0",
Search: "auth bug",
},
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query IssueSearch\b`),
httpmock.GraphQLQuery(`
{ "data": {
"repository": { "hasIssuesEnabled": true },
"search": {
"issueCount": 0,
"nodes": []
}
} }`, func(_ string, params map[string]interface{}) {
assert.Equal(t, map[string]interface{}{
"owner": "OWNER",
"repo": "REPO",
"limit": float64(30),
"query": "repo:OWNER/REPO is:issue is:open milestone:\"Big 1.0\" auth bug",
"type": "ISSUE",
}, params)
}))
},
},
{
name: "milestone by title",
args: args{
limit: 30,
repo: ghrepo.New("OWNER", "REPO"),
filters: prShared.FilterOptions{
Entity: "issue",
State: "open",
Milestone: "1.x",
},
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [{ "title":"1.x", "id": "MDk6TWlsZXN0b25lMTIzNDU=" }],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query IssueList\b`),
httpmock.GraphQLQuery(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issues": { "nodes": [] }
} } }`, func(_ string, params map[string]interface{}) {
assert.Equal(t, map[string]interface{}{
"owner": "OWNER",
"repo": "REPO",
"limit": float64(30),
"states": []interface{}{"OPEN"},
"milestone": "12345",
}, params)
}))
},
},
{
name: "@me syntax",
args: args{
limit: 30,
repo: ghrepo.New("OWNER", "REPO"),
filters: prShared.FilterOptions{
Entity: "issue",
State: "open",
Author: "@me",
Assignee: "@me",
Mention: "@me",
},
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "monalisa"} } }`))
reg.Register(
httpmock.GraphQL(`query IssueList\b`),
httpmock.GraphQLQuery(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issues": { "nodes": [] }
} } }`, func(_ string, params map[string]interface{}) {
assert.Equal(t, map[string]interface{}{
"owner": "OWNER",
"repo": "REPO",
"limit": float64(30),
"states": []interface{}{"OPEN"},
"assignee": "monalisa",
"author": "monalisa",
"mention": "monalisa",
}, params)
}))
},
},
{
name: "@me with search",
args: args{
limit: 30,
repo: ghrepo.New("OWNER", "REPO"),
filters: prShared.FilterOptions{
Entity: "issue",
State: "open",
Author: "@me",
Assignee: "@me",
Mention: "@me",
Search: "auth bug",
},
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query IssueSearch\b`),
httpmock.GraphQLQuery(`
{ "data": {
"repository": { "hasIssuesEnabled": true },
"search": {
"issueCount": 0,
"nodes": []
}
} }`, func(_ string, params map[string]interface{}) {
assert.Equal(t, map[string]interface{}{
"owner": "OWNER",
"repo": "REPO",
"limit": float64(30),
"query": "repo:OWNER/REPO is:issue is:open assignee:@me author:@me mentions:@me auth bug",
"type": "ISSUE",
}, params)
}))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpreg := &httpmock.Registry{}
defer httpreg.Verify(t)
if tt.httpStubs != nil {
tt.httpStubs(httpreg)
}
client := &http.Client{Transport: httpreg}
_, err := issueList(client, tt.args.repo, tt.args.filters, tt.args.limit)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

View file

@ -157,17 +157,17 @@ 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()
params := u.Query()
params.Set("q", IssueSearchBuild(options))
u.RawQuery = params.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)
}
@ -181,7 +181,7 @@ func IssueSearchBuild(options FilterOptions) string {
query += fmt.Sprintf("author:%s ", options.Author)
}
if options.BaseBranch != "" {
query += fmt.Sprintf("base:%s ", options.BaseBranch)
query += fmt.Sprintf("base:%s ", quoteValueForQuery(options.BaseBranch))
}
if options.Mention != "" {
query += fmt.Sprintf("mentions:%s ", options.Mention)
@ -193,7 +193,7 @@ func IssueSearchBuild(options FilterOptions) string {
query += options.Search
}
return query
return strings.TrimSpace(query)
}
func quoteValueForQuery(v string) string {