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] 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 +}