Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2

appview: add search filters for issues and pulls

Signed-off-by: Thomas Karpiniec <tkarpiniec@icloud.com>

authored by octet-stream.net and committed by tangled.org 1f5feacc 67c0e9bc

+745 -235
+7
appview/indexer/bleve/query.go
··· 13 13 return q 14 14 } 15 15 16 + func MatchPhraseQuery(field, phrase, analyzer string) query.Query { 17 + q := bleve.NewMatchPhraseQuery(phrase) 18 + q.FieldVal = field 19 + q.Analyzer = analyzer 20 + return q 21 + } 22 + 16 23 func BoolFieldQuery(field string, val bool) query.Query { 17 24 q := bleve.NewBoolFieldQuery(val) 18 25 q.FieldVal = field
+69 -23
appview/indexer/issues/indexer.go
··· 84 84 85 85 docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 86 86 docMapping.AddFieldMappingsAt("is_open", boolFieldMapping) 87 + docMapping.AddFieldMappingsAt("author_did", keywordFieldMapping) 88 + docMapping.AddFieldMappingsAt("labels", keywordFieldMapping) 87 89 88 90 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 89 91 "type": unicodenorm.Name, ··· 170 168 return err 171 169 } 172 170 173 - // issueData data stored and will be indexed 174 171 type issueData struct { 175 - ID int64 `json:"id"` 176 - RepoAt string `json:"repo_at"` 177 - IssueID int `json:"issue_id"` 178 - Title string `json:"title"` 179 - Body string `json:"body"` 172 + ID int64 `json:"id"` 173 + RepoAt string `json:"repo_at"` 174 + IssueID int `json:"issue_id"` 175 + Title string `json:"title"` 176 + Body string `json:"body"` 177 + IsOpen bool `json:"is_open"` 178 + AuthorDid string `json:"author_did"` 179 + Labels []string `json:"labels"` 180 180 181 - IsOpen bool `json:"is_open"` 182 181 Comments []IssueCommentData `json:"comments"` 183 182 } 184 183 185 184 func makeIssueData(issue *models.Issue) *issueData { 186 185 return &issueData{ 187 - ID: issue.Id, 188 - RepoAt: issue.RepoAt.String(), 189 - IssueID: issue.IssueId, 190 - Title: issue.Title, 191 - Body: issue.Body, 192 - IsOpen: issue.Open, 186 + ID: issue.Id, 187 + RepoAt: issue.RepoAt.String(), 188 + IssueID: issue.IssueId, 189 + Title: issue.Title, 190 + Body: issue.Body, 191 + IsOpen: issue.Open, 192 + AuthorDid: issue.Did, 193 + Labels: issue.Labels.LabelNames(), 193 194 } 194 195 } 195 196 ··· 227 222 return ix.indexer.Delete(base36.Encode(issueId)) 228 223 } 229 224 230 - // Search searches for issues 231 225 func (ix *Indexer) Search(ctx context.Context, opts models.IssueSearchOptions) (*SearchResult, error) { 232 - var queries []query.Query 226 + var musts []query.Query 227 + var mustNots []query.Query 233 228 234 - if opts.Keyword != "" { 235 - queries = append(queries, bleve.NewDisjunctionQuery( 236 - bleveutil.MatchAndQuery("title", opts.Keyword, issueIndexerAnalyzer, 0), 237 - bleveutil.MatchAndQuery("body", opts.Keyword, issueIndexerAnalyzer, 0), 229 + for _, keyword := range opts.Keywords { 230 + musts = append(musts, bleve.NewDisjunctionQuery( 231 + bleveutil.MatchAndQuery("title", keyword, issueIndexerAnalyzer, 0), 232 + bleveutil.MatchAndQuery("body", keyword, issueIndexerAnalyzer, 0), 238 233 )) 239 234 } 240 - queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 241 - queries = append(queries, bleveutil.BoolFieldQuery("is_open", opts.IsOpen)) 242 - // TODO: append more queries 243 235 244 - var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) 236 + for _, phrase := range opts.Phrases { 237 + musts = append(musts, bleve.NewDisjunctionQuery( 238 + bleveutil.MatchPhraseQuery("title", phrase, issueIndexerAnalyzer), 239 + bleveutil.MatchPhraseQuery("body", phrase, issueIndexerAnalyzer), 240 + )) 241 + } 242 + 243 + for _, keyword := range opts.NegatedKeywords { 244 + mustNots = append(mustNots, bleve.NewDisjunctionQuery( 245 + bleveutil.MatchAndQuery("title", keyword, issueIndexerAnalyzer, 0), 246 + bleveutil.MatchAndQuery("body", keyword, issueIndexerAnalyzer, 0), 247 + )) 248 + } 249 + 250 + for _, phrase := range opts.NegatedPhrases { 251 + mustNots = append(mustNots, bleve.NewDisjunctionQuery( 252 + bleveutil.MatchPhraseQuery("title", phrase, issueIndexerAnalyzer), 253 + bleveutil.MatchPhraseQuery("body", phrase, issueIndexerAnalyzer), 254 + )) 255 + } 256 + 257 + musts = append(musts, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 258 + if opts.IsOpen != nil { 259 + musts = append(musts, bleveutil.BoolFieldQuery("is_open", *opts.IsOpen)) 260 + } 261 + 262 + if opts.AuthorDid != "" { 263 + musts = append(musts, bleveutil.KeywordFieldQuery("author_did", opts.AuthorDid)) 264 + } 265 + 266 + for _, label := range opts.Labels { 267 + musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) 268 + } 269 + 270 + if opts.NegatedAuthorDid != "" { 271 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", opts.NegatedAuthorDid)) 272 + } 273 + 274 + for _, label := range opts.NegatedLabels { 275 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("labels", label)) 276 + } 277 + 278 + indexerQuery := bleve.NewBooleanQuery() 279 + indexerQuery.AddMust(musts...) 280 + indexerQuery.AddMustNot(mustNots...) 245 281 searchReq := bleve.NewSearchRequestOptions(indexerQuery, opts.Page.Limit, opts.Page.Offset, false) 246 282 res, err := ix.indexer.SearchInContext(ctx, searchReq) 247 283 if err != nil {
+264
appview/indexer/issues/indexer_test.go
··· 1 + package issues_indexer 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "testing" 7 + 8 + "github.com/blevesearch/bleve/v2" 9 + "github.com/stretchr/testify/assert" 10 + "github.com/stretchr/testify/require" 11 + "tangled.org/core/appview/models" 12 + "tangled.org/core/appview/pagination" 13 + "tangled.org/core/appview/searchquery" 14 + ) 15 + 16 + func setupTestIndexer(t *testing.T) (*Indexer, func()) { 17 + t.Helper() 18 + 19 + tmpDir, err := os.MkdirTemp("", "issue_indexer_test") 20 + require.NoError(t, err) 21 + 22 + ix := NewIndexer(tmpDir) 23 + 24 + mapping, err := generateIssueIndexMapping() 25 + require.NoError(t, err) 26 + 27 + indexer, err := bleve.New(tmpDir, mapping) 28 + require.NoError(t, err) 29 + ix.indexer = indexer 30 + 31 + cleanup := func() { 32 + ix.indexer.Close() 33 + os.RemoveAll(tmpDir) 34 + } 35 + 36 + return ix, cleanup 37 + } 38 + 39 + func boolPtr(b bool) *bool { return &b } 40 + 41 + func makeLabelState(labels ...string) models.LabelState { 42 + state := models.NewLabelState() 43 + for _, label := range labels { 44 + state.Inner()[label] = make(map[string]struct{}) 45 + state.SetName(label, label) 46 + } 47 + return state 48 + } 49 + 50 + func TestSearchFilters(t *testing.T) { 51 + ix, cleanup := setupTestIndexer(t) 52 + defer cleanup() 53 + 54 + ctx := context.Background() 55 + 56 + err := ix.Index(ctx, 57 + 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")}, 58 + 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")}, 59 + 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")}, 60 + ) 61 + require.NoError(t, err) 62 + 63 + opts := func() models.IssueSearchOptions { 64 + return models.IssueSearchOptions{ 65 + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", 66 + IsOpen: boolPtr(true), 67 + Page: pagination.Page{Limit: 10}, 68 + } 69 + } 70 + 71 + // Keyword in title 72 + o := opts() 73 + o.Keywords = []string{"bug"} 74 + result, err := ix.Search(ctx, o) 75 + require.NoError(t, err) 76 + assert.Equal(t, uint64(1), result.Total) 77 + assert.Contains(t, result.Hits, int64(1)) 78 + 79 + // Keyword in body 80 + o = opts() 81 + o.Keywords = []string{"theme"} 82 + result, err = ix.Search(ctx, o) 83 + require.NoError(t, err) 84 + assert.Equal(t, uint64(1), result.Total) 85 + assert.Contains(t, result.Hits, int64(2)) 86 + 87 + // Phrase match 88 + o = opts() 89 + o.Phrases = []string{"login bug"} 90 + result, err = ix.Search(ctx, o) 91 + require.NoError(t, err) 92 + assert.Equal(t, uint64(1), result.Total) 93 + assert.Contains(t, result.Hits, int64(1)) 94 + 95 + // Author filter 96 + o = opts() 97 + o.AuthorDid = "did:plc:alice" 98 + result, err = ix.Search(ctx, o) 99 + require.NoError(t, err) 100 + assert.Equal(t, uint64(1), result.Total) 101 + assert.Contains(t, result.Hits, int64(1)) 102 + 103 + // Label filter 104 + o = opts() 105 + o.Labels = []string{"bug"} 106 + result, err = ix.Search(ctx, o) 107 + require.NoError(t, err) 108 + assert.Equal(t, uint64(1), result.Total) 109 + assert.Contains(t, result.Hits, int64(1)) 110 + 111 + // State filter (closed) 112 + o = opts() 113 + o.IsOpen = boolPtr(false) 114 + o.Labels = []string{"bug"} 115 + result, err = ix.Search(ctx, o) 116 + require.NoError(t, err) 117 + assert.Equal(t, uint64(1), result.Total) 118 + assert.Contains(t, result.Hits, int64(3)) 119 + 120 + // Combined: keyword + author + label 121 + o = opts() 122 + o.Keywords = []string{"login"} 123 + o.AuthorDid = "did:plc:alice" 124 + o.Labels = []string{"bug"} 125 + result, err = ix.Search(ctx, o) 126 + require.NoError(t, err) 127 + assert.Equal(t, uint64(1), result.Total) 128 + assert.Contains(t, result.Hits, int64(1)) 129 + } 130 + 131 + func TestSearchLabelAND(t *testing.T) { 132 + ix, cleanup := setupTestIndexer(t) 133 + defer cleanup() 134 + 135 + ctx := context.Background() 136 + 137 + err := ix.Index(ctx, 138 + 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")}, 139 + 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")}, 140 + ) 141 + require.NoError(t, err) 142 + 143 + result, err := ix.Search(ctx, models.IssueSearchOptions{ 144 + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", 145 + IsOpen: boolPtr(true), 146 + Labels: []string{"bug", "urgent"}, 147 + Page: pagination.Page{Limit: 10}, 148 + }) 149 + require.NoError(t, err) 150 + assert.Equal(t, uint64(1), result.Total) 151 + assert.Contains(t, result.Hits, int64(2)) 152 + } 153 + 154 + func TestSearchNegation(t *testing.T) { 155 + ix, cleanup := setupTestIndexer(t) 156 + defer cleanup() 157 + 158 + ctx := context.Background() 159 + 160 + err := ix.Index(ctx, 161 + 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")}, 162 + 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")}, 163 + 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")}, 164 + ) 165 + require.NoError(t, err) 166 + 167 + opts := func() models.IssueSearchOptions { 168 + return models.IssueSearchOptions{ 169 + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", 170 + IsOpen: boolPtr(true), 171 + Page: pagination.Page{Limit: 10}, 172 + } 173 + } 174 + 175 + // Negated label: exclude "bug", should only return issue 2 176 + o := opts() 177 + o.NegatedLabels = []string{"bug"} 178 + result, err := ix.Search(ctx, o) 179 + require.NoError(t, err) 180 + assert.Equal(t, uint64(1), result.Total) 181 + assert.Contains(t, result.Hits, int64(2)) 182 + 183 + // Negated keyword: exclude "login", should return issues 2 and 3 184 + o = opts() 185 + o.NegatedKeywords = []string{"login"} 186 + result, err = ix.Search(ctx, o) 187 + require.NoError(t, err) 188 + assert.Equal(t, uint64(2), result.Total) 189 + assert.Contains(t, result.Hits, int64(2)) 190 + assert.Contains(t, result.Hits, int64(3)) 191 + 192 + // Positive label + negated label: bug but not urgent 193 + o = opts() 194 + o.Labels = []string{"bug"} 195 + o.NegatedLabels = []string{"urgent"} 196 + result, err = ix.Search(ctx, o) 197 + require.NoError(t, err) 198 + assert.Equal(t, uint64(1), result.Total) 199 + assert.Contains(t, result.Hits, int64(1)) 200 + 201 + // Negated phrase 202 + o = opts() 203 + o.NegatedPhrases = []string{"dark theme"} 204 + result, err = ix.Search(ctx, o) 205 + require.NoError(t, err) 206 + assert.Equal(t, uint64(2), result.Total) 207 + assert.NotContains(t, result.Hits, int64(2)) 208 + } 209 + 210 + func TestSearchNegatedPhraseParsed(t *testing.T) { 211 + ix, cleanup := setupTestIndexer(t) 212 + defer cleanup() 213 + 214 + ctx := context.Background() 215 + 216 + err := ix.Index(ctx, 217 + 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"}, 218 + 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"}, 219 + 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"}, 220 + ) 221 + require.NoError(t, err) 222 + 223 + // Parse a query with a negated quoted phrase, as the handler would 224 + query := searchquery.Parse(`-"dark theme"`) 225 + var negatedPhrases []string 226 + for _, item := range query.Items() { 227 + if item.Kind == searchquery.KindQuoted && item.Negated { 228 + negatedPhrases = append(negatedPhrases, item.Value) 229 + } 230 + } 231 + require.Equal(t, []string{"dark theme"}, negatedPhrases) 232 + 233 + result, err := ix.Search(ctx, models.IssueSearchOptions{ 234 + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", 235 + IsOpen: boolPtr(true), 236 + NegatedPhrases: negatedPhrases, 237 + Page: pagination.Page{Limit: 10}, 238 + }) 239 + require.NoError(t, err) 240 + assert.Equal(t, uint64(2), result.Total) 241 + assert.NotContains(t, result.Hits, int64(2)) 242 + } 243 + 244 + func TestSearchNoResults(t *testing.T) { 245 + ix, cleanup := setupTestIndexer(t) 246 + defer cleanup() 247 + 248 + ctx := context.Background() 249 + 250 + err := ix.Index(ctx, 251 + models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Issue", Body: "Body", Open: true, Did: "did:plc:alice"}, 252 + ) 253 + require.NoError(t, err) 254 + 255 + result, err := ix.Search(ctx, models.IssueSearchOptions{ 256 + Keywords: []string{"nonexistent"}, 257 + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", 258 + IsOpen: boolPtr(true), 259 + Page: pagination.Page{Limit: 10}, 260 + }) 261 + require.NoError(t, err) 262 + assert.Equal(t, uint64(0), result.Total) 263 + assert.Empty(t, result.Hits) 264 + }
+69 -22
appview/indexer/pulls/indexer.go
··· 79 79 80 80 docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 81 81 docMapping.AddFieldMappingsAt("state", keywordFieldMapping) 82 + docMapping.AddFieldMappingsAt("author_did", keywordFieldMapping) 83 + docMapping.AddFieldMappingsAt("labels", keywordFieldMapping) 82 84 83 85 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 84 86 "type": unicodenorm.Name, ··· 165 163 return err 166 164 } 167 165 168 - // pullData data stored and will be indexed 169 166 type pullData struct { 170 - ID int64 `json:"id"` 171 - RepoAt string `json:"repo_at"` 172 - PullID int `json:"pull_id"` 173 - Title string `json:"title"` 174 - Body string `json:"body"` 175 - State string `json:"state"` 167 + ID int64 `json:"id"` 168 + RepoAt string `json:"repo_at"` 169 + PullID int `json:"pull_id"` 170 + Title string `json:"title"` 171 + Body string `json:"body"` 172 + State string `json:"state"` 173 + AuthorDid string `json:"author_did"` 174 + Labels []string `json:"labels"` 176 175 177 176 Comments []pullCommentData `json:"comments"` 178 177 } 179 178 180 179 func makePullData(pull *models.Pull) *pullData { 181 180 return &pullData{ 182 - ID: int64(pull.ID), 183 - RepoAt: pull.RepoAt.String(), 184 - PullID: pull.PullId, 185 - Title: pull.Title, 186 - Body: pull.Body, 187 - State: pull.State.String(), 181 + ID: int64(pull.ID), 182 + RepoAt: pull.RepoAt.String(), 183 + PullID: pull.PullId, 184 + Title: pull.Title, 185 + Body: pull.Body, 186 + State: pull.State.String(), 187 + AuthorDid: pull.OwnerDid, 188 + Labels: pull.Labels.LabelNames(), 188 189 } 189 190 } 190 191 ··· 222 217 return ix.indexer.Delete(base36.Encode(pullID)) 223 218 } 224 219 225 - // Search searches for pulls 226 220 func (ix *Indexer) Search(ctx context.Context, opts models.PullSearchOptions) (*searchResult, error) { 227 - var queries []query.Query 221 + var musts []query.Query 222 + var mustNots []query.Query 228 223 229 224 // TODO(boltless): remove this after implementing pulls page pagination 230 225 limit := opts.Page.Limit ··· 232 227 limit = 500 233 228 } 234 229 235 - if opts.Keyword != "" { 236 - queries = append(queries, bleve.NewDisjunctionQuery( 237 - bleveutil.MatchAndQuery("title", opts.Keyword, pullIndexerAnalyzer, 0), 238 - bleveutil.MatchAndQuery("body", opts.Keyword, pullIndexerAnalyzer, 0), 230 + for _, keyword := range opts.Keywords { 231 + musts = append(musts, bleve.NewDisjunctionQuery( 232 + bleveutil.MatchAndQuery("title", keyword, pullIndexerAnalyzer, 0), 233 + bleveutil.MatchAndQuery("body", keyword, pullIndexerAnalyzer, 0), 239 234 )) 240 235 } 241 - queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 242 - queries = append(queries, bleveutil.KeywordFieldQuery("state", opts.State.String())) 243 236 244 - var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) 237 + for _, phrase := range opts.Phrases { 238 + musts = append(musts, bleve.NewDisjunctionQuery( 239 + bleveutil.MatchPhraseQuery("title", phrase, pullIndexerAnalyzer), 240 + bleveutil.MatchPhraseQuery("body", phrase, pullIndexerAnalyzer), 241 + )) 242 + } 243 + 244 + for _, keyword := range opts.NegatedKeywords { 245 + mustNots = append(mustNots, bleve.NewDisjunctionQuery( 246 + bleveutil.MatchAndQuery("title", keyword, pullIndexerAnalyzer, 0), 247 + bleveutil.MatchAndQuery("body", keyword, pullIndexerAnalyzer, 0), 248 + )) 249 + } 250 + 251 + for _, phrase := range opts.NegatedPhrases { 252 + mustNots = append(mustNots, bleve.NewDisjunctionQuery( 253 + bleveutil.MatchPhraseQuery("title", phrase, pullIndexerAnalyzer), 254 + bleveutil.MatchPhraseQuery("body", phrase, pullIndexerAnalyzer), 255 + )) 256 + } 257 + 258 + musts = append(musts, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 259 + if opts.State != nil { 260 + musts = append(musts, bleveutil.KeywordFieldQuery("state", opts.State.String())) 261 + } 262 + 263 + if opts.AuthorDid != "" { 264 + musts = append(musts, bleveutil.KeywordFieldQuery("author_did", opts.AuthorDid)) 265 + } 266 + 267 + for _, label := range opts.Labels { 268 + musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) 269 + } 270 + 271 + if opts.NegatedAuthorDid != "" { 272 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", opts.NegatedAuthorDid)) 273 + } 274 + 275 + for _, label := range opts.NegatedLabels { 276 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("labels", label)) 277 + } 278 + 279 + indexerQuery := bleve.NewBooleanQuery() 280 + indexerQuery.AddMust(musts...) 281 + indexerQuery.AddMustNot(mustNots...) 245 282 searchReq := bleve.NewSearchRequestOptions(indexerQuery, limit, opts.Page.Offset, false) 246 283 res, err := ix.indexer.SearchInContext(ctx, searchReq) 247 284 if err != nil {
+124 -59
appview/issues/issues.go
··· 27 27 "tangled.org/core/appview/pages/repoinfo" 28 28 "tangled.org/core/appview/pagination" 29 29 "tangled.org/core/appview/reporesolver" 30 + "tangled.org/core/appview/searchquery" 30 31 "tangled.org/core/appview/validator" 31 32 "tangled.org/core/idresolver" 32 33 "tangled.org/core/orm" ··· 794 793 l := rp.logger.With("handler", "RepoIssues") 795 794 796 795 params := r.URL.Query() 797 - state := params.Get("state") 798 - isOpen := true 799 - switch state { 800 - case "open": 801 - isOpen = true 802 - case "closed": 803 - isOpen = false 804 - default: 805 - isOpen = true 806 - } 807 - 808 796 page := pagination.FromContext(r.Context()) 809 797 810 798 user := rp.oauth.GetMultiAccountUser(r) ··· 803 813 return 804 814 } 805 815 816 + query := searchquery.Parse(params.Get("q")) 817 + 818 + var isOpen *bool 819 + if urlState := params.Get("state"); urlState != "" { 820 + switch urlState { 821 + case "open": 822 + isOpen = ptrBool(true) 823 + case "closed": 824 + isOpen = ptrBool(false) 825 + } 826 + query.Set("state", urlState) 827 + } else if queryState := query.Get("state"); queryState != nil { 828 + switch *queryState { 829 + case "open": 830 + isOpen = ptrBool(true) 831 + case "closed": 832 + isOpen = ptrBool(false) 833 + } 834 + } else if _, hasQ := params["q"]; !hasQ { 835 + // no q param at all -- default to open 836 + isOpen = ptrBool(true) 837 + query.Set("state", "open") 838 + } 839 + 840 + var authorDid string 841 + if authorHandle := query.Get("author"); authorHandle != nil { 842 + identity, err := rp.idResolver.ResolveIdent(r.Context(), *authorHandle) 843 + if err != nil { 844 + l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 845 + } else { 846 + authorDid = identity.DID.String() 847 + } 848 + } 849 + 850 + var negatedAuthorDid string 851 + if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 { 852 + identity, err := rp.idResolver.ResolveIdent(r.Context(), negatedAuthors[0]) 853 + if err != nil { 854 + l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err) 855 + } else { 856 + negatedAuthorDid = identity.DID.String() 857 + } 858 + } 859 + 860 + labels := query.GetAll("label") 861 + negatedLabels := query.GetAllNegated("label") 862 + 863 + var keywords, negatedKeywords []string 864 + var phrases, negatedPhrases []string 865 + for _, item := range query.Items() { 866 + switch item.Kind { 867 + case searchquery.KindKeyword: 868 + if item.Negated { 869 + negatedKeywords = append(negatedKeywords, item.Value) 870 + } else { 871 + keywords = append(keywords, item.Value) 872 + } 873 + case searchquery.KindQuoted: 874 + if item.Negated { 875 + negatedPhrases = append(negatedPhrases, item.Value) 876 + } else { 877 + phrases = append(phrases, item.Value) 878 + } 879 + } 880 + } 881 + 882 + searchOpts := models.IssueSearchOptions{ 883 + Keywords: keywords, 884 + Phrases: phrases, 885 + RepoAt: f.RepoAt().String(), 886 + IsOpen: isOpen, 887 + AuthorDid: authorDid, 888 + Labels: labels, 889 + NegatedKeywords: negatedKeywords, 890 + NegatedPhrases: negatedPhrases, 891 + NegatedLabels: negatedLabels, 892 + NegatedAuthorDid: negatedAuthorDid, 893 + Page: page, 894 + } 895 + 806 896 totalIssues := 0 807 - if isOpen { 897 + if isOpen == nil { 898 + totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed 899 + } else if *isOpen { 808 900 totalIssues = f.RepoStats.IssueCount.Open 809 901 } else { 810 902 totalIssues = f.RepoStats.IssueCount.Closed 811 903 } 812 904 813 - keyword := params.Get("q") 814 - 815 - repoInfo := rp.repoResolver.GetRepoInfo(r, user) 816 - 817 905 var issues []models.Issue 818 - searchOpts := models.IssueSearchOptions{ 819 - Keyword: keyword, 820 - RepoAt: f.RepoAt().String(), 821 - IsOpen: isOpen, 822 - Page: page, 823 - } 824 - if keyword != "" { 906 + 907 + if searchOpts.HasSearchFilters() { 825 908 res, err := rp.indexer.Search(r.Context(), searchOpts) 826 909 if err != nil { 827 910 l.Error("failed to search for issues", "err", err) ··· 903 840 l.Debug("searched issues with indexer", "count", len(res.Hits)) 904 841 totalIssues = int(res.Total) 905 842 906 - // count matching issues in the opposite state to display correct counts 907 - countRes, err := rp.indexer.Search(r.Context(), models.IssueSearchOptions{ 908 - Keyword: keyword, RepoAt: f.RepoAt().String(), IsOpen: !isOpen, 909 - Page: pagination.Page{Limit: 1}, 910 - }) 911 - if err == nil { 912 - if isOpen { 913 - repoInfo.Stats.IssueCount.Open = int(res.Total) 914 - repoInfo.Stats.IssueCount.Closed = int(countRes.Total) 915 - } else { 916 - repoInfo.Stats.IssueCount.Closed = int(res.Total) 917 - repoInfo.Stats.IssueCount.Open = int(countRes.Total) 843 + if len(res.Hits) > 0 { 844 + issues, err = db.GetIssues( 845 + rp.db, 846 + orm.FilterIn("id", res.Hits), 847 + ) 848 + if err != nil { 849 + l.Error("failed to get issues", "err", err) 850 + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 851 + return 918 852 } 919 853 } 920 - 921 - issues, err = db.GetIssues( 922 - rp.db, 923 - orm.FilterIn("id", res.Hits), 924 - ) 925 - if err != nil { 926 - l.Error("failed to get issues", "err", err) 927 - rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 928 - return 929 - } 930 - 931 854 } else { 932 - openInt := 0 933 - if isOpen { 934 - openInt = 1 855 + filters := []orm.Filter{ 856 + orm.FilterEq("repo_at", f.RepoAt()), 857 + } 858 + if isOpen != nil { 859 + openInt := 0 860 + if *isOpen { 861 + openInt = 1 862 + } 863 + filters = append(filters, orm.FilterEq("open", openInt)) 935 864 } 936 865 issues, err = db.GetIssuesPaginated( 937 866 rp.db, 938 867 page, 939 - orm.FilterEq("repo_at", f.RepoAt()), 940 - orm.FilterEq("open", openInt), 868 + filters..., 941 869 ) 942 870 if err != nil { 943 871 l.Error("failed to get issues", "err", err) ··· 953 899 defs[l.AtUri().String()] = &l 954 900 } 955 901 902 + filterState := "" 903 + if isOpen != nil { 904 + if *isOpen { 905 + filterState = "open" 906 + } else { 907 + filterState = "closed" 908 + } 909 + } 910 + 956 911 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 957 - LoggedInUser: rp.oauth.GetMultiAccountUser(r), 958 - RepoInfo: repoInfo, 959 - Issues: issues, 960 - IssueCount: totalIssues, 961 - LabelDefs: defs, 962 - FilteringByOpen: isOpen, 963 - FilterQuery: keyword, 964 - Page: page, 912 + LoggedInUser: rp.oauth.GetMultiAccountUser(r), 913 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 914 + Issues: issues, 915 + IssueCount: totalIssues, 916 + LabelDefs: defs, 917 + FilterState: filterState, 918 + FilterQuery: query.String(), 919 + Page: page, 965 920 }) 966 921 } 922 + 923 + func ptrBool(b bool) *bool { return &b } 967 924 968 925 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 969 926 l := rp.logger.With("handler", "NewIssue")
+35 -17
appview/models/search.go
··· 3 3 import "tangled.org/core/appview/pagination" 4 4 5 5 type IssueSearchOptions struct { 6 - Keyword string 7 - RepoAt string 8 - IsOpen bool 6 + Keywords []string 7 + Phrases []string 8 + RepoAt string 9 + IsOpen *bool 10 + AuthorDid string 11 + Labels []string 12 + 13 + NegatedKeywords []string 14 + NegatedPhrases []string 15 + NegatedLabels []string 16 + NegatedAuthorDid string 9 17 10 18 Page pagination.Page 19 + } 20 + 21 + func (o *IssueSearchOptions) HasSearchFilters() bool { 22 + return len(o.Keywords) > 0 || len(o.Phrases) > 0 || 23 + o.AuthorDid != "" || o.NegatedAuthorDid != "" || 24 + len(o.Labels) > 0 || len(o.NegatedLabels) > 0 || 25 + len(o.NegatedKeywords) > 0 || len(o.NegatedPhrases) > 0 11 26 } 12 27 13 28 type PullSearchOptions struct { 14 - Keyword string 15 - RepoAt string 16 - State PullState 29 + Keywords []string 30 + Phrases []string 31 + RepoAt string 32 + State *PullState 33 + AuthorDid string 34 + Labels []string 35 + 36 + NegatedKeywords []string 37 + NegatedPhrases []string 38 + NegatedLabels []string 39 + NegatedAuthorDid string 17 40 18 41 Page pagination.Page 19 42 } 20 43 21 - // func (so *SearchOptions) ToFilters() []filter { 22 - // var filters []filter 23 - // if so.IsOpen != nil { 24 - // openValue := 0 25 - // if *so.IsOpen { 26 - // openValue = 1 27 - // } 28 - // filters = append(filters, FilterEq("open", openValue)) 29 - // } 30 - // return filters 31 - // } 44 + func (o *PullSearchOptions) HasSearchFilters() bool { 45 + return len(o.Keywords) > 0 || len(o.Phrases) > 0 || 46 + o.AuthorDid != "" || o.NegatedAuthorDid != "" || 47 + len(o.Labels) > 0 || len(o.NegatedLabels) > 0 || 48 + len(o.NegatedKeywords) > 0 || len(o.NegatedPhrases) > 0 49 + }
+10 -10
appview/pages/pages.go
··· 979 979 } 980 980 981 981 type RepoIssuesParams struct { 982 - LoggedInUser *oauth.MultiAccountUser 983 - RepoInfo repoinfo.RepoInfo 984 - Active string 985 - Issues []models.Issue 986 - IssueCount int 987 - LabelDefs map[string]*models.LabelDefinition 988 - Page pagination.Page 989 - FilteringByOpen bool 990 - FilterQuery string 982 + LoggedInUser *oauth.MultiAccountUser 983 + RepoInfo repoinfo.RepoInfo 984 + Active string 985 + Issues []models.Issue 986 + IssueCount int 987 + LabelDefs map[string]*models.LabelDefinition 988 + Page pagination.Page 989 + FilterState string 990 + FilterQuery string 991 991 } 992 992 993 993 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 1117 1117 RepoInfo repoinfo.RepoInfo 1118 1118 Pulls []*models.Pull 1119 1119 Active string 1120 - FilteringBy models.PullState 1120 + FilterState string 1121 1121 FilterQuery string 1122 1122 Stacks map[string]models.Stack 1123 1123 Pipelines map[string]models.Pipeline
+23 -1
appview/pages/templates/fragments/tabSelector.html
··· 3 3 {{ $all := .Values }} 4 4 {{ $active := .Active }} 5 5 {{ $include := .Include }} 6 + {{ $form := .Form }} 6 7 <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 7 8 {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 8 9 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 9 10 {{ range $index, $value := $all }} 10 11 {{ $isActive := eq $value.Key $active }} 12 + {{ if $form }} 13 + <button type="submit" 14 + form="{{ $form }}" 15 + name="{{ $name }}" value="{{ $value.Key }}" 16 + hx-get="?{{ $name }}={{ $value.Key }}" 17 + hx-include="{{ $include }}" 18 + hx-push-url="true" 19 + hx-target="body" 20 + hx-on:htmx:config-request="if(!event.detail.parameters.q) delete event.detail.parameters.q" 21 + class="p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 22 + {{ if $value.Icon }} 23 + {{ i $value.Icon "size-4" }} 24 + {{ end }} 25 + 26 + {{ with $value.Meta }} 27 + {{ . }} 28 + {{ end }} 29 + 30 + {{ $value.Value }} 31 + </button> 32 + {{ else }} 11 33 <a href="?{{ $name }}={{ $value.Key }}" 12 34 {{ if $include }} 13 35 hx-get="?{{ $name }}={{ $value.Key }}" ··· 49 27 50 28 {{ $value.Value }} 51 29 </a> 30 + {{ end }} 52 31 {{ end }} 53 32 </div> 54 33 {{ end }} 55 -
+10 -18
appview/pages/templates/repo/issues/issues.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - {{ $active := "closed" }} 12 - {{ if .FilteringByOpen }} 13 - {{ $active = "open" }} 14 - {{ end }} 11 + {{ $active := .FilterState }} 15 12 16 - {{ $open := 13 + {{ $open := 17 14 (dict 18 15 "Key" "open" 19 16 "Value" "open" 20 17 "Icon" "circle-dot" 21 18 "Meta" (string .RepoInfo.Stats.IssueCount.Open)) }} 22 - {{ $closed := 19 + {{ $closed := 23 20 (dict 24 21 "Key" "closed" 25 22 "Value" "closed" ··· 25 28 {{ $values := list $open $closed }} 26 29 27 30 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 28 - <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 29 - <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 31 + <form id="search-form" class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 30 32 <div class="flex-1 flex relative"> 31 33 <input 32 34 id="search-q" ··· 36 40 placeholder="search issues..." 37 41 > 38 42 <a 39 - href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}" 43 + {{ if $active }}href="?q=state:{{ $active }}"{{ else }}href="?"{{ end }} 40 44 class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block" 41 45 > 42 46 {{ i "x" "w-4 h-4" }} ··· 50 54 </button> 51 55 </form> 52 56 <div class="sm:row-start-1"> 53 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }} 57 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q" "Form" "search-form") }} 54 58 </div> 55 59 <a 56 60 href="/{{ .RepoInfo.FullName }}/issues/new" ··· 68 72 {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 69 73 </div> 70 74 {{if gt .IssueCount .Page.Limit }} 71 - {{ $state := "closed" }} 72 - {{ if .FilteringByOpen }} 73 - {{ $state = "open" }} 74 - {{ end }} 75 - {{ template "fragments/pagination" (dict 76 - "Page" .Page 77 - "TotalCount" .IssueCount 75 + {{ template "fragments/pagination" (dict 76 + "Page" .Page 77 + "TotalCount" .IssueCount 78 78 "BasePath" (printf "/%s/issues" .RepoInfo.FullName) 79 - "QueryParams" (queryParams "state" $state "q" .FilterQuery) 79 + "QueryParams" (queryParams "q" .FilterQuery) 80 80 ) }} 81 81 {{ end }} 82 82 {{ end }}
+11 -17
appview/pages/templates/repo/pulls/pulls.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - {{ $active := "closed" }} 12 - {{ if .FilteringBy.IsOpen }} 13 - {{ $active = "open" }} 14 - {{ else if .FilteringBy.IsMerged }} 15 - {{ $active = "merged" }} 16 - {{ end }} 17 - {{ $open := 11 + {{ $active := .FilterState }} 12 + {{ $open := 18 13 (dict 19 14 "Key" "open" 20 15 "Value" "open" 21 16 "Icon" "git-pull-request" 22 17 "Meta" (string .RepoInfo.Stats.PullCount.Open)) }} 23 - {{ $merged := 18 + {{ $merged := 24 19 (dict 25 20 "Key" "merged" 26 21 "Value" "merged" 27 22 "Icon" "git-merge" 28 23 "Meta" (string .RepoInfo.Stats.PullCount.Merged)) }} 29 - {{ $closed := 24 + {{ $closed := 30 25 (dict 31 26 "Key" "closed" 32 27 "Value" "closed" ··· 29 34 "Meta" (string .RepoInfo.Stats.PullCount.Closed)) }} 30 35 {{ $values := list $open $merged $closed }} 31 36 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 32 - <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 33 - <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 37 + <form id="search-form" class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 34 38 <div class="flex-1 flex relative"> 35 39 <input 36 40 id="search-q" ··· 40 46 placeholder="search pulls..." 41 47 > 42 48 <a 43 - href="?state={{ .FilteringBy.String }}" 49 + {{ if $active }}href="?q=state:{{ $active }}"{{ else }}href="?"{{ end }} 44 50 class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block" 45 51 > 46 52 {{ i "x" "w-4 h-4" }} ··· 54 60 </button> 55 61 </form> 56 62 <div class="sm:row-start-1"> 57 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }} 63 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q" "Form" "search-form") }} 58 64 </div> 59 65 <a 60 66 href="/{{ .RepoInfo.FullName }}/pulls/new" ··· 156 162 {{ end }} 157 163 </div> 158 164 {{if gt .PullCount .Page.Limit }} 159 - {{ template "fragments/pagination" (dict 160 - "Page" .Page 161 - "TotalCount" .PullCount 165 + {{ template "fragments/pagination" (dict 166 + "Page" .Page 167 + "TotalCount" .PullCount 162 168 "BasePath" (printf "/%s/pulls" .RepoInfo.FullName) 163 - "QueryParams" (queryParams "state" .FilteringBy.String "q" .FilterQuery) 169 + "QueryParams" (queryParams "q" .FilterQuery) 164 170 ) }} 165 171 {{ end }} 166 172 {{ end }}
+123 -68
appview/pulls/pulls.go
··· 31 31 "tangled.org/core/appview/pages/repoinfo" 32 32 "tangled.org/core/appview/pagination" 33 33 "tangled.org/core/appview/reporesolver" 34 + "tangled.org/core/appview/searchquery" 34 35 "tangled.org/core/appview/validator" 35 36 "tangled.org/core/appview/xrpcclient" 36 37 "tangled.org/core/idresolver" ··· 525 524 526 525 user := s.oauth.GetMultiAccountUser(r) 527 526 params := r.URL.Query() 528 - 529 - state := models.PullOpen 530 - switch params.Get("state") { 531 - case "closed": 532 - state = models.PullClosed 533 - case "merged": 534 - state = models.PullMerged 535 - } 536 - 537 527 page := pagination.FromContext(r.Context()) 538 528 539 529 f, err := s.repoResolver.Resolve(r) ··· 533 541 return 534 542 } 535 543 536 - var totalPulls int 537 - switch state { 538 - case models.PullOpen: 539 - totalPulls = f.RepoStats.PullCount.Open 540 - case models.PullMerged: 541 - totalPulls = f.RepoStats.PullCount.Merged 542 - case models.PullClosed: 543 - totalPulls = f.RepoStats.PullCount.Closed 544 + query := searchquery.Parse(params.Get("q")) 545 + 546 + var state *models.PullState 547 + if urlState := params.Get("state"); urlState != "" { 548 + switch urlState { 549 + case "open": 550 + state = ptrPullState(models.PullOpen) 551 + case "closed": 552 + state = ptrPullState(models.PullClosed) 553 + case "merged": 554 + state = ptrPullState(models.PullMerged) 555 + } 556 + query.Set("state", urlState) 557 + } else if queryState := query.Get("state"); queryState != nil { 558 + switch *queryState { 559 + case "open": 560 + state = ptrPullState(models.PullOpen) 561 + case "closed": 562 + state = ptrPullState(models.PullClosed) 563 + case "merged": 564 + state = ptrPullState(models.PullMerged) 565 + } 566 + } else if _, hasQ := params["q"]; !hasQ { 567 + state = ptrPullState(models.PullOpen) 568 + query.Set("state", "open") 544 569 } 545 570 546 - keyword := params.Get("q") 571 + var authorDid string 572 + if authorHandle := query.Get("author"); authorHandle != nil { 573 + identity, err := s.idResolver.ResolveIdent(r.Context(), *authorHandle) 574 + if err != nil { 575 + l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 576 + } else { 577 + authorDid = identity.DID.String() 578 + } 579 + } 580 + 581 + var negatedAuthorDid string 582 + if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 { 583 + identity, err := s.idResolver.ResolveIdent(r.Context(), negatedAuthors[0]) 584 + if err != nil { 585 + l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err) 586 + } else { 587 + negatedAuthorDid = identity.DID.String() 588 + } 589 + } 590 + 591 + labels := query.GetAll("label") 592 + negatedLabels := query.GetAllNegated("label") 593 + 594 + var keywords, negatedKeywords []string 595 + var phrases, negatedPhrases []string 596 + for _, item := range query.Items() { 597 + switch item.Kind { 598 + case searchquery.KindKeyword: 599 + if item.Negated { 600 + negatedKeywords = append(negatedKeywords, item.Value) 601 + } else { 602 + keywords = append(keywords, item.Value) 603 + } 604 + case searchquery.KindQuoted: 605 + if item.Negated { 606 + negatedPhrases = append(negatedPhrases, item.Value) 607 + } else { 608 + phrases = append(phrases, item.Value) 609 + } 610 + } 611 + } 612 + 613 + searchOpts := models.PullSearchOptions{ 614 + Keywords: keywords, 615 + Phrases: phrases, 616 + RepoAt: f.RepoAt().String(), 617 + State: state, 618 + AuthorDid: authorDid, 619 + Labels: labels, 620 + NegatedKeywords: negatedKeywords, 621 + NegatedPhrases: negatedPhrases, 622 + NegatedLabels: negatedLabels, 623 + NegatedAuthorDid: negatedAuthorDid, 624 + Page: page, 625 + } 626 + 627 + var totalPulls int 628 + if state == nil { 629 + totalPulls = f.RepoStats.PullCount.Open + f.RepoStats.PullCount.Merged + f.RepoStats.PullCount.Closed 630 + } else { 631 + switch *state { 632 + case models.PullOpen: 633 + totalPulls = f.RepoStats.PullCount.Open 634 + case models.PullMerged: 635 + totalPulls = f.RepoStats.PullCount.Merged 636 + case models.PullClosed: 637 + totalPulls = f.RepoStats.PullCount.Closed 638 + } 639 + } 547 640 548 641 repoInfo := s.repoResolver.GetRepoInfo(r, user) 549 642 550 643 var pulls []*models.Pull 551 - searchOpts := models.PullSearchOptions{ 552 - Keyword: keyword, 553 - RepoAt: f.RepoAt().String(), 554 - State: state, 555 - Page: page, 556 - } 557 - l.Debug("searching with", "searchOpts", searchOpts) 558 - if keyword != "" { 644 + 645 + if searchOpts.HasSearchFilters() { 559 646 res, err := s.indexer.Search(r.Context(), searchOpts) 560 647 if err != nil { 561 648 l.Error("failed to search for pulls", "err", err) ··· 643 572 totalPulls = int(res.Total) 644 573 l.Debug("searched pulls with indexer", "count", len(res.Hits)) 645 574 646 - // count matching pulls in the other states to display correct counts 647 - for _, other := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} { 648 - if other == state { 649 - continue 650 - } 651 - countRes, err := s.indexer.Search(r.Context(), models.PullSearchOptions{ 652 - Keyword: keyword, RepoAt: f.RepoAt().String(), State: other, 653 - Page: pagination.Page{Limit: 1}, 654 - }) 575 + if len(res.Hits) > 0 { 576 + pulls, err = db.GetPulls( 577 + s.db, 578 + orm.FilterIn("id", res.Hits), 579 + ) 655 580 if err != nil { 656 - continue 581 + l.Error("failed to get pulls", "err", err) 582 + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 583 + return 657 584 } 658 - switch other { 659 - case models.PullOpen: 660 - repoInfo.Stats.PullCount.Open = int(countRes.Total) 661 - case models.PullMerged: 662 - repoInfo.Stats.PullCount.Merged = int(countRes.Total) 663 - case models.PullClosed: 664 - repoInfo.Stats.PullCount.Closed = int(countRes.Total) 665 - } 666 - } 667 - switch state { 668 - case models.PullOpen: 669 - repoInfo.Stats.PullCount.Open = int(res.Total) 670 - case models.PullMerged: 671 - repoInfo.Stats.PullCount.Merged = int(res.Total) 672 - case models.PullClosed: 673 - repoInfo.Stats.PullCount.Closed = int(res.Total) 674 - } 675 - 676 - pulls, err = db.GetPulls( 677 - s.db, 678 - orm.FilterIn("id", res.Hits), 679 - ) 680 - if err != nil { 681 - log.Println("failed to get pulls", err) 682 - s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 683 - return 684 585 } 685 586 } else { 587 + filters := []orm.Filter{ 588 + orm.FilterEq("repo_at", f.RepoAt()), 589 + } 590 + if state != nil { 591 + filters = append(filters, orm.FilterEq("state", *state)) 592 + } 686 593 pulls, err = db.GetPullsPaginated( 687 594 s.db, 688 595 page, 689 - orm.FilterEq("repo_at", f.RepoAt()), 690 - orm.FilterEq("state", searchOpts.State), 596 + filters..., 691 597 ) 692 598 if err != nil { 693 - log.Println("failed to get pulls", err) 599 + l.Error("failed to get pulls", "err", err) 694 600 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 695 601 return 696 602 } ··· 736 688 orm.FilterContains("scope", tangled.RepoPullNSID), 737 689 ) 738 690 if err != nil { 739 - log.Println("failed to fetch labels", err) 691 + l.Error("failed to fetch labels", "err", err) 740 692 s.pages.Error503(w) 741 693 return 742 694 } ··· 746 698 defs[l.AtUri().String()] = &l 747 699 } 748 700 701 + filterState := "" 702 + if state != nil { 703 + filterState = state.String() 704 + } 705 + 749 706 s.pages.RepoPulls(w, pages.RepoPullsParams{ 750 707 LoggedInUser: s.oauth.GetMultiAccountUser(r), 751 708 RepoInfo: repoInfo, 752 709 Pulls: pulls, 753 710 LabelDefs: defs, 754 - FilteringBy: state, 755 - FilterQuery: keyword, 711 + FilterState: filterState, 712 + FilterQuery: query.String(), 756 713 Stacks: stacks, 757 714 Pipelines: m, 758 715 Page: page, ··· 2538 2485 w.Close() 2539 2486 return &b 2540 2487 } 2488 + 2489 + func ptrPullState(s models.PullState) *models.PullState { return &s }