Merge pull request #3414 from cli/json-format
Add `--json` export flag for issues and pull requests
This commit is contained in:
commit
31cccb4604
30 changed files with 1462 additions and 484 deletions
107
api/export_pr.go
Normal file
107
api/export_pr.go
Normal 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
148
api/export_pr_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
188
api/query_builder.go
Normal 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
39
api/query_builder_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
229
pkg/cmd/issue/list/http.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -155,6 +155,8 @@ type FilterOptions struct {
|
|||
Mention string
|
||||
Milestone string
|
||||
Search string
|
||||
|
||||
Fields []string
|
||||
}
|
||||
|
||||
func (opts *FilterOptions) IsDefault() bool {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
123
pkg/cmdutil/json_flags.go
Normal 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
|
||||
}
|
||||
173
pkg/cmdutil/json_flags_test.go
Normal file
173
pkg/cmdutil/json_flags_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue