Merge pull request #9728 from heaths/issue9704

Supporting filtering on `gist list`
This commit is contained in:
Tyler McGoffin 2024-10-15 16:26:13 -07:00 committed by GitHub
commit 7aef6ec391
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 578 additions and 38 deletions

View file

@ -3,9 +3,11 @@ package list
import (
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/tableprinter"
"github.com/cli/cli/v2/internal/text"
@ -20,8 +22,10 @@ type ListOptions struct {
Config func() (gh.Config, error)
HttpClient func() (*http.Client, error)
Limit int
Visibility string // all, secret, public
Limit int
Filter *regexp.Regexp
IncludeContent bool
Visibility string // all, secret, public
}
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
@ -33,10 +37,36 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
var flagPublic bool
var flagSecret bool
var flagFilter string
cmd := &cobra.Command{
Use: "list",
Short: "List your gists",
Use: "list",
Short: "List your gists",
Long: heredoc.Docf(`
List gists from your user account.
You can use a regular expression to filter the description, file names,
or even the content of files in the gist using %[1]s--filter%[1]s.
For supported regular expression syntax, see <https://pkg.go.dev/regexp/syntax>.
Use %[1]s--include-content%[1]s to include content of files, noting that
this will be slower and increase the rate limit used. Instead of printing a table,
code will be printed with highlights similar to %[1]sgh search code%[1]s:
{{gist ID}} {{file name}}
{{description}}
{{matching lines from content}}
No highlights or other color is printed when output is redirected.
`, "`"),
Example: heredoc.Doc(`
# list all secret gists from your user account
$ gh gist list --secret
# find all gists from your user account mentioning "octo" anywhere
$ gh gist list --filter octo --include-content
`),
Aliases: []string{"ls"},
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
@ -44,6 +74,18 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit)
}
if flagFilter == "" {
if opts.IncludeContent {
return cmdutil.FlagErrorf("cannot use --include-content without --filter")
}
} else {
if filter, err := regexp.CompilePOSIX(flagFilter); err != nil {
return err
} else {
opts.Filter = filter
}
}
opts.Visibility = "all"
if flagSecret {
opts.Visibility = "secret"
@ -61,6 +103,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 10, "Maximum number of gists to fetch")
cmd.Flags().BoolVar(&flagPublic, "public", false, "Show only public gists")
cmd.Flags().BoolVar(&flagSecret, "secret", false, "Show only secret gists")
cmd.Flags().StringVar(&flagFilter, "filter", "", "Filter gists using a regular `expression`")
cmd.Flags().BoolVar(&opts.IncludeContent, "include-content", false, "Include gists' file content when filtering")
return cmd
}
@ -78,7 +122,13 @@ func listRun(opts *ListOptions) error {
host, _ := cfg.Authentication().DefaultHost()
gists, err := shared.ListGists(client, host, opts.Limit, opts.Visibility)
// Filtering can take a while so start the progress indicator. StopProgressIndicator will no-op if not running.
if opts.Filter != nil {
opts.IO.StartProgressIndicator()
}
gists, err := shared.ListGists(client, host, opts.Limit, opts.Filter, opts.IncludeContent, opts.Visibility)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
@ -93,8 +143,38 @@ func listRun(opts *ListOptions) error {
fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
}
cs := opts.IO.ColorScheme()
tp := tableprinter.New(opts.IO, tableprinter.WithHeader("ID", "DESCRIPTION", "FILES", "VISIBILITY", "UPDATED"))
if opts.Filter != nil && opts.IncludeContent {
return printContent(opts.IO, gists, opts.Filter)
}
return printTable(opts.IO, gists, opts.Filter)
}
func printTable(io *iostreams.IOStreams, gists []shared.Gist, filter *regexp.Regexp) error {
cs := io.ColorScheme()
tp := tableprinter.New(io, tableprinter.WithHeader("ID", "DESCRIPTION", "FILES", "VISIBILITY", "UPDATED"))
// Highlight filter matches in the description when printing the table.
highlightDescription := func(s string) string {
if filter != nil {
if str, err := highlightMatch(s, filter, nil, cs.Bold, cs.Highlight); err == nil {
return str
}
}
return cs.Bold(s)
}
// Highlight the files column when any file name matches the filter.
highlightFilesFunc := func(gist *shared.Gist) func(string) string {
if filter != nil {
for _, file := range gist.Files {
if filter.MatchString(file.Filename) {
return cs.Highlight
}
}
}
return normal
}
for _, gist := range gists {
fileCount := len(gist.Files)
@ -119,9 +199,12 @@ func listRun(opts *ListOptions) error {
tp.AddField(gist.ID)
tp.AddField(
text.RemoveExcessiveWhitespace(description),
tableprinter.WithColor(cs.Bold),
tableprinter.WithColor(highlightDescription),
)
tp.AddField(
text.Pluralize(fileCount, "file"),
tableprinter.WithColor(highlightFilesFunc(&gist)),
)
tp.AddField(text.Pluralize(fileCount, "file"))
tp.AddField(visibility, tableprinter.WithColor(visColor))
tp.AddTimeField(time.Now(), gist.UpdatedAt, cs.Gray)
tp.EndRow()
@ -129,3 +212,98 @@ func listRun(opts *ListOptions) error {
return tp.Render()
}
// printContent prints a gist with optional description and content similar to `gh search code`
// including highlighted matches in the form:
//
// {{gist ID}} {{file name}}
// {{description, if any}}
// {{content lines with matches, if any}}
//
// If printing to a non-TTY stream the format will be the same but without highlights.
func printContent(io *iostreams.IOStreams, gists []shared.Gist, filter *regexp.Regexp) error {
const tab string = " "
cs := io.ColorScheme()
out := &strings.Builder{}
var filename, description string
var err error
split := func(r rune) bool {
return r == '\n' || r == '\r'
}
for _, gist := range gists {
for _, file := range gist.Files {
matched := false
out.Reset()
if filename, err = highlightMatch(file.Filename, filter, &matched, cs.Green, cs.Highlight); err != nil {
return err
}
fmt.Fprintln(out, cs.Blue(gist.ID), filename)
if gist.Description != "" {
if description, err = highlightMatch(gist.Description, filter, &matched, cs.Bold, cs.Highlight); err != nil {
return err
}
fmt.Fprintf(out, "%s%s\n", tab, description)
}
if file.Content != "" {
for _, line := range strings.FieldsFunc(file.Content, split) {
if filter.MatchString(line) {
if line, err = highlightMatch(line, filter, &matched, normal, cs.Highlight); err != nil {
return err
}
fmt.Fprintf(out, "%[1]s%[1]s%[2]s\n", tab, line)
}
}
}
if matched {
fmt.Fprintln(io.Out, out.String())
}
}
}
return nil
}
func highlightMatch(s string, filter *regexp.Regexp, matched *bool, color, highlight func(string) string) (string, error) {
matches := filter.FindAllStringIndex(s, -1)
if matches == nil {
return color(s), nil
}
out := strings.Builder{}
// Color up to the first match. If an empty string, no ANSI color sequence is added.
if _, err := out.WriteString(color(s[:matches[0][0]])); err != nil {
return "", err
}
// Highlight each match, then color the remaining text which, if an empty string, no ANSI color sequence is added.
for i, match := range matches {
if _, err := out.WriteString(highlight(s[match[0]:match[1]])); err != nil {
return "", err
}
text := s[match[1]:]
if i+1 < len(matches) {
text = s[match[1]:matches[i+1][0]]
}
if _, err := out.WriteString(color(text)); err != nil {
return "", nil
}
}
if matched != nil {
*matched = *matched || true
}
return out.String(), nil
}
func normal(s string) string {
return s
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"net/http"
"regexp"
"testing"
"time"
@ -19,9 +20,10 @@ import (
func TestNewCmdList(t *testing.T) {
tests := []struct {
name string
cli string
wants ListOptions
name string
cli string
wants ListOptions
wantsErr bool
}{
{
name: "no arguments",
@ -70,6 +72,31 @@ func TestNewCmdList(t *testing.T) {
Visibility: "all",
},
},
{
name: "invalid limit",
cli: "--limit 0",
wantsErr: true,
},
{
name: "filter and include-content",
cli: "--filter octo --include-content",
wants: ListOptions{
Limit: 10,
Filter: regexp.MustCompilePOSIX("octo"),
IncludeContent: true,
Visibility: "all",
},
},
{
name: "invalid filter",
cli: "--filter octo(",
wantsErr: true,
},
{
name: "include content without filter",
cli: "--include-content",
wantsErr: true,
},
}
for _, tt := range tests {
@ -90,6 +117,10 @@ func TestNewCmdList(t *testing.T) {
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility)
@ -110,6 +141,7 @@ func Test_listRun(t *testing.T) {
wantErr bool
wantOut string
stubs func(*httpmock.Registry)
color bool
nontty bool
}{
{
@ -358,6 +390,225 @@ func Test_listRun(t *testing.T) {
`),
nontty: true,
},
{
name: "filtered",
opts: &ListOptions{
Filter: regexp.MustCompile("octo"),
Visibility: "all",
},
nontty: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(query),
httpmock.StringResponse(fmt.Sprintf(
`{ "data": { "viewer": { "gists": { "nodes": [
{
"name": "1234",
"files": [
{ "name": "main.txt", "text": "foo" }
],
"description": "octo match in the description",
"updatedAt": "%[1]v",
"isPublic": true
},
{
"name": "2345",
"files": [
{ "name": "main.txt", "text": "foo" },
{ "name": "octo.txt", "text": "bar" }
],
"description": "match in the file name",
"updatedAt": "%[1]v",
"isPublic": false
},
{
"name": "3456",
"files": [
{ "name": "main.txt", "text": "octo in the text" }
],
"description": "match in the file text",
"updatedAt": "%[1]v",
"isPublic": true
}
] } } } }`,
absTime.Format(time.RFC3339),
)),
)
},
wantOut: heredoc.Docf(`
1234%[1]socto match in the description%[1]s1 file%[1]spublic%[1]s2020-07-30T15:24:28Z
2345%[1]smatch in the file name%[1]s2 files%[1]ssecret%[1]s2020-07-30T15:24:28Z
`, "\t"),
},
{
name: "filtered (tty)",
opts: &ListOptions{
Filter: regexp.MustCompile("octo"),
Visibility: "all",
},
color: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(query),
httpmock.StringResponse(fmt.Sprintf(
`{ "data": { "viewer": { "gists": { "nodes": [
{
"name": "1234",
"files": [
{ "name": "main.txt", "text": "foo" }
],
"description": "octo match in the description",
"updatedAt": "%[1]v",
"isPublic": true
},
{
"name": "2345",
"files": [
{ "name": "main.txt", "text": "foo" },
{ "name": "octo.txt", "text": "bar" }
],
"description": "match in the file name",
"updatedAt": "%[1]v",
"isPublic": false
},
{
"name": "3456",
"files": [
{ "name": "main.txt", "text": "octo in the text" }
],
"description": "match in the file text",
"updatedAt": "%[1]v",
"isPublic": true
}
] } } } }`,
sixHoursAgo.Format(time.RFC3339),
)),
)
},
wantOut: heredoc.Docf(`
%[1]s[0;2;4;37mID %[1]s[0m %[1]s[0;2;4;37mDESCRIPTION %[1]s[0m %[1]s[0;2;4;37mFILES %[1]s[0m %[1]s[0;2;4;37mVISIBILITY%[1]s[0m %[1]s[0;2;4;37mUPDATED %[1]s[0m
1234 %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m 1 file %[1]s[0;32mpublic %[1]s[0m %[1]s[38;5;242mabout 6 hours ago%[1]s[m
2345 %[1]s[0;1;39mmatch in the file name %[1]s[0m %[1]s[0;30;43m2 files%[1]s[0m %[1]s[0;31msecret %[1]s[0m %[1]s[38;5;242mabout 6 hours ago%[1]s[m
`, "\x1b"),
},
{
name: "filtered with content",
opts: &ListOptions{
Filter: regexp.MustCompile("octo"),
IncludeContent: true,
Visibility: "all",
},
nontty: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(query),
httpmock.StringResponse(fmt.Sprintf(
`{ "data": { "viewer": { "gists": { "nodes": [
{
"name": "1234",
"files": [
{ "name": "main.txt", "text": "foo" }
],
"description": "octo match in the description",
"updatedAt": "%[1]v",
"isPublic": true
},
{
"name": "2345",
"files": [
{ "name": "main.txt", "text": "foo" },
{ "name": "octo.txt", "text": "bar" }
],
"description": "match in the file name",
"updatedAt": "%[1]v",
"isPublic": false
},
{
"name": "3456",
"files": [
{ "name": "main.txt", "text": "octo in the text" }
],
"description": "match in the file text",
"updatedAt": "%[1]v",
"isPublic": true
}
] } } } }`,
absTime.Format(time.RFC3339),
)),
)
},
wantOut: heredoc.Doc(`
1234 main.txt
octo match in the description
2345 octo.txt
match in the file name
3456 main.txt
match in the file text
octo in the text
`),
},
{
name: "filtered with content (tty)",
opts: &ListOptions{
Filter: regexp.MustCompile("octo"),
IncludeContent: true,
Visibility: "all",
},
color: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(query),
httpmock.StringResponse(fmt.Sprintf(
`{ "data": { "viewer": { "gists": { "nodes": [
{
"name": "1234",
"files": [
{ "name": "main.txt", "text": "foo" }
],
"description": "octo match in the description",
"updatedAt": "%[1]v",
"isPublic": true
},
{
"name": "2345",
"files": [
{ "name": "main.txt", "text": "foo" },
{ "name": "octo.txt", "text": "bar" }
],
"description": "match in the file name",
"updatedAt": "%[1]v",
"isPublic": false
},
{
"name": "3456",
"files": [
{ "name": "main.txt", "text": "octo in the text" }
],
"description": "match in the file text",
"updatedAt": "%[1]v",
"isPublic": true
}
] } } } }`,
sixHoursAgo.Format(time.RFC3339),
)),
)
},
wantOut: heredoc.Docf(`
%[1]s[0;34m1234%[1]s[0m %[1]s[0;32mmain.txt%[1]s[0m
%[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m
%[1]s[0;34m2345%[1]s[0m %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;32m.txt%[1]s[0m
%[1]s[0;1;39mmatch in the file name%[1]s[0m
%[1]s[0;34m3456%[1]s[0m %[1]s[0;32mmain.txt%[1]s[0m
%[1]s[0;1;39mmatch in the file text%[1]s[0m
%[1]s[0;30;43mocto%[1]s[0m in the text
`, "\x1b"),
},
}
for _, tt := range tests {
@ -373,6 +624,7 @@ func Test_listRun(t *testing.T) {
}
ios, _, stdout, _ := iostreams.Test()
ios.SetColorEnabled(tt.color)
ios.SetStdoutTTY(!tt.nontty)
tt.opts.IO = ios
@ -396,3 +648,59 @@ func Test_listRun(t *testing.T) {
})
}
}
func Test_highlightMatch(t *testing.T) {
regex := regexp.MustCompilePOSIX(`[Oo]cto`)
tests := []struct {
name string
input string
color bool
want string
}{
{
name: "single match",
input: "Octo",
want: "Octo",
},
{
name: "single match (color)",
input: "Octo",
color: true,
want: "\x1b[0;30;43mOcto\x1b[0m",
},
{
name: "single match with extra",
input: "Hello, Octocat!",
want: "Hello, Octocat!",
},
{
name: "single match with extra (color)",
input: "Hello, Octocat!",
color: true,
want: "\x1b[0;34mHello, \x1b[0m\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat!\x1b[0m",
},
{
name: "multiple matches",
input: "Octocat/octo",
want: "Octocat/octo",
},
{
name: "multiple matches (color)",
input: "Octocat/octo",
color: true,
want: "\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat/\x1b[0m\x1b[0;30;43mocto\x1b[0m",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs := iostreams.NewColorScheme(tt.color, false, false)
matched := false
got, err := highlightMatch(tt.input, regex, &matched, cs.Blue, cs.Highlight)
assert.NoError(t, err)
assert.True(t, matched)
assert.Equal(t, tt.want, got)
})
}
}

View file

@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
@ -74,7 +75,9 @@ func GistIDFromURL(gistURL string) (string, error) {
return "", fmt.Errorf("Invalid gist URL %s", u)
}
func ListGists(client *http.Client, hostname string, limit int, visibility string) ([]Gist, error) {
const maxPerPage = 100
func ListGists(client *http.Client, hostname string, limit int, filter *regexp.Regexp, includeContent bool, visibility string) ([]Gist, error) {
type response struct {
Viewer struct {
Gists struct {
@ -82,6 +85,7 @@ func ListGists(client *http.Client, hostname string, limit int, visibility strin
Description string
Files []struct {
Name string
Text string `graphql:"text @include(if: $includeContent)"`
}
IsPublic bool
Name string
@ -96,14 +100,33 @@ func ListGists(client *http.Client, hostname string, limit int, visibility strin
}
perPage := limit
if perPage > 100 {
perPage = 100
if perPage > maxPerPage {
perPage = maxPerPage
}
variables := map[string]interface{}{
"per_page": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
"visibility": githubv4.GistPrivacy(strings.ToUpper(visibility)),
"per_page": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
"visibility": githubv4.GistPrivacy(strings.ToUpper(visibility)),
"includeContent": githubv4.Boolean(includeContent),
}
filterFunc := func(gist *Gist) bool {
if filter.MatchString(gist.Description) {
return true
}
for _, file := range gist.Files {
if filter.MatchString(file.Filename) {
return true
}
if includeContent && filter.MatchString(file.Content) {
return true
}
}
return false
}
gql := api.NewClientFromHTTP(client)
@ -122,19 +145,22 @@ pagination:
for _, file := range gist.Files {
files[file.Name] = &GistFile{
Filename: file.Name,
Content: file.Text,
}
}
gists = append(
gists,
Gist{
ID: gist.Name,
Description: gist.Description,
Files: files,
UpdatedAt: gist.UpdatedAt,
Public: gist.IsPublic,
},
)
gist := Gist{
ID: gist.Name,
Description: gist.Description,
Files: files,
UpdatedAt: gist.UpdatedAt,
Public: gist.IsPublic,
}
if filter == nil || filterFunc(&gist) {
gists = append(gists, gist)
}
if len(gists) == limit {
break pagination
}
@ -177,7 +203,7 @@ func IsBinaryContents(contents []byte) bool {
}
func PromptGists(prompter prompter.Prompter, client *http.Client, host string, cs *iostreams.ColorScheme) (gistID string, err error) {
gists, err := ListGists(client, host, 10, "all")
gists, err := ListGists(client, host, 10, nil, false, "all")
if err != nil {
return "", err
}

View file

@ -143,7 +143,7 @@ func displayResults(io *iostreams.IOStreams, results search.CodeResult) error {
}
fmt.Fprintf(io.Out, "%s %s\n", cs.Blue(code.Repository.FullName), cs.GreenBold(code.Path))
for _, match := range code.TextMatches {
lines := formatMatch(match.Fragment, match.Matches, io.ColorEnabled())
lines := formatMatch(match.Fragment, match.Matches, io)
for _, line := range lines {
fmt.Fprintf(io.Out, "\t%s\n", strings.TrimSpace(line))
}
@ -153,7 +153,7 @@ func displayResults(io *iostreams.IOStreams, results search.CodeResult) error {
}
for _, code := range results.Items {
for _, match := range code.TextMatches {
lines := formatMatch(match.Fragment, match.Matches, io.ColorEnabled())
lines := formatMatch(match.Fragment, match.Matches, io)
for _, line := range lines {
fmt.Fprintf(io.Out, "%s:%s: %s\n", cs.Blue(code.Repository.FullName), cs.GreenBold(code.Path), strings.TrimSpace(line))
}
@ -162,7 +162,9 @@ func displayResults(io *iostreams.IOStreams, results search.CodeResult) error {
return nil
}
func formatMatch(t string, matches []search.Match, colorize bool) []string {
func formatMatch(t string, matches []search.Match, io *iostreams.IOStreams) []string {
cs := io.ColorScheme()
startIndices := map[int]struct{}{}
endIndices := map[int]struct{}{}
for _, m := range matches {
@ -186,14 +188,10 @@ func formatMatch(t string, matches []search.Match, colorize bool) []string {
continue
}
if _, ok := startIndices[i]; ok {
if colorize {
b.WriteString("\x1b[30;43m") // black text on yellow background
}
b.WriteString(cs.HighlightStart())
found = true
} else if _, ok := endIndices[i]; ok {
if colorize {
b.WriteString("\x1b[m") // color reset
}
b.WriteString(cs.Reset())
}
b.WriteRune(c)
}

View file

@ -8,6 +8,10 @@ import (
"github.com/mgutz/ansi"
)
const (
highlightStyle = "black:yellow"
)
var (
magenta = ansi.ColorFunc("magenta")
cyan = ansi.ColorFunc("cyan")
@ -20,6 +24,8 @@ var (
bold = ansi.ColorFunc("default+b")
cyanBold = ansi.ColorFunc("cyan+b")
greenBold = ansi.ColorFunc("green+b")
highlightStart = ansi.ColorCode(highlightStyle)
highlight = ansi.ColorFunc(highlightStyle)
gray256 = func(t string) string {
return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t)
@ -176,6 +182,30 @@ func (c *ColorScheme) FailureIconWithColor(colo func(string) string) string {
return colo("X")
}
func (c *ColorScheme) HighlightStart() string {
if !c.enabled {
return ""
}
return highlightStart
}
func (c *ColorScheme) Highlight(t string) string {
if !c.enabled {
return t
}
return highlight(t)
}
func (c *ColorScheme) Reset() string {
if !c.enabled {
return ""
}
return ansi.Reset
}
func (c *ColorScheme) ColorFromString(s string) func(string) string {
s = strings.ToLower(s)
var fn func(string) string