Skip to content

Add useful template functions to jq filters #178

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: trunk
Choose a base branch
from
Open
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
42 changes: 42 additions & 0 deletions internal/text/text.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package text

import (
"time"

"github.com/cli/go-gh/v2/pkg/text"
)

func TimeFormatFunc(format, input string) (string, error) {
t, err := time.Parse(time.RFC3339, input)
if err != nil {
return "", err
}
return t.Format(format), nil
}

func TimeAgoFunc(now time.Time, input string) (string, error) {
t, err := time.Parse(time.RFC3339, input)
if err != nil {
return "", err
}
return timeAgo(now.Sub(t)), nil
}

func timeAgo(ago time.Duration) string {
if ago < time.Minute {
return "just now"
}
if ago < time.Hour {
return text.Pluralize(int(ago.Minutes()), "minute") + " ago"
}
if ago < 24*time.Hour {
return text.Pluralize(int(ago.Hours()), "hour") + " ago"
}
if ago < 30*24*time.Hour {
return text.Pluralize(int(ago.Hours())/24, "day") + " ago"
}
if ago < 365*24*time.Hour {
return text.Pluralize(int(ago.Hours())/24/30, "month") + " ago"
}
return text.Pluralize(int(ago.Hours()/24/365), "year") + " ago"
}
46 changes: 46 additions & 0 deletions internal/text/text_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package text

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestTimeFormatFunc(t *testing.T) {
_, err := TimeFormatFunc("Mon, 02 Jan 2006 15:04:05 MST", "invalid")
require.Error(t, err)

actual, err := TimeFormatFunc("Mon, 02 Jan 2006 15:04:05 MST", "2025-01-20T01:08:15Z")
require.NoError(t, err)
assert.Equal(t, "Mon, 20 Jan 2025 01:08:15 UTC", actual)
}

func TestTimeAgoFunc(t *testing.T) {
const form = "2006-Jan-02 15:04:05"
now, _ := time.Parse(form, "2020-Nov-22 14:00:00")
cases := map[string]string{
"2020-11-22T14:00:00Z": "just now",
"2020-11-22T13:59:30Z": "just now",
"2020-11-22T13:59:00Z": "1 minute ago",
"2020-11-22T13:30:00Z": "30 minutes ago",
"2020-11-22T13:00:00Z": "1 hour ago",
"2020-11-22T02:00:00Z": "12 hours ago",
"2020-11-21T14:00:00Z": "1 day ago",
"2020-11-07T14:00:00Z": "15 days ago",
"2020-10-24T14:00:00Z": "29 days ago",
"2020-10-23T14:00:00Z": "1 month ago",
"2020-09-23T14:00:00Z": "2 months ago",
"2019-11-22T14:00:00Z": "1 year ago",
"2018-11-22T14:00:00Z": "2 years ago",
}
for createdAt, expected := range cases {
relative, err := TimeAgoFunc(now, createdAt)
require.NoError(t, err)
assert.Equal(t, expected, relative)
}

_, err := TimeAgoFunc(now, "invalid")
assert.Error(t, err)
}
2 changes: 1 addition & 1 deletion internal/yamlmap/yaml_map.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func (m *Map) AddEntry(key string, value *Map) {
}

func (m *Map) Empty() bool {
return m.Content == nil || len(m.Content) == 0
return len(m.Content) == 0
}

func (m *Map) FindEntry(key string) (*Map, error) {
Expand Down
88 changes: 88 additions & 0 deletions pkg/jq/functions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package jq

import (
"fmt"
"time"

"github.com/cli/go-gh/v2/internal/text"
"github.com/itchyny/gojq"
)

// WithTemplateFunctions adds some functions from the template package including:
// - timeago: parses RFC3339 date-times and return relative time e.g., "5 minutes ago".
// - timefmt: parses RFC3339 date-times,and formats according to layout argument documented at https://pkg.go.dev/time#Layout.
func WithTemplateFunctions() EvaluateOption {
return func(opts *evaluateOptions) {
now := time.Now()

opts.compilerOptions = append(
opts.compilerOptions,
gojq.WithFunction("timeago", 0, 0, timeAgoJqFunc(now)),
)

opts.compilerOptions = append(
opts.compilerOptions,
gojq.WithFunction("timefmt", 1, 1, timeFmtJq),
)
}
}

func timeAgoJqFunc(now time.Time) func(v any, _ []any) any {
return func(v any, _ []any) any {
if input, ok := v.(string); ok {
if t, err := text.TimeAgoFunc(now, input); err != nil {
return cannotFormatError(v, err)
} else {
return t
}
}

return notStringError(v)
}
}

func timeFmtJq(v any, vs []any) any {
var input, format string
var ok bool

if input, ok = v.(string); !ok {
return notStringError(v)
}

if len(vs) != 1 {
return fmt.Errorf("timefmt requires time format argument")
}

if format, ok = vs[0].(string); !ok {
return notStringError(v)
}

if t, err := text.TimeFormatFunc(format, input); err != nil {
return cannotFormatError(v, err)
} else {
return t
}
}

type valueError struct {
error
value any
}

func notStringError(v any) gojq.ValueError {
return valueError{
error: fmt.Errorf("%v is not a string", v),
value: v,
}
}

func cannotFormatError(v any, err error) gojq.ValueError {
return valueError{
error: fmt.Errorf("cannot format %v, %w", v, err),
value: v,
}
}

func (v valueError) Value() any {
return v.value
}
72 changes: 72 additions & 0 deletions pkg/jq/functions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package jq

import (
"bytes"
"fmt"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWithTemplateFunctions(t *testing.T) {
tests := []struct {
name string
input string
filter string
wantW string
wantError bool
}{
{
name: "timeago",
input: fmt.Sprintf(`{"time":"%s"}`, time.Now().Add(-5*time.Minute).Format(time.RFC3339)),
filter: `.time | timeago`,
wantW: "5 minutes ago\n",
},
{
name: "timeago with int",
input: `{"time":42}`,
filter: `.time | timeago`,
wantError: true,
},
{
name: "timeago with non-date string",
input: `{"time":"not a date-time"}`,
filter: `.time | timeago`,
wantError: true,
},
{
name: "timefmt",
input: `{"time":"2025-01-20T01:08:15Z"}`,
filter: `.time | timefmt("Mon, 02 Jan 2006 15:04:05 MST")`,
wantW: "Mon, 20 Jan 2025 01:08:15 UTC\n",
},
{
name: "timeago with int",
input: `{"time":42}`,
filter: `.time | timefmt("Mon, 02 Jan 2006 15:04:05 MST")`,
wantError: true,
},
{
name: "timeago with invalid date-time string",
input: `{"time":"not a date-time"}`,
filter: `.time | timefmt("Mon, 02 Jan 2006 15:04:05 MST")`,
wantError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := bytes.Buffer{}
err := Evaluate(strings.NewReader(tt.input), &buf, tt.filter, WithTemplateFunctions())
if tt.wantError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantW, buf.String())
})
}
}
41 changes: 35 additions & 6 deletions pkg/jq/jq.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,38 @@ import (
"github.com/itchyny/gojq"
)

// evaluateOptions is passed to an EvaluationOption function.
type evaluateOptions struct {
compilerOptions []gojq.CompilerOption
}

// EvaluateOption is used to configure the pkg/jq.Evaluate functions.
type EvaluateOption func(*evaluateOptions)

// WithModulePaths sets the jq module lookup paths e.g.,
// "~/.jq", "$ORIGIN/../lib/gh", and "$ORIGIN/../lib".
func WithModulePaths(paths []string) EvaluateOption {
return func(opts *evaluateOptions) {
opts.compilerOptions = append(
opts.compilerOptions,
gojq.WithModuleLoader(gojq.NewModuleLoader(paths)),
)
}
}

// Evaluate a jq expression against an input and write it to an output.
// Any top-level scalar values produced by the jq expression are written out
// directly, as raw values and not as JSON scalars, similar to how jq --raw
// works.
func Evaluate(input io.Reader, output io.Writer, expr string) error {
return EvaluateFormatted(input, output, expr, "", false)
func Evaluate(input io.Reader, output io.Writer, expr string, options ...EvaluateOption) error {
return EvaluateFormatted(input, output, expr, "", false, options...)
}

// Evaluate a jq expression against an input and write it to an output,
// optionally with indentation and colorization. Any top-level scalar values
// produced by the jq expression are written out directly, as raw values and not
// as JSON scalars, similar to how jq --raw works.
func EvaluateFormatted(input io.Reader, output io.Writer, expr string, indent string, colorize bool) error {
func EvaluateFormatted(input io.Reader, output io.Writer, expr string, indent string, colorize bool, options ...EvaluateOption) error {
query, err := gojq.Parse(expr)
if err != nil {
var e *gojq.ParseError
Expand All @@ -42,11 +61,21 @@ func EvaluateFormatted(input io.Reader, output io.Writer, expr string, indent st
return err
}

opts := evaluateOptions{
// Default compiler options.
compilerOptions: []gojq.CompilerOption{
gojq.WithEnvironLoader(func() []string {
return os.Environ()
}),
},
}
for _, opt := range options {
opt(&opts)
}

code, err := gojq.Compile(
query,
gojq.WithEnvironLoader(func() []string {
return os.Environ()
}))
opts.compilerOptions...)
if err != nil {
return err
}
Expand Down
Loading
Loading