Skip to content

Commit

Permalink
Add useful template functions to jq filters
Browse files Browse the repository at this point in the history
Relates to #10262 but needs to be used in cli/cli if accepted.
  • Loading branch information
heaths committed Jan 20, 2025
1 parent 13104ed commit 474b5c5
Show file tree
Hide file tree
Showing 8 changed files with 339 additions and 44 deletions.
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)
}
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

0 comments on commit 474b5c5

Please sign in to comment.