diff --git a/Makefile b/Makefile index a555565..1a39649 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ build: lint: @hash golangci-lint > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ export BINARY="golangci-lint"; \ - curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin v1.23.1; \ + curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin v1.23.6; \ fi golangci-lint run --timeout 5m diff --git a/README.md b/README.md index ba28840..e6b6bbd 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,15 @@ If no `upstream` repository is found, `upstream` becomes synonymous with `origin * Search pull requests based on keyword(s) * Create a new pull request `sip pulls create` * Check pull request status (default based on current branch) `sip pulls status` - * Checkout a pull request to test locally `sip pulls checkout` \ No newline at end of file + * Checkout a pull request to test locally `sip pulls checkout` + +### Search filters +Sip supports certain search filters for issues/PRs. +Anything in the query that doesn't match one of the below filters will be sent as a keyword + +* State `is:open is:closed is:merged` - only the last state in the query will be applied +* Author `author:jolheiser` - only the last author in the query will be applied +* Labels `label:bug label:feature` - all labels must apply to return results +* Milestone `mileston:v1.0.0` - only the last milestone in the query will be applied + +e.g. `test is:open query author:jolheiser milestone:0.2.0` will search for issues/PRs with keywords `test query` that are `open`, authored by `jolheiser`, and in the `0.2.0` milestone. \ No newline at end of file diff --git a/cmd/issues.go b/cmd/issues.go index 55576dd..f36ba9e 100644 --- a/cmd/issues.go +++ b/cmd/issues.go @@ -106,8 +106,9 @@ func queryIssues(ctx *cli.Context, client *gitea.Client, pulls bool) ([]*gitea.I if err := survey.Ask(questions, &answers); err != nil { return nil, err } + filter := sdk.NewIssueFilter(answers.Query) ownerRepo := strings.Split(answers.Repo, "/") - opts := gitea.ListIssueOption{KeyWord: answers.Query, State: "all"} + opts := gitea.ListIssueOption{KeyWord: filter.Query, State: "all"} issues, err := sdk.GetIssues(client, ownerRepo[0], ownerRepo[1], opts) if err != nil { return nil, err @@ -115,10 +116,16 @@ func queryIssues(ctx *cli.Context, client *gitea.Client, pulls bool) ([]*gitea.I filtered := make([]*gitea.Issue, 0) for _, issue := range issues { + // Filter out issues if searching PRs and vice-versa if (pulls && issue.PullRequest == nil) || (!pulls && issue.PullRequest != nil) { continue } + + if !filter.Match(issue) { + continue + } + filtered = append(filtered, issue) } diff --git a/modules/sdk/issues.go b/modules/sdk/issues.go index 488ea0e..b7aeb09 100644 --- a/modules/sdk/issues.go +++ b/modules/sdk/issues.go @@ -2,6 +2,7 @@ package sdk import ( "code.gitea.io/sdk/gitea" + "strings" ) // GetIssues returns all matching Issues from a Gitea instance @@ -23,3 +24,97 @@ func GetIssues(client *gitea.Client, owner, repo string, opts gitea.ListIssueOpt } return issues, nil } + +type State int + +const ( + None State = iota + Open + Closed + Merged +) + +type IssueFilter struct { + Query string + State State + Author string + Labels []string + Milestone string +} + +//nolint:gocognit +// This function is high in cognitive complexity, +// however it is hopefully structured to be less painful than gocognit thinks +func (f *IssueFilter) Match(issue *gitea.Issue) bool { + + // Filter out state + if f.State == Open && issue.State != gitea.StateOpen { + return false + } + if f.State == Closed && issue.State != gitea.StateClosed { + return false + } + if f.State == Merged && (issue.PullRequest == nil || !issue.PullRequest.HasMerged) { + return false + } + + // Filter out author + if f.Author != "" && (issue.Poster == nil || !strings.EqualFold(f.Author, issue.Poster.UserName)) { + return false + } + + // Filter out labels + for _, fl := range f.Labels { + hasLabel := false + for _, il := range issue.Labels { + if fl == strings.ToLower(il.Name) { + hasLabel = true + break + } + } + if !hasLabel { + return false + } + } + + // Filter out milestone + if f.Milestone != "" && (issue.Milestone == nil || !strings.EqualFold(f.Milestone, issue.Milestone.Title)) { + return false + } + + return true +} + +func NewIssueFilter(query string) *IssueFilter { + filter := &IssueFilter{} + for _, q := range strings.Split(query, " ") { + kv := strings.Split(q, ":") + if len(kv) == 2 { + kv[0] = strings.ToLower(kv[0]) + kv[1] = strings.ToLower(kv[1]) + switch kv[0] { + case "is": + switch kv[1] { + case "open", "opened": + filter.State = Open + case "close", "closed": + filter.State = Closed + case "merge", "merged": + filter.State = Merged + default: + filter.State = None + } + case "author": + filter.Author = kv[1] + case "label": + filter.Labels = append(filter.Labels, kv[1]) + case "milestone": + filter.Milestone = kv[1] + } + continue + } + filter.Query += " " + q + } + filter.Query = strings.TrimSpace(filter.Query) + return filter +} diff --git a/modules/sdk/issues_test.go b/modules/sdk/issues_test.go new file mode 100644 index 0000000..9cf5768 --- /dev/null +++ b/modules/sdk/issues_test.go @@ -0,0 +1,48 @@ +package sdk + +import ( + "strconv" + "testing" +) + +func TestIssueFilter(t *testing.T) { + + tt := []struct { + Query string + Result *IssueFilter + }{ + // Grouped in fives to make searching failed tests easier + {"test query", &IssueFilter{Query: "test query"}}, + {"test query author:jolheiser", &IssueFilter{Query: "test query", Author: "jolheiser"}}, + {"test query author:jolheiser author:zeripath", &IssueFilter{Query: "test query", Author: "zeripath"}}, + {"test is:open query", &IssueFilter{Query: "test query", State: Open}}, + {"is:closed", &IssueFilter{State: Closed}}, + + {"is:closed is:merged", &IssueFilter{State: Merged}}, + {"is:merged test author:jolheiser query", &IssueFilter{Query: "test query", State: Merged, Author: "jolheiser"}}, + {"label:bug", &IssueFilter{Labels: []string{"bug"}}}, + {"label:bug label:feature", &IssueFilter{Labels: []string{"bug", "feature"}}}, + {"label:bug author:jolheiser test is:open query is:closed", &IssueFilter{Query: "test query", Author: "jolheiser", State: Closed, Labels: []string{"bug"}}}, + + {"is:closed milestone:0.1.0", &IssueFilter{State: Closed, Milestone: "0.1.0"}}, + {"milestone:v1.0.0 keyword", &IssueFilter{Query: "keyword", Milestone: "v1.0.0"}}, + } + + for idx, tc := range tt { + t.Run(strconv.Itoa(idx+1), func(t *testing.T) { + filter := NewIssueFilter(tc.Query) + if tc.Result.Query != filter.Query { + t.Fail() + } + if tc.Result.State != filter.State { + t.Fail() + } + if tc.Result.Author != filter.Author { + t.Fail() + } + if len(tc.Result.Labels) != len(filter.Labels) { + t.Fail() + } + }) + } +} diff --git a/modules/sdk/sdk_test.go b/modules/sdk/sdk_test.go new file mode 100644 index 0000000..2fd2413 --- /dev/null +++ b/modules/sdk/sdk_test.go @@ -0,0 +1,10 @@ +package sdk + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +}