Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to search by arbitrary metadata #568

Merged
merged 2 commits into from
Feb 27, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
18 changes: 18 additions & 0 deletions cache/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -109,6 +119,7 @@ func NoLabelFilter() Filter {
type Matcher struct {
Status []Filter
Author []Filter
Metadata []Filter
Actor []Filter
Participant []Filter
Label []Filter
Expand All @@ -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))
}
Expand All @@ -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
}
Expand Down
13 changes: 13 additions & 0 deletions commands/ls.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
type lsOptions struct {
statusQuery []string
authorQuery []string
metadataQuery []string
participantQuery []string
actorQuery []string
labelQuery []string
Expand Down Expand Up @@ -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,
MichaelMure marked this conversation as resolved.
Show resolved Hide resolved
"Filter by metadata. Example: github-url=URL")
flags.StringSliceVarP(&options.participantQuery, "participant", "p", nil,
"Filter by participant")
flags.StringSliceVarP(&options.actorQuery, "actor", "A", nil,
Expand Down Expand Up @@ -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...)
Expand Down
4 changes: 4 additions & 0 deletions doc/man/git-bug-ls.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions doc/md/git-bug_ls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions misc/bash_completion/git-bug
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions misc/powershell_completion/git-bug
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
58 changes: 50 additions & 8 deletions query/lexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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,
Expand All @@ -50,26 +63,55 @@ 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 {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had this exact function to handle the quotes at some point, before writing this more complex/robust lexer. I don't remember exactly why, but IIRC this left a few edges cases open (maybe the unmatched quote?). I'll dig a bit to see if there is a better way to do that. Otherwise, this can be merged, because, well, it works.

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 {
tokens = append(tokens, newTokenSearch(removeQuote(field)))
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
}
Expand Down
8 changes: 8 additions & 0 deletions query/lexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
15 changes: 15 additions & 0 deletions query/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions query/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions query/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down