Add ability to search by arbitrary metadata

Miklos Vajna created

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 <https://github.com/MichaelMure/git-bug/issues/567>.

Change summary

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(-)

Detailed changes

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
 	}

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...)

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

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

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")

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')

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
 }

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{

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

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 {

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