From 61a8049592c3764ad3be90a3075f95e8f72dd95b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 13 Apr 2021 16:48:21 +0200 Subject: [PATCH 01/14] Extract JSON filtering functionality from `gh api` --- pkg/cmd/api/api.go | 5 +++-- pkg/{cmd/api => export}/filter.go | 4 ++-- pkg/{cmd/api => export}/filter_test.go | 4 ++-- pkg/{cmd/api => export}/template.go | 4 ++-- pkg/{cmd/api => export}/template_test.go | 4 ++-- 5 files changed, 11 insertions(+), 10 deletions(-) rename pkg/{cmd/api => export}/filter.go (91%) rename pkg/{cmd/api => export}/filter_test.go (95%) rename pkg/{cmd/api => export}/template.go (97%) rename pkg/{cmd/api => export}/template_test.go (97%) diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 714f555cb..997b6fabe 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -22,6 +22,7 @@ import ( "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/export" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/jsoncolor" "github.com/spf13/cobra" @@ -370,13 +371,13 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream if opts.FilterOutput != "" { // TODO: reuse parsed query across pagination invocations - err = filterJSON(opts.IO.Out, responseBody, opts.FilterOutput) + err = export.FilterJSON(opts.IO.Out, responseBody, opts.FilterOutput) if err != nil { return } } else if opts.Template != "" { // TODO: reuse parsed template across pagination invocations - err = executeTemplate(opts.IO.Out, responseBody, opts.Template, opts.IO.ColorEnabled()) + err = export.ExecuteTemplate(opts.IO.Out, responseBody, opts.Template, opts.IO.ColorEnabled()) if err != nil { return } diff --git a/pkg/cmd/api/filter.go b/pkg/export/filter.go similarity index 91% rename from pkg/cmd/api/filter.go rename to pkg/export/filter.go index 4872ed353..514fd48ca 100644 --- a/pkg/cmd/api/filter.go +++ b/pkg/export/filter.go @@ -1,4 +1,4 @@ -package api +package export import ( "encoding/json" @@ -9,7 +9,7 @@ import ( "github.com/itchyny/gojq" ) -func filterJSON(w io.Writer, input io.Reader, queryStr string) error { +func FilterJSON(w io.Writer, input io.Reader, queryStr string) error { query, err := gojq.Parse(queryStr) if err != nil { return err diff --git a/pkg/cmd/api/filter_test.go b/pkg/export/filter_test.go similarity index 95% rename from pkg/cmd/api/filter_test.go rename to pkg/export/filter_test.go index 93dc4fbe8..b432f4ec1 100644 --- a/pkg/cmd/api/filter_test.go +++ b/pkg/export/filter_test.go @@ -1,4 +1,4 @@ -package api +package export import ( "bytes" @@ -73,7 +73,7 @@ func Test_filterJSON(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := &bytes.Buffer{} - if err := filterJSON(w, tt.args.json, tt.args.query); (err != nil) != tt.wantErr { + if err := FilterJSON(w, tt.args.json, tt.args.query); (err != nil) != tt.wantErr { t.Errorf("filterJSON() error = %v, wantErr %v", err, tt.wantErr) return } diff --git a/pkg/cmd/api/template.go b/pkg/export/template.go similarity index 97% rename from pkg/cmd/api/template.go rename to pkg/export/template.go index 78c7a96c8..0e3a11049 100644 --- a/pkg/cmd/api/template.go +++ b/pkg/export/template.go @@ -1,4 +1,4 @@ -package api +package export import ( "encoding/json" @@ -50,7 +50,7 @@ func parseTemplate(tpl string, colorEnabled bool) (*template.Template, error) { return template.New("").Funcs(templateFuncs).Parse(tpl) } -func executeTemplate(w io.Writer, input io.Reader, templateStr string, colorEnabled bool) error { +func ExecuteTemplate(w io.Writer, input io.Reader, templateStr string, colorEnabled bool) error { t, err := parseTemplate(templateStr, colorEnabled) if err != nil { return err diff --git a/pkg/cmd/api/template_test.go b/pkg/export/template_test.go similarity index 97% rename from pkg/cmd/api/template_test.go rename to pkg/export/template_test.go index 067b87fcc..5950ef461 100644 --- a/pkg/cmd/api/template_test.go +++ b/pkg/export/template_test.go @@ -1,4 +1,4 @@ -package api +package export import ( "bytes" @@ -151,7 +151,7 @@ func Test_executeTemplate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := &bytes.Buffer{} - if err := executeTemplate(w, tt.args.json, tt.args.template, tt.args.colorize); (err != nil) != tt.wantErr { + if err := ExecuteTemplate(w, tt.args.json, tt.args.template, tt.args.colorize); (err != nil) != tt.wantErr { t.Errorf("executeTemplate() error = %v, wantErr %v", err, tt.wantErr) return } From 19ea49b5a9bbf2cf4fb693eeb0299ff6bd9dc66a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 13 Apr 2021 19:11:13 +0200 Subject: [PATCH 02/14] Move issue list queries to under the `issue/list` package --- api/queries_issue.go | 211 --------------- pkg/cmd/issue/list/http.go | 242 ++++++++++++++++++ .../cmd/issue/list/http_test.go | 11 +- pkg/cmd/issue/list/list.go | 4 +- 4 files changed, 250 insertions(+), 218 deletions(-) create mode 100644 pkg/cmd/issue/list/http.go rename api/queries_issue_test.go => pkg/cmd/issue/list/http_test.go (93%) 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, From abe452bb192acb99f20fc3bc836e4f0ebed5138f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 13 Apr 2021 20:29:31 +0200 Subject: [PATCH 03/14] Add `--json` export flag for issues and pull requests The `--json` flag accepts a list of GraphQL fields to query for and output in JSON format. To get the list of available flags, run the command with a blank value for `--json`. Additional `--jq` and `--template` flags are available just like in `gh api`. --- api/export_pr.go | 89 +++++++++++++++++++ api/queries_comments.go | 16 ++-- api/queries_issue.go | 17 ++-- api/queries_pr.go | 40 ++++----- api/queries_pr_review.go | 16 ++-- api/query_builder.go | 149 ++++++++++++++++++++++++++++++++ api/reaction_groups.go | 34 +++++++- pkg/cmd/issue/list/http.go | 47 ++++------ pkg/cmd/issue/list/http_test.go | 9 +- pkg/cmd/issue/list/list.go | 45 ++++++---- pkg/cmd/issue/view/view.go | 7 ++ pkg/cmd/pr/list/http.go | 19 +--- pkg/cmd/pr/list/list.go | 40 +++++++-- pkg/cmd/pr/shared/params.go | 2 + pkg/cmd/pr/view/view.go | 8 ++ pkg/cmdutil/json_flags.go | 101 ++++++++++++++++++++++ 16 files changed, 521 insertions(+), 118 deletions(-) create mode 100644 api/export_pr.go create mode 100644 api/query_builder.go create mode 100644 pkg/cmdutil/json_flags.go diff --git a/api/export_pr.go b/api/export_pr.go new file mode 100644 index 000000000..2f1d62e45 --- /dev/null +++ b/api/export_pr.go @@ -0,0 +1,89 @@ +package api + +import ( + "reflect" + "strings" +) + +func (issue *Issue) ExportData(fields []string) *map[string]interface{} { + v := reflect.ValueOf(issue).Elem() + data := map[string]interface{}{} + + for _, f := range fields { + switch f { + case "milestone": + if issue.Milestone.Title != "" { + data[f] = &issue.Milestone + } else { + data[f] = nil + } + case "comments": + data[f] = issue.Comments.Nodes + case "assignees": + data[f] = issue.Assignees.Nodes + case "labels": + data[f] = issue.Labels.Nodes + case "projectCards": + data[f] = issue.ProjectCards.Nodes + default: + sf := fieldByName(v, f) + data[f] = sf.Interface() + } + } + + return &data +} + +func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} { + v := reflect.ValueOf(pr).Elem() + data := map[string]interface{}{} + + for _, f := range fields { + switch f { + case "headRepository": + data[f] = map[string]string{"name": pr.HeadRepository.Name} + case "milestone": + if pr.Milestone.Title != "" { + data[f] = &pr.Milestone + } else { + data[f] = nil + } + case "statusCheckRollup": + if n := pr.Commits.Nodes; len(n) > 0 { + data[f] = n[0].Commit.StatusCheckRollup.Contexts.Nodes + } else { + data[f] = nil + } + case "comments": + data[f] = pr.Comments.Nodes + case "assignees": + data[f] = pr.Assignees.Nodes + case "labels": + data[f] = pr.Labels.Nodes + case "projectCards": + data[f] = pr.ProjectCards.Nodes + case "reviews": + data[f] = pr.Reviews.Nodes + case "reviewRequests": + requests := make([]interface{}, 0, len(pr.ReviewRequests.Nodes)) + for _, req := range pr.ReviewRequests.Nodes { + if req.RequestedReviewer.TypeName == "" { + continue + } + requests = append(requests, req.RequestedReviewer) + } + data[f] = &requests + default: + sf := fieldByName(v, f) + data[f] = sf.Interface() + } + } + + return &data +} + +func fieldByName(v reflect.Value, field string) reflect.Value { + return v.FieldByNameFunc(func(s string) bool { + return strings.EqualFold(field, s) + }) +} diff --git a/api/queries_comments.go b/api/queries_comments.go index db7482692..db6ad25e7 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -16,14 +16,14 @@ type Comments struct { } type Comment struct { - Author Author - AuthorAssociation string - Body string - CreatedAt time.Time - IncludesCreatedEdit bool - IsMinimized bool - MinimizedReason string - ReactionGroups ReactionGroups + Author Author `json:"author"` + AuthorAssociation string `json:"authorAssociation"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` + IncludesCreatedEdit bool `json:"includesCreatedEdit"` + IsMinimized bool `json:"isMinimized"` + MinimizedReason string `json:"minimizedReason"` + ReactionGroups ReactionGroups `json:"reactionGroups"` } type PageInfo struct { diff --git a/api/queries_issue.go b/api/queries_issue.go index 866d622c5..42b22d29c 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -30,6 +30,7 @@ type Issue struct { Body string CreatedAt time.Time UpdatedAt time.Time + ClosedAt *time.Time Comments Comments Author Author Assignees Assignees @@ -41,7 +42,7 @@ type Issue struct { type Assignees struct { Nodes []struct { - Login string + Login string `json:"login"` } TotalCount int } @@ -56,7 +57,7 @@ func (a Assignees) Logins() []string { type Labels struct { Nodes []struct { - Name string + Name string `json:"name"` } TotalCount int } @@ -72,11 +73,11 @@ func (l Labels) Names() []string { type ProjectCards struct { Nodes []struct { Project struct { - Name string - } + Name string `json:"name"` + } `json:"project"` Column struct { - Name string - } + Name string `json:"name"` + } `json:"column"` } TotalCount int } @@ -90,7 +91,7 @@ func (p ProjectCards) ProjectNames() []string { } type Milestone struct { - Title string + Title string `json:"title"` } type IssuesDisabledError struct { @@ -98,7 +99,7 @@ type IssuesDisabledError struct { } type Author struct { - Login string + Login string `json:"login"` } const fragments = ` diff --git a/api/queries_pr.go b/api/queries_pr.go index 9b4661e37..f726f6151 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -42,12 +42,14 @@ type PullRequest struct { Additions int Deletions int MergeStateStatus string + CreatedAt time.Time + UpdatedAt time.Time + ClosedAt *time.Time + MergedAt *time.Time - Author struct { - Login string - } + Author Author HeadRepositoryOwner struct { - Login string + Login string `json:"login"` } HeadRepository struct { Name string @@ -75,15 +77,16 @@ type PullRequest struct { StatusCheckRollup struct { Contexts struct { Nodes []struct { - Name string - Context string - State string - Status string - Conclusion string - StartedAt time.Time - CompletedAt time.Time - DetailsURL string - TargetURL string + TypeName string `json:"__typename"` + Name string `json:"name"` + Context string `json:"context,omitempty"` + State string `json:"state,omitempty"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + StartedAt time.Time `json:"startedAt"` + CompletedAt time.Time `json:"completedAt"` + DetailsURL string `json:"detailsUrl"` + TargetURL string `json:"targetUrl,omitempty"` } } } @@ -104,8 +107,8 @@ type ReviewRequests struct { Nodes []struct { RequestedReviewer struct { TypeName string `json:"__typename"` - Login string - Name string + Login string `json:"login"` + Name string `json:"name"` } } TotalCount int @@ -972,10 +975,3 @@ func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) er path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch) return client.REST(repo.RepoHost(), "DELETE", path, nil, nil) } - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index 27365429e..67d3c5ddb 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -28,14 +28,14 @@ type PullRequestReviews struct { } type PullRequestReview struct { - Author Author - AuthorAssociation string - Body string - CreatedAt time.Time - IncludesCreatedEdit bool - ReactionGroups ReactionGroups - State string - URL string + Author Author `json:"author"` + AuthorAssociation string `json:"authorAssociation"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` + IncludesCreatedEdit bool `json:"includesCreatedEdit"` + ReactionGroups ReactionGroups `json:"reactionGroups"` + State string `json:"state"` + URL string `json:"url"` } func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error { diff --git a/api/query_builder.go b/api/query_builder.go new file mode 100644 index 000000000..1cd21d27f --- /dev/null +++ b/api/query_builder.go @@ -0,0 +1,149 @@ +package api + +import ( + "strings" +) + +func squeeze(r rune) rune { + switch r { + case '\n', '\t': + return -1 + default: + return r + } +} + +func shortenQuery(q string) string { + return strings.Map(squeeze, q) +} + +var issueComments = shortenQuery(` + comments(last: 100) { + nodes { + author{login}, + authorAssociation, + body, + createdAt, + includesCreatedEdit, + isMinimized, + minimizedReason, + reactionGroups{content,users{totalCount}} + }, + totalCount + } +`) + +var prReviewRequests = shortenQuery(` + reviewRequests(last: 100) { + nodes { + requestedReviewer { + __typename, + ...on User{login}, + ...on Team{name} + } + }, + totalCount + } +`) + +var prStatusCheckRollup = shortenQuery(` + commits(last: 1) { + totalCount, + nodes { + commit { + oid, + statusCheckRollup { + contexts(last: 100) { + nodes { + __typename + ...on StatusContext { + context, + state, + targetUrl + }, + ...on CheckRun { + name, + status, + conclusion, + startedAt, + completedAt, + detailsUrl + } + } + } + } + } + } + } +`) + +var IssueFields = []string{ + "assignees", + "author", + "body", + "closed", + "comments", + "createdAt", + "closedAt", + "id", + "labels", + "milestone", + "number", + "projectCards", + "reactionGroups", + "state", + "title", + "updatedAt", + "url", +} + +var PullRequestFields = append(IssueFields, + "additions", + "baseRefName", + "deletions", + "headRefName", + "headRepository", + "headRepositoryOwner", + "isCrossRepository", + "isDraft", + "maintainerCanModify", + "mergeable", + "mergedAt", + "mergeStateStatus", + "reviewDecision", + "reviewRequests", + "statusCheckRollup", +) + +func PullRequestGraphQL(fields []string) string { + var q []string + for _, field := range fields { + switch field { + case "author": + q = append(q, `author{login}`) + case "headRepositoryOwner": + q = append(q, `headRepositoryOwner{login}`) + case "headRepository": + q = append(q, `headRepository{name}`) + case "assignees": + q = append(q, `assignees(first:100){nodes{login},totalCount}`) + case "labels": + q = append(q, `labels(first:100){nodes{name},totalCount}`) + case "projectCards": + q = append(q, `projectCards(first:100){nodes{project{name}column{name}},totalCount}`) + case "milestone": + q = append(q, `milestone{title}`) + case "reactionGroups": + q = append(q, `reactionGroups{content,users{totalCount}}`) + case "comments": + q = append(q, issueComments) + case "reviewRequests": + q = append(q, prReviewRequests) + case "statusCheckRollup": + q = append(q, prStatusCheckRollup) + default: + q = append(q, field) + } + } + return strings.Join(q, ",") +} diff --git a/api/reaction_groups.go b/api/reaction_groups.go index 849fe4b36..769edc6aa 100644 --- a/api/reaction_groups.go +++ b/api/reaction_groups.go @@ -1,14 +1,42 @@ package api +import ( + "bytes" + "encoding/json" +) + type ReactionGroups []ReactionGroup +func (rg ReactionGroups) MarshalJSON() ([]byte, error) { + buf := bytes.Buffer{} + buf.WriteRune('[') + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + + hasPrev := false + for _, g := range rg { + if g.Users.TotalCount == 0 { + continue + } + if hasPrev { + buf.WriteRune(',') + } + if err := encoder.Encode(&g); err != nil { + return nil, err + } + hasPrev = true + } + buf.WriteRune(']') + return buf.Bytes(), nil +} + type ReactionGroup struct { - Content string - Users ReactionGroupUsers + Content string `json:"content"` + Users ReactionGroupUsers `json:"users"` } type ReactionGroupUsers struct { - TotalCount int + TotalCount int `json:"totalCount"` } func (rg ReactionGroup) Count() int { diff --git a/pkg/cmd/issue/list/http.go b/pkg/cmd/issue/list/http.go index 9fc75567b..2794be45f 100644 --- a/pkg/cmd/issue/list/http.go +++ b/pkg/cmd/issue/list/http.go @@ -8,27 +8,12 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" ) -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) { +func listIssues(client *api.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) { var states []string - switch state { + switch filters.State { case "open", "": states = []string{"OPEN"} case "closed": @@ -36,9 +21,10 @@ func IssueList(client *api.Client, repo ghrepo.Interface, state string, assignee case "all": states = []string{"OPEN", "CLOSED"} default: - return nil, fmt.Errorf("invalid state: %s", state) + return nil, fmt.Errorf("invalid state: %s", filters.State) } + fragments := fmt.Sprintf("fragment issue on Issue {%s}", api.PullRequestGraphQL(filters.Fields)) 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) { @@ -62,25 +48,25 @@ func IssueList(client *api.Client, repo ghrepo.Interface, state string, assignee "repo": repo.RepoName(), "states": states, } - if assigneeString != "" { - variables["assignee"] = assigneeString + if filters.Assignee != "" { + variables["assignee"] = filters.Assignee } - if authorString != "" { - variables["author"] = authorString + if filters.Author != "" { + variables["author"] = filters.Author } - if mentionString != "" { - variables["mention"] = mentionString + if filters.Mention != "" { + variables["mention"] = filters.Mention } - if milestoneString != "" { + if filters.Milestone != "" { var milestone *api.RepoMilestone - if milestoneNumber, err := strconv.ParseInt(milestoneString, 10, 32); err == nil { + if milestoneNumber, err := strconv.ParseInt(filters.Milestone, 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) + milestone, err = api.MilestoneByTitle(client, repo, "all", filters.Milestone) if err != nil { return nil, err } @@ -143,7 +129,8 @@ loop: return &res, nil } -func IssueSearch(client *api.Client, repo ghrepo.Interface, searchQuery string, limit int) (*api.IssuesAndTotalCount, error) { +func searchIssues(client *api.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) { + fragments := fmt.Sprintf("fragment issue on Issue {%s}", api.PullRequestGraphQL(filters.Fields)) query := fragments + `query IssueSearch($repo: String!, $owner: String!, $type: SearchType!, $limit: Int, $after: String, $query: String!) { repository(name: $repo, owner: $owner) { @@ -174,7 +161,7 @@ func IssueSearch(client *api.Client, repo ghrepo.Interface, searchQuery string, } perPage := min(limit, 100) - searchQuery = fmt.Sprintf("repo:%s/%s %s", repo.RepoOwner(), repo.RepoName(), searchQuery) + searchQuery := fmt.Sprintf("repo:%s/%s %s", repo.RepoOwner(), repo.RepoName(), prShared.SearchQueryBuild(filters)) variables := map[string]interface{}{ "owner": repo.RepoOwner(), diff --git a/pkg/cmd/issue/list/http_test.go b/pkg/cmd/issue/list/http_test.go index 61278b0b8..c5374b909 100644 --- a/pkg/cmd/issue/list/http_test.go +++ b/pkg/cmd/issue/list/http_test.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/httpmock" ) @@ -48,7 +49,11 @@ func TestIssueList(t *testing.T) { ) repo, _ := ghrepo.FromFullName("OWNER/REPO") - _, err := IssueList(client, repo, "open", "", 251, "", "", "") + filters := prShared.FilterOptions{ + Entity: "issue", + State: "open", + } + _, err := listIssues(client, repo, filters, 251) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -128,7 +133,7 @@ func TestIssueList_pagination(t *testing.T) { ) repo := ghrepo.New("OWNER", "REPO") - res, err := IssueList(client, repo, "", "", 0, "", "", "") + res, err := listIssues(client, repo, prShared.FilterOptions{}, 0) if err != nil { t.Fatalf("IssueList() error = %v", err) } diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 1ec116b52..9d0a0d468 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -31,6 +31,7 @@ type ListOptions struct { Browser browser WebMode bool + Export *cmdutil.ExportFormat Assignee string Labels []string @@ -86,10 +87,20 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman 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 `query`") + cmdutil.AddJSONFlags(cmd, &opts.Export, api.IssueFields) return cmd } +var defaultFields = []string{ + "number", + "title", + "url", + "state", + "updatedAt", + "labels", +} + func listRun(opts *ListOptions) error { httpClient, err := opts.HttpClient() if err != nil { @@ -110,6 +121,7 @@ func listRun(opts *ListOptions) error { Mention: opts.Mention, Milestone: opts.Milestone, Search: opts.Search, + Fields: defaultFields, } isTerminal := opts.IO.IsStdoutTTY() @@ -127,6 +139,10 @@ func listRun(opts *ListOptions) error { return opts.Browser.Browse(openURL) } + if opts.Export != nil { + filterOptions.Fields = opts.Export.Fields + } + listResult, err := issueList(httpClient, baseRepo, filterOptions, opts.LimitResults) if err != nil { return err @@ -138,6 +154,14 @@ func listRun(opts *ListOptions) error { } defer opts.IO.StopPager() + if opts.Export != nil { + data := make([]interface{}, len(listResult.Issues)) + for i, issue := range listResult.Issues { + data[i] = issue.ExportData(opts.Export.Fields) + } + return opts.Export.Write(opts.IO.Out, &data, opts.IO.ColorEnabled()) + } + if isTerminal { title := prShared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, !filterOptions.IsDefault()) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) @@ -160,32 +184,23 @@ func issueList(client *http.Client, repo ghrepo.Interface, filters prShared.Filt filters.Milestone = milestone.Title } - searchQuery := prShared.SearchQueryBuild(filters) - return IssueSearch(apiClient, repo, searchQuery, limit) + return searchIssues(apiClient, repo, filters, limit) } + var err error meReplacer := shared.NewMeReplacer(apiClient, repo.RepoHost()) - filterAssignee, err := meReplacer.Replace(filters.Assignee) + filters.Assignee, err = meReplacer.Replace(filters.Assignee) if err != nil { return nil, err } - filterAuthor, err := meReplacer.Replace(filters.Author) + filters.Author, err = meReplacer.Replace(filters.Author) if err != nil { return nil, err } - filterMention, err := meReplacer.Replace(filters.Mention) + filters.Mention, err = meReplacer.Replace(filters.Mention) if err != nil { return nil, err } - return IssueList( - apiClient, - repo, - filters.State, - filterAssignee, - limit, - filterAuthor, - filterMention, - filters.Milestone, - ) + return listIssues(apiClient, repo, filters, limit) } diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 39e354133..509787b87 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -33,6 +33,7 @@ type ViewOptions struct { SelectorArg string WebMode bool Comments bool + Export *cmdutil.ExportFormat Now func() time.Time } @@ -71,6 +72,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser") cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View issue comments") + cmdutil.AddJSONFlags(cmd, &opts.Export, api.IssueFields) return cmd } @@ -113,6 +115,11 @@ func viewRun(opts *ViewOptions) error { } defer opts.IO.StopPager() + if opts.Export != nil { + exportIssue := issue.ExportData(opts.Export.Fields) + return opts.Export.Write(opts.IO.Out, exportIssue, opts.IO.ColorEnabled()) + } + if opts.IO.IsStdoutTTY() { return printHumanIssuePreview(opts, issue) } diff --git a/pkg/cmd/pr/list/http.go b/pkg/cmd/pr/list/http.go index adf492e91..0f24e972d 100644 --- a/pkg/cmd/pr/list/http.go +++ b/pkg/cmd/pr/list/http.go @@ -10,19 +10,6 @@ import ( "github.com/cli/cli/pkg/githubsearch" ) -const fragment = `fragment pr on PullRequest { - number - title - state - url - headRefName - headRepositoryOwner { - login - } - isCrossRepository - isDraft -}` - func listPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.PullRequestAndTotalCount, error) { if filters.Author != "" || filters.Assignee != "" || filters.Search != "" || len(filters.Labels) > 0 { return searchPullRequests(httpClient, repo, filters, limit) @@ -41,6 +28,7 @@ func listPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters pr } } + fragment := fmt.Sprintf("fragment pr on PullRequest{%s}", api.PullRequestGraphQL(filters.Fields)) query := fragment + ` query PullRequestList( $owner: String!, @@ -109,7 +97,7 @@ loop: res.TotalCount = prData.TotalCount for _, pr := range prData.Nodes { - if _, exists := check[pr.Number]; exists { + if _, exists := check[pr.Number]; exists && pr.Number > 0 { continue } check[pr.Number] = struct{}{} @@ -143,6 +131,7 @@ func searchPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters } } + fragment := fmt.Sprintf("fragment pr on PullRequest{%s}", api.PullRequestGraphQL(filters.Fields)) query := fragment + ` query PullRequestSearch( $q: String!, @@ -209,7 +198,7 @@ loop: res.TotalCount = prData.IssueCount for _, pr := range prData.Nodes { - if _, exists := check[pr.Number]; exists { + if _, exists := check[pr.Number]; exists && pr.Number > 0 { continue } check[pr.Number] = struct{}{} diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 292d39f37..509e50818 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -29,12 +29,14 @@ type ListOptions struct { WebMode bool LimitResults int - State string - BaseBranch string - Labels []string - Author string - Assignee string - Search string + Export *cmdutil.ExportFormat + + State string + BaseBranch string + Labels []string + Author string + Assignee string + Search string } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { @@ -76,10 +78,22 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Author, "author", "A", "", "Filter by author") cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee") cmd.Flags().StringVarP(&opts.Search, "search", "S", "", "Search pull requests with `query`") + cmdutil.AddJSONFlags(cmd, &opts.Export, api.PullRequestFields) return cmd } +var defaultFields = []string{ + "number", + "title", + "state", + "url", + "headRefName", + "headRepositoryOwner", + "isCrossRepository", + "isDraft", +} + func listRun(opts *ListOptions) error { httpClient, err := opts.HttpClient() if err != nil { @@ -99,6 +113,10 @@ func listRun(opts *ListOptions) error { Labels: opts.Labels, BaseBranch: opts.BaseBranch, Search: opts.Search, + Fields: defaultFields, + } + if opts.Export != nil { + filters.Fields = opts.Export.Fields } if opts.WebMode { @@ -121,10 +139,18 @@ func listRun(opts *ListOptions) error { err = opts.IO.StartPager() if err != nil { - return err + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v", err) } defer opts.IO.StopPager() + if opts.Export != nil { + data := make([]interface{}, len(listResult.PullRequests)) + for i, pr := range listResult.PullRequests { + data[i] = pr.ExportData(opts.Export.Fields) + } + return opts.Export.Write(opts.IO.Out, &data, opts.IO.ColorEnabled()) + } + if opts.IO.IsStdoutTTY() { title := shared.ListHeader(ghrepo.FullName(baseRepo), "pull request", len(listResult.PullRequests), listResult.TotalCount, !filters.IsDefault()) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 61205b433..d8348ec2c 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -155,6 +155,8 @@ type FilterOptions struct { Mention string Milestone string Search string + + Fields []string } func (opts *FilterOptions) IsDefault() bool { diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index 57cab8e5a..d4f897b83 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -35,6 +35,8 @@ type ViewOptions struct { Remotes func() (context.Remotes, error) Branch func() (string, error) + Export *cmdutil.ExportFormat + SelectorArg string BrowserMode bool Comments bool @@ -83,6 +85,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open a pull request in the browser") cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View pull request comments") + cmdutil.AddJSONFlags(cmd, &opts.Export, api.PullRequestFields) return cmd } @@ -113,6 +116,11 @@ func viewRun(opts *ViewOptions) error { } defer opts.IO.StopPager() + if opts.Export != nil { + exportPR := pr.ExportData(opts.Export.Fields) + return opts.Export.Write(opts.IO.Out, exportPR, opts.IO.ColorEnabled()) + } + if connectedToTerminal { return printHumanPrPreview(opts, pr) } diff --git a/pkg/cmdutil/json_flags.go b/pkg/cmdutil/json_flags.go new file mode 100644 index 000000000..0aaa64f55 --- /dev/null +++ b/pkg/cmdutil/json_flags.go @@ -0,0 +1,101 @@ +package cmdutil + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strings" + + "github.com/cli/cli/pkg/export" + "github.com/cli/cli/pkg/jsoncolor" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type JSONFlagError struct { + error +} + +func AddJSONFlags(cmd *cobra.Command, exportTarget **ExportFormat, fields []string) { + f := cmd.Flags() + f.StringSlice("json", nil, "Output JSON with the specified `fields`") + f.StringP("jq", "q", "", "Filter JSON output using a jq `expression`") + f.StringP("template", "t", "", "Format JSON output using a Go template") + + oldPreRun := cmd.PreRunE + cmd.PreRunE = func(c *cobra.Command, args []string) error { + if oldPreRun != nil { + if err := oldPreRun(c, args); err != nil { + return err + } + } + if export, err := checkJSONFlags(c); err == nil { + *exportTarget = export + } else { + return err + } + return nil + } + + cmd.SetFlagErrorFunc(func(c *cobra.Command, e error) error { + if e.Error() == "flag needs an argument: --json" { + sort.Strings(fields) + return JSONFlagError{fmt.Errorf("Specify one or more comma-separated fields for `--json`:\n %s", strings.Join(fields, "\n "))} + } + return c.Parent().FlagErrorFunc()(c, e) + }) +} + +func checkJSONFlags(cmd *cobra.Command) (*ExportFormat, error) { + f := cmd.Flags() + jsonFlag := f.Lookup("json") + jqFlag := f.Lookup("jq") + tplFlag := f.Lookup("template") + webFlag := f.Lookup("web") + + if jsonFlag.Changed { + if webFlag != nil && webFlag.Changed { + return nil, errors.New("cannot use `--web` with `--json`") + } + jv := jsonFlag.Value.(pflag.SliceValue) + return &ExportFormat{ + Fields: jv.GetSlice(), + Filter: jqFlag.Value.String(), + Template: tplFlag.Value.String(), + }, nil + } else if jqFlag.Changed { + return nil, errors.New("cannot use `--jq` without specifying `--json`") + } else if tplFlag.Changed { + return nil, errors.New("cannot use `--template` without specifying `--json`") + } + return nil, nil +} + +type ExportFormat struct { + Fields []string + Filter string + Template string +} + +func (e *ExportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) error { + buf := bytes.Buffer{} + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(data); err != nil { + return err + } + + if e.Filter != "" { + return export.FilterJSON(w, &buf, e.Filter) + } else if e.Template != "" { + return export.ExecuteTemplate(w, &buf, e.Template, colorEnabled) + } else if colorEnabled { + return jsoncolor.Write(w, &buf, " ") + } + + _, err := io.Copy(w, &buf) + return err +} From e158fac1a9649f538b890e4f4d4f25c8053433e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 13 Apr 2021 20:54:09 +0200 Subject: [PATCH 04/14] Restructure PullRequestStatus function --- api/queries_pr.go | 253 +++++++++++++++++++----------------- pkg/cmd/pr/status/status.go | 9 +- 2 files changed, 141 insertions(+), 121 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index f726f6151..91bc2b2f4 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -307,7 +307,13 @@ func determinePullRequestFeatures(httpClient *http.Client, hostname string) (prF return } -func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, currentPRHeadRef, currentUsername string) (*PullRequestsPayload, error) { +type StatusOptions struct { + CurrentPR int + HeadRef string + Username string +} + +func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOptions) (*PullRequestsPayload, error) { type edges struct { TotalCount int Edges []struct { @@ -327,12 +333,137 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu ReviewRequested edges } - cachedClient := NewCachedClient(client.http, time.Hour*24) - prFeatures, err := determinePullRequestFeatures(cachedClient, repo.RepoHost()) + fragments, err := pullRequestFragment(client.http, repo.RepoHost()) if err != nil { return nil, err } + queryPrefix := ` + query PullRequestStatus($owner: String!, $repo: String!, $headRefName: String!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) { + repository(owner: $owner, name: $repo) { + defaultBranchRef { + name + } + pullRequests(headRefName: $headRefName, first: $per_page, orderBy: { field: CREATED_AT, direction: DESC }) { + totalCount + edges { + node { + ...prWithReviews + } + } + } + } + ` + if options.CurrentPR > 0 { + queryPrefix = ` + query PullRequestStatus($owner: String!, $repo: String!, $number: Int!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) { + repository(owner: $owner, name: $repo) { + defaultBranchRef { + name + } + pullRequest(number: $number) { + ...prWithReviews + } + } + ` + } + + query := fragments + queryPrefix + ` + viewerCreated: search(query: $viewerQuery, type: ISSUE, first: $per_page) { + totalCount: issueCount + edges { + node { + ...prWithReviews + } + } + } + reviewRequested: search(query: $reviewerQuery, type: ISSUE, first: $per_page) { + totalCount: issueCount + edges { + node { + ...pr + } + } + } + } + ` + + currentUsername := options.Username + if currentUsername == "@me" && ghinstance.IsEnterprise(repo.RepoHost()) { + var err error + currentUsername, err = CurrentLoginName(client, repo.RepoHost()) + if err != nil { + return nil, err + } + } + + viewerQuery := fmt.Sprintf("repo:%s state:open is:pr author:%s", ghrepo.FullName(repo), currentUsername) + reviewerQuery := fmt.Sprintf("repo:%s state:open review-requested:%s", ghrepo.FullName(repo), currentUsername) + + currentPRHeadRef := options.HeadRef + branchWithoutOwner := currentPRHeadRef + if idx := strings.Index(currentPRHeadRef, ":"); idx >= 0 { + branchWithoutOwner = currentPRHeadRef[idx+1:] + } + + variables := map[string]interface{}{ + "viewerQuery": viewerQuery, + "reviewerQuery": reviewerQuery, + "owner": repo.RepoOwner(), + "repo": repo.RepoName(), + "headRefName": branchWithoutOwner, + "number": options.CurrentPR, + } + + var resp response + err = client.GraphQL(repo.RepoHost(), query, variables, &resp) + if err != nil { + return nil, err + } + + var viewerCreated []PullRequest + for _, edge := range resp.ViewerCreated.Edges { + viewerCreated = append(viewerCreated, edge.Node) + } + + var reviewRequested []PullRequest + for _, edge := range resp.ReviewRequested.Edges { + reviewRequested = append(reviewRequested, edge.Node) + } + + var currentPR = resp.Repository.PullRequest + if currentPR == nil { + for _, edge := range resp.Repository.PullRequests.Edges { + if edge.Node.HeadLabel() == currentPRHeadRef { + currentPR = &edge.Node + break // Take the most recent PR for the current branch + } + } + } + + payload := PullRequestsPayload{ + ViewerCreated: PullRequestAndTotalCount{ + PullRequests: viewerCreated, + TotalCount: resp.ViewerCreated.TotalCount, + }, + ReviewRequested: PullRequestAndTotalCount{ + PullRequests: reviewRequested, + TotalCount: resp.ReviewRequested.TotalCount, + }, + CurrentPR: currentPR, + DefaultBranch: resp.Repository.DefaultBranchRef.Name, + } + + return &payload, nil +} + +func pullRequestFragment(httpClient *http.Client, hostname string) (string, error) { + cachedClient := NewCachedClient(httpClient, time.Hour*24) + prFeatures, err := determinePullRequestFeatures(cachedClient, hostname) + if err != nil { + return "", err + } + var reviewsFragment string if prFeatures.HasReviewDecision { reviewsFragment = "reviewDecision" @@ -394,121 +525,7 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu %s } `, requiresStrictStatusChecks, statusesFragment, reviewsFragment) - - queryPrefix := ` - query PullRequestStatus($owner: String!, $repo: String!, $headRefName: String!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) { - repository(owner: $owner, name: $repo) { - defaultBranchRef { - name - } - pullRequests(headRefName: $headRefName, first: $per_page, orderBy: { field: CREATED_AT, direction: DESC }) { - totalCount - edges { - node { - ...prWithReviews - } - } - } - } - ` - if currentPRNumber > 0 { - queryPrefix = ` - query PullRequestStatus($owner: String!, $repo: String!, $number: Int!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) { - repository(owner: $owner, name: $repo) { - defaultBranchRef { - name - } - pullRequest(number: $number) { - ...prWithReviews - } - } - ` - } - - query := fragments + queryPrefix + ` - viewerCreated: search(query: $viewerQuery, type: ISSUE, first: $per_page) { - totalCount: issueCount - edges { - node { - ...prWithReviews - } - } - } - reviewRequested: search(query: $reviewerQuery, type: ISSUE, first: $per_page) { - totalCount: issueCount - edges { - node { - ...pr - } - } - } - } - ` - - if currentUsername == "@me" && ghinstance.IsEnterprise(repo.RepoHost()) { - currentUsername, err = CurrentLoginName(client, repo.RepoHost()) - if err != nil { - return nil, err - } - } - - viewerQuery := fmt.Sprintf("repo:%s state:open is:pr author:%s", ghrepo.FullName(repo), currentUsername) - reviewerQuery := fmt.Sprintf("repo:%s state:open review-requested:%s", ghrepo.FullName(repo), currentUsername) - - branchWithoutOwner := currentPRHeadRef - if idx := strings.Index(currentPRHeadRef, ":"); idx >= 0 { - branchWithoutOwner = currentPRHeadRef[idx+1:] - } - - variables := map[string]interface{}{ - "viewerQuery": viewerQuery, - "reviewerQuery": reviewerQuery, - "owner": repo.RepoOwner(), - "repo": repo.RepoName(), - "headRefName": branchWithoutOwner, - "number": currentPRNumber, - } - - var resp response - err = client.GraphQL(repo.RepoHost(), query, variables, &resp) - if err != nil { - return nil, err - } - - var viewerCreated []PullRequest - for _, edge := range resp.ViewerCreated.Edges { - viewerCreated = append(viewerCreated, edge.Node) - } - - var reviewRequested []PullRequest - for _, edge := range resp.ReviewRequested.Edges { - reviewRequested = append(reviewRequested, edge.Node) - } - - var currentPR = resp.Repository.PullRequest - if currentPR == nil { - for _, edge := range resp.Repository.PullRequests.Edges { - if edge.Node.HeadLabel() == currentPRHeadRef { - currentPR = &edge.Node - break // Take the most recent PR for the current branch - } - } - } - - payload := PullRequestsPayload{ - ViewerCreated: PullRequestAndTotalCount{ - PullRequests: viewerCreated, - TotalCount: resp.ViewerCreated.TotalCount, - }, - ReviewRequested: PullRequestAndTotalCount{ - PullRequests: reviewRequested, - TotalCount: resp.ReviewRequested.TotalCount, - }, - CurrentPR: currentPR, - DefaultBranch: resp.Repository.DefaultBranchRef.Name, - } - - return &payload, nil + return fragments, nil } func prCommitsFragment(httpClient *http.Client, hostname string) (string, error) { diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index b43bf8cfb..6124bc6a9 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -88,9 +88,12 @@ func statusRun(opts *StatusOptions) error { } } - // the `@me` macro is available because the API lookup is ElasticSearch-based - currentUser := "@me" - prPayload, err := api.PullRequests(apiClient, baseRepo, currentPRNumber, currentPRHeadRef, currentUser) + options := api.StatusOptions{ + Username: "@me", + CurrentPR: currentPRNumber, + HeadRef: currentPRHeadRef, + } + prPayload, err := api.PullRequestStatus(apiClient, baseRepo, options) if err != nil { return err } From 3f22e3b353dd6a334c615748f0b28061ea15119d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 13 Apr 2021 21:12:30 +0200 Subject: [PATCH 05/14] Add `pr status --json` support --- api/export_pr.go | 8 ++++++++ api/queries_pr.go | 21 +++++++++++++++++---- pkg/cmd/pr/list/list.go | 8 ++------ pkg/cmd/pr/status/status.go | 20 +++++++++++++++++++- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/api/export_pr.go b/api/export_pr.go index 2f1d62e45..25789edb7 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -82,6 +82,14 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} { return &data } +func ExportPRs(prs []PullRequest, fields []string) *[]interface{} { + data := make([]interface{}, len(prs)) + for i, pr := range prs { + data[i] = pr.ExportData(fields) + } + return &data +} + func fieldByName(v reflect.Value, field string) reflect.Value { return v.FieldByNameFunc(func(s string) bool { return strings.EqualFold(field, s) diff --git a/api/queries_pr.go b/api/queries_pr.go index 91bc2b2f4..0e9513428 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/set" "github.com/shurcooL/githubv4" "golang.org/x/sync/errgroup" ) @@ -311,6 +312,7 @@ type StatusOptions struct { CurrentPR int HeadRef string Username string + Fields []string } func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOptions) (*PullRequestsPayload, error) { @@ -333,9 +335,20 @@ func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOpti ReviewRequested edges } - fragments, err := pullRequestFragment(client.http, repo.RepoHost()) - if err != nil { - return nil, err + var fragments string + if len(options.Fields) > 0 { + fields := set.NewStringSet() + fields.AddValues(options.Fields) + // these are always necessary to find the PR for the current branch + fields.AddValues([]string{"isCrossRepository", "headRepositoryOwner", "headRefName"}) + gr := PullRequestGraphQL(fields.ToSlice()) + fragments = fmt.Sprintf("fragment pr on PullRequest{%[1]s}fragment prWithReviews on PullRequest{%[1]s}", gr) + } else { + var err error + fragments, err = pullRequestFragment(client.http, repo.RepoHost()) + if err != nil { + return nil, err + } } queryPrefix := ` @@ -416,7 +429,7 @@ func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOpti } var resp response - err = client.GraphQL(repo.RepoHost(), query, variables, &resp) + err := client.GraphQL(repo.RepoHost(), query, variables, &resp) if err != nil { return nil, err } diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 509e50818..b15acc324 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -139,16 +139,12 @@ func listRun(opts *ListOptions) error { err = opts.IO.StartPager() if err != nil { - fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v", err) + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) } defer opts.IO.StopPager() if opts.Export != nil { - data := make([]interface{}, len(listResult.PullRequests)) - for i, pr := range listResult.PullRequests { - data[i] = pr.ExportData(opts.Export.Fields) - } - return opts.Export.Write(opts.IO.Out, &data, opts.IO.ColorEnabled()) + return opts.Export.Write(opts.IO.Out, api.ExportPRs(listResult.PullRequests, opts.Export.Fields), opts.IO.ColorEnabled()) } if opts.IO.IsStdoutTTY() { diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 6124bc6a9..7bdd9c11b 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -29,6 +29,7 @@ type StatusOptions struct { Branch func() (string, error) HasRepoOverride bool + Export *cmdutil.ExportFormat } func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { @@ -56,6 +57,8 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co }, } + cmdutil.AddJSONFlags(cmd, &opts.Export, api.PullRequestFields) + return cmd } @@ -93,6 +96,9 @@ func statusRun(opts *StatusOptions) error { CurrentPR: currentPRNumber, HeadRef: currentPRHeadRef, } + if opts.Export != nil { + options.Fields = opts.Export.Fields + } prPayload, err := api.PullRequestStatus(apiClient, baseRepo, options) if err != nil { return err @@ -100,10 +106,22 @@ func statusRun(opts *StatusOptions) error { err = opts.IO.StartPager() if err != nil { - return err + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) } defer opts.IO.StopPager() + if opts.Export != nil { + data := map[string]interface{}{ + "currentBranch": nil, + "createdBy": api.ExportPRs(prPayload.ViewerCreated.PullRequests, opts.Export.Fields), + "needsReview": api.ExportPRs(prPayload.ReviewRequested.PullRequests, opts.Export.Fields), + } + if prPayload.CurrentPR != nil { + data["currentBranch"] = prPayload.CurrentPR.ExportData(opts.Export.Fields) + } + return opts.Export.Write(opts.IO.Out, &data, opts.IO.ColorEnabled()) + } + out := opts.IO.Out cs := opts.IO.ColorScheme() From a516ee68335c49f0bd54478fc34524f588040533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 13 Apr 2021 21:26:13 +0200 Subject: [PATCH 06/14] Add `issue status --json` support --- api/export_pr.go | 8 ++++++++ api/queries_issue.go | 26 ++++++++------------------ pkg/cmd/issue/list/list.go | 6 +----- pkg/cmd/issue/status/status.go | 33 +++++++++++++++++++++++++++++++-- 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/api/export_pr.go b/api/export_pr.go index 25789edb7..6b9719616 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -82,6 +82,14 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} { return &data } +func ExportIssues(issues []Issue, fields []string) *[]interface{} { + data := make([]interface{}, len(issues)) + for i, issue := range issues { + data[i] = issue.ExportData(fields) + } + return &data +} + func ExportPRs(prs []PullRequest, fields []string) *[]interface{} { data := make([]interface{}, len(prs)) for i, pr := range prs { diff --git a/api/queries_issue.go b/api/queries_issue.go index 42b22d29c..7804a7613 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -102,22 +102,6 @@ type Author struct { Login string `json:"login"` } -const fragments = ` - fragment issue on Issue { - number - title - url - state - updatedAt - labels(first: 100) { - nodes { - name - } - totalCount - } - } -` - // IssueCreate creates an issue in a GitHub repository func IssueCreate(client *Client, repo *Repository, params map[string]interface{}) (*Issue, error) { query := ` @@ -153,7 +137,12 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} return &result.CreateIssue.Issue, nil } -func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) (*IssuesPayload, error) { +type IssueStatusOptions struct { + Username string + Fields []string +} + +func IssueStatus(client *Client, repo ghrepo.Interface, options IssueStatusOptions) (*IssuesPayload, error) { type response struct { Repository struct { Assigned struct { @@ -172,6 +161,7 @@ func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) } } + fragments := fmt.Sprintf("fragment issue on Issue{%s}", PullRequestGraphQL(options.Fields)) query := fragments + ` query IssueStatus($owner: String!, $repo: String!, $viewer: String!, $per_page: Int = 10) { repository(owner: $owner, name: $repo) { @@ -200,7 +190,7 @@ func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) variables := map[string]interface{}{ "owner": repo.RepoOwner(), "repo": repo.RepoName(), - "viewer": currentUsername, + "viewer": options.Username, } var resp response diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 9d0a0d468..670380830 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -155,11 +155,7 @@ func listRun(opts *ListOptions) error { defer opts.IO.StopPager() if opts.Export != nil { - data := make([]interface{}, len(listResult.Issues)) - for i, issue := range listResult.Issues { - data[i] = issue.ExportData(opts.Export.Fields) - } - return opts.Export.Write(opts.IO.Out, &data, opts.IO.ColorEnabled()) + return opts.Export.Write(opts.IO.Out, api.ExportIssues(listResult.Issues, opts.Export.Fields), opts.IO.ColorEnabled()) } if isTerminal { diff --git a/pkg/cmd/issue/status/status.go b/pkg/cmd/issue/status/status.go index 218d5d38a..60567c6f3 100644 --- a/pkg/cmd/issue/status/status.go +++ b/pkg/cmd/issue/status/status.go @@ -19,6 +19,8 @@ type StatusOptions struct { Config func() (config.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + + Export *cmdutil.ExportFormat } func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { @@ -43,9 +45,20 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co }, } + cmdutil.AddJSONFlags(cmd, &opts.Export, api.IssueFields) + return cmd } +var defaultFields = []string{ + "number", + "title", + "url", + "state", + "updatedAt", + "labels", +} + func statusRun(opts *StatusOptions) error { httpClient, err := opts.HttpClient() if err != nil { @@ -63,17 +76,33 @@ func statusRun(opts *StatusOptions) error { return err } - issuePayload, err := api.IssueStatus(apiClient, baseRepo, currentUser) + options := api.IssueStatusOptions{ + Username: currentUser, + Fields: defaultFields, + } + if opts.Export != nil { + options.Fields = opts.Export.Fields + } + issuePayload, err := api.IssueStatus(apiClient, baseRepo, options) if err != nil { return err } err = opts.IO.StartPager() if err != nil { - return err + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) } defer opts.IO.StopPager() + if opts.Export != nil { + data := map[string]interface{}{ + "createdBy": api.ExportIssues(issuePayload.Authored.Issues, opts.Export.Fields), + "assigned": api.ExportIssues(issuePayload.Assigned.Issues, opts.Export.Fields), + "mentioned": api.ExportIssues(issuePayload.Mentioned.Issues, opts.Export.Fields), + } + return opts.Export.Write(opts.IO.Out, &data, opts.IO.ColorEnabled()) + } + out := opts.IO.Out fmt.Fprintln(out, "") From e63904bacd8e0b8e77cefb373dc4901f6dbcfffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 14 Apr 2021 16:14:41 +0200 Subject: [PATCH 07/14] Expose more fields for PR JSON export --- api/export_pr.go | 2 ++ api/queries_pr.go | 19 +++++++++++++++++++ api/queries_pr_review.go | 9 ++++++--- api/query_builder.go | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/api/export_pr.go b/api/export_pr.go index 6b9719616..c6fc3db05 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -64,6 +64,8 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} { data[f] = pr.ProjectCards.Nodes case "reviews": data[f] = pr.Reviews.Nodes + case "files": + data[f] = pr.Files.Nodes case "reviewRequests": requests := make([]interface{}, 0, len(pr.ReviewRequests.Nodes)) for _, req := range pr.ReviewRequests.Nodes { diff --git a/api/queries_pr.go b/api/queries_pr.go index 0e9513428..29d1a0470 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -42,13 +42,22 @@ type PullRequest struct { Mergeable string Additions int Deletions int + ChangedFiles int MergeStateStatus string CreatedAt time.Time UpdatedAt time.Time ClosedAt *time.Time MergedAt *time.Time + MergeCommit *Commit + PotentialMergeCommit *Commit + + Files struct { + Nodes []PullRequestFile + } + Author Author + MergedBy *Author HeadRepositoryOwner struct { Login string `json:"login"` } @@ -104,6 +113,16 @@ type PullRequest struct { ReviewRequests ReviewRequests } +type Commit struct { + OID string `json:"oid"` +} + +type PullRequestFile struct { + Path string `json:"path"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` +} + type ReviewRequests struct { Nodes []struct { RequestedReviewer struct { diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index 67d3c5ddb..030472d75 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -31,11 +31,11 @@ type PullRequestReview struct { Author Author `json:"author"` AuthorAssociation string `json:"authorAssociation"` Body string `json:"body"` - CreatedAt time.Time `json:"createdAt"` + SubmittedAt *time.Time `json:"submittedAt"` IncludesCreatedEdit bool `json:"includesCreatedEdit"` ReactionGroups ReactionGroups `json:"reactionGroups"` State string `json:"state"` - URL string `json:"url"` + URL string `json:"url,omitempty"` } func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error { @@ -115,7 +115,10 @@ func (prr PullRequestReview) Content() string { } func (prr PullRequestReview) Created() time.Time { - return prr.CreatedAt + if prr.SubmittedAt == nil { + return time.Time{} + } + return *prr.SubmittedAt } func (prr PullRequestReview) HiddenReason() string { diff --git a/api/query_builder.go b/api/query_builder.go index 1cd21d27f..84d9d8f91 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -46,6 +46,29 @@ var prReviewRequests = shortenQuery(` } `) +var prReviews = shortenQuery(` + reviews(last: 100) { + nodes { + author{login}, + authorAssociation, + submittedAt, + body, + state, + reactionGroups{content,users{totalCount}} + } + } +`) + +var prFiles = shortenQuery(` + files(first: 100) { + nodes { + additions, + deletions, + path + } + } +`) + var prStatusCheckRollup = shortenQuery(` commits(last: 1) { totalCount, @@ -100,7 +123,9 @@ var IssueFields = []string{ var PullRequestFields = append(IssueFields, "additions", "baseRefName", + "changedFiles", "deletions", + "files", "headRefName", "headRepository", "headRepositoryOwner", @@ -108,10 +133,14 @@ var PullRequestFields = append(IssueFields, "isDraft", "maintainerCanModify", "mergeable", + "mergeCommit", "mergedAt", + "mergedBy", "mergeStateStatus", + "potentialMergeCommit", "reviewDecision", "reviewRequests", + "reviews", "statusCheckRollup", ) @@ -121,6 +150,8 @@ func PullRequestGraphQL(fields []string) string { switch field { case "author": q = append(q, `author{login}`) + case "mergedBy": + q = append(q, `mergedBy{login}`) case "headRepositoryOwner": q = append(q, `headRepositoryOwner{login}`) case "headRepository": @@ -135,10 +166,18 @@ func PullRequestGraphQL(fields []string) string { q = append(q, `milestone{title}`) case "reactionGroups": q = append(q, `reactionGroups{content,users{totalCount}}`) + case "mergeCommit": + q = append(q, `mergeCommit{oid}`) + case "potentialMergeCommit": + q = append(q, `potentialMergeCommit{oid}`) case "comments": q = append(q, issueComments) case "reviewRequests": q = append(q, prReviewRequests) + case "reviews": + q = append(q, prReviews) + case "files": + q = append(q, prFiles) case "statusCheckRollup": q = append(q, prStatusCheckRollup) default: From 3ad41e3e651647236ed4ece290afb12dbdc924bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 14 Apr 2021 18:14:51 +0200 Subject: [PATCH 08/14] Change JSON Exporter to an interface --- pkg/cmd/issue/list/list.go | 15 ++++++++------- pkg/cmd/issue/status/status.go | 18 +++++++++--------- pkg/cmd/issue/view/view.go | 10 +++++----- pkg/cmd/pr/list/list.go | 13 +++++++------ pkg/cmd/pr/status/status.go | 18 +++++++++--------- pkg/cmd/pr/view/view.go | 10 +++++----- pkg/cmdutil/json_flags.go | 31 ++++++++++++++++++++----------- 7 files changed, 63 insertions(+), 52 deletions(-) diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 670380830..ff3aba976 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -30,8 +30,8 @@ type ListOptions struct { BaseRepo func() (ghrepo.Interface, error) Browser browser - WebMode bool - Export *cmdutil.ExportFormat + WebMode bool + Exporter cmdutil.Exporter Assignee string Labels []string @@ -87,7 +87,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman 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 `query`") - cmdutil.AddJSONFlags(cmd, &opts.Export, api.IssueFields) + cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.IssueFields) return cmd } @@ -139,8 +139,8 @@ func listRun(opts *ListOptions) error { return opts.Browser.Browse(openURL) } - if opts.Export != nil { - filterOptions.Fields = opts.Export.Fields + if opts.Exporter != nil { + filterOptions.Fields = opts.Exporter.Fields() } listResult, err := issueList(httpClient, baseRepo, filterOptions, opts.LimitResults) @@ -154,8 +154,9 @@ func listRun(opts *ListOptions) error { } defer opts.IO.StopPager() - if opts.Export != nil { - return opts.Export.Write(opts.IO.Out, api.ExportIssues(listResult.Issues, opts.Export.Fields), opts.IO.ColorEnabled()) + if opts.Exporter != nil { + data := api.ExportIssues(listResult.Issues, opts.Exporter.Fields()) + return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled()) } if isTerminal { diff --git a/pkg/cmd/issue/status/status.go b/pkg/cmd/issue/status/status.go index 60567c6f3..764ec52fa 100644 --- a/pkg/cmd/issue/status/status.go +++ b/pkg/cmd/issue/status/status.go @@ -20,7 +20,7 @@ type StatusOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - Export *cmdutil.ExportFormat + Exporter cmdutil.Exporter } func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { @@ -45,7 +45,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co }, } - cmdutil.AddJSONFlags(cmd, &opts.Export, api.IssueFields) + cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.IssueFields) return cmd } @@ -80,8 +80,8 @@ func statusRun(opts *StatusOptions) error { Username: currentUser, Fields: defaultFields, } - if opts.Export != nil { - options.Fields = opts.Export.Fields + if opts.Exporter != nil { + options.Fields = opts.Exporter.Fields() } issuePayload, err := api.IssueStatus(apiClient, baseRepo, options) if err != nil { @@ -94,13 +94,13 @@ func statusRun(opts *StatusOptions) error { } defer opts.IO.StopPager() - if opts.Export != nil { + if opts.Exporter != nil { data := map[string]interface{}{ - "createdBy": api.ExportIssues(issuePayload.Authored.Issues, opts.Export.Fields), - "assigned": api.ExportIssues(issuePayload.Assigned.Issues, opts.Export.Fields), - "mentioned": api.ExportIssues(issuePayload.Mentioned.Issues, opts.Export.Fields), + "createdBy": api.ExportIssues(issuePayload.Authored.Issues, opts.Exporter.Fields()), + "assigned": api.ExportIssues(issuePayload.Assigned.Issues, opts.Exporter.Fields()), + "mentioned": api.ExportIssues(issuePayload.Mentioned.Issues, opts.Exporter.Fields()), } - return opts.Export.Write(opts.IO.Out, &data, opts.IO.ColorEnabled()) + return opts.Exporter.Write(opts.IO.Out, &data, opts.IO.ColorEnabled()) } out := opts.IO.Out diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 509787b87..03a9eb848 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -33,7 +33,7 @@ type ViewOptions struct { SelectorArg string WebMode bool Comments bool - Export *cmdutil.ExportFormat + Exporter cmdutil.Exporter Now func() time.Time } @@ -72,7 +72,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser") cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View issue comments") - cmdutil.AddJSONFlags(cmd, &opts.Export, api.IssueFields) + cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.IssueFields) return cmd } @@ -115,9 +115,9 @@ func viewRun(opts *ViewOptions) error { } defer opts.IO.StopPager() - if opts.Export != nil { - exportIssue := issue.ExportData(opts.Export.Fields) - return opts.Export.Write(opts.IO.Out, exportIssue, opts.IO.ColorEnabled()) + if opts.Exporter != nil { + exportIssue := issue.ExportData(opts.Exporter.Fields()) + return opts.Exporter.Write(opts.IO.Out, exportIssue, opts.IO.ColorEnabled()) } if opts.IO.IsStdoutTTY() { diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index b15acc324..de58d5ad2 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -29,7 +29,7 @@ type ListOptions struct { WebMode bool LimitResults int - Export *cmdutil.ExportFormat + Exporter cmdutil.Exporter State string BaseBranch string @@ -78,7 +78,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Author, "author", "A", "", "Filter by author") cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee") cmd.Flags().StringVarP(&opts.Search, "search", "S", "", "Search pull requests with `query`") - cmdutil.AddJSONFlags(cmd, &opts.Export, api.PullRequestFields) + cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.PullRequestFields) return cmd } @@ -115,8 +115,8 @@ func listRun(opts *ListOptions) error { Search: opts.Search, Fields: defaultFields, } - if opts.Export != nil { - filters.Fields = opts.Export.Fields + if opts.Exporter != nil { + filters.Fields = opts.Exporter.Fields() } if opts.WebMode { @@ -143,8 +143,9 @@ func listRun(opts *ListOptions) error { } defer opts.IO.StopPager() - if opts.Export != nil { - return opts.Export.Write(opts.IO.Out, api.ExportPRs(listResult.PullRequests, opts.Export.Fields), opts.IO.ColorEnabled()) + if opts.Exporter != nil { + data := api.ExportPRs(listResult.PullRequests, opts.Exporter.Fields()) + return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled()) } if opts.IO.IsStdoutTTY() { diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 7bdd9c11b..115ee035b 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -29,7 +29,7 @@ type StatusOptions struct { Branch func() (string, error) HasRepoOverride bool - Export *cmdutil.ExportFormat + Exporter cmdutil.Exporter } func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { @@ -57,7 +57,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co }, } - cmdutil.AddJSONFlags(cmd, &opts.Export, api.PullRequestFields) + cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.PullRequestFields) return cmd } @@ -96,8 +96,8 @@ func statusRun(opts *StatusOptions) error { CurrentPR: currentPRNumber, HeadRef: currentPRHeadRef, } - if opts.Export != nil { - options.Fields = opts.Export.Fields + if opts.Exporter != nil { + options.Fields = opts.Exporter.Fields() } prPayload, err := api.PullRequestStatus(apiClient, baseRepo, options) if err != nil { @@ -110,16 +110,16 @@ func statusRun(opts *StatusOptions) error { } defer opts.IO.StopPager() - if opts.Export != nil { + if opts.Exporter != nil { data := map[string]interface{}{ "currentBranch": nil, - "createdBy": api.ExportPRs(prPayload.ViewerCreated.PullRequests, opts.Export.Fields), - "needsReview": api.ExportPRs(prPayload.ReviewRequested.PullRequests, opts.Export.Fields), + "createdBy": api.ExportPRs(prPayload.ViewerCreated.PullRequests, opts.Exporter.Fields()), + "needsReview": api.ExportPRs(prPayload.ReviewRequested.PullRequests, opts.Exporter.Fields()), } if prPayload.CurrentPR != nil { - data["currentBranch"] = prPayload.CurrentPR.ExportData(opts.Export.Fields) + data["currentBranch"] = prPayload.CurrentPR.ExportData(opts.Exporter.Fields()) } - return opts.Export.Write(opts.IO.Out, &data, opts.IO.ColorEnabled()) + return opts.Exporter.Write(opts.IO.Out, &data, opts.IO.ColorEnabled()) } out := opts.IO.Out diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index d4f897b83..19ebbe756 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -35,7 +35,7 @@ type ViewOptions struct { Remotes func() (context.Remotes, error) Branch func() (string, error) - Export *cmdutil.ExportFormat + Exporter cmdutil.Exporter SelectorArg string BrowserMode bool @@ -85,7 +85,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open a pull request in the browser") cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View pull request comments") - cmdutil.AddJSONFlags(cmd, &opts.Export, api.PullRequestFields) + cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.PullRequestFields) return cmd } @@ -116,9 +116,9 @@ func viewRun(opts *ViewOptions) error { } defer opts.IO.StopPager() - if opts.Export != nil { - exportPR := pr.ExportData(opts.Export.Fields) - return opts.Export.Write(opts.IO.Out, exportPR, opts.IO.ColorEnabled()) + if opts.Exporter != nil { + exportPR := pr.ExportData(opts.Exporter.Fields()) + return opts.Exporter.Write(opts.IO.Out, exportPR, opts.IO.ColorEnabled()) } if connectedToTerminal { diff --git a/pkg/cmdutil/json_flags.go b/pkg/cmdutil/json_flags.go index 0aaa64f55..30630fee1 100644 --- a/pkg/cmdutil/json_flags.go +++ b/pkg/cmdutil/json_flags.go @@ -19,7 +19,7 @@ type JSONFlagError struct { error } -func AddJSONFlags(cmd *cobra.Command, exportTarget **ExportFormat, fields []string) { +func AddJSONFlags(cmd *cobra.Command, exportTarget *Exporter, fields []string) { f := cmd.Flags() f.StringSlice("json", nil, "Output JSON with the specified `fields`") f.StringP("jq", "q", "", "Filter JSON output using a jq `expression`") @@ -62,9 +62,9 @@ func checkJSONFlags(cmd *cobra.Command) (*ExportFormat, error) { } jv := jsonFlag.Value.(pflag.SliceValue) return &ExportFormat{ - Fields: jv.GetSlice(), - Filter: jqFlag.Value.String(), - Template: tplFlag.Value.String(), + fields: jv.GetSlice(), + filter: jqFlag.Value.String(), + template: tplFlag.Value.String(), }, nil } else if jqFlag.Changed { return nil, errors.New("cannot use `--jq` without specifying `--json`") @@ -74,10 +74,19 @@ func checkJSONFlags(cmd *cobra.Command) (*ExportFormat, error) { return nil, nil } +type Exporter interface { + Fields() []string + Write(w io.Writer, data interface{}, colorEnabled bool) error +} + type ExportFormat struct { - Fields []string - Filter string - Template string + fields []string + filter string + template string +} + +func (e *ExportFormat) Fields() []string { + return e.fields } func (e *ExportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) error { @@ -88,10 +97,10 @@ func (e *ExportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) e return err } - if e.Filter != "" { - return export.FilterJSON(w, &buf, e.Filter) - } else if e.Template != "" { - return export.ExecuteTemplate(w, &buf, e.Template, colorEnabled) + if e.filter != "" { + return export.FilterJSON(w, &buf, e.filter) + } else if e.template != "" { + return export.ExecuteTemplate(w, &buf, e.template, colorEnabled) } else if colorEnabled { return jsoncolor.Write(w, &buf, " ") } From e327b42f79a74876a8a67614b7c4a9c7006d42de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 14 Apr 2021 18:27:15 +0200 Subject: [PATCH 09/14] Add `gh help formatting` topic & link to it from commands with JSON output --- pkg/cmd/api/api.go | 16 ---------------- pkg/cmd/root/help.go | 15 +++++++++++---- pkg/cmd/root/help_topic.go | 25 +++++++++++++++++++++++++ pkg/cmd/root/root.go | 1 + 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 997b6fabe..249b5a30c 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -101,22 +101,6 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command there are no more pages of results. For GraphQL requests, this requires that the original query accepts an %[1]s$endCursor: String%[1]s variable and that it fetches the %[1]spageInfo{ hasNextPage, endCursor }%[1]s set of fields from a collection. - - The %[1]s--jq%[1]s option accepts a query in jq syntax and will print only the resulting - values that match the query. This is equivalent to piping the output to %[1]sjq -r%[1]s, - but does not require the jq utility to be installed on the system. To learn more - about the query syntax, see: https://stedolan.github.io/jq/manual/v1.6/ - - With %[1]s--template%[1]s, the provided Go template is rendered using the JSON data as input. - For the syntax of Go templates, see: https://golang.org/pkg/text/template/ - - The following functions are available in templates: - - %[1]scolor