Merge pull request #3414 from cli/json-format

Add `--json` export flag for issues and pull requests
This commit is contained in:
Mislav Marohnić 2021-04-14 20:15:29 +02:00 committed by GitHub
commit 31cccb4604
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1462 additions and 484 deletions

107
api/export_pr.go Normal file
View file

@ -0,0 +1,107 @@
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 "files":
data[f] = pr.Files.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 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 {
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)
})
}

148
api/export_pr_test.go Normal file
View file

@ -0,0 +1,148 @@
package api
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssue_ExportData(t *testing.T) {
tests := []struct {
name string
fields []string
inputJSON string
outputJSON string
}{
{
name: "simple",
fields: []string{"number", "title"},
inputJSON: heredoc.Doc(`
{ "title": "Bugs hugs", "number": 2345 }
`),
outputJSON: heredoc.Doc(`
{
"number": 2345,
"title": "Bugs hugs"
}
`),
},
{
name: "project cards",
fields: []string{"projectCards"},
inputJSON: heredoc.Doc(`
{ "projectCards": { "nodes": [
{
"project": { "name": "Rewrite" },
"column": { "name": "TO DO" }
}
] } }
`),
outputJSON: heredoc.Doc(`
{
"projectCards": [
{
"project": {
"name": "Rewrite"
},
"column": {
"name": "TO DO"
}
}
]
}
`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var issue Issue
dec := json.NewDecoder(strings.NewReader(tt.inputJSON))
require.NoError(t, dec.Decode(&issue))
exported := issue.ExportData(tt.fields)
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
enc.SetIndent("", "\t")
require.NoError(t, enc.Encode(exported))
assert.Equal(t, tt.outputJSON, buf.String())
})
}
}
func TestPullRequest_ExportData(t *testing.T) {
tests := []struct {
name string
fields []string
inputJSON string
outputJSON string
}{
{
name: "simple",
fields: []string{"number", "title"},
inputJSON: heredoc.Doc(`
{ "title": "Bugs hugs", "number": 2345 }
`),
outputJSON: heredoc.Doc(`
{
"number": 2345,
"title": "Bugs hugs"
}
`),
},
{
name: "status checks",
fields: []string{"statusCheckRollup"},
inputJSON: heredoc.Doc(`
{ "commits": { "nodes": [
{ "commit": { "statusCheckRollup": { "contexts": { "nodes": [
{
"__typename": "CheckRun",
"name": "mycheck",
"status": "COMPLETED",
"conclusion": "SUCCESS",
"startedAt": "2020-08-31T15:44:24+02:00",
"completedAt": "2020-08-31T15:45:24+02:00",
"detailsUrl": "http://example.com/details"
}
] } } } }
] } }
`),
outputJSON: heredoc.Doc(`
{
"statusCheckRollup": [
{
"__typename": "CheckRun",
"name": "mycheck",
"status": "COMPLETED",
"conclusion": "SUCCESS",
"startedAt": "2020-08-31T15:44:24+02:00",
"completedAt": "2020-08-31T15:45:24+02:00",
"detailsUrl": "http://example.com/details"
}
]
}
`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var pr PullRequest
dec := json.NewDecoder(strings.NewReader(tt.inputJSON))
require.NoError(t, dec.Decode(&pr))
exported := pr.ExportData(tt.fields)
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
enc.SetIndent("", "\t")
require.NoError(t, enc.Encode(exported))
assert.Equal(t, tt.outputJSON, buf.String())
})
}
}

View file

@ -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 {

View file

@ -2,10 +2,7 @@ package api
import (
"context"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
"github.com/cli/cli/internal/ghrepo"
@ -33,6 +30,7 @@ type Issue struct {
Body string
CreatedAt time.Time
UpdatedAt time.Time
ClosedAt *time.Time
Comments Comments
Author Author
Assignees Assignees
@ -44,7 +42,7 @@ type Issue struct {
type Assignees struct {
Nodes []struct {
Login string
Login string `json:"login"`
}
TotalCount int
}
@ -59,7 +57,7 @@ func (a Assignees) Logins() []string {
type Labels struct {
Nodes []struct {
Name string
Name string `json:"name"`
}
TotalCount int
}
@ -75,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
}
@ -93,7 +91,7 @@ func (p ProjectCards) ProjectNames() []string {
}
type Milestone struct {
Title string
Title string `json:"title"`
}
type IssuesDisabledError struct {
@ -101,25 +99,9 @@ type IssuesDisabledError struct {
}
type Author struct {
Login string
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 := `
@ -155,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 {
@ -174,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) {
@ -202,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
@ -233,123 +221,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 +321,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 +402,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
}

View file

@ -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"
)
@ -41,13 +42,24 @@ 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
Author struct {
Login string
MergeCommit *Commit
PotentialMergeCommit *Commit
Files struct {
Nodes []PullRequestFile
}
Author Author
MergedBy *Author
HeadRepositoryOwner struct {
Login string
Login string `json:"login"`
}
HeadRepository struct {
Name string
@ -75,15 +87,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"`
}
}
}
@ -100,12 +113,22 @@ 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 {
TypeName string `json:"__typename"`
Login string
Name string
Login string `json:"login"`
Name string `json:"name"`
}
}
TotalCount int
@ -304,7 +327,14 @@ 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
Fields []string
}
func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOptions) (*PullRequestsPayload, error) {
type edges struct {
TotalCount int
Edges []struct {
@ -324,12 +354,148 @@ 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())
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 := `
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"
@ -391,121 +557,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) {
@ -972,10 +1024,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
}

View file

@ -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"`
SubmittedAt *time.Time `json:"submittedAt"`
IncludesCreatedEdit bool `json:"includesCreatedEdit"`
ReactionGroups ReactionGroups `json:"reactionGroups"`
State string `json:"state"`
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 {

188
api/query_builder.go Normal file
View file

@ -0,0 +1,188 @@
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 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,
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",
"changedFiles",
"deletions",
"files",
"headRefName",
"headRepository",
"headRepositoryOwner",
"isCrossRepository",
"isDraft",
"maintainerCanModify",
"mergeable",
"mergeCommit",
"mergedAt",
"mergedBy",
"mergeStateStatus",
"potentialMergeCommit",
"reviewDecision",
"reviewRequests",
"reviews",
"statusCheckRollup",
)
func PullRequestGraphQL(fields []string) string {
var q []string
for _, field := range fields {
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":
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 "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:
q = append(q, field)
}
}
return strings.Join(q, ",")
}

39
api/query_builder_test.go Normal file
View file

@ -0,0 +1,39 @@
package api
import "testing"
func TestPullRequestGraphQL(t *testing.T) {
tests := []struct {
name string
fields []string
want string
}{
{
name: "empty",
fields: []string(nil),
want: "",
},
{
name: "simple fields",
fields: []string{"number", "title"},
want: "number,title",
},
{
name: "fields with nested structures",
fields: []string{"author", "assignees"},
want: "author{login},assignees(first:100){nodes{login},totalCount}",
},
{
name: "compressed query",
fields: []string{"files"},
want: "files(first: 100) {nodes {additions,deletions,path}}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := PullRequestGraphQL(tt.fields); got != tt.want {
t.Errorf("PullRequestGraphQL() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -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 {

View file

@ -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"
@ -100,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 <style>, <input>%[1]s: colorize input using https://github.com/mgutz/ansi
- %[1]sautocolor%[1]s: like %[1]scolor%[1]s, but only emits color to terminals
- %[1]stimefmt <format> <time>%[1]s: formats a timestamp using Go's Time.Format function
- %[1]stimeago <time>%[1]s: renders a timestamp as relative to now
- %[1]spluck <field> <list>%[1]s: collects values of a field from all items in the input
- %[1]sjoin <sep> <list>%[1]s: joins values in the list using a separator
`, "`"),
Example: heredoc.Doc(`
# list releases in the current repository
@ -370,13 +355,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
}

229
pkg/cmd/issue/list/http.go Normal file
View file

@ -0,0 +1,229 @@
package list
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
)
func listIssues(client *api.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) {
var states []string
switch filters.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", 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) {
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 filters.Assignee != "" {
variables["assignee"] = filters.Assignee
}
if filters.Author != "" {
variables["author"] = filters.Author
}
if filters.Mention != "" {
variables["mention"] = filters.Mention
}
if filters.Milestone != "" {
var milestone *api.RepoMilestone
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", filters.Milestone)
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 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) {
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(), prShared.SearchQueryBuild(filters))
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
}

View file

@ -1,4 +1,4 @@
package api
package list
import (
"encoding/json"
@ -7,13 +7,15 @@ import (
"github.com/stretchr/testify/assert"
"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"
)
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`),
@ -47,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)
}
@ -78,7 +84,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`),
@ -127,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)
}
@ -135,14 +141,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)

View file

@ -30,7 +30,8 @@ type ListOptions struct {
BaseRepo func() (ghrepo.Interface, error)
Browser browser
WebMode bool
WebMode bool
Exporter cmdutil.Exporter
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.Exporter, 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.Exporter != nil {
filterOptions.Fields = opts.Exporter.Fields()
}
listResult, err := issueList(httpClient, baseRepo, filterOptions, opts.LimitResults)
if err != nil {
return err
@ -138,6 +154,11 @@ func listRun(opts *ListOptions) error {
}
defer opts.IO.StopPager()
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 {
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 +181,23 @@ func issueList(client *http.Client, repo ghrepo.Interface, filters prShared.Filt
filters.Milestone = milestone.Title
}
searchQuery := prShared.SearchQueryBuild(filters)
return api.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 api.IssueList(
apiClient,
repo,
filters.State,
filterAssignee,
limit,
filterAuthor,
filterMention,
filters.Milestone,
)
return listIssues(apiClient, repo, filters, limit)
}

View file

@ -19,6 +19,8 @@ type StatusOptions struct {
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Exporter cmdutil.Exporter
}
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.Exporter, 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.Exporter != nil {
options.Fields = opts.Exporter.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.Exporter != nil {
data := map[string]interface{}{
"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.Exporter.Write(opts.IO.Out, &data, opts.IO.ColorEnabled())
}
out := opts.IO.Out
fmt.Fprintln(out, "")

View file

@ -33,6 +33,7 @@ type ViewOptions struct {
SelectorArg string
WebMode bool
Comments bool
Exporter cmdutil.Exporter
Now func() time.Time
}
@ -52,7 +53,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
Display the title, body, and other information about an issue.
With '--web', open the issue in a web browser instead.
`),
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
@ -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.Exporter, api.IssueFields)
return cmd
}
@ -113,6 +115,11 @@ func viewRun(opts *ViewOptions) error {
}
defer opts.IO.StopPager()
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() {
return printHumanIssuePreview(opts, issue)
}

View file

@ -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{}{}

View file

@ -29,12 +29,14 @@ type ListOptions struct {
WebMode bool
LimitResults int
State string
BaseBranch string
Labels []string
Author string
Assignee string
Search string
Exporter cmdutil.Exporter
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.Exporter, 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.Exporter != nil {
filters.Fields = opts.Exporter.Fields()
}
if opts.WebMode {
@ -121,10 +139,15 @@ func listRun(opts *ListOptions) 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.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() {
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)

View file

@ -155,6 +155,8 @@ type FilterOptions struct {
Mention string
Milestone string
Search string
Fields []string
}
func (opts *FilterOptions) IsDefault() bool {

View file

@ -29,6 +29,7 @@ type StatusOptions struct {
Branch func() (string, error)
HasRepoOverride bool
Exporter cmdutil.Exporter
}
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.Exporter, api.PullRequestFields)
return cmd
}
@ -88,19 +91,37 @@ 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,
}
if opts.Exporter != nil {
options.Fields = opts.Exporter.Fields()
}
prPayload, err := api.PullRequestStatus(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.Exporter != nil {
data := map[string]interface{}{
"currentBranch": nil,
"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.Exporter.Fields())
}
return opts.Exporter.Write(opts.IO.Out, &data, opts.IO.ColorEnabled())
}
out := opts.IO.Out
cs := opts.IO.ColorScheme()

View file

@ -10,7 +10,7 @@
},
"authorAssociation": "NONE",
"body": "Review 1",
"createdAt": "2020-01-02T12:00:00Z",
"submittedAt": "2020-01-02T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{
@ -71,7 +71,7 @@
},
"authorAssociation": "OWNER",
"body": "Review 2",
"createdAt": "2020-01-04T12:00:00Z",
"submittedAt": "2020-01-04T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{
@ -132,7 +132,7 @@
},
"authorAssociation": "MEMBER",
"body": "Review 3",
"createdAt": "2020-01-06T12:00:00Z",
"submittedAt": "2020-01-06T12:00:00Z",
"includesCreatedEdit": true,
"reactionGroups": [
{
@ -193,7 +193,7 @@
},
"authorAssociation": "NONE",
"body": "Review 4",
"createdAt": "2020-01-08T12:00:00Z",
"submittedAt": "2020-01-08T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{
@ -254,7 +254,7 @@
},
"authorAssociation": "NONE",
"body": "Review 5",
"createdAt": "2020-01-10T12:00:00Z",
"submittedAt": "2020-01-10T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{

View file

@ -35,6 +35,8 @@ type ViewOptions struct {
Remotes func() (context.Remotes, error)
Branch func() (string, error)
Exporter cmdutil.Exporter
SelectorArg string
BrowserMode bool
Comments bool
@ -60,7 +62,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
is displayed.
With '--web', open the pull request in a web browser instead.
`),
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
@ -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.Exporter, api.PullRequestFields)
return cmd
}
@ -113,6 +116,11 @@ func viewRun(opts *ViewOptions) error {
}
defer opts.IO.StopPager()
if opts.Exporter != nil {
exportPR := pr.ExportData(opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, exportPR, opts.IO.ColorEnabled())
}
if connectedToTerminal {
return printHumanPrPreview(opts, pr)
}

View file

@ -119,11 +119,18 @@ func rootHelpFunc(cs *iostreams.ColorScheme, command *cobra.Command, args []stri
Body string
}
longText := command.Long
if longText == "" {
longText = command.Short
}
if longText != "" && command.LocalFlags().Lookup("jq") != nil {
longText = strings.TrimRight(longText, "\n") +
"\n\nFor more information about output formatting flags, see `gh help formatting`."
}
helpEntries := []helpEntry{}
if command.Long != "" {
helpEntries = append(helpEntries, helpEntry{"", command.Long})
} else if command.Short != "" {
helpEntries = append(helpEntries, helpEntry{"", command.Short})
if longText != "" {
helpEntries = append(helpEntries, helpEntry{"", longText})
}
helpEntries = append(helpEntries, helpEntry{"USAGE", command.UseLine()})
if len(coreCommands) > 0 {

View file

@ -50,6 +50,31 @@ var HelpTopics = map[string]map[string]string{
"reference": {
"short": "A comprehensive reference of all gh commands",
},
"formatting": {
"short": "Formatting options for JSON data exported from gh",
"long": heredoc.Docf(`
Some gh commands support exporting the data as JSON as an alternative to their usual
line-based plain text output. This is suitable for passing structured data to scripts.
The JSON output is enabled with the %[1]s--json%[1]s option, followed by the list of fields
to fetch. Use the flag without a value to get the list of available fields.
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 <style>, <input>%[1]s: colorize input using https://github.com/mgutz/ansi
- %[1]sautocolor%[1]s: like %[1]scolor%[1]s, but only emits color to terminals
- %[1]stimefmt <format> <time>%[1]s: formats a timestamp using Go's Time.Format function
- %[1]stimeago <time>%[1]s: renders a timestamp as relative to now
- %[1]spluck <field> <list>%[1]s: collects values of a field from all items in the input
- %[1]sjoin <sep> <list>%[1]s: joins values in the list using a separator
`, "`"),
},
}
func NewHelpTopic(topic string) *cobra.Command {

View file

@ -103,6 +103,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
// Help topics
cmd.AddCommand(NewHelpTopic("environment"))
cmd.AddCommand(NewHelpTopic("formatting"))
referenceCmd := NewHelpTopic("reference")
referenceCmd.SetHelpFunc(referenceHelpFn(f.IOStreams))
cmd.AddCommand(referenceCmd)

123
pkg/cmdutil/json_flags.go Normal file
View file

@ -0,0 +1,123 @@
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/cli/cli/pkg/set"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type JSONFlagError struct {
error
}
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`")
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 {
if export == nil {
*exportTarget = nil
} else {
allowedFields := set.NewStringSet()
allowedFields.AddValues(fields)
for _, f := range export.fields {
if !allowedFields.Contains(f) {
sort.Strings(fields)
return JSONFlagError{fmt.Errorf("Unknown JSON field: %q\nAvailable fields:\n %s", f, strings.Join(fields, "\n "))}
}
}
*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 Exporter interface {
Fields() []string
Write(w io.Writer, data interface{}, colorEnabled bool) error
}
type exportFormat struct {
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 {
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
}

View file

@ -0,0 +1,173 @@
package cmdutil
import (
"bytes"
"io/ioutil"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAddJSONFlags(t *testing.T) {
tests := []struct {
name string
fields []string
args []string
wantsExport *exportFormat
wantsError string
}{
{
name: "no JSON flag",
fields: []string{},
args: []string{},
wantsExport: nil,
},
{
name: "empty JSON flag",
fields: []string{"one", "two"},
args: []string{"--json"},
wantsExport: nil,
wantsError: "Specify one or more comma-separated fields for `--json`:\n one\n two",
},
{
name: "invalid JSON field",
fields: []string{"id", "number"},
args: []string{"--json", "idontexist"},
wantsExport: nil,
wantsError: "Unknown JSON field: \"idontexist\"\nAvailable fields:\n id\n number",
},
{
name: "cannot combine --json with --web",
fields: []string{"id", "number", "title"},
args: []string{"--json", "id", "--web"},
wantsExport: nil,
wantsError: "cannot use `--web` with `--json`",
},
{
name: "cannot use --jq without --json",
fields: []string{},
args: []string{"--jq", ".number"},
wantsExport: nil,
wantsError: "cannot use `--jq` without specifying `--json`",
},
{
name: "cannot use --template without --json",
fields: []string{},
args: []string{"--template", "{{.number}}"},
wantsExport: nil,
wantsError: "cannot use `--template` without specifying `--json`",
},
{
name: "with JSON fields",
fields: []string{"id", "number", "title"},
args: []string{"--json", "number,title"},
wantsExport: &exportFormat{
fields: []string{"number", "title"},
filter: "",
template: "",
},
},
{
name: "with jq filter",
fields: []string{"id", "number", "title"},
args: []string{"--json", "number", "-q.number"},
wantsExport: &exportFormat{
fields: []string{"number"},
filter: ".number",
template: "",
},
},
{
name: "with Go template",
fields: []string{"id", "number", "title"},
args: []string{"--json", "number", "-t", "{{.number}}"},
wantsExport: &exportFormat{
fields: []string{"number"},
filter: "",
template: "{{.number}}",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Run: func(*cobra.Command, []string) {}}
cmd.Flags().Bool("web", false, "")
var exporter Exporter
AddJSONFlags(cmd, &exporter, tt.fields)
cmd.SetArgs(tt.args)
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err := cmd.ExecuteC()
if tt.wantsError == "" {
require.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantsError)
return
}
if tt.wantsExport == nil {
assert.Nil(t, exporter)
} else {
assert.Equal(t, tt.wantsExport, exporter)
}
})
}
}
func Test_exportFormat_Write(t *testing.T) {
type args struct {
data interface{}
colorEnabled bool
}
tests := []struct {
name string
exporter exportFormat
args args
wantW string
wantErr bool
}{
{
name: "regular JSON output",
exporter: exportFormat{},
args: args{
data: map[string]string{"name": "hubot"},
colorEnabled: false,
},
wantW: "{\"name\":\"hubot\"}\n",
wantErr: false,
},
{
name: "with jq filter",
exporter: exportFormat{filter: ".name"},
args: args{
data: map[string]string{"name": "hubot"},
colorEnabled: false,
},
wantW: "hubot\n",
wantErr: false,
},
{
name: "with Go template",
exporter: exportFormat{template: "{{.name}}"},
args: args{
data: map[string]string{"name": "hubot"},
colorEnabled: false,
},
wantW: "hubot",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &bytes.Buffer{}
if err := tt.exporter.Write(w, tt.args.data, tt.args.colorEnabled); (err != nil) != tt.wantErr {
t.Errorf("exportFormat.Write() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotW := w.String(); gotW != tt.wantW {
t.Errorf("exportFormat.Write() = %v, want %v", gotW, tt.wantW)
}
})
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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
}