diff options
-rw-r--r-- | bridge/github/import.go | 5 | ||||
-rw-r--r-- | bridge/gitlab/import.go | 7 | ||||
-rw-r--r-- | bridge/gitlab/import_test.go | 8 | ||||
-rw-r--r-- | bridge/jira/import.go | 12 | ||||
-rw-r--r-- | bridge/jira/jira.go | 1 | ||||
-rw-r--r-- | bridge/launchpad/import.go | 5 | ||||
-rw-r--r-- | cache/filter.go | 47 | ||||
-rw-r--r-- | cache/query.go | 172 | ||||
-rw-r--r-- | cache/query_test.go | 41 | ||||
-rw-r--r-- | cache/repo_cache.go | 24 | ||||
-rw-r--r-- | cache/repo_cache_test.go | 5 | ||||
-rw-r--r-- | cache/sorting.go | 18 | ||||
-rw-r--r-- | commands/ls.go | 95 | ||||
-rw-r--r-- | go.mod | 6 | ||||
-rw-r--r-- | go.sum | 13 | ||||
-rw-r--r-- | graphql/resolvers/repo.go | 12 | ||||
-rw-r--r-- | query/lexer.go | 71 | ||||
-rw-r--r-- | query/lexer_test.go | 45 | ||||
-rw-r--r-- | query/parser.go | 99 | ||||
-rw-r--r-- | query/parser_test.go | 97 | ||||
-rw-r--r-- | query/query.go | 49 | ||||
-rw-r--r-- | termui/bug_table.go | 7 | ||||
-rw-r--r-- | termui/termui.go | 5 |
23 files changed, 508 insertions, 336 deletions
diff --git a/bridge/github/import.go b/bridge/github/import.go index a74c49c5..78e93436 100644 --- a/bridge/github/import.go +++ b/bridge/github/import.go @@ -108,7 +108,10 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline } // resolve bug - b, err := repo.ResolveBugCreateMetadata(metaKeyGithubUrl, issue.Url.String()) + b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool { + return excerpt.CreateMetadata[core.MetaKeyOrigin] == target && + excerpt.CreateMetadata[metaKeyGithubId] == parseId(issue.Id) + }) if err != nil && err != bug.ErrBugNotExist { return nil, err } diff --git a/bridge/gitlab/import.go b/bridge/gitlab/import.go index 5ed5f0e3..0a47a783 100644 --- a/bridge/gitlab/import.go +++ b/bridge/gitlab/import.go @@ -123,7 +123,12 @@ func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue } // resolve bug - b, err := repo.ResolveBugCreateMetadata(metaKeyGitlabUrl, issue.WebURL) + b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool { + return excerpt.CreateMetadata[core.MetaKeyOrigin] == target && + excerpt.CreateMetadata[metaKeyGitlabId] == parseID(issue.IID) && + excerpt.CreateMetadata[metaKeyGitlabBaseUrl] == gi.conf[confKeyProjectID] && + excerpt.CreateMetadata[metaKeyGitlabProject] == gi.conf[confKeyGitlabBaseUrl] + }) if err == nil { return b, nil } diff --git a/bridge/gitlab/import_test.go b/bridge/gitlab/import_test.go index f916d20c..42a37cda 100644 --- a/bridge/gitlab/import_test.go +++ b/bridge/gitlab/import_test.go @@ -29,7 +29,7 @@ func TestImport(t *testing.T) { }{ { name: "simple issue", - url: "https://gitlab.com/git-bug/test/issues/1", + url: "https://gitlab.com/git-bug/test/-/issues/1", bug: &bug.Snapshot{ Operations: []bug.Operation{ bug.NewCreateOp(author, 0, "simple issue", "initial comment", nil), @@ -40,7 +40,7 @@ func TestImport(t *testing.T) { }, { name: "empty issue", - url: "https://gitlab.com/git-bug/test/issues/2", + url: "https://gitlab.com/git-bug/test/-/issues/2", bug: &bug.Snapshot{ Operations: []bug.Operation{ bug.NewCreateOp(author, 0, "empty issue", "", nil), @@ -49,7 +49,7 @@ func TestImport(t *testing.T) { }, { name: "complex issue", - url: "https://gitlab.com/git-bug/test/issues/3", + url: "https://gitlab.com/git-bug/test/-/issues/3", bug: &bug.Snapshot{ Operations: []bug.Operation{ bug.NewCreateOp(author, 0, "complex issue", "initial comment", nil), @@ -66,7 +66,7 @@ func TestImport(t *testing.T) { }, { name: "editions", - url: "https://gitlab.com/git-bug/test/issues/4", + url: "https://gitlab.com/git-bug/test/-/issues/4", bug: &bug.Snapshot{ Operations: []bug.Operation{ bug.NewCreateOp(author, 0, "editions", "initial comment edited", nil), diff --git a/bridge/jira/import.go b/bridge/jira/import.go index 3d6d5414..b66b0fa3 100644 --- a/bridge/jira/import.go +++ b/bridge/jira/import.go @@ -216,7 +216,16 @@ func (ji *jiraImporter) ensureIssue(repo *cache.RepoCache, issue Issue) (*cache. return nil, err } - b, err := repo.ResolveBugCreateMetadata(metaKeyJiraId, issue.ID) + b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool { + if _, ok := excerpt.CreateMetadata[metaKeyJiraBaseUrl]; ok && + excerpt.CreateMetadata[metaKeyJiraBaseUrl] != ji.conf[confKeyBaseUrl] { + return false + } + + return excerpt.CreateMetadata[core.MetaKeyOrigin] == target && + excerpt.CreateMetadata[metaKeyJiraId] == issue.ID && + excerpt.CreateMetadata[metaKeyJiraProject] == ji.conf[confKeyProject] + }) if err != nil && err != bug.ErrBugNotExist { return nil, err } @@ -241,6 +250,7 @@ func (ji *jiraImporter) ensureIssue(repo *cache.RepoCache, issue Issue) (*cache. metaKeyJiraId: issue.ID, metaKeyJiraKey: issue.Key, metaKeyJiraProject: ji.conf[confKeyProject], + metaKeyJiraBaseUrl: ji.conf[confKeyBaseUrl], }) if err != nil { return nil, err diff --git a/bridge/jira/jira.go b/bridge/jira/jira.go index 066c6597..6423843c 100644 --- a/bridge/jira/jira.go +++ b/bridge/jira/jira.go @@ -20,6 +20,7 @@ const ( metaKeyJiraKey = "jira-key" metaKeyJiraUser = "jira-user" metaKeyJiraProject = "jira-project" + metaKeyJiraBaseUrl = "jira-base-url" metaKeyJiraExportTime = "jira-export-time" metaKeyJiraLogin = "jira-login" diff --git a/bridge/launchpad/import.go b/bridge/launchpad/import.go index 3b6d7fe0..7f528c7d 100644 --- a/bridge/launchpad/import.go +++ b/bridge/launchpad/import.go @@ -62,7 +62,10 @@ func (li *launchpadImporter) ImportAll(ctx context.Context, repo *cache.RepoCach return default: lpBugID := fmt.Sprintf("%d", lpBug.ID) - b, err := repo.ResolveBugCreateMetadata(metaKeyLaunchpadID, lpBugID) + b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool { + return excerpt.CreateMetadata[core.MetaKeyOrigin] == target && + excerpt.CreateMetadata[metaKeyLaunchpadID] == lpBugID + }) if err != nil && err != bug.ErrBugNotExist { out <- core.NewImportError(err, entity.Id(lpBugID)) return diff --git a/cache/filter.go b/cache/filter.go index ebe774ac..166cd48d 100644 --- a/cache/filter.go +++ b/cache/filter.go @@ -5,6 +5,7 @@ import ( "github.com/MichaelMure/git-bug/bug" "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/query" ) // resolver has the resolving functions needed by filters. @@ -17,15 +18,10 @@ type resolver interface { type Filter func(excerpt *BugExcerpt, resolver resolver) bool // StatusFilter return a Filter that match a bug status -func StatusFilter(query string) (Filter, error) { - status, err := bug.StatusFromString(query) - if err != nil { - return nil, err - } - +func StatusFilter(status bug.Status) Filter { return func(excerpt *BugExcerpt, resolver resolver) bool { return excerpt.Status == status - }, nil + } } // AuthorFilter return a Filter that match a bug author @@ -116,8 +112,8 @@ func NoLabelFilter() Filter { } } -// Filters is a collection of Filter that implement a complex filter -type Filters struct { +// Matcher is a collection of Filter that implement a complex filter +type Matcher struct { Status []Filter Author []Filter Actor []Filter @@ -127,8 +123,35 @@ type Filters struct { NoFilters []Filter } +// compileMatcher transform a query.Filters into a specialized matcher +// for the cache. +func compileMatcher(filters query.Filters) *Matcher { + result := &Matcher{} + + for _, value := range filters.Status { + result.Status = append(result.Status, StatusFilter(value)) + } + for _, value := range filters.Author { + result.Author = append(result.Author, AuthorFilter(value)) + } + for _, value := range filters.Actor { + result.Actor = append(result.Actor, ActorFilter(value)) + } + for _, value := range filters.Participant { + result.Participant = append(result.Participant, ParticipantFilter(value)) + } + for _, value := range filters.Label { + result.Label = append(result.Label, LabelFilter(value)) + } + for _, value := range filters.Title { + result.Title = append(result.Title, TitleFilter(value)) + } + + return result +} + // Match check if a bug match the set of filters -func (f *Filters) Match(excerpt *BugExcerpt, resolver resolver) bool { +func (f *Matcher) Match(excerpt *BugExcerpt, resolver resolver) bool { if match := f.orMatch(f.Status, excerpt, resolver); !match { return false } @@ -161,7 +184,7 @@ func (f *Filters) Match(excerpt *BugExcerpt, resolver resolver) bool { } // Check if any of the filters provided match the bug -func (*Filters) orMatch(filters []Filter, excerpt *BugExcerpt, resolver resolver) bool { +func (*Matcher) orMatch(filters []Filter, excerpt *BugExcerpt, resolver resolver) bool { if len(filters) == 0 { return true } @@ -175,7 +198,7 @@ func (*Filters) orMatch(filters []Filter, excerpt *BugExcerpt, resolver resolver } // Check if all of the filters provided match the bug -func (*Filters) andMatch(filters []Filter, excerpt *BugExcerpt, resolver resolver) bool { +func (*Matcher) andMatch(filters []Filter, excerpt *BugExcerpt, resolver resolver) bool { if len(filters) == 0 { return true } diff --git a/cache/query.go b/cache/query.go deleted file mode 100644 index 967c18d6..00000000 --- a/cache/query.go +++ /dev/null @@ -1,172 +0,0 @@ -package cache - -import ( - "fmt" - "strings" - "unicode" -) - -type Query struct { - Filters - OrderBy - OrderDirection -} - -// Return an identity query with default sorting (creation-desc) -func NewQuery() *Query { - return &Query{ - OrderBy: OrderByCreation, - OrderDirection: OrderDescending, - } -} - -// ParseQuery parse a query DSL -// -// Ex: "status:open author:descartes sort:edit-asc" -// -// Supported filter qualifiers and syntax are described in docs/queries.md -func ParseQuery(query string) (*Query, error) { - fields := splitQuery(query) - - result := &Query{ - OrderBy: OrderByCreation, - OrderDirection: OrderDescending, - } - - sortingDone := false - - for _, field := range fields { - split := strings.Split(field, ":") - if len(split) != 2 { - return nil, fmt.Errorf("can't parse \"%s\"", field) - } - - qualifierName := split[0] - qualifierQuery := removeQuote(split[1]) - - switch qualifierName { - case "status", "state": - f, err := StatusFilter(qualifierQuery) - if err != nil { - return nil, err - } - result.Status = append(result.Status, f) - - case "author": - f := AuthorFilter(qualifierQuery) - result.Author = append(result.Author, f) - - case "actor": - f := ActorFilter(qualifierQuery) - result.Actor = append(result.Actor, f) - - case "participant": - f := ParticipantFilter(qualifierQuery) - result.Participant = append(result.Participant, f) - - case "label": - f := LabelFilter(qualifierQuery) - result.Label = append(result.Label, f) - - case "title": - f := TitleFilter(qualifierQuery) - result.Title = append(result.Title, f) - - case "no": - err := result.parseNoFilter(qualifierQuery) - if err != nil { - return nil, err - } - - case "sort": - if sortingDone { - return nil, fmt.Errorf("multiple sorting") - } - - err := result.parseSorting(qualifierQuery) - if err != nil { - return nil, err - } - - sortingDone = true - - default: - return nil, fmt.Errorf("unknown qualifier name %s", qualifierName) - } - } - - return result, nil -} - -func splitQuery(query string) []string { - lastQuote := rune(0) - f := func(c rune) bool { - switch { - case c == lastQuote: - lastQuote = rune(0) - return false - case lastQuote != rune(0): - return false - case unicode.In(c, unicode.Quotation_Mark): - lastQuote = c - return false - default: - return unicode.IsSpace(c) - } - } - - return strings.FieldsFunc(query, f) -} - -func removeQuote(field string) string { - if len(field) >= 2 { - if field[0] == '"' && field[len(field)-1] == '"' { - return field[1 : len(field)-1] - } - } - return field -} - -func (q *Query) parseNoFilter(query string) error { - switch query { - case "label": - q.NoFilters = append(q.NoFilters, NoLabelFilter()) - default: - return fmt.Errorf("unknown \"no\" filter %s", query) - } - - return nil -} - -func (q *Query) parseSorting(query string) error { - switch query { - // default ASC - case "id-desc": - q.OrderBy = OrderById - q.OrderDirection = OrderDescending - case "id", "id-asc": - q.OrderBy = OrderById - q.OrderDirection = OrderAscending - - // default DESC - case "creation", "creation-desc": - q.OrderBy = OrderByCreation - q.OrderDirection = OrderDescending - case "creation-asc": - q.OrderBy = OrderByCreation - q.OrderDirection = OrderAscending - - // default DESC - case "edit", "edit-desc": - q.OrderBy = OrderByEdit - q.OrderDirection = OrderDescending - case "edit-asc": - q.OrderBy = OrderByEdit - q.OrderDirection = OrderAscending - - default: - return fmt.Errorf("unknown sorting %s", query) - } - - return nil -} diff --git a/cache/query_test.go b/cache/query_test.go deleted file mode 100644 index 9ae62ac4..00000000 --- a/cache/query_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package cache - -import "testing" - -func TestQueryParse(t *testing.T) { - - var tests = []struct { - input string - ok bool - }{ - {"gibberish", false}, - - {"status:", false}, - - {"status:open", true}, - {"status:closed", true}, - {"status:unknown", false}, - - {"author:rene", true}, - {`author:"René Descartes"`, true}, - - {"actor:bernhard", true}, - {"participant:leonhard", true}, - - {"label:hello", true}, - {`label:"Good first issue"`, true}, - - {"title:titleOne", true}, - {`title:"Bug titleTwo"`, true}, - - {"sort:edit", true}, - {"sort:unknown", false}, - } - - for _, test := range tests { - _, err := ParseQuery(test.input) - if (err == nil) != test.ok { - t.Fatalf("Unexpected parse result, expected: %v, err: %v", test.ok, err) - } - } -} diff --git a/cache/repo_cache.go b/cache/repo_cache.go index d859a966..f2e1c7d0 100644 --- a/cache/repo_cache.go +++ b/cache/repo_cache.go @@ -18,6 +18,7 @@ import ( "github.com/MichaelMure/git-bug/bug" "github.com/MichaelMure/git-bug/entity" "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/query" "github.com/MichaelMure/git-bug/repository" "github.com/MichaelMure/git-bug/util/git" "github.com/MichaelMure/git-bug/util/process" @@ -525,37 +526,44 @@ func (c *RepoCache) resolveBugMatcher(f func(*BugExcerpt) bool) (entity.Id, erro } // QueryBugs return the id of all Bug matching the given Query -func (c *RepoCache) QueryBugs(query *Query) []entity.Id { +func (c *RepoCache) QueryBugs(q *query.Query) []entity.Id { c.muBug.RLock() defer c.muBug.RUnlock() - if query == nil { + if q == nil { return c.AllBugsIds() } + matcher := compileMatcher(q.Filters) + var filtered []*BugExcerpt for _, excerpt := range c.bugExcerpts { - if query.Match(excerpt, c) { + if matcher.Match(excerpt, c) { filtered = append(filtered, excerpt) } } var sorter sort.Interface - switch query.OrderBy { - case OrderById: + switch q.OrderBy { + case query.OrderById: sorter = BugsById(filtered) - case OrderByCreation: + case query.OrderByCreation: sorter = BugsByCreationTime(filtered) - case OrderByEdit: + case query.OrderByEdit: sorter = BugsByEditTime(filtered) default: panic("missing sort type") } - if query.OrderDirection == OrderDescending { + switch q.OrderDirection { + case query.OrderAscending: + // Nothing to do + case query.OrderDescending: sorter = sort.Reverse(sorter) + default: + panic("missing sort direction") } sort.Sort(sorter) diff --git a/cache/repo_cache_test.go b/cache/repo_cache_test.go index c3bd3cc4..51393dfd 100644 --- a/cache/repo_cache_test.go +++ b/cache/repo_cache_test.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/MichaelMure/git-bug/query" "github.com/MichaelMure/git-bug/repository" ) @@ -68,9 +69,9 @@ func TestCache(t *testing.T) { require.NoError(t, err) // Querying - query, err := ParseQuery("status:open author:descartes sort:edit-asc") + q, err := query.Parse("status:open author:descartes sort:edit-asc") require.NoError(t, err) - require.Len(t, cache.QueryBugs(query), 2) + require.Len(t, cache.QueryBugs(q), 2) // Close require.NoError(t, cache.Close()) diff --git a/cache/sorting.go b/cache/sorting.go deleted file mode 100644 index 19034a9d..00000000 --- a/cache/sorting.go +++ /dev/null @@ -1,18 +0,0 @@ -package cache - -type OrderBy int - -const ( - _ OrderBy = iota - OrderById - OrderByCreation - OrderByEdit -) - -type OrderDirection int - -const ( - _ OrderDirection = iota - OrderAscending - OrderDescending -) diff --git a/commands/ls.go b/commands/ls.go index 70a948e6..f125d916 100644 --- a/commands/ls.go +++ b/commands/ls.go @@ -7,21 +7,20 @@ import ( text "github.com/MichaelMure/go-term-text" "github.com/spf13/cobra" + "github.com/MichaelMure/git-bug/bug" "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/query" "github.com/MichaelMure/git-bug/util/colors" "github.com/MichaelMure/git-bug/util/interrupt" ) var ( - lsStatusQuery []string - lsAuthorQuery []string - lsParticipantQuery []string - lsLabelQuery []string - lsTitleQuery []string - lsActorQuery []string - lsNoQuery []string - lsSortBy string - lsSortDirection string + lsQuery query.Query + + lsStatusQuery []string + lsNoQuery []string + lsSortBy string + lsSortDirection string ) func runLsBug(cmd *cobra.Command, args []string) error { @@ -32,21 +31,22 @@ func runLsBug(cmd *cobra.Command, args []string) error { defer backend.Close() interrupt.RegisterCleaner(backend.Close) - var query *cache.Query + var q *query.Query if len(args) >= 1 { - query, err = cache.ParseQuery(strings.Join(args, " ")) + q, err = query.Parse(strings.Join(args, " ")) if err != nil { return err } } else { - query, err = lsQueryFromFlags() + err = completeQuery() if err != nil { return err } + q = &lsQuery } - allIds := backend.QueryBugs(query) + allIds := backend.QueryBugs(q) for _, id := range allIds { b, err := backend.ResolveBugExcerpt(id) @@ -96,73 +96,46 @@ func runLsBug(cmd *cobra.Command, args []string) error { return nil } -// Transform the command flags into a query -func lsQueryFromFlags() (*cache.Query, error) { - query := cache.NewQuery() - - for _, status := range lsStatusQuery { - f, err := cache.StatusFilter(status) +// Finish the command flags transformation into the query.Query +func completeQuery() error { + for _, str := range lsStatusQuery { + status, err := bug.StatusFromString(str) if err != nil { - return nil, err + return err } - query.Status = append(query.Status, f) - } - - for _, title := range lsTitleQuery { - f := cache.TitleFilter(title) - query.Title = append(query.Title, f) - } - - for _, author := range lsAuthorQuery { - f := cache.AuthorFilter(author) - query.Author = append(query.Author, f) - } - - for _, actor := range lsActorQuery { - f := cache.ActorFilter(actor) - query.Actor = append(query.Actor, f) - } - - for _, participant := range lsParticipantQuery { - f := cache.ParticipantFilter(participant) - query.Participant = append(query.Participant, f) - } - - for _, label := range lsLabelQuery { - f := cache.LabelFilter(label) - query.Label = append(query.Label, f) + lsQuery.Status = append(lsQuery.Status, status) } for _, no := range lsNoQuery { switch no { case "label": - query.NoFilters = append(query.NoFilters, cache.NoLabelFilter()) + lsQuery.NoLabel = true default: - return nil, fmt.Errorf("unknown \"no\" filter %s", no) + return fmt.Errorf("unknown \"no\" filter %s", no) } } switch lsSortBy { case "id": - query.OrderBy = cache.OrderById + lsQuery.OrderBy = query.OrderById case "creation": - query.OrderBy = cache.OrderByCreation + lsQuery.OrderBy = query.OrderByCreation case "edit": - query.OrderBy = cache.OrderByEdit + lsQuery.OrderBy = query.OrderByEdit default: - return nil, fmt.Errorf("unknown sort flag %s", lsSortBy) + return fmt.Errorf("unknown sort flag %s", lsSortBy) } switch lsSortDirection { case "asc": - query.OrderDirection = cache.OrderAscending + lsQuery.OrderDirection = query.OrderAscending case "desc": - query.OrderDirection = cache.OrderDescending + lsQuery.OrderDirection = query.OrderDescending default: - return nil, fmt.Errorf("unknown sort direction %s", lsSortDirection) + return fmt.Errorf("unknown sort direction %s", lsSortDirection) } - return query, nil + return nil } var lsCmd = &cobra.Command{ @@ -188,15 +161,15 @@ func init() { lsCmd.Flags().StringSliceVarP(&lsStatusQuery, "status", "s", nil, "Filter by status. Valid values are [open,closed]") - lsCmd.Flags().StringSliceVarP(&lsAuthorQuery, "author", "a", nil, + lsCmd.Flags().StringSliceVarP(&lsQuery.Author, "author", "a", nil, "Filter by author") - lsCmd.Flags().StringSliceVarP(&lsParticipantQuery, "participant", "p", nil, + lsCmd.Flags().StringSliceVarP(&lsQuery.Participant, "participant", "p", nil, "Filter by participant") - lsCmd.Flags().StringSliceVarP(&lsActorQuery, "actor", "A", nil, + lsCmd.Flags().StringSliceVarP(&lsQuery.Actor, "actor", "A", nil, "Filter by actor") - lsCmd.Flags().StringSliceVarP(&lsLabelQuery, "label", "l", nil, + lsCmd.Flags().StringSliceVarP(&lsQuery.Label, "label", "l", nil, "Filter by label") - lsCmd.Flags().StringSliceVarP(&lsTitleQuery, "title", "t", nil, + lsCmd.Flags().StringSliceVarP(&lsQuery.Title, "title", "t", nil, "Filter by title") lsCmd.Flags().StringSliceVarP(&lsNoQuery, "no", "n", nil, "Filter by absence of something. Valid values are [label]") @@ -4,7 +4,7 @@ go 1.11 require ( github.com/99designs/gqlgen v0.10.3-0.20200209012558-b7a58a1c0e4b - github.com/MichaelMure/go-term-text v0.2.7 + github.com/MichaelMure/go-term-text v0.2.8 github.com/araddon/dateparse v0.0.0-20190622164848-0fb0a474d195 github.com/awesome-gocui/gocui v0.6.1-0.20191115151952-a34ffb055986 github.com/blang/semver v3.5.1+incompatible @@ -17,7 +17,7 @@ require ( github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/icrowley/fake v0.0.0-20180203215853-4178557ae428 github.com/mattn/go-isatty v0.0.12 - github.com/mattn/go-runewidth v0.0.8 + github.com/mattn/go-runewidth v0.0.9 github.com/phayes/freeport v0.0.0-20171002181615-b8543db493a5 github.com/pkg/errors v0.9.1 github.com/shurcooL/githubv4 v0.0.0-20190601194912-068505affed7 @@ -27,7 +27,7 @@ require ( github.com/stretchr/testify v1.5.1 github.com/theckman/goconstraint v1.11.0 github.com/vektah/gqlparser v1.3.1 - github.com/xanzy/go-gitlab v0.27.0 + github.com/xanzy/go-gitlab v0.29.0 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 golang.org/x/sync v0.0.0-20190423024810-112230192c58 @@ -11,6 +11,8 @@ github.com/MichaelMure/go-term-text v0.2.6 h1:dSmJSzk2iI5xWymSMrMbdVM1bxYWu3DjDF github.com/MichaelMure/go-term-text v0.2.6/go.mod h1:o2Z5T3b28F4kwAojGvvNdbzjHf9t18vbQ7E2pmTe2Ww= github.com/MichaelMure/go-term-text v0.2.7 h1:nSYvYGwXxJoiQu6kdGSErpxZ6ah/4WlJyp/niqQor6g= github.com/MichaelMure/go-term-text v0.2.7/go.mod h1:6z+q5b/nP1V8I9KkWQcUi5QpmF8DVrz9vLJ4hdoxHnM= +github.com/MichaelMure/go-term-text v0.2.8 h1:daXIVPjPkAhcLhA+tfjQBHYjatb1D42/LY1Nw2PXYlU= +github.com/MichaelMure/go-term-text v0.2.8/go.mod h1:6z+q5b/nP1V8I9KkWQcUi5QpmF8DVrz9vLJ4hdoxHnM= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/agnivade/levenshtein v1.0.1 h1:3oJU7J3FGFmyhn8KHjmVaZCN5hxTr7GxgRue+sxIXdQ= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= @@ -91,6 +93,11 @@ github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoA github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-retryablehttp v0.6.4 h1:BbgctKO892xEyOXnGiaAwIoSq1QZ/SS4AhjoAh9DnfY= +github.com/hashicorp/go-retryablehttp v0.6.4/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= @@ -126,6 +133,8 @@ github.com/mattn/go-runewidth v0.0.6 h1:V2iyH+aX9C5fsYCpK60U8BYIvmhqxuOL3JZcqc1N github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.8 h1:3tS41NlGYSmhhe/8fhGRzc+z3AYCw1Fe1WAyLuujKs0= github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -222,6 +231,8 @@ github.com/xanzy/go-gitlab v0.26.0 h1:eAnJRBUC+GDJSy8OoGCZBqBMpXsGOOT235TFm/F8C0 github.com/xanzy/go-gitlab v0.26.0/go.mod h1:t4Bmvnxj7k37S4Y17lfLx+nLqkf/oQwT2HagfWKv5Og= github.com/xanzy/go-gitlab v0.27.0 h1:zy7xBB8+PID6izH07ZArtkEisJ192dtQajRaeo4+glg= github.com/xanzy/go-gitlab v0.27.0/go.mod h1:t4Bmvnxj7k37S4Y17lfLx+nLqkf/oQwT2HagfWKv5Og= +github.com/xanzy/go-gitlab v0.29.0 h1:9tMvAkG746eIlzcdpnRgpcKPA1woUDmldMIjR/E5OWM= +github.com/xanzy/go-gitlab v0.29.0/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= @@ -275,6 +286,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/graphql/resolvers/repo.go b/graphql/resolvers/repo.go index d090544d..639e8f90 100644 --- a/graphql/resolvers/repo.go +++ b/graphql/resolvers/repo.go @@ -4,11 +4,11 @@ import ( "context" "github.com/MichaelMure/git-bug/bug" - "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/entity" "github.com/MichaelMure/git-bug/graphql/connections" "github.com/MichaelMure/git-bug/graphql/graph" "github.com/MichaelMure/git-bug/graphql/models" + "github.com/MichaelMure/git-bug/query" ) var _ graph.RepositoryResolver = &repoResolver{} @@ -28,19 +28,19 @@ func (repoResolver) AllBugs(_ context.Context, obj *models.Repository, after *st Last: last, } - var query *cache.Query + var q *query.Query if queryStr != nil { - query2, err := cache.ParseQuery(*queryStr) + query2, err := query.Parse(*queryStr) if err != nil { return nil, err } - query = query2 + q = query2 } else { - query = cache.NewQuery() + q = query.NewQuery() } // Simply pass a []string with the ids to the pagination algorithm - source := obj.Repo.QueryBugs(query) + source := obj.Repo.QueryBugs(q) // The edger create a custom edge holding just the id edger := func(id entity.Id, offset int) connections.Edge { diff --git a/query/lexer.go b/query/lexer.go new file mode 100644 index 00000000..ec05f44e --- /dev/null +++ b/query/lexer.go @@ -0,0 +1,71 @@ +package query + +import ( + "fmt" + "strings" + "unicode" +) + +type token struct { + qualifier string + value string +} + +// TODO: this lexer implementation behave badly with unmatched quotes. +// A hand written one would be better instead of relying on strings.FieldsFunc() + +// tokenize parse and break a input into tokens ready to be +// interpreted later by a parser to get the semantic. +func tokenize(query string) ([]token, error) { + fields := splitQuery(query) + + var tokens []token + for _, field := range fields { + split := strings.Split(field, ":") + if len(split) != 2 { + return nil, fmt.Errorf("can't tokenize \"%s\"", 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, token{ + qualifier: split[0], + value: removeQuote(split[1]), + }) + } + return tokens, nil +} + +func splitQuery(query string) []string { + lastQuote := rune(0) + f := func(c rune) bool { + switch { + case c == lastQuote: + lastQuote = rune(0) + return false + case lastQuote != rune(0): + return false + case unicode.In(c, unicode.Quotation_Mark): + lastQuote = c + return false + default: + return unicode.IsSpace(c) + } + } + + return strings.FieldsFunc(query, f) +} + +func removeQuote(field string) string { + if len(field) >= 2 { + if field[0] == '"' && field[len(field)-1] == '"' { + return field[1 : len(field)-1] + } + } + return field +} diff --git a/query/lexer_test.go b/query/lexer_test.go new file mode 100644 index 00000000..922e3fc9 --- /dev/null +++ b/query/lexer_test.go @@ -0,0 +1,45 @@ +package query + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTokenize(t *testing.T) { + var tests = []struct { + input string + tokens []token + }{ + {"gibberish", nil}, + {"status:", nil}, + {":value", nil}, + + {"status:open", []token{{"status", "open"}}}, + {"status:closed", []token{{"status", "closed"}}}, + + {"author:rene", []token{{"author", "rene"}}}, + {`author:"René Descartes"`, []token{{"author", "René Descartes"}}}, + + { + `status:open status:closed author:rene author:"René Descartes"`, + []token{ + {"status", "open"}, + {"status", "closed"}, + {"author", "rene"}, + {"author", "René Descartes"}, + }, + }, + } + + for _, tc := range tests { + tokens, err := tokenize(tc.input) + if tc.tokens == nil { + assert.Error(t, err) + assert.Nil(t, tokens) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.tokens, tokens) + } + } +} diff --git a/query/parser.go b/query/parser.go new file mode 100644 index 00000000..a379f750 --- /dev/null +++ b/query/parser.go @@ -0,0 +1,99 @@ +package query + +import ( + "fmt" + + "github.com/MichaelMure/git-bug/bug" +) + +// Parse parse a query DSL +// +// Ex: "status:open author:descartes sort:edit-asc" +// +// Supported filter qualifiers and syntax are described in docs/queries.md +func Parse(query string) (*Query, error) { + tokens, err := tokenize(query) + if err != nil { + return nil, err + } + + q := &Query{ + OrderBy: OrderByCreation, + OrderDirection: OrderDescending, + } + sortingDone := false + + for _, t := range tokens { + switch t.qualifier { + case "status", "state": + status, err := bug.StatusFromString(t.value) + if err != nil { + return nil, err + } + q.Status = append(q.Status, status) + case "author": + q.Author = append(q.Author, t.value) + case "actor": + q.Actor = append(q.Actor, t.value) + case "participant": + q.Participant = append(q.Participant, t.value) + case "label": + q.Label = append(q.Label, t.value) + case "title": + q.Title = append(q.Title, t.value) + case "no": + switch t.value { + case "label": + q.NoLabel = true + default: + return nil, fmt.Errorf("unknown \"no\" filter \"%s\"", t.value) + } + case "sort": + if sortingDone { + return nil, fmt.Errorf("multiple sorting") + } + err = parseSorting(q, t.value) + if err != nil { + return nil, err + } + sortingDone = true + + default: + return nil, fmt.Errorf("unknown qualifier \"%s\"", t.qualifier) + } + } + return q, nil +} + +func parseSorting(q *Query, value string) error { + switch value { + // default ASC + case "id-desc": + q.OrderBy = OrderById + q.OrderDirection = OrderDescending + case "id", "id-asc": + q.OrderBy = OrderById + q.OrderDirection = OrderAscending + + // default DESC + case "creation", "creation-desc": + q.OrderBy = OrderByCreation + q.OrderDirection = OrderDescending + case "creation-asc": + q.OrderBy = OrderByCreation + q.OrderDirection = OrderAscending + + // default DESC + case "edit", "edit-desc": + q.OrderBy = OrderByEdit + q.OrderDirection = OrderDescending + case "edit-asc": + q.OrderBy = OrderByEdit + q.OrderDirection = OrderAscending + + default: + return fmt.Errorf("unknown sorting %s", value) + } + + return nil +} diff --git a/query/parser_test.go b/query/parser_test.go new file mode 100644 index 00000000..6a509adb --- /dev/null +++ b/query/parser_test.go @@ -0,0 +1,97 @@ +package query + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/MichaelMure/git-bug/bug" +) + +func TestParse(t *testing.T) { + var tests = []struct { + input string + output *Query + }{ + {"gibberish", nil}, + {"status:", nil}, + {":value", nil}, + + {"status:open", &Query{ + Filters: Filters{Status: []bug.Status{bug.OpenStatus}}, + }}, + {"status:closed", &Query{ + Filters: Filters{Status: []bug.Status{bug.ClosedStatus}}, + }}, + {"status:unknown", nil}, + + {"author:rene", &Query{ + Filters: Filters{Author: []string{"rene"}}, + }}, + {`author:"René Descartes"`, &Query{ + Filters: Filters{Author: []string{"René Descartes"}}, + }}, + + {"actor:bernhard", &Query{ + Filters: Filters{Actor: []string{"bernhard"}}, + }}, + {"participant:leonhard", &Query{ + Filters: Filters{Participant: []string{"leonhard"}}, + }}, + + {"label:hello", &Query{ + Filters: Filters{Label: []string{"hello"}}, + }}, + {`label:"Good first issue"`, &Query{ + Filters: Filters{Label: []string{"Good first issue"}}, + }}, + + {"title:titleOne", &Query{ + Filters: Filters{Title: []string{"titleOne"}}, + }}, + {`title:"Bug titleTwo"`, &Query{ + Filters: Filters{Title: []string{"Bug titleTwo"}}, + }}, + + {"no:label", &Query{ + Filters: Filters{NoLabel: true}, + }}, + + {"sort:edit", &Query{ + OrderBy: OrderByEdit, + }}, + {"sort:unknown", nil}, + + {`status:open author:"René Descartes" participant:leonhard label:hello label:"Good first issue" sort:edit-desc`, + &Query{ + Filters: Filters{ + Status: []bug.Status{bug.OpenStatus}, + Author: []string{"René Descartes"}, + Participant: []string{"leonhard"}, + Label: []string{"hello", "Good first issue"}, + }, + OrderBy: OrderByEdit, + OrderDirection: OrderDescending, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + query, err := Parse(tc.input) + if tc.output == nil { + assert.Error(t, err) + assert.Nil(t, query) + } else { + assert.NoError(t, err) + if tc.output.OrderBy != 0 { + assert.Equal(t, tc.output.OrderBy, query.OrderBy) + } + if tc.output.OrderDirection != 0 { + assert.Equal(t, tc.output.OrderDirection, query.OrderDirection) + } + assert.Equal(t, tc.output.Filters, query.Filters) + } + }) + } +} diff --git a/query/query.go b/query/query.go new file mode 100644 index 00000000..a499ad38 --- /dev/null +++ b/query/query.go @@ -0,0 +1,49 @@ +package query + +import "github.com/MichaelMure/git-bug/bug" + +// Query is the intermediary representation of a Bug's query. It is either +// produced by parsing a query string (ex: "status:open author:rene") or created +// manually. This query doesn't do anything by itself and need to be interpreted +// for the specific domain of application. +type Query struct { + Filters + OrderBy + OrderDirection +} + +// NewQuery return an identity query with the default sorting (creation-desc). +func NewQuery() *Query { + return &Query{ + OrderBy: OrderByCreation, + OrderDirection: OrderDescending, + } +} + +// Filters is a collection of Filter that implement a complex filter +type Filters struct { + Status []bug.Status + Author []string + Actor []string + Participant []string + Label []string + Title []string + NoLabel bool +} + +type OrderBy int + +const ( + _ OrderBy = iota + OrderById + OrderByCreation + OrderByEdit +) + +type OrderDirection int + +const ( + _ OrderDirection = iota + OrderAscending + OrderDescending +) diff --git a/termui/bug_table.go b/termui/bug_table.go index 41aa4e83..80d5ebcb 100644 --- a/termui/bug_table.go +++ b/termui/bug_table.go @@ -12,6 +12,7 @@ import ( "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/query" "github.com/MichaelMure/git-bug/util/colors" ) @@ -26,7 +27,7 @@ const defaultQuery = "status:open" type bugTable struct { repo *cache.RepoCache queryStr string - query *cache.Query + query *query.Query allIds []entity.Id excerpts []*cache.BugExcerpt pageCursor int @@ -34,14 +35,14 @@ type bugTable struct { } func newBugTable(c *cache.RepoCache) *bugTable { - query, err := cache.ParseQuery(defaultQuery) + q, err := query.Parse(defaultQuery) if err != nil { panic(err) } return &bugTable{ repo: c, - query: query, + query: q, queryStr: defaultQuery, pageCursor: 0, selectCursor: 0, diff --git a/termui/termui.go b/termui/termui.go index 1ef960de..96b7583c 100644 --- a/termui/termui.go +++ b/termui/termui.go @@ -12,6 +12,7 @@ import ( "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/entity" "github.com/MichaelMure/git-bug/input" + "github.com/MichaelMure/git-bug/query" ) var errTerminateMainloop = errors.New("terminate gocui mainloop") @@ -336,12 +337,12 @@ func editQueryWithEditor(bt *bugTable) error { bt.queryStr = queryStr - query, err := cache.ParseQuery(queryStr) + q, err := query.Parse(queryStr) if err != nil { ui.msgPopup.Activate(msgPopupErrorTitle, err.Error()) } else { - bt.query = query + bt.query = q } initGui(nil) |