From cb61245078a0e8f14e359ed20e0582a695645a08 Mon Sep 17 00:00:00 2001 From: Miklos Vajna Date: Sun, 14 Feb 2021 16:03:51 +0100 Subject: [PATCH] Add ability to search by arbitrary metadata Example: ~/git/git-bug/git-bug ls --metadata github-url=https://github.com/author/myproject/issues/42 or ~/git/git-bug/git-bug ls metadata:github-url:\"https://github.com/author/myproject/issues/42\" Fixes the cmdline part of . --- cache/filter.go | 18 ++++++++++ commands/ls.go | 13 +++++++ doc/man/git-bug-ls.1 | 4 +++ doc/md/git-bug_ls.md | 1 + misc/bash_completion/git-bug | 6 ++++ misc/powershell_completion/git-bug | 2 ++ query/lexer.go | 58 +++++++++++++++++++++++++----- query/lexer_test.go | 8 +++++ query/parser.go | 15 ++++++++ query/parser_test.go | 5 +++ query/query.go | 7 ++++ 11 files changed, 129 insertions(+), 8 deletions(-) diff --git a/cache/filter.go b/cache/filter.go index 2ac56ab5..c167fe71 100644 --- a/cache/filter.go +++ b/cache/filter.go @@ -38,6 +38,16 @@ func AuthorFilter(query string) Filter { } } +// MetadataFilter return a Filter that match a bug metadata at creation time +func MetadataFilter(pair query.StringPair) Filter { + return func(excerpt *BugExcerpt, resolver resolver) bool { + if value, ok := excerpt.CreateMetadata[pair.Key]; ok { + return value == pair.Value + } + return false + } +} + // LabelFilter return a Filter that match a label func LabelFilter(label string) Filter { return func(excerpt *BugExcerpt, resolver resolver) bool { @@ -109,6 +119,7 @@ func NoLabelFilter() Filter { type Matcher struct { Status []Filter Author []Filter + Metadata []Filter Actor []Filter Participant []Filter Label []Filter @@ -127,6 +138,9 @@ func compileMatcher(filters query.Filters) *Matcher { for _, value := range filters.Author { result.Author = append(result.Author, AuthorFilter(value)) } + for _, value := range filters.Metadata { + result.Metadata = append(result.Metadata, MetadataFilter(value)) + } for _, value := range filters.Actor { result.Actor = append(result.Actor, ActorFilter(value)) } @@ -153,6 +167,10 @@ func (f *Matcher) Match(excerpt *BugExcerpt, resolver resolver) bool { return false } + if match := f.orMatch(f.Metadata, excerpt, resolver); !match { + return false + } + if match := f.orMatch(f.Participant, excerpt, resolver); !match { return false } diff --git a/commands/ls.go b/commands/ls.go index 327fd37f..71c420c6 100644 --- a/commands/ls.go +++ b/commands/ls.go @@ -19,6 +19,7 @@ import ( type lsOptions struct { statusQuery []string authorQuery []string + metadataQuery []string participantQuery []string actorQuery []string labelQuery []string @@ -65,6 +66,8 @@ git bug ls status:open --by creation "foo bar" baz "Filter by status. Valid values are [open,closed]") flags.StringSliceVarP(&options.authorQuery, "author", "a", nil, "Filter by author") + flags.StringSliceVarP(&options.metadataQuery, "metadata", "m", nil, + "Filter by metadata. Example: github-url=URL") flags.StringSliceVarP(&options.participantQuery, "participant", "p", nil, "Filter by participant") flags.StringSliceVarP(&options.actorQuery, "actor", "A", nil, @@ -337,6 +340,16 @@ func completeQuery(q *query.Query, opts lsOptions) error { } q.Author = append(q.Author, opts.authorQuery...) + for _, str := range opts.metadataQuery { + tokens := strings.Split(str, "=") + if len(tokens) < 2 { + return fmt.Errorf("no \"=\" in key=value metadata markup") + } + var pair query.StringPair + pair.Key = tokens[0] + pair.Value = tokens[1] + q.Metadata = append(q.Metadata, pair) + } q.Participant = append(q.Participant, opts.participantQuery...) q.Actor = append(q.Actor, opts.actorQuery...) q.Label = append(q.Label, opts.labelQuery...) diff --git a/doc/man/git-bug-ls.1 b/doc/man/git-bug-ls.1 index a0e60db7..0ab51709 100644 --- a/doc/man/git-bug-ls.1 +++ b/doc/man/git-bug-ls.1 @@ -28,6 +28,10 @@ You can pass an additional query to filter and order the list. This query can be \fB\-a\fP, \fB\-\-author\fP=[] Filter by author +.PP +\fB\-m\fP, \fB\-\-metadata\fP=[] + Filter by metadata. Example: github\-url=URL + .PP \fB\-p\fP, \fB\-\-participant\fP=[] Filter by participant diff --git a/doc/md/git-bug_ls.md b/doc/md/git-bug_ls.md index df54224f..7d1e490d 100644 --- a/doc/md/git-bug_ls.md +++ b/doc/md/git-bug_ls.md @@ -34,6 +34,7 @@ git bug ls status:open --by creation "foo bar" baz ``` -s, --status strings Filter by status. Valid values are [open,closed] -a, --author strings Filter by author + -m, --metadata strings Filter by metadata. Example: github-url=URL -p, --participant strings Filter by participant -A, --actor strings Filter by actor -l, --label strings Filter by label diff --git a/misc/bash_completion/git-bug b/misc/bash_completion/git-bug index 912e87b4..3cedd86a 100644 --- a/misc/bash_completion/git-bug +++ b/misc/bash_completion/git-bug @@ -851,6 +851,12 @@ _git-bug_ls() local_nonpersistent_flags+=("--author") local_nonpersistent_flags+=("--author=") local_nonpersistent_flags+=("-a") + flags+=("--metadata=") + two_word_flags+=("--metadata") + two_word_flags+=("-m") + local_nonpersistent_flags+=("--metadata") + local_nonpersistent_flags+=("--metadata=") + local_nonpersistent_flags+=("-m") flags+=("--participant=") two_word_flags+=("--participant") two_word_flags+=("-p") diff --git a/misc/powershell_completion/git-bug b/misc/powershell_completion/git-bug index c2aa0adf..29cb327a 100644 --- a/misc/powershell_completion/git-bug +++ b/misc/powershell_completion/git-bug @@ -146,6 +146,8 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock { [CompletionResult]::new('--status', 'status', [CompletionResultType]::ParameterName, 'Filter by status. Valid values are [open,closed]') [CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'Filter by author') [CompletionResult]::new('--author', 'author', [CompletionResultType]::ParameterName, 'Filter by author') + [CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Filter by metadata. Example: github-url=URL') + [CompletionResult]::new('--metadata', 'metadata', [CompletionResultType]::ParameterName, 'Filter by metadata. Example: github-url=URL') [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Filter by participant') [CompletionResult]::new('--participant', 'participant', [CompletionResultType]::ParameterName, 'Filter by participant') [CompletionResult]::new('-A', 'A', [CompletionResultType]::ParameterName, 'Filter by actor') diff --git a/query/lexer.go b/query/lexer.go index 5ca700c7..45f657df 100644 --- a/query/lexer.go +++ b/query/lexer.go @@ -11,16 +11,20 @@ type tokenKind int const ( _ tokenKind = iota tokenKindKV + tokenKindKVV tokenKindSearch ) type token struct { kind tokenKind - // KV + // KV and KVV qualifier string value string + // KVV only + subQualifier string + // Search term string } @@ -33,6 +37,15 @@ func newTokenKV(qualifier, value string) token { } } +func newTokenKVV(qualifier, subQualifier, value string) token { + return token{ + kind: tokenKindKVV, + qualifier: qualifier, + subQualifier: subQualifier, + value: value, + } +} + func newTokenSearch(term string) token { return token{ kind: tokenKindSearch, @@ -50,7 +63,23 @@ func tokenize(query string) ([]token, error) { var tokens []token for _, field := range fields { - split := strings.Split(field, ":") + // Split using ':' as separator, but separators inside '"' don't count. + quoted := false + split := strings.FieldsFunc(field, func(r rune) bool { + if r == '"' { + quoted = !quoted + } + return !quoted && r == ':' + }) + if (strings.HasPrefix(field, ":")) { + split = append([]string{""}, split...) + } + if (strings.HasSuffix(field, ":")) { + split = append(split, "") + } + if (quoted) { + return nil, fmt.Errorf("can't tokenize \"%s\": unmatched quote", field) + } // full text search if len(split) == 1 { @@ -58,18 +87,31 @@ func tokenize(query string) ([]token, error) { continue } - if len(split) != 2 { - return nil, fmt.Errorf("can't tokenize \"%s\"", field) + if len(split) > 3 { + return nil, fmt.Errorf("can't tokenize \"%s\": too many separators", field) } if len(split[0]) == 0 { return nil, fmt.Errorf("can't tokenize \"%s\": empty qualifier", field) } - if len(split[1]) == 0 { - return nil, fmt.Errorf("empty value for qualifier \"%s\"", split[0]) - } - tokens = append(tokens, newTokenKV(split[0], removeQuote(split[1]))) + if len(split) == 2 { + if len(split[1]) == 0 { + return nil, fmt.Errorf("empty value for qualifier \"%s\"", split[0]) + } + + tokens = append(tokens, newTokenKV(split[0], removeQuote(split[1]))) + } else { + if len(split[1]) == 0 { + return nil, fmt.Errorf("empty sub-qualifier for qualifier \"%s\"", split[0]) + } + + if len(split[2]) == 0 { + return nil, fmt.Errorf("empty value for qualifier \"%s:%s\"", split[0], split[1]) + } + + tokens = append(tokens, newTokenKVV(split[0], removeQuote(split[1]), removeQuote(split[2]))) + } } return tokens, nil } diff --git a/query/lexer_test.go b/query/lexer_test.go index 59f17dec..4ffb35a0 100644 --- a/query/lexer_test.go +++ b/query/lexer_test.go @@ -37,6 +37,14 @@ func TestTokenize(t *testing.T) { {`key:'value value`, nil}, {`key:value value'`, nil}, + // sub-qualifier posive testing + {`key:subkey:"value:value"`, []token{newTokenKVV("key", "subkey", "value:value")}}, + + // sub-qualifier negative testing + {`key:subkey:value:value`, nil}, + {`key:subkey:`, nil}, + {`key:subkey:"value`, nil}, + // full text search {"search", []token{newTokenSearch("search")}}, {"search more terms", []token{ diff --git a/query/parser.go b/query/parser.go index 762a47e5..e820c629 100644 --- a/query/parser.go +++ b/query/parser.go @@ -67,6 +67,21 @@ func Parse(query string) (*Query, error) { default: return nil, fmt.Errorf("unknown qualifier \"%s\"", t.qualifier) } + + case tokenKindKVV: + switch t.qualifier { + case "metadata": + if len(t.subQualifier) == 0 { + return nil, fmt.Errorf("empty value for sub-qualifier \"metadata:%s\"", t.subQualifier) + } + var pair StringPair + pair.Key = t.subQualifier + pair.Value = t.value + q.Metadata = append(q.Metadata, pair) + + default: + return nil, fmt.Errorf("unknown qualifier \"%s:%s\"", t.qualifier, t.subQualifier) + } } } return q, nil diff --git a/query/parser_test.go b/query/parser_test.go index 87dd870a..6d91d6cc 100644 --- a/query/parser_test.go +++ b/query/parser_test.go @@ -84,6 +84,11 @@ func TestParse(t *testing.T) { OrderDirection: OrderDescending, }, }, + + // Metadata + {`metadata:key:"https://www.example.com/"`, &Query{ + Filters: Filters{Metadata: []StringPair{{"key", "https://www.example.com/"}}}, + }}, } for _, tc := range tests { diff --git a/query/query.go b/query/query.go index 816d6414..3a2321cf 100644 --- a/query/query.go +++ b/query/query.go @@ -23,10 +23,17 @@ func NewQuery() *Query { type Search []string +// Used for key-value pairs when filtering based on metadata +type StringPair struct { + Key string + Value string +} + // Filters is a collection of Filter that implement a complex filter type Filters struct { Status []bug.Status Author []string + Metadata []StringPair Actor []string Participant []string Label []string