diff --git a/api/queries_issue.go b/api/queries_issue.go index 55ec25ef7..866d622c5 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -2,10 +2,7 @@ package api import ( "context" - "encoding/base64" "fmt" - "strconv" - "strings" "time" "github.com/cli/cli/internal/ghrepo" @@ -233,123 +230,6 @@ func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) return &payload, nil } -func IssueList(client *Client, repo ghrepo.Interface, state string, assigneeString string, limit int, authorString string, mentionString string, milestoneString string) (*IssuesAndTotalCount, error) { - var states []string - switch state { - case "open", "": - states = []string{"OPEN"} - case "closed": - states = []string{"CLOSED"} - case "all": - states = []string{"OPEN", "CLOSED"} - default: - return nil, fmt.Errorf("invalid state: %s", state) - } - - query := fragments + ` - query IssueList($owner: String!, $repo: String!, $limit: Int, $endCursor: String, $states: [IssueState!] = OPEN, $assignee: String, $author: String, $mention: String, $milestone: String) { - repository(owner: $owner, name: $repo) { - hasIssuesEnabled - issues(first: $limit, after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, filterBy: {assignee: $assignee, createdBy: $author, mentioned: $mention, milestone: $milestone}) { - totalCount - nodes { - ...issue - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - ` - - variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "repo": repo.RepoName(), - "states": states, - } - if assigneeString != "" { - variables["assignee"] = assigneeString - } - if authorString != "" { - variables["author"] = authorString - } - if mentionString != "" { - variables["mention"] = mentionString - } - - if milestoneString != "" { - var milestone *RepoMilestone - if milestoneNumber, err := strconv.ParseInt(milestoneString, 10, 32); err == nil { - milestone, err = MilestoneByNumber(client, repo, int32(milestoneNumber)) - if err != nil { - return nil, err - } - } else { - milestone, err = MilestoneByTitle(client, repo, "all", milestoneString) - if err != nil { - return nil, err - } - } - - milestoneRESTID, err := milestoneNodeIdToDatabaseId(milestone.ID) - if err != nil { - return nil, err - } - variables["milestone"] = milestoneRESTID - } - - type responseData struct { - Repository struct { - Issues struct { - TotalCount int - Nodes []Issue - PageInfo struct { - HasNextPage bool - EndCursor string - } - } - HasIssuesEnabled bool - } - } - - var issues []Issue - var totalCount int - pageLimit := min(limit, 100) - -loop: - for { - var response responseData - variables["limit"] = pageLimit - err := client.GraphQL(repo.RepoHost(), query, variables, &response) - if err != nil { - return nil, err - } - if !response.Repository.HasIssuesEnabled { - return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo)) - } - totalCount = response.Repository.Issues.TotalCount - - for _, issue := range response.Repository.Issues.Nodes { - issues = append(issues, issue) - if len(issues) == limit { - break loop - } - } - - if response.Repository.Issues.PageInfo.HasNextPage { - variables["endCursor"] = response.Repository.Issues.PageInfo.EndCursor - pageLimit = min(pageLimit, limit-len(issues)) - } else { - break - } - } - - res := IssuesAndTotalCount{Issues: issues, TotalCount: totalCount} - return &res, nil -} - func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, error) { type response struct { Repository struct { @@ -450,80 +330,6 @@ 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 := fragments + - `query IssueSearch($repo: String!, $owner: String!, $type: SearchType!, $limit: Int, $after: String, $query: String!) { - repository(name: $repo, owner: $owner) { - hasIssuesEnabled - } - search(type: $type, last: $limit, after: $after, query: $query) { - issueCount - nodes { ...issue } - pageInfo { - hasNextPage - endCursor - } - } - }` - - type response struct { - Repository struct { - HasIssuesEnabled bool - } - Search struct { - IssueCount int - Nodes []Issue - PageInfo struct { - HasNextPage bool - EndCursor string - } - } - } - - perPage := min(limit, 100) - searchQuery = fmt.Sprintf("repo:%s/%s %s", repo.RepoOwner(), repo.RepoName(), searchQuery) - - variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "repo": repo.RepoName(), - "type": "ISSUE", - "limit": perPage, - "query": 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 _, issue := range resp.Search.Nodes { - ic.Issues = append(ic.Issues, issue) - if len(ic.Issues) == limit { - break loop - } - } - - if !resp.Search.PageInfo.HasNextPage { - break - } - variables["after"] = resp.Search.PageInfo.EndCursor - variables["perPage"] = min(perPage, limit-len(ic.Issues)) - } - - return &ic, nil -} - func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error { var mutation struct { CloseIssue struct { @@ -605,23 +411,6 @@ func IssueUpdate(client *Client, repo ghrepo.Interface, params githubv4.UpdateIs return err } -// milestoneNodeIdToDatabaseId extracts the REST Database ID from the GraphQL Node ID -// This conversion is necessary since the GraphQL API requires the use of the milestone's database ID -// for querying the related issues. -func milestoneNodeIdToDatabaseId(nodeId string) (string, error) { - // The Node ID is Base64 obfuscated, with an underlying pattern: - // "09:Milestone12345", where "12345" is the database ID - decoded, err := base64.StdEncoding.DecodeString(nodeId) - if err != nil { - return "", err - } - splitted := strings.Split(string(decoded), "Milestone") - if len(splitted) != 2 { - return "", fmt.Errorf("couldn't get database id from node id") - } - return splitted[1], nil -} - func (i Issue) Link() string { return i.URL } diff --git a/pkg/cmd/issue/list/http.go b/pkg/cmd/issue/list/http.go new file mode 100644 index 000000000..9fc75567b --- /dev/null +++ b/pkg/cmd/issue/list/http.go @@ -0,0 +1,242 @@ +package list + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" +) + +const fragments = ` + fragment issue on Issue { + number + title + url + state + updatedAt + labels(first: 100) { + nodes { + name + } + totalCount + } + } +` + +func IssueList(client *api.Client, repo ghrepo.Interface, state string, assigneeString string, limit int, authorString string, mentionString string, milestoneString string) (*api.IssuesAndTotalCount, error) { + var states []string + switch state { + case "open", "": + states = []string{"OPEN"} + case "closed": + states = []string{"CLOSED"} + case "all": + states = []string{"OPEN", "CLOSED"} + default: + return nil, fmt.Errorf("invalid state: %s", state) + } + + query := fragments + ` + query IssueList($owner: String!, $repo: String!, $limit: Int, $endCursor: String, $states: [IssueState!] = OPEN, $assignee: String, $author: String, $mention: String, $milestone: String) { + repository(owner: $owner, name: $repo) { + hasIssuesEnabled + issues(first: $limit, after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, filterBy: {assignee: $assignee, createdBy: $author, mentioned: $mention, milestone: $milestone}) { + totalCount + nodes { + ...issue + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + ` + + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "repo": repo.RepoName(), + "states": states, + } + if assigneeString != "" { + variables["assignee"] = assigneeString + } + if authorString != "" { + variables["author"] = authorString + } + if mentionString != "" { + variables["mention"] = mentionString + } + + if milestoneString != "" { + var milestone *api.RepoMilestone + if milestoneNumber, err := strconv.ParseInt(milestoneString, 10, 32); err == nil { + milestone, err = api.MilestoneByNumber(client, repo, int32(milestoneNumber)) + if err != nil { + return nil, err + } + } else { + milestone, err = api.MilestoneByTitle(client, repo, "all", milestoneString) + if err != nil { + return nil, err + } + } + + milestoneRESTID, err := milestoneNodeIdToDatabaseId(milestone.ID) + if err != nil { + return nil, err + } + variables["milestone"] = milestoneRESTID + } + + type responseData struct { + Repository struct { + Issues struct { + TotalCount int + Nodes []api.Issue + PageInfo struct { + HasNextPage bool + EndCursor string + } + } + HasIssuesEnabled bool + } + } + + var issues []api.Issue + var totalCount int + pageLimit := min(limit, 100) + +loop: + for { + var response responseData + variables["limit"] = pageLimit + err := client.GraphQL(repo.RepoHost(), query, variables, &response) + if err != nil { + return nil, err + } + if !response.Repository.HasIssuesEnabled { + return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo)) + } + totalCount = response.Repository.Issues.TotalCount + + for _, issue := range response.Repository.Issues.Nodes { + issues = append(issues, issue) + if len(issues) == limit { + break loop + } + } + + if response.Repository.Issues.PageInfo.HasNextPage { + variables["endCursor"] = response.Repository.Issues.PageInfo.EndCursor + pageLimit = min(pageLimit, limit-len(issues)) + } else { + break + } + } + + res := api.IssuesAndTotalCount{Issues: issues, TotalCount: totalCount} + return &res, nil +} + +func IssueSearch(client *api.Client, repo ghrepo.Interface, searchQuery string, limit int) (*api.IssuesAndTotalCount, error) { + 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, last: $limit, after: $after, query: $query) { + issueCount + nodes { ...issue } + pageInfo { + hasNextPage + endCursor + } + } + }` + + type response struct { + Repository struct { + HasIssuesEnabled bool + } + Search struct { + IssueCount int + Nodes []api.Issue + PageInfo struct { + HasNextPage bool + EndCursor string + } + } + } + + perPage := min(limit, 100) + searchQuery = fmt.Sprintf("repo:%s/%s %s", repo.RepoOwner(), repo.RepoName(), searchQuery) + + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "repo": repo.RepoName(), + "type": "ISSUE", + "limit": perPage, + "query": searchQuery, + } + + ic := api.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 _, issue := range resp.Search.Nodes { + ic.Issues = append(ic.Issues, issue) + if len(ic.Issues) == limit { + break loop + } + } + + if !resp.Search.PageInfo.HasNextPage { + break + } + variables["after"] = resp.Search.PageInfo.EndCursor + variables["perPage"] = min(perPage, limit-len(ic.Issues)) + } + + return &ic, nil +} + +// milestoneNodeIdToDatabaseId extracts the REST Database ID from the GraphQL Node ID +// This conversion is necessary since the GraphQL API requires the use of the milestone's database ID +// for querying the related issues. +func milestoneNodeIdToDatabaseId(nodeId string) (string, error) { + // The Node ID is Base64 obfuscated, with an underlying pattern: + // "09:Milestone12345", where "12345" is the database ID + decoded, err := base64.StdEncoding.DecodeString(nodeId) + if err != nil { + return "", err + } + splitted := strings.Split(string(decoded), "Milestone") + if len(splitted) != 2 { + return "", fmt.Errorf("couldn't get database id from node id") + } + return splitted[1], nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/api/queries_issue_test.go b/pkg/cmd/issue/list/http_test.go similarity index 93% rename from api/queries_issue_test.go rename to pkg/cmd/issue/list/http_test.go index 829005995..61278b0b8 100644 --- a/api/queries_issue_test.go +++ b/pkg/cmd/issue/list/http_test.go @@ -1,4 +1,4 @@ -package api +package list import ( "encoding/json" @@ -7,13 +7,14 @@ import ( "github.com/stretchr/testify/assert" + "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/httpmock" ) func TestIssueList(t *testing.T) { http := &httpmock.Registry{} - client := NewClient(ReplaceTripper(http)) + client := api.NewClient(api.ReplaceTripper(http)) http.Register( httpmock.GraphQL(`query IssueList\b`), @@ -78,7 +79,7 @@ func TestIssueList(t *testing.T) { func TestIssueList_pagination(t *testing.T) { http := &httpmock.Registry{} - client := NewClient(ReplaceTripper(http)) + client := api.NewClient(api.ReplaceTripper(http)) http.Register( httpmock.GraphQL(`query IssueList\b`), @@ -135,14 +136,14 @@ func TestIssueList_pagination(t *testing.T) { assert.Equal(t, 2, res.TotalCount) assert.Equal(t, 2, len(res.Issues)) - getLabels := func(i Issue) []string { + getLabels := func(i api.Issue) []string { var labels []string for _, l := range i.Labels.Nodes { labels = append(labels, l.Name) } return labels } - getAssignees := func(i Issue) []string { + getAssignees := func(i api.Issue) []string { var logins []string for _, u := range i.Assignees.Nodes { logins = append(logins, u.Login) diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 663b44ecd..1ec116b52 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -161,7 +161,7 @@ func issueList(client *http.Client, repo ghrepo.Interface, filters prShared.Filt } searchQuery := prShared.SearchQueryBuild(filters) - return api.IssueSearch(apiClient, repo, searchQuery, limit) + return IssueSearch(apiClient, repo, searchQuery, limit) } meReplacer := shared.NewMeReplacer(apiClient, repo.RepoHost()) @@ -178,7 +178,7 @@ func issueList(client *http.Client, repo ghrepo.Interface, filters prShared.Filt return nil, err } - return api.IssueList( + return IssueList( apiClient, repo, filters.State,