Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions pkg/cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,14 +294,16 @@ func apiRun(opts *ApiOptions) error {
host = opts.Hostname
}

template := export.NewTemplate(opts.IO, opts.Template)

hasNextPage := true
for hasNextPage {
resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders)
if err != nil {
return err
}

endCursor, err := processResponse(resp, opts, headersOutputStream)
endCursor, err := processResponse(resp, opts, headersOutputStream, &template)
if err != nil {
return err
}
Expand All @@ -324,10 +326,10 @@ func apiRun(opts *ApiOptions) error {
}
}

return nil
return template.End()
}

func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream io.Writer) (endCursor string, err error) {
func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream io.Writer, template *export.Template) (endCursor string, err error) {
if opts.ShowResponseHeaders {
fmt.Fprintln(headersOutputStream, resp.Proto, resp.Status)
printHeaders(headersOutputStream, resp.Header, opts.IO.ColorEnabled())
Expand Down Expand Up @@ -365,7 +367,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
}
} else if opts.Template != "" {
// TODO: reuse parsed template across pagination invocations
err = export.ExecuteTemplate(opts.IO.Out, responseBody, opts.Template, opts.IO.ColorEnabled())
err = template.Execute(responseBody)
if err != nil {
return
}
Expand Down
105 changes: 103 additions & 2 deletions pkg/cmd/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/cli/cli/internal/config"
"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/google/shlex"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -671,6 +672,101 @@ func Test_apiRun_paginationGraphQL(t *testing.T) {
assert.Equal(t, "PAGE1_END", endCursor)
}

func Test_apiRun_paginated_template(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(true)

requestCount := 0
responses := []*http.Response{
{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{`application/json`}},
Body: ioutil.NopCloser(bytes.NewBufferString(`{
"data": {
"nodes": [
{
"page": 1,
"caption": "page one"
}
],
"pageInfo": {
"endCursor": "PAGE1_END",
"hasNextPage": true
}
}
}`)),
},
{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{`application/json`}},
Body: ioutil.NopCloser(bytes.NewBufferString(`{
"data": {
"nodes": [
{
"page": 20,
"caption": "page twenty"
}
],
"pageInfo": {
"endCursor": "PAGE20_END",
"hasNextPage": false
}
}
}`)),
},
}

options := ApiOptions{
IO: io,
HttpClient: func() (*http.Client, error) {
var tr roundTripper = func(req *http.Request) (*http.Response, error) {
resp := responses[requestCount]
resp.Request = req
requestCount++
return resp, nil
}
return &http.Client{Transport: tr}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},

RequestMethod: "POST",
RequestPath: "graphql",
Paginate: true,
// test that templates executed per page properly render a table.
Template: `{{range .data.nodes}}{{tablerow .page .caption}}{{end}}`,
}

err := apiRun(&options)
require.NoError(t, err)

assert.Equal(t, heredoc.Doc(`
1 page one
20 page twenty
`), stdout.String(), "stdout")
assert.Equal(t, "", stderr.String(), "stderr")

var requestData struct {
Variables map[string]interface{}
}

bb, err := ioutil.ReadAll(responses[0].Request.Body)
require.NoError(t, err)
err = json.Unmarshal(bb, &requestData)
require.NoError(t, err)
_, hasCursor := requestData.Variables["endCursor"].(string)
assert.Equal(t, false, hasCursor)

bb, err = ioutil.ReadAll(responses[1].Request.Body)
require.NoError(t, err)
err = json.Unmarshal(bb, &requestData)
require.NoError(t, err)
endCursor, hasCursor := requestData.Variables["endCursor"].(string)
assert.Equal(t, true, hasCursor)
assert.Equal(t, "PAGE1_END", endCursor)
}

func Test_apiRun_inputFile(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -1167,10 +1263,15 @@ func Test_processResponse_template(t *testing.T) {
]`)),
}

_, err := processResponse(&resp, &ApiOptions{
opts := ApiOptions{
IO: io,
Template: `{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " }}){{"\n"}}{{end}}`,
}, ioutil.Discard)
}
template := export.NewTemplate(io, opts.Template)
_, err := processResponse(&resp, &opts, ioutil.Discard, &template)
require.NoError(t, err)

err = template.End()
require.NoError(t, err)

assert.Equal(t, heredoc.Doc(`
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/issue/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()

if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, listResult.Issues, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, listResult.Issues)
}

if isTerminal {
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/issue/status/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func statusRun(opts *StatusOptions) error {
"assigned": issuePayload.Assigned.Issues,
"mentioned": issuePayload.Mentioned.Issues,
}
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, data)
}

out := opts.IO.Out
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/issue/view/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()

if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, issue, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, issue)
}

if opts.IO.IsStdoutTTY() {
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/pr/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()

if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, listResult.PullRequests, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, listResult.PullRequests)
}

if opts.IO.IsStdoutTTY() {
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/pr/status/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ func statusRun(opts *StatusOptions) error {
if prPayload.CurrentPR != nil {
data["currentBranch"] = prPayload.CurrentPR
}
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, data)
}

out := opts.IO.Out
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/pr/view/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()

if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, pr, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, pr)
}

if connectedToTerminal {
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/release/view/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()

if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, release, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, release)
}

if opts.IO.IsStdoutTTY() {
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/repo/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()

if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, listResult.Repositories, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, listResult.Repositories)
}

cs := opts.IO.ColorScheme()
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/repo/view/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()

if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, repo, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, repo)
}

fullName := ghrepo.FullName(toView)
Expand Down
7 changes: 3 additions & 4 deletions pkg/cmd/repo/view/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package view
import (
"bytes"
"fmt"
"io"
"net/http"
"testing"

Expand Down Expand Up @@ -669,9 +668,9 @@ func (e *testExporter) Fields() []string {
return e.fields
}

func (e *testExporter) Write(w io.Writer, data interface{}, colorize bool) error {
func (e *testExporter) Write(io *iostreams.IOStreams, data interface{}) error {
r := data.(*api.Repository)
fmt.Fprintf(w, "name: %s\n", r.Name)
fmt.Fprintf(w, "defaultBranchRef: %s\n", r.DefaultBranchRef.Name)
fmt.Fprintf(io.Out, "name: %s\n", r.Name)
fmt.Fprintf(io.Out, "defaultBranchRef: %s\n", r.DefaultBranchRef.Name)
return nil
}
26 changes: 22 additions & 4 deletions pkg/cmd/root/help_topic.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,30 @@ var HelpTopics = map[string]map[string]string{
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]scolor <style> <input>%[1]s: colorize input using https://github.com/mgutz/ansi
- %[1]sjoin <sep> <list>%[1]s: joins values in the list using a separator
- %[1]spluck <field> <list>%[1]s: collects values of a field from all items in the input
- %[1]stablerow <fields>...%[1]s: aligns fields in output vertically as a table
- %[1]stablerender%[1]s: renders fields added by tablerow in place
- %[1]stimeago <time>%[1]s: renders a timestamp as relative to now
- %[1]stimefmt <format> <time>%[1]s: formats a timestamp using Go's Time.Format function
- %[1]struncate <length> <input>%[1]s: ensures input fits within length

EXAMPLES
# format issues as table
$ gh issue list --json number,title --template \
'{{range .}}{{tablerow (printf "#%%v" .number | autocolor "green") .title}}{{end}}'

# format a pull request using multiple tables with headers
$ gh pr view 3519 --json number,title,body,reviews,assignees --template \
'{{printf "#%%v" .number}} {{.title}}

{{.body}}

{{tablerow "ASSIGNEE" "NAME"}}{{range .assignees}}{{tablerow .login .name}}{{end}}{{tablerender}}
{{tablerow "REVIEWER" "STATE" "COMMENT"}}{{range .reviews}}{{tablerow .author.login .state .body}}{{end}}
'
`, "`"),
},
}
Expand Down
10 changes: 6 additions & 4 deletions pkg/cmdutil/json_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"

"github.com/cli/cli/pkg/export"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/jsoncolor"
"github.com/cli/cli/pkg/set"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -107,7 +108,7 @@ func checkJSONFlags(cmd *cobra.Command) (*exportFormat, error) {

type Exporter interface {
Fields() []string
Write(w io.Writer, data interface{}, colorEnabled bool) error
Write(io *iostreams.IOStreams, data interface{}) error
}

type exportFormat struct {
Expand All @@ -123,19 +124,20 @@ func (e *exportFormat) Fields() []string {
// Write serializes data into JSON output written to w. If the object passed as data implements exportable,
// or if data is a map or slice of exportable object, ExportData() will be called on each object to obtain
// raw data for serialization.
func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) error {
func (e *exportFormat) Write(ios *iostreams.IOStreams, data interface{}) error {
buf := bytes.Buffer{}
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(e.exportData(reflect.ValueOf(data))); err != nil {
return err
}

w := ios.Out
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 export.ExecuteTemplate(ios, &buf, e.template)
} else if ios.ColorEnabled() {
return jsoncolor.Write(w, &buf, " ")
}

Expand Down
Loading