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 <https://github.com/MichaelMure/git-bug/issues/567>.
This commit is contained in:
Miklos Vajna 2021-02-14 16:03:51 +01:00
parent 956f98b676
commit cb61245078
11 changed files with 129 additions and 8 deletions

18
cache/filter.go vendored
View File

@ -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
}

View File

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

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

@ -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) == 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
}

View File

@ -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{

View File

@ -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

View File

@ -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 {

View File

@ -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