Skip to content

Commit

Permalink
Add helper template functions for rendering tables (cli#3519)
Browse files Browse the repository at this point in the history
Co-authored-by: Mislav Marohnić <[email protected]>
  • Loading branch information
heaths and mislav authored Aug 23, 2021
1 parent a53ea0c commit e297345
Show file tree
Hide file tree
Showing 21 changed files with 517 additions and 65 deletions.
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

0 comments on commit e297345

Please sign in to comment.