diff --git a/internal/text/text.go b/internal/text/text.go new file mode 100644 index 0000000..c725c04 --- /dev/null +++ b/internal/text/text.go @@ -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" +} diff --git a/internal/text/text_test.go b/internal/text/text_test.go new file mode 100644 index 0000000..775a8c0 --- /dev/null +++ b/internal/text/text_test.go @@ -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) +} diff --git a/internal/yamlmap/yaml_map.go b/internal/yamlmap/yaml_map.go index 78d0991..a440047 100644 --- a/internal/yamlmap/yaml_map.go +++ b/internal/yamlmap/yaml_map.go @@ -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) { diff --git a/pkg/jq/functions.go b/pkg/jq/functions.go new file mode 100644 index 0000000..0a54bc7 --- /dev/null +++ b/pkg/jq/functions.go @@ -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 +} diff --git a/pkg/jq/functions_test.go b/pkg/jq/functions_test.go new file mode 100644 index 0000000..d73dd4d --- /dev/null +++ b/pkg/jq/functions_test.go @@ -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()) + }) + } +} diff --git a/pkg/jq/jq.go b/pkg/jq/jq.go index ade2308..1a5e191 100644 --- a/pkg/jq/jq.go +++ b/pkg/jq/jq.go @@ -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 @@ -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 } diff --git a/pkg/jq/jq_test.go b/pkg/jq/jq_test.go index 1264de9..f6aa17f 100644 --- a/pkg/jq/jq_test.go +++ b/pkg/jq/jq_test.go @@ -2,14 +2,48 @@ package jq import ( "bytes" + "fmt" "io" + "os" "strings" "testing" + "time" "github.com/MakeNowJust/heredoc" "github.com/stretchr/testify/assert" ) +func ExampleEvaluate() { + now := time.Now() + input := strings.NewReader(fmt.Sprintf(`[ + { + "event": "first event", + "time": "%s" + }, + { + "event": "second event", + "time": "%s" + } + ]`, + now.Add(-10*time.Minute).Format(time.RFC3339), + now.Add(-5*time.Minute).Format(time.RFC3339), + )) + + output := bytes.Buffer{} + err := Evaluate(input, &output, "map(.time |= timeago) | .[]", WithTemplateFunctions()) + if err != nil { + panic(err) + } + + if _, err := io.Copy(os.Stdout, &output); err != nil { + panic(err) + } + + // Output: + // {"event":"first event","time":"10 minutes ago"} + // {"event":"second event","time":"5 minutes ago"} +} + func TestEvaluateFormatted(t *testing.T) { t.Setenv("CODE", "code_c") type args struct { @@ -17,6 +51,7 @@ func TestEvaluateFormatted(t *testing.T) { expr string indent string colorize bool + options []EvaluateOption } tests := []struct { name string @@ -225,11 +260,29 @@ func TestEvaluateFormatted(t *testing.T) { [1, ^ unexpected EOF`, }, + { + name: "with module path", + args: args{ + json: strings.NewReader(`[1,2]`), + expr: `import "mod" as m; map(m::inc)`, + options: []EvaluateOption{ + WithModulePaths([]string{"testdata"}), + }, + }, + wantW: "[2,3]\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := &bytes.Buffer{} - err := EvaluateFormatted(tt.args.json, w, tt.args.expr, tt.args.indent, tt.args.colorize) + err := EvaluateFormatted( + tt.args.json, + w, + tt.args.expr, + tt.args.indent, + tt.args.colorize, + tt.args.options..., + ) if tt.wantErr { assert.Error(t, err) assert.EqualError(t, err, tt.wantErrMsg) diff --git a/pkg/jq/testdata/mod.jq b/pkg/jq/testdata/mod.jq new file mode 100644 index 0000000..ba75028 --- /dev/null +++ b/pkg/jq/testdata/mod.jq @@ -0,0 +1 @@ +def inc: . + 1; diff --git a/pkg/template/template.go b/pkg/template/template.go index a561ebb..24b6f34 100644 --- a/pkg/template/template.go +++ b/pkg/template/template.go @@ -13,6 +13,7 @@ import ( "text/template" "time" + intext "github.com/cli/go-gh/v2/internal/text" "github.com/cli/go-gh/v2/pkg/tableprinter" "github.com/cli/go-gh/v2/pkg/text" color "github.com/mgutz/ansi" @@ -75,9 +76,9 @@ func (t *Template) Parse(tmpl string) error { return tableRowFunc(t.tp, fields...) }, "timeago": func(input string) (string, error) { - return timeAgoFunc(now, input) + return intext.TimeAgoFunc(now, input) }, - "timefmt": timeFormatFunc, + "timefmt": intext.TimeFormatFunc, "truncate": truncateFunc, } if !t.colorEnabled { @@ -147,22 +148,6 @@ func joinFunc(sep string, input []interface{}) (string, error) { return strings.Join(results, sep), nil } -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 truncateFunc(maxWidth int, v interface{}) (string, error) { if v == nil { return "", nil @@ -222,25 +207,6 @@ func jsonScalarToString(input interface{}) (string, error) { } } -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" -} - // TruncateMultiline returns a copy of the string s that has been shortened to fit the maximum // display width. If string s has multiple lines the first line will be shortened and all others // removed.