-
Notifications
You must be signed in to change notification settings - Fork 51
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add useful template functions to jq filters
Relates to #10262 but needs to be used in cli/cli if accepted.
- Loading branch information
Showing
8 changed files
with
339 additions
and
44 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.