From 1f5feacc4fd25142d88abe3623ece012f1320427 Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Wed, 11 Feb 2026 15:04:41 +1100 Subject: [PATCH] appview: add search filters for issues and pulls Signed-off-by: Thomas Karpiniec --- appview/indexer/bleve/query.go | 7 + appview/indexer/issues/indexer.go | 92 ++++-- appview/indexer/issues/indexer_test.go | 264 ++++++++++++++++++ appview/indexer/pulls/indexer.go | 91 ++++-- appview/issues/issues.go | 183 ++++++++---- appview/models/search.go | 52 ++-- appview/pages/pages.go | 20 +- .../templates/fragments/tabSelector.html | 24 +- .../pages/templates/repo/issues/issues.html | 28 +- appview/pages/templates/repo/pulls/pulls.html | 28 +- appview/pulls/pulls.go | 189 ++++++++----- 11 files changed, 744 insertions(+), 234 deletions(-) create mode 100644 appview/indexer/issues/indexer_test.go diff --git a/appview/indexer/bleve/query.go b/appview/indexer/bleve/query.go index 2a92ae55..d96c9d5e 100644 --- a/appview/indexer/bleve/query.go +++ b/appview/indexer/bleve/query.go @@ -13,6 +13,13 @@ func MatchAndQuery(field, keyword, analyzer string, fuzziness int) query.Query { return q } +func MatchPhraseQuery(field, phrase, analyzer string) query.Query { + q := bleve.NewMatchPhraseQuery(phrase) + q.FieldVal = field + q.Analyzer = analyzer + return q +} + func BoolFieldQuery(field string, val bool) query.Query { q := bleve.NewBoolFieldQuery(val) q.FieldVal = field diff --git a/appview/indexer/issues/indexer.go b/appview/indexer/issues/indexer.go index a8da2420..e25dfe73 100644 --- a/appview/indexer/issues/indexer.go +++ b/appview/indexer/issues/indexer.go @@ -84,6 +84,8 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) { docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) docMapping.AddFieldMappingsAt("is_open", boolFieldMapping) + docMapping.AddFieldMappingsAt("author_did", keywordFieldMapping) + docMapping.AddFieldMappingsAt("labels", keywordFieldMapping) err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ "type": unicodenorm.Name, @@ -168,26 +170,29 @@ func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error { return err } -// issueData data stored and will be indexed type issueData struct { - ID int64 `json:"id"` - RepoAt string `json:"repo_at"` - IssueID int `json:"issue_id"` - Title string `json:"title"` - Body string `json:"body"` + ID int64 `json:"id"` + RepoAt string `json:"repo_at"` + IssueID int `json:"issue_id"` + Title string `json:"title"` + Body string `json:"body"` + IsOpen bool `json:"is_open"` + AuthorDid string `json:"author_did"` + Labels []string `json:"labels"` - IsOpen bool `json:"is_open"` Comments []IssueCommentData `json:"comments"` } func makeIssueData(issue *models.Issue) *issueData { return &issueData{ - ID: issue.Id, - RepoAt: issue.RepoAt.String(), - IssueID: issue.IssueId, - Title: issue.Title, - Body: issue.Body, - IsOpen: issue.Open, + ID: issue.Id, + RepoAt: issue.RepoAt.String(), + IssueID: issue.IssueId, + Title: issue.Title, + Body: issue.Body, + IsOpen: issue.Open, + AuthorDid: issue.Did, + Labels: issue.Labels.LabelNames(), } } @@ -222,21 +227,62 @@ func (ix *Indexer) Delete(ctx context.Context, issueId int64) error { return ix.indexer.Delete(base36.Encode(issueId)) } -// Search searches for issues func (ix *Indexer) Search(ctx context.Context, opts models.IssueSearchOptions) (*SearchResult, error) { - var queries []query.Query + var musts []query.Query + var mustNots []query.Query - if opts.Keyword != "" { - queries = append(queries, bleve.NewDisjunctionQuery( - bleveutil.MatchAndQuery("title", opts.Keyword, issueIndexerAnalyzer, 0), - bleveutil.MatchAndQuery("body", opts.Keyword, issueIndexerAnalyzer, 0), + for _, keyword := range opts.Keywords { + musts = append(musts, bleve.NewDisjunctionQuery( + bleveutil.MatchAndQuery("title", keyword, issueIndexerAnalyzer, 0), + bleveutil.MatchAndQuery("body", keyword, issueIndexerAnalyzer, 0), )) } - queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) - queries = append(queries, bleveutil.BoolFieldQuery("is_open", opts.IsOpen)) - // TODO: append more queries - var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) + for _, phrase := range opts.Phrases { + musts = append(musts, bleve.NewDisjunctionQuery( + bleveutil.MatchPhraseQuery("title", phrase, issueIndexerAnalyzer), + bleveutil.MatchPhraseQuery("body", phrase, issueIndexerAnalyzer), + )) + } + + for _, keyword := range opts.NegatedKeywords { + mustNots = append(mustNots, bleve.NewDisjunctionQuery( + bleveutil.MatchAndQuery("title", keyword, issueIndexerAnalyzer, 0), + bleveutil.MatchAndQuery("body", keyword, issueIndexerAnalyzer, 0), + )) + } + + for _, phrase := range opts.NegatedPhrases { + mustNots = append(mustNots, bleve.NewDisjunctionQuery( + bleveutil.MatchPhraseQuery("title", phrase, issueIndexerAnalyzer), + bleveutil.MatchPhraseQuery("body", phrase, issueIndexerAnalyzer), + )) + } + + musts = append(musts, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) + if opts.IsOpen != nil { + musts = append(musts, bleveutil.BoolFieldQuery("is_open", *opts.IsOpen)) + } + + if opts.AuthorDid != "" { + musts = append(musts, bleveutil.KeywordFieldQuery("author_did", opts.AuthorDid)) + } + + for _, label := range opts.Labels { + musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) + } + + if opts.NegatedAuthorDid != "" { + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", opts.NegatedAuthorDid)) + } + + for _, label := range opts.NegatedLabels { + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("labels", label)) + } + + indexerQuery := bleve.NewBooleanQuery() + indexerQuery.AddMust(musts...) + indexerQuery.AddMustNot(mustNots...) searchReq := bleve.NewSearchRequestOptions(indexerQuery, opts.Page.Limit, opts.Page.Offset, false) res, err := ix.indexer.SearchInContext(ctx, searchReq) if err != nil { diff --git a/appview/indexer/issues/indexer_test.go b/appview/indexer/issues/indexer_test.go new file mode 100644 index 00000000..da974e16 --- /dev/null +++ b/appview/indexer/issues/indexer_test.go @@ -0,0 +1,264 @@ +package issues_indexer + +import ( + "context" + "os" + "testing" + + "github.com/blevesearch/bleve/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pagination" + "tangled.org/core/appview/searchquery" +) + +func setupTestIndexer(t *testing.T) (*Indexer, func()) { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "issue_indexer_test") + require.NoError(t, err) + + ix := NewIndexer(tmpDir) + + mapping, err := generateIssueIndexMapping() + require.NoError(t, err) + + indexer, err := bleve.New(tmpDir, mapping) + require.NoError(t, err) + ix.indexer = indexer + + cleanup := func() { + ix.indexer.Close() + os.RemoveAll(tmpDir) + } + + return ix, cleanup +} + +func boolPtr(b bool) *bool { return &b } + +func makeLabelState(labels ...string) models.LabelState { + state := models.NewLabelState() + for _, label := range labels { + state.Inner()[label] = make(map[string]struct{}) + state.SetName(label, label) + } + return state +} + +func TestSearchFilters(t *testing.T) { + ix, cleanup := setupTestIndexer(t) + defer cleanup() + + ctx := context.Background() + + err := ix.Index(ctx, + models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix login bug", Body: "Users cannot login", Open: true, Did: "did:plc:alice", Labels: makeLabelState("bug")}, + models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Add dark mode", Body: "Implement dark theme", Open: true, Did: "did:plc:bob", Labels: makeLabelState("feature")}, + models.Issue{Id: 3, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix login timeout", Body: "Login takes too long", Open: false, Did: "did:plc:alice", Labels: makeLabelState("bug")}, + ) + require.NoError(t, err) + + opts := func() models.IssueSearchOptions { + return models.IssueSearchOptions{ + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", + IsOpen: boolPtr(true), + Page: pagination.Page{Limit: 10}, + } + } + + // Keyword in title + o := opts() + o.Keywords = []string{"bug"} + result, err := ix.Search(ctx, o) + require.NoError(t, err) + assert.Equal(t, uint64(1), result.Total) + assert.Contains(t, result.Hits, int64(1)) + + // Keyword in body + o = opts() + o.Keywords = []string{"theme"} + result, err = ix.Search(ctx, o) + require.NoError(t, err) + assert.Equal(t, uint64(1), result.Total) + assert.Contains(t, result.Hits, int64(2)) + + // Phrase match + o = opts() + o.Phrases = []string{"login bug"} + result, err = ix.Search(ctx, o) + require.NoError(t, err) + assert.Equal(t, uint64(1), result.Total) + assert.Contains(t, result.Hits, int64(1)) + + // Author filter + o = opts() + o.AuthorDid = "did:plc:alice" + result, err = ix.Search(ctx, o) + require.NoError(t, err) + assert.Equal(t, uint64(1), result.Total) + assert.Contains(t, result.Hits, int64(1)) + + // Label filter + o = opts() + o.Labels = []string{"bug"} + result, err = ix.Search(ctx, o) + require.NoError(t, err) + assert.Equal(t, uint64(1), result.Total) + assert.Contains(t, result.Hits, int64(1)) + + // State filter (closed) + o = opts() + o.IsOpen = boolPtr(false) + o.Labels = []string{"bug"} + result, err = ix.Search(ctx, o) + require.NoError(t, err) + assert.Equal(t, uint64(1), result.Total) + assert.Contains(t, result.Hits, int64(3)) + + // Combined: keyword + author + label + o = opts() + o.Keywords = []string{"login"} + o.AuthorDid = "did:plc:alice" + o.Labels = []string{"bug"} + result, err = ix.Search(ctx, o) + require.NoError(t, err) + assert.Equal(t, uint64(1), result.Total) + assert.Contains(t, result.Hits, int64(1)) +} + +func TestSearchLabelAND(t *testing.T) { + ix, cleanup := setupTestIndexer(t) + defer cleanup() + + ctx := context.Background() + + err := ix.Index(ctx, + models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Issue 1", Body: "Body", Open: true, Did: "did:plc:alice", Labels: makeLabelState("bug")}, + models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Issue 2", Body: "Body", Open: true, Did: "did:plc:bob", Labels: makeLabelState("bug", "urgent")}, + ) + require.NoError(t, err) + + result, err := ix.Search(ctx, models.IssueSearchOptions{ + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", + IsOpen: boolPtr(true), + Labels: []string{"bug", "urgent"}, + Page: pagination.Page{Limit: 10}, + }) + require.NoError(t, err) + assert.Equal(t, uint64(1), result.Total) + assert.Contains(t, result.Hits, int64(2)) +} + +func TestSearchNegation(t *testing.T) { + ix, cleanup := setupTestIndexer(t) + defer cleanup() + + ctx := context.Background() + + err := ix.Index(ctx, + models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix login bug", Body: "Users cannot login", Open: true, Did: "did:plc:alice", Labels: makeLabelState("bug")}, + models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Add dark mode", Body: "Implement dark theme", Open: true, Did: "did:plc:bob", Labels: makeLabelState("feature")}, + models.Issue{Id: 3, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix timeout bug", Body: "Timeout on save", Open: true, Did: "did:plc:alice", Labels: makeLabelState("bug", "urgent")}, + ) + require.NoError(t, err) + + opts := func() models.IssueSearchOptions { + return models.IssueSearchOptions{ + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", + IsOpen: boolPtr(true), + Page: pagination.Page{Limit: 10}, + } + } + + // Negated label: exclude "bug", should only return issue 2 + o := opts() + o.NegatedLabels = []string{"bug"} + result, err := ix.Search(ctx, o) + require.NoError(t, err) + assert.Equal(t, uint64(1), result.Total) + assert.Contains(t, result.Hits, int64(2)) + + // Negated keyword: exclude "login", should return issues 2 and 3 + o = opts() + o.NegatedKeywords = []string{"login"} + result, err = ix.Search(ctx, o) + require.NoError(t, err) + assert.Equal(t, uint64(2), result.Total) + assert.Contains(t, result.Hits, int64(2)) + assert.Contains(t, result.Hits, int64(3)) + + // Positive label + negated label: bug but not urgent + o = opts() + o.Labels = []string{"bug"} + o.NegatedLabels = []string{"urgent"} + result, err = ix.Search(ctx, o) + require.NoError(t, err) + assert.Equal(t, uint64(1), result.Total) + assert.Contains(t, result.Hits, int64(1)) + + // Negated phrase + o = opts() + o.NegatedPhrases = []string{"dark theme"} + result, err = ix.Search(ctx, o) + require.NoError(t, err) + assert.Equal(t, uint64(2), result.Total) + assert.NotContains(t, result.Hits, int64(2)) +} + +func TestSearchNegatedPhraseParsed(t *testing.T) { + ix, cleanup := setupTestIndexer(t) + defer cleanup() + + ctx := context.Background() + + err := ix.Index(ctx, + models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix login bug", Body: "Users cannot login", Open: true, Did: "did:plc:alice"}, + models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Add dark mode", Body: "Implement dark theme", Open: true, Did: "did:plc:bob"}, + models.Issue{Id: 3, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix timeout bug", Body: "Timeout on save", Open: true, Did: "did:plc:alice"}, + ) + require.NoError(t, err) + + // Parse a query with a negated quoted phrase, as the handler would + query := searchquery.Parse(`-"dark theme"`) + var negatedPhrases []string + for _, item := range query.Items() { + if item.Kind == searchquery.KindQuoted && item.Negated { + negatedPhrases = append(negatedPhrases, item.Value) + } + } + require.Equal(t, []string{"dark theme"}, negatedPhrases) + + result, err := ix.Search(ctx, models.IssueSearchOptions{ + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", + IsOpen: boolPtr(true), + NegatedPhrases: negatedPhrases, + Page: pagination.Page{Limit: 10}, + }) + require.NoError(t, err) + assert.Equal(t, uint64(2), result.Total) + assert.NotContains(t, result.Hits, int64(2)) +} + +func TestSearchNoResults(t *testing.T) { + ix, cleanup := setupTestIndexer(t) + defer cleanup() + + ctx := context.Background() + + err := ix.Index(ctx, + models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Issue", Body: "Body", Open: true, Did: "did:plc:alice"}, + ) + require.NoError(t, err) + + result, err := ix.Search(ctx, models.IssueSearchOptions{ + Keywords: []string{"nonexistent"}, + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", + IsOpen: boolPtr(true), + Page: pagination.Page{Limit: 10}, + }) + require.NoError(t, err) + assert.Equal(t, uint64(0), result.Total) + assert.Empty(t, result.Hits) +} diff --git a/appview/indexer/pulls/indexer.go b/appview/indexer/pulls/indexer.go index 438eda23..ba9bbeb7 100644 --- a/appview/indexer/pulls/indexer.go +++ b/appview/indexer/pulls/indexer.go @@ -79,6 +79,8 @@ func generatePullIndexMapping() (mapping.IndexMapping, error) { docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) docMapping.AddFieldMappingsAt("state", keywordFieldMapping) + docMapping.AddFieldMappingsAt("author_did", keywordFieldMapping) + docMapping.AddFieldMappingsAt("labels", keywordFieldMapping) err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ "type": unicodenorm.Name, @@ -163,26 +165,29 @@ func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error { return err } -// pullData data stored and will be indexed type pullData struct { - ID int64 `json:"id"` - RepoAt string `json:"repo_at"` - PullID int `json:"pull_id"` - Title string `json:"title"` - Body string `json:"body"` - State string `json:"state"` + ID int64 `json:"id"` + RepoAt string `json:"repo_at"` + PullID int `json:"pull_id"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + AuthorDid string `json:"author_did"` + Labels []string `json:"labels"` Comments []pullCommentData `json:"comments"` } func makePullData(pull *models.Pull) *pullData { return &pullData{ - ID: int64(pull.ID), - RepoAt: pull.RepoAt.String(), - PullID: pull.PullId, - Title: pull.Title, - Body: pull.Body, - State: pull.State.String(), + ID: int64(pull.ID), + RepoAt: pull.RepoAt.String(), + PullID: pull.PullId, + Title: pull.Title, + Body: pull.Body, + State: pull.State.String(), + AuthorDid: pull.OwnerDid, + Labels: pull.Labels.LabelNames(), } } @@ -217,9 +222,9 @@ func (ix *Indexer) Delete(ctx context.Context, pullID int64) error { return ix.indexer.Delete(base36.Encode(pullID)) } -// Search searches for pulls func (ix *Indexer) Search(ctx context.Context, opts models.PullSearchOptions) (*searchResult, error) { - var queries []query.Query + var musts []query.Query + var mustNots []query.Query // TODO(boltless): remove this after implementing pulls page pagination limit := opts.Page.Limit @@ -227,16 +232,58 @@ func (ix *Indexer) Search(ctx context.Context, opts models.PullSearchOptions) (* limit = 500 } - if opts.Keyword != "" { - queries = append(queries, bleve.NewDisjunctionQuery( - bleveutil.MatchAndQuery("title", opts.Keyword, pullIndexerAnalyzer, 0), - bleveutil.MatchAndQuery("body", opts.Keyword, pullIndexerAnalyzer, 0), + for _, keyword := range opts.Keywords { + musts = append(musts, bleve.NewDisjunctionQuery( + bleveutil.MatchAndQuery("title", keyword, pullIndexerAnalyzer, 0), + bleveutil.MatchAndQuery("body", keyword, pullIndexerAnalyzer, 0), )) } - queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) - queries = append(queries, bleveutil.KeywordFieldQuery("state", opts.State.String())) - var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) + for _, phrase := range opts.Phrases { + musts = append(musts, bleve.NewDisjunctionQuery( + bleveutil.MatchPhraseQuery("title", phrase, pullIndexerAnalyzer), + bleveutil.MatchPhraseQuery("body", phrase, pullIndexerAnalyzer), + )) + } + + for _, keyword := range opts.NegatedKeywords { + mustNots = append(mustNots, bleve.NewDisjunctionQuery( + bleveutil.MatchAndQuery("title", keyword, pullIndexerAnalyzer, 0), + bleveutil.MatchAndQuery("body", keyword, pullIndexerAnalyzer, 0), + )) + } + + for _, phrase := range opts.NegatedPhrases { + mustNots = append(mustNots, bleve.NewDisjunctionQuery( + bleveutil.MatchPhraseQuery("title", phrase, pullIndexerAnalyzer), + bleveutil.MatchPhraseQuery("body", phrase, pullIndexerAnalyzer), + )) + } + + musts = append(musts, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) + if opts.State != nil { + musts = append(musts, bleveutil.KeywordFieldQuery("state", opts.State.String())) + } + + if opts.AuthorDid != "" { + musts = append(musts, bleveutil.KeywordFieldQuery("author_did", opts.AuthorDid)) + } + + for _, label := range opts.Labels { + musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) + } + + if opts.NegatedAuthorDid != "" { + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", opts.NegatedAuthorDid)) + } + + for _, label := range opts.NegatedLabels { + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("labels", label)) + } + + indexerQuery := bleve.NewBooleanQuery() + indexerQuery.AddMust(musts...) + indexerQuery.AddMustNot(mustNots...) searchReq := bleve.NewSearchRequestOptions(indexerQuery, limit, opts.Page.Offset, false) res, err := ix.indexer.SearchInContext(ctx, searchReq) if err != nil { diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 555f73d3..3f394916 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -27,6 +27,7 @@ import ( "tangled.org/core/appview/pages/repoinfo" "tangled.org/core/appview/pagination" "tangled.org/core/appview/reporesolver" + "tangled.org/core/appview/searchquery" "tangled.org/core/appview/validator" "tangled.org/core/idresolver" "tangled.org/core/orm" @@ -793,17 +794,6 @@ func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "RepoIssues") params := r.URL.Query() - state := params.Get("state") - isOpen := true - switch state { - case "open": - isOpen = true - case "closed": - isOpen = false - default: - isOpen = true - } - page := pagination.FromContext(r.Context()) user := rp.oauth.GetMultiAccountUser(r) @@ -813,25 +803,98 @@ func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { return } + query := searchquery.Parse(params.Get("q")) + + var isOpen *bool + if urlState := params.Get("state"); urlState != "" { + switch urlState { + case "open": + isOpen = ptrBool(true) + case "closed": + isOpen = ptrBool(false) + } + query.Set("state", urlState) + } else if queryState := query.Get("state"); queryState != nil { + switch *queryState { + case "open": + isOpen = ptrBool(true) + case "closed": + isOpen = ptrBool(false) + } + } else if _, hasQ := params["q"]; !hasQ { + // no q param at all -- default to open + isOpen = ptrBool(true) + query.Set("state", "open") + } + + var authorDid string + if authorHandle := query.Get("author"); authorHandle != nil { + identity, err := rp.idResolver.ResolveIdent(r.Context(), *authorHandle) + if err != nil { + l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) + } else { + authorDid = identity.DID.String() + } + } + + var negatedAuthorDid string + if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 { + identity, err := rp.idResolver.ResolveIdent(r.Context(), negatedAuthors[0]) + if err != nil { + l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err) + } else { + negatedAuthorDid = identity.DID.String() + } + } + + labels := query.GetAll("label") + negatedLabels := query.GetAllNegated("label") + + var keywords, negatedKeywords []string + var phrases, negatedPhrases []string + for _, item := range query.Items() { + switch item.Kind { + case searchquery.KindKeyword: + if item.Negated { + negatedKeywords = append(negatedKeywords, item.Value) + } else { + keywords = append(keywords, item.Value) + } + case searchquery.KindQuoted: + if item.Negated { + negatedPhrases = append(negatedPhrases, item.Value) + } else { + phrases = append(phrases, item.Value) + } + } + } + + searchOpts := models.IssueSearchOptions{ + Keywords: keywords, + Phrases: phrases, + RepoAt: f.RepoAt().String(), + IsOpen: isOpen, + AuthorDid: authorDid, + Labels: labels, + NegatedKeywords: negatedKeywords, + NegatedPhrases: negatedPhrases, + NegatedLabels: negatedLabels, + NegatedAuthorDid: negatedAuthorDid, + Page: page, + } + totalIssues := 0 - if isOpen { + if isOpen == nil { + totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed + } else if *isOpen { totalIssues = f.RepoStats.IssueCount.Open } else { totalIssues = f.RepoStats.IssueCount.Closed } - keyword := params.Get("q") - - repoInfo := rp.repoResolver.GetRepoInfo(r, user) - var issues []models.Issue - searchOpts := models.IssueSearchOptions{ - Keyword: keyword, - RepoAt: f.RepoAt().String(), - IsOpen: isOpen, - Page: page, - } - if keyword != "" { + + if searchOpts.HasSearchFilters() { res, err := rp.indexer.Search(r.Context(), searchOpts) if err != nil { l.Error("failed to search for issues", "err", err) @@ -840,41 +903,32 @@ func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { l.Debug("searched issues with indexer", "count", len(res.Hits)) totalIssues = int(res.Total) - // count matching issues in the opposite state to display correct counts - countRes, err := rp.indexer.Search(r.Context(), models.IssueSearchOptions{ - Keyword: keyword, RepoAt: f.RepoAt().String(), IsOpen: !isOpen, - Page: pagination.Page{Limit: 1}, - }) - if err == nil { - if isOpen { - repoInfo.Stats.IssueCount.Open = int(res.Total) - repoInfo.Stats.IssueCount.Closed = int(countRes.Total) - } else { - repoInfo.Stats.IssueCount.Closed = int(res.Total) - repoInfo.Stats.IssueCount.Open = int(countRes.Total) + if len(res.Hits) > 0 { + issues, err = db.GetIssues( + rp.db, + orm.FilterIn("id", res.Hits), + ) + if err != nil { + l.Error("failed to get issues", "err", err) + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") + return } } - - issues, err = db.GetIssues( - rp.db, - orm.FilterIn("id", res.Hits), - ) - if err != nil { - l.Error("failed to get issues", "err", err) - rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") - return - } - } else { - openInt := 0 - if isOpen { - openInt = 1 + filters := []orm.Filter{ + orm.FilterEq("repo_at", f.RepoAt()), + } + if isOpen != nil { + openInt := 0 + if *isOpen { + openInt = 1 + } + filters = append(filters, orm.FilterEq("open", openInt)) } issues, err = db.GetIssuesPaginated( rp.db, page, - orm.FilterEq("repo_at", f.RepoAt()), - orm.FilterEq("open", openInt), + filters..., ) if err != nil { l.Error("failed to get issues", "err", err) @@ -899,18 +953,29 @@ func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { defs[l.AtUri().String()] = &l } + filterState := "" + if isOpen != nil { + if *isOpen { + filterState = "open" + } else { + filterState = "closed" + } + } + rp.pages.RepoIssues(w, pages.RepoIssuesParams{ - LoggedInUser: rp.oauth.GetMultiAccountUser(r), - RepoInfo: repoInfo, - Issues: issues, - IssueCount: totalIssues, - LabelDefs: defs, - FilteringByOpen: isOpen, - FilterQuery: keyword, - Page: page, + LoggedInUser: rp.oauth.GetMultiAccountUser(r), + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), + Issues: issues, + IssueCount: totalIssues, + LabelDefs: defs, + FilterState: filterState, + FilterQuery: query.String(), + Page: page, }) } +func ptrBool(b bool) *bool { return &b } + func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "NewIssue") user := rp.oauth.GetMultiAccountUser(r) diff --git a/appview/models/search.go b/appview/models/search.go index 207e526a..ea0d0d95 100644 --- a/appview/models/search.go +++ b/appview/models/search.go @@ -3,29 +3,47 @@ package models import "tangled.org/core/appview/pagination" type IssueSearchOptions struct { - Keyword string - RepoAt string - IsOpen bool + Keywords []string + Phrases []string + RepoAt string + IsOpen *bool + AuthorDid string + Labels []string + + NegatedKeywords []string + NegatedPhrases []string + NegatedLabels []string + NegatedAuthorDid string Page pagination.Page } +func (o *IssueSearchOptions) HasSearchFilters() bool { + return len(o.Keywords) > 0 || len(o.Phrases) > 0 || + o.AuthorDid != "" || o.NegatedAuthorDid != "" || + len(o.Labels) > 0 || len(o.NegatedLabels) > 0 || + len(o.NegatedKeywords) > 0 || len(o.NegatedPhrases) > 0 +} + type PullSearchOptions struct { - Keyword string - RepoAt string - State PullState + Keywords []string + Phrases []string + RepoAt string + State *PullState + AuthorDid string + Labels []string + + NegatedKeywords []string + NegatedPhrases []string + NegatedLabels []string + NegatedAuthorDid string Page pagination.Page } -// func (so *SearchOptions) ToFilters() []filter { -// var filters []filter -// if so.IsOpen != nil { -// openValue := 0 -// if *so.IsOpen { -// openValue = 1 -// } -// filters = append(filters, FilterEq("open", openValue)) -// } -// return filters -// } +func (o *PullSearchOptions) HasSearchFilters() bool { + return len(o.Keywords) > 0 || len(o.Phrases) > 0 || + o.AuthorDid != "" || o.NegatedAuthorDid != "" || + len(o.Labels) > 0 || len(o.NegatedLabels) > 0 || + len(o.NegatedKeywords) > 0 || len(o.NegatedPhrases) > 0 +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index ae259e17..82ad0b2a 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -979,15 +979,15 @@ func (p *Pages) RepoWebhooksSettings(w io.Writer, params RepoWebhooksSettingsPar } type RepoIssuesParams struct { - LoggedInUser *oauth.MultiAccountUser - RepoInfo repoinfo.RepoInfo - Active string - Issues []models.Issue - IssueCount int - LabelDefs map[string]*models.LabelDefinition - Page pagination.Page - FilteringByOpen bool - FilterQuery string + LoggedInUser *oauth.MultiAccountUser + RepoInfo repoinfo.RepoInfo + Active string + Issues []models.Issue + IssueCount int + LabelDefs map[string]*models.LabelDefinition + Page pagination.Page + FilterState string + FilterQuery string } func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { @@ -1117,7 +1117,7 @@ type RepoPullsParams struct { RepoInfo repoinfo.RepoInfo Pulls []*models.Pull Active string - FilteringBy models.PullState + FilterState string FilterQuery string Stacks map[string]models.Stack Pipelines map[string]models.Pipeline diff --git a/appview/pages/templates/fragments/tabSelector.html b/appview/pages/templates/fragments/tabSelector.html index 4b7bea01..889526ba 100644 --- a/appview/pages/templates/fragments/tabSelector.html +++ b/appview/pages/templates/fragments/tabSelector.html @@ -3,11 +3,33 @@ {{ $all := .Values }} {{ $active := .Active }} {{ $include := .Include }} + {{ $form := .Form }}
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} {{ range $index, $value := $all }} {{ $isActive := eq $value.Key $active }} + {{ if $form }} + + {{ else }} + {{ end }} {{ end }}
{{ end }} - diff --git a/appview/pages/templates/repo/issues/issues.html b/appview/pages/templates/repo/issues/issues.html index 4229b986..ad9ea64e 100644 --- a/appview/pages/templates/repo/issues/issues.html +++ b/appview/pages/templates/repo/issues/issues.html @@ -8,18 +8,15 @@ {{ end }} {{ define "repoContent" }} - {{ $active := "closed" }} - {{ if .FilteringByOpen }} - {{ $active = "open" }} - {{ end }} + {{ $active := .FilterState }} - {{ $open := + {{ $open := (dict "Key" "open" "Value" "open" "Icon" "circle-dot" "Meta" (string .RepoInfo.Stats.IssueCount.Open)) }} - {{ $closed := + {{ $closed := (dict "Key" "closed" "Value" "closed" @@ -28,8 +25,7 @@ {{ $values := list $open $closed }}
-
- +
{{if gt .IssueCount .Page.Limit }} - {{ $state := "closed" }} - {{ if .FilteringByOpen }} - {{ $state = "open" }} - {{ end }} - {{ template "fragments/pagination" (dict - "Page" .Page - "TotalCount" .IssueCount + {{ template "fragments/pagination" (dict + "Page" .Page + "TotalCount" .IssueCount "BasePath" (printf "/%s/issues" .RepoInfo.FullName) - "QueryParams" (queryParams "state" $state "q" .FilterQuery) + "QueryParams" (queryParams "q" .FilterQuery) ) }} {{ end }} {{ end }} diff --git a/appview/pages/templates/repo/pulls/pulls.html b/appview/pages/templates/repo/pulls/pulls.html index 4eb2b0ae..5dd16db1 100644 --- a/appview/pages/templates/repo/pulls/pulls.html +++ b/appview/pages/templates/repo/pulls/pulls.html @@ -8,25 +8,20 @@ {{ end }} {{ define "repoContent" }} - {{ $active := "closed" }} - {{ if .FilteringBy.IsOpen }} - {{ $active = "open" }} - {{ else if .FilteringBy.IsMerged }} - {{ $active = "merged" }} - {{ end }} - {{ $open := + {{ $active := .FilterState }} + {{ $open := (dict "Key" "open" "Value" "open" "Icon" "git-pull-request" "Meta" (string .RepoInfo.Stats.PullCount.Open)) }} - {{ $merged := + {{ $merged := (dict "Key" "merged" "Value" "merged" "Icon" "git-merge" "Meta" (string .RepoInfo.Stats.PullCount.Merged)) }} - {{ $closed := + {{ $closed := (dict "Key" "closed" "Value" "closed" @@ -34,8 +29,7 @@ "Meta" (string .RepoInfo.Stats.PullCount.Closed)) }} {{ $values := list $open $merged $closed }}
-
- +
{{if gt .PullCount .Page.Limit }} - {{ template "fragments/pagination" (dict - "Page" .Page - "TotalCount" .PullCount + {{ template "fragments/pagination" (dict + "Page" .Page + "TotalCount" .PullCount "BasePath" (printf "/%s/pulls" .RepoInfo.FullName) - "QueryParams" (queryParams "state" .FilteringBy.String "q" .FilterQuery) + "QueryParams" (queryParams "q" .FilterQuery) ) }} {{ end }} {{ end }} diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index a27115ef..37fcd677 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -31,6 +31,7 @@ import ( "tangled.org/core/appview/pages/repoinfo" "tangled.org/core/appview/pagination" "tangled.org/core/appview/reporesolver" + "tangled.org/core/appview/searchquery" "tangled.org/core/appview/validator" "tangled.org/core/appview/xrpcclient" "tangled.org/core/idresolver" @@ -524,15 +525,6 @@ func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetMultiAccountUser(r) params := r.URL.Query() - - state := models.PullOpen - switch params.Get("state") { - case "closed": - state = models.PullClosed - case "merged": - state = models.PullMerged - } - page := pagination.FromContext(r.Context()) f, err := s.repoResolver.Resolve(r) @@ -541,29 +533,108 @@ func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { return } - var totalPulls int - switch state { - case models.PullOpen: - totalPulls = f.RepoStats.PullCount.Open - case models.PullMerged: - totalPulls = f.RepoStats.PullCount.Merged - case models.PullClosed: - totalPulls = f.RepoStats.PullCount.Closed + query := searchquery.Parse(params.Get("q")) + + var state *models.PullState + if urlState := params.Get("state"); urlState != "" { + switch urlState { + case "open": + state = ptrPullState(models.PullOpen) + case "closed": + state = ptrPullState(models.PullClosed) + case "merged": + state = ptrPullState(models.PullMerged) + } + query.Set("state", urlState) + } else if queryState := query.Get("state"); queryState != nil { + switch *queryState { + case "open": + state = ptrPullState(models.PullOpen) + case "closed": + state = ptrPullState(models.PullClosed) + case "merged": + state = ptrPullState(models.PullMerged) + } + } else if _, hasQ := params["q"]; !hasQ { + state = ptrPullState(models.PullOpen) + query.Set("state", "open") } - keyword := params.Get("q") + var authorDid string + if authorHandle := query.Get("author"); authorHandle != nil { + identity, err := s.idResolver.ResolveIdent(r.Context(), *authorHandle) + if err != nil { + l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) + } else { + authorDid = identity.DID.String() + } + } - repoInfo := s.repoResolver.GetRepoInfo(r, user) + var negatedAuthorDid string + if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 { + identity, err := s.idResolver.ResolveIdent(r.Context(), negatedAuthors[0]) + if err != nil { + l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err) + } else { + negatedAuthorDid = identity.DID.String() + } + } + + labels := query.GetAll("label") + negatedLabels := query.GetAllNegated("label") + + var keywords, negatedKeywords []string + var phrases, negatedPhrases []string + for _, item := range query.Items() { + switch item.Kind { + case searchquery.KindKeyword: + if item.Negated { + negatedKeywords = append(negatedKeywords, item.Value) + } else { + keywords = append(keywords, item.Value) + } + case searchquery.KindQuoted: + if item.Negated { + negatedPhrases = append(negatedPhrases, item.Value) + } else { + phrases = append(phrases, item.Value) + } + } + } - var pulls []*models.Pull searchOpts := models.PullSearchOptions{ - Keyword: keyword, - RepoAt: f.RepoAt().String(), - State: state, - Page: page, + Keywords: keywords, + Phrases: phrases, + RepoAt: f.RepoAt().String(), + State: state, + AuthorDid: authorDid, + Labels: labels, + NegatedKeywords: negatedKeywords, + NegatedPhrases: negatedPhrases, + NegatedLabels: negatedLabels, + NegatedAuthorDid: negatedAuthorDid, + Page: page, + } + + var totalPulls int + if state == nil { + totalPulls = f.RepoStats.PullCount.Open + f.RepoStats.PullCount.Merged + f.RepoStats.PullCount.Closed + } else { + switch *state { + case models.PullOpen: + totalPulls = f.RepoStats.PullCount.Open + case models.PullMerged: + totalPulls = f.RepoStats.PullCount.Merged + case models.PullClosed: + totalPulls = f.RepoStats.PullCount.Closed + } } - l.Debug("searching with", "searchOpts", searchOpts) - if keyword != "" { + + repoInfo := s.repoResolver.GetRepoInfo(r, user) + + var pulls []*models.Pull + + if searchOpts.HasSearchFilters() { res, err := s.indexer.Search(r.Context(), searchOpts) if err != nil { l.Error("failed to search for pulls", "err", err) @@ -572,54 +643,31 @@ func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { totalPulls = int(res.Total) l.Debug("searched pulls with indexer", "count", len(res.Hits)) - // count matching pulls in the other states to display correct counts - for _, other := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} { - if other == state { - continue - } - countRes, err := s.indexer.Search(r.Context(), models.PullSearchOptions{ - Keyword: keyword, RepoAt: f.RepoAt().String(), State: other, - Page: pagination.Page{Limit: 1}, - }) + if len(res.Hits) > 0 { + pulls, err = db.GetPulls( + s.db, + orm.FilterIn("id", res.Hits), + ) if err != nil { - continue - } - switch other { - case models.PullOpen: - repoInfo.Stats.PullCount.Open = int(countRes.Total) - case models.PullMerged: - repoInfo.Stats.PullCount.Merged = int(countRes.Total) - case models.PullClosed: - repoInfo.Stats.PullCount.Closed = int(countRes.Total) + l.Error("failed to get pulls", "err", err) + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") + return } } - switch state { - case models.PullOpen: - repoInfo.Stats.PullCount.Open = int(res.Total) - case models.PullMerged: - repoInfo.Stats.PullCount.Merged = int(res.Total) - case models.PullClosed: - repoInfo.Stats.PullCount.Closed = int(res.Total) + } else { + filters := []orm.Filter{ + orm.FilterEq("repo_at", f.RepoAt()), } - - pulls, err = db.GetPulls( - s.db, - orm.FilterIn("id", res.Hits), - ) - if err != nil { - log.Println("failed to get pulls", err) - s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") - return + if state != nil { + filters = append(filters, orm.FilterEq("state", *state)) } - } else { pulls, err = db.GetPullsPaginated( s.db, page, - orm.FilterEq("repo_at", f.RepoAt()), - orm.FilterEq("state", searchOpts.State), + filters..., ) if err != nil { - log.Println("failed to get pulls", err) + l.Error("failed to get pulls", "err", err) s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") return } @@ -688,7 +736,7 @@ func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { orm.FilterContains("scope", tangled.RepoPullNSID), ) if err != nil { - log.Println("failed to fetch labels", err) + l.Error("failed to fetch labels", "err", err) s.pages.Error503(w) return } @@ -698,13 +746,18 @@ func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { defs[l.AtUri().String()] = &l } + filterState := "" + if state != nil { + filterState = state.String() + } + s.pages.RepoPulls(w, pages.RepoPullsParams{ LoggedInUser: s.oauth.GetMultiAccountUser(r), RepoInfo: repoInfo, Pulls: pulls, LabelDefs: defs, - FilteringBy: state, - FilterQuery: keyword, + FilterState: filterState, + FilterQuery: query.String(), Stacks: stacks, Pipelines: m, Page: page, @@ -2485,3 +2538,5 @@ func gz(s string) io.Reader { w.Close() return &b } + +func ptrPullState(s models.PullState) *models.PullState { return &s } -- 2.51.2