Monorepo for Tangled

appview: add search filters for issues and pulls

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

authored by

Thomas Karpiniec and committed by tangled.org 1f5feacc 67c0e9bc

+744 -234
+7
appview/indexer/bleve/query.go
··· 13 return q 14 } 15 16 func BoolFieldQuery(field string, val bool) query.Query { 17 q := bleve.NewBoolFieldQuery(val) 18 q.FieldVal = field
··· 13 return q 14 } 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 + 23 func BoolFieldQuery(field string, val bool) query.Query { 24 q := bleve.NewBoolFieldQuery(val) 25 q.FieldVal = field
+69 -23
appview/indexer/issues/indexer.go
··· 84 85 docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 86 docMapping.AddFieldMappingsAt("is_open", boolFieldMapping) 87 88 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 89 "type": unicodenorm.Name, ··· 168 return err 169 } 170 171 - // issueData data stored and will be indexed 172 type issueData struct { 173 - ID int64 `json:"id"` 174 - RepoAt string `json:"repo_at"` 175 - IssueID int `json:"issue_id"` 176 - Title string `json:"title"` 177 - Body string `json:"body"` 178 179 - IsOpen bool `json:"is_open"` 180 Comments []IssueCommentData `json:"comments"` 181 } 182 183 func makeIssueData(issue *models.Issue) *issueData { 184 return &issueData{ 185 - ID: issue.Id, 186 - RepoAt: issue.RepoAt.String(), 187 - IssueID: issue.IssueId, 188 - Title: issue.Title, 189 - Body: issue.Body, 190 - IsOpen: issue.Open, 191 } 192 } 193 ··· 222 return ix.indexer.Delete(base36.Encode(issueId)) 223 } 224 225 - // Search searches for issues 226 func (ix *Indexer) Search(ctx context.Context, opts models.IssueSearchOptions) (*SearchResult, error) { 227 - var queries []query.Query 228 229 - if opts.Keyword != "" { 230 - queries = append(queries, bleve.NewDisjunctionQuery( 231 - bleveutil.MatchAndQuery("title", opts.Keyword, issueIndexerAnalyzer, 0), 232 - bleveutil.MatchAndQuery("body", opts.Keyword, issueIndexerAnalyzer, 0), 233 )) 234 } 235 - queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 236 - queries = append(queries, bleveutil.BoolFieldQuery("is_open", opts.IsOpen)) 237 - // TODO: append more queries 238 239 - var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) 240 searchReq := bleve.NewSearchRequestOptions(indexerQuery, opts.Page.Limit, opts.Page.Offset, false) 241 res, err := ix.indexer.SearchInContext(ctx, searchReq) 242 if err != nil {
··· 84 85 docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 86 docMapping.AddFieldMappingsAt("is_open", boolFieldMapping) 87 + docMapping.AddFieldMappingsAt("author_did", keywordFieldMapping) 88 + docMapping.AddFieldMappingsAt("labels", keywordFieldMapping) 89 90 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 91 "type": unicodenorm.Name, ··· 170 return err 171 } 172 173 type issueData struct { 174 + ID int64 `json:"id"` 175 + RepoAt string `json:"repo_at"` 176 + IssueID int `json:"issue_id"` 177 + Title string `json:"title"` 178 + Body string `json:"body"` 179 + IsOpen bool `json:"is_open"` 180 + AuthorDid string `json:"author_did"` 181 + Labels []string `json:"labels"` 182 183 Comments []IssueCommentData `json:"comments"` 184 } 185 186 func makeIssueData(issue *models.Issue) *issueData { 187 return &issueData{ 188 + ID: issue.Id, 189 + RepoAt: issue.RepoAt.String(), 190 + IssueID: issue.IssueId, 191 + Title: issue.Title, 192 + Body: issue.Body, 193 + IsOpen: issue.Open, 194 + AuthorDid: issue.Did, 195 + Labels: issue.Labels.LabelNames(), 196 } 197 } 198 ··· 227 return ix.indexer.Delete(base36.Encode(issueId)) 228 } 229 230 func (ix *Indexer) Search(ctx context.Context, opts models.IssueSearchOptions) (*SearchResult, error) { 231 + var musts []query.Query 232 + var mustNots []query.Query 233 234 + for _, keyword := range opts.Keywords { 235 + musts = append(musts, bleve.NewDisjunctionQuery( 236 + bleveutil.MatchAndQuery("title", keyword, issueIndexerAnalyzer, 0), 237 + bleveutil.MatchAndQuery("body", keyword, issueIndexerAnalyzer, 0), 238 )) 239 } 240 241 + for _, phrase := range opts.Phrases { 242 + musts = append(musts, bleve.NewDisjunctionQuery( 243 + bleveutil.MatchPhraseQuery("title", phrase, issueIndexerAnalyzer), 244 + bleveutil.MatchPhraseQuery("body", phrase, issueIndexerAnalyzer), 245 + )) 246 + } 247 + 248 + for _, keyword := range opts.NegatedKeywords { 249 + mustNots = append(mustNots, bleve.NewDisjunctionQuery( 250 + bleveutil.MatchAndQuery("title", keyword, issueIndexerAnalyzer, 0), 251 + bleveutil.MatchAndQuery("body", keyword, issueIndexerAnalyzer, 0), 252 + )) 253 + } 254 + 255 + for _, phrase := range opts.NegatedPhrases { 256 + mustNots = append(mustNots, bleve.NewDisjunctionQuery( 257 + bleveutil.MatchPhraseQuery("title", phrase, issueIndexerAnalyzer), 258 + bleveutil.MatchPhraseQuery("body", phrase, issueIndexerAnalyzer), 259 + )) 260 + } 261 + 262 + musts = append(musts, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 263 + if opts.IsOpen != nil { 264 + musts = append(musts, bleveutil.BoolFieldQuery("is_open", *opts.IsOpen)) 265 + } 266 + 267 + if opts.AuthorDid != "" { 268 + musts = append(musts, bleveutil.KeywordFieldQuery("author_did", opts.AuthorDid)) 269 + } 270 + 271 + for _, label := range opts.Labels { 272 + musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) 273 + } 274 + 275 + if opts.NegatedAuthorDid != "" { 276 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", opts.NegatedAuthorDid)) 277 + } 278 + 279 + for _, label := range opts.NegatedLabels { 280 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("labels", label)) 281 + } 282 + 283 + indexerQuery := bleve.NewBooleanQuery() 284 + indexerQuery.AddMust(musts...) 285 + indexerQuery.AddMustNot(mustNots...) 286 searchReq := bleve.NewSearchRequestOptions(indexerQuery, opts.Page.Limit, opts.Page.Offset, false) 287 res, err := ix.indexer.SearchInContext(ctx, searchReq) 288 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 80 docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 81 docMapping.AddFieldMappingsAt("state", keywordFieldMapping) 82 83 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 84 "type": unicodenorm.Name, ··· 163 return err 164 } 165 166 - // pullData data stored and will be indexed 167 type pullData struct { 168 - ID int64 `json:"id"` 169 - RepoAt string `json:"repo_at"` 170 - PullID int `json:"pull_id"` 171 - Title string `json:"title"` 172 - Body string `json:"body"` 173 - State string `json:"state"` 174 175 Comments []pullCommentData `json:"comments"` 176 } 177 178 func makePullData(pull *models.Pull) *pullData { 179 return &pullData{ 180 - ID: int64(pull.ID), 181 - RepoAt: pull.RepoAt.String(), 182 - PullID: pull.PullId, 183 - Title: pull.Title, 184 - Body: pull.Body, 185 - State: pull.State.String(), 186 } 187 } 188 ··· 217 return ix.indexer.Delete(base36.Encode(pullID)) 218 } 219 220 - // Search searches for pulls 221 func (ix *Indexer) Search(ctx context.Context, opts models.PullSearchOptions) (*searchResult, error) { 222 - var queries []query.Query 223 224 // TODO(boltless): remove this after implementing pulls page pagination 225 limit := opts.Page.Limit ··· 227 limit = 500 228 } 229 230 - if opts.Keyword != "" { 231 - queries = append(queries, bleve.NewDisjunctionQuery( 232 - bleveutil.MatchAndQuery("title", opts.Keyword, pullIndexerAnalyzer, 0), 233 - bleveutil.MatchAndQuery("body", opts.Keyword, pullIndexerAnalyzer, 0), 234 )) 235 } 236 - queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 237 - queries = append(queries, bleveutil.KeywordFieldQuery("state", opts.State.String())) 238 239 - var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) 240 searchReq := bleve.NewSearchRequestOptions(indexerQuery, limit, opts.Page.Offset, false) 241 res, err := ix.indexer.SearchInContext(ctx, searchReq) 242 if err != nil {
··· 79 80 docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 81 docMapping.AddFieldMappingsAt("state", keywordFieldMapping) 82 + docMapping.AddFieldMappingsAt("author_did", keywordFieldMapping) 83 + docMapping.AddFieldMappingsAt("labels", keywordFieldMapping) 84 85 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 86 "type": unicodenorm.Name, ··· 165 return err 166 } 167 168 type pullData struct { 169 + ID int64 `json:"id"` 170 + RepoAt string `json:"repo_at"` 171 + PullID int `json:"pull_id"` 172 + Title string `json:"title"` 173 + Body string `json:"body"` 174 + State string `json:"state"` 175 + AuthorDid string `json:"author_did"` 176 + Labels []string `json:"labels"` 177 178 Comments []pullCommentData `json:"comments"` 179 } 180 181 func makePullData(pull *models.Pull) *pullData { 182 return &pullData{ 183 + ID: int64(pull.ID), 184 + RepoAt: pull.RepoAt.String(), 185 + PullID: pull.PullId, 186 + Title: pull.Title, 187 + Body: pull.Body, 188 + State: pull.State.String(), 189 + AuthorDid: pull.OwnerDid, 190 + Labels: pull.Labels.LabelNames(), 191 } 192 } 193 ··· 222 return ix.indexer.Delete(base36.Encode(pullID)) 223 } 224 225 func (ix *Indexer) Search(ctx context.Context, opts models.PullSearchOptions) (*searchResult, error) { 226 + var musts []query.Query 227 + var mustNots []query.Query 228 229 // TODO(boltless): remove this after implementing pulls page pagination 230 limit := opts.Page.Limit ··· 232 limit = 500 233 } 234 235 + for _, keyword := range opts.Keywords { 236 + musts = append(musts, bleve.NewDisjunctionQuery( 237 + bleveutil.MatchAndQuery("title", keyword, pullIndexerAnalyzer, 0), 238 + bleveutil.MatchAndQuery("body", keyword, pullIndexerAnalyzer, 0), 239 + )) 240 + } 241 + 242 + for _, phrase := range opts.Phrases { 243 + musts = append(musts, bleve.NewDisjunctionQuery( 244 + bleveutil.MatchPhraseQuery("title", phrase, pullIndexerAnalyzer), 245 + bleveutil.MatchPhraseQuery("body", phrase, pullIndexerAnalyzer), 246 + )) 247 + } 248 + 249 + for _, keyword := range opts.NegatedKeywords { 250 + mustNots = append(mustNots, bleve.NewDisjunctionQuery( 251 + bleveutil.MatchAndQuery("title", keyword, pullIndexerAnalyzer, 0), 252 + bleveutil.MatchAndQuery("body", keyword, pullIndexerAnalyzer, 0), 253 + )) 254 + } 255 + 256 + for _, phrase := range opts.NegatedPhrases { 257 + mustNots = append(mustNots, bleve.NewDisjunctionQuery( 258 + bleveutil.MatchPhraseQuery("title", phrase, pullIndexerAnalyzer), 259 + bleveutil.MatchPhraseQuery("body", phrase, pullIndexerAnalyzer), 260 )) 261 } 262 + 263 + musts = append(musts, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 264 + if opts.State != nil { 265 + musts = append(musts, bleveutil.KeywordFieldQuery("state", opts.State.String())) 266 + } 267 + 268 + if opts.AuthorDid != "" { 269 + musts = append(musts, bleveutil.KeywordFieldQuery("author_did", opts.AuthorDid)) 270 + } 271 + 272 + for _, label := range opts.Labels { 273 + musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) 274 + } 275 276 + if opts.NegatedAuthorDid != "" { 277 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", opts.NegatedAuthorDid)) 278 + } 279 + 280 + for _, label := range opts.NegatedLabels { 281 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("labels", label)) 282 + } 283 + 284 + indexerQuery := bleve.NewBooleanQuery() 285 + indexerQuery.AddMust(musts...) 286 + indexerQuery.AddMustNot(mustNots...) 287 searchReq := bleve.NewSearchRequestOptions(indexerQuery, limit, opts.Page.Offset, false) 288 res, err := ix.indexer.SearchInContext(ctx, searchReq) 289 if err != nil {
+124 -59
appview/issues/issues.go
··· 27 "tangled.org/core/appview/pages/repoinfo" 28 "tangled.org/core/appview/pagination" 29 "tangled.org/core/appview/reporesolver" 30 "tangled.org/core/appview/validator" 31 "tangled.org/core/idresolver" 32 "tangled.org/core/orm" ··· 793 l := rp.logger.With("handler", "RepoIssues") 794 795 params := r.URL.Query() 796 - state := params.Get("state") 797 - isOpen := true 798 - switch state { 799 - case "open": 800 - isOpen = true 801 - case "closed": 802 - isOpen = false 803 - default: 804 - isOpen = true 805 - } 806 - 807 page := pagination.FromContext(r.Context()) 808 809 user := rp.oauth.GetMultiAccountUser(r) ··· 811 if err != nil { 812 l.Error("failed to get repo and knot", "err", err) 813 return 814 } 815 816 totalIssues := 0 817 - if isOpen { 818 totalIssues = f.RepoStats.IssueCount.Open 819 } else { 820 totalIssues = f.RepoStats.IssueCount.Closed 821 } 822 823 - keyword := params.Get("q") 824 - 825 - repoInfo := rp.repoResolver.GetRepoInfo(r, user) 826 - 827 var issues []models.Issue 828 - searchOpts := models.IssueSearchOptions{ 829 - Keyword: keyword, 830 - RepoAt: f.RepoAt().String(), 831 - IsOpen: isOpen, 832 - Page: page, 833 - } 834 - if keyword != "" { 835 res, err := rp.indexer.Search(r.Context(), searchOpts) 836 if err != nil { 837 l.Error("failed to search for issues", "err", err) ··· 840 l.Debug("searched issues with indexer", "count", len(res.Hits)) 841 totalIssues = int(res.Total) 842 843 - // count matching issues in the opposite state to display correct counts 844 - countRes, err := rp.indexer.Search(r.Context(), models.IssueSearchOptions{ 845 - Keyword: keyword, RepoAt: f.RepoAt().String(), IsOpen: !isOpen, 846 - Page: pagination.Page{Limit: 1}, 847 - }) 848 - if err == nil { 849 - if isOpen { 850 - repoInfo.Stats.IssueCount.Open = int(res.Total) 851 - repoInfo.Stats.IssueCount.Closed = int(countRes.Total) 852 - } else { 853 - repoInfo.Stats.IssueCount.Closed = int(res.Total) 854 - repoInfo.Stats.IssueCount.Open = int(countRes.Total) 855 } 856 } 857 - 858 - issues, err = db.GetIssues( 859 - rp.db, 860 - orm.FilterIn("id", res.Hits), 861 - ) 862 - if err != nil { 863 - l.Error("failed to get issues", "err", err) 864 - rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 865 - return 866 } 867 - 868 - } else { 869 - openInt := 0 870 - if isOpen { 871 - openInt = 1 872 } 873 issues, err = db.GetIssuesPaginated( 874 rp.db, 875 page, 876 - orm.FilterEq("repo_at", f.RepoAt()), 877 - orm.FilterEq("open", openInt), 878 ) 879 if err != nil { 880 l.Error("failed to get issues", "err", err) ··· 899 defs[l.AtUri().String()] = &l 900 } 901 902 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 903 - LoggedInUser: rp.oauth.GetMultiAccountUser(r), 904 - RepoInfo: repoInfo, 905 - Issues: issues, 906 - IssueCount: totalIssues, 907 - LabelDefs: defs, 908 - FilteringByOpen: isOpen, 909 - FilterQuery: keyword, 910 - Page: page, 911 }) 912 } 913 914 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 915 l := rp.logger.With("handler", "NewIssue")
··· 27 "tangled.org/core/appview/pages/repoinfo" 28 "tangled.org/core/appview/pagination" 29 "tangled.org/core/appview/reporesolver" 30 + "tangled.org/core/appview/searchquery" 31 "tangled.org/core/appview/validator" 32 "tangled.org/core/idresolver" 33 "tangled.org/core/orm" ··· 794 l := rp.logger.With("handler", "RepoIssues") 795 796 params := r.URL.Query() 797 page := pagination.FromContext(r.Context()) 798 799 user := rp.oauth.GetMultiAccountUser(r) ··· 801 if err != nil { 802 l.Error("failed to get repo and knot", "err", err) 803 return 804 + } 805 + 806 + query := searchquery.Parse(params.Get("q")) 807 + 808 + var isOpen *bool 809 + if urlState := params.Get("state"); urlState != "" { 810 + switch urlState { 811 + case "open": 812 + isOpen = ptrBool(true) 813 + case "closed": 814 + isOpen = ptrBool(false) 815 + } 816 + query.Set("state", urlState) 817 + } else if queryState := query.Get("state"); queryState != nil { 818 + switch *queryState { 819 + case "open": 820 + isOpen = ptrBool(true) 821 + case "closed": 822 + isOpen = ptrBool(false) 823 + } 824 + } else if _, hasQ := params["q"]; !hasQ { 825 + // no q param at all -- default to open 826 + isOpen = ptrBool(true) 827 + query.Set("state", "open") 828 + } 829 + 830 + var authorDid string 831 + if authorHandle := query.Get("author"); authorHandle != nil { 832 + identity, err := rp.idResolver.ResolveIdent(r.Context(), *authorHandle) 833 + if err != nil { 834 + l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 835 + } else { 836 + authorDid = identity.DID.String() 837 + } 838 + } 839 + 840 + var negatedAuthorDid string 841 + if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 { 842 + identity, err := rp.idResolver.ResolveIdent(r.Context(), negatedAuthors[0]) 843 + if err != nil { 844 + l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err) 845 + } else { 846 + negatedAuthorDid = identity.DID.String() 847 + } 848 + } 849 + 850 + labels := query.GetAll("label") 851 + negatedLabels := query.GetAllNegated("label") 852 + 853 + var keywords, negatedKeywords []string 854 + var phrases, negatedPhrases []string 855 + for _, item := range query.Items() { 856 + switch item.Kind { 857 + case searchquery.KindKeyword: 858 + if item.Negated { 859 + negatedKeywords = append(negatedKeywords, item.Value) 860 + } else { 861 + keywords = append(keywords, item.Value) 862 + } 863 + case searchquery.KindQuoted: 864 + if item.Negated { 865 + negatedPhrases = append(negatedPhrases, item.Value) 866 + } else { 867 + phrases = append(phrases, item.Value) 868 + } 869 + } 870 + } 871 + 872 + searchOpts := models.IssueSearchOptions{ 873 + Keywords: keywords, 874 + Phrases: phrases, 875 + RepoAt: f.RepoAt().String(), 876 + IsOpen: isOpen, 877 + AuthorDid: authorDid, 878 + Labels: labels, 879 + NegatedKeywords: negatedKeywords, 880 + NegatedPhrases: negatedPhrases, 881 + NegatedLabels: negatedLabels, 882 + NegatedAuthorDid: negatedAuthorDid, 883 + Page: page, 884 } 885 886 totalIssues := 0 887 + if isOpen == nil { 888 + totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed 889 + } else if *isOpen { 890 totalIssues = f.RepoStats.IssueCount.Open 891 } else { 892 totalIssues = f.RepoStats.IssueCount.Closed 893 } 894 895 var issues []models.Issue 896 + 897 + if searchOpts.HasSearchFilters() { 898 res, err := rp.indexer.Search(r.Context(), searchOpts) 899 if err != nil { 900 l.Error("failed to search for issues", "err", err) ··· 903 l.Debug("searched issues with indexer", "count", len(res.Hits)) 904 totalIssues = int(res.Total) 905 906 + if len(res.Hits) > 0 { 907 + issues, err = db.GetIssues( 908 + rp.db, 909 + orm.FilterIn("id", res.Hits), 910 + ) 911 + if err != nil { 912 + l.Error("failed to get issues", "err", err) 913 + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 914 + return 915 } 916 } 917 + } else { 918 + filters := []orm.Filter{ 919 + orm.FilterEq("repo_at", f.RepoAt()), 920 } 921 + if isOpen != nil { 922 + openInt := 0 923 + if *isOpen { 924 + openInt = 1 925 + } 926 + filters = append(filters, orm.FilterEq("open", openInt)) 927 } 928 issues, err = db.GetIssuesPaginated( 929 rp.db, 930 page, 931 + filters..., 932 ) 933 if err != nil { 934 l.Error("failed to get issues", "err", err) ··· 953 defs[l.AtUri().String()] = &l 954 } 955 956 + filterState := "" 957 + if isOpen != nil { 958 + if *isOpen { 959 + filterState = "open" 960 + } else { 961 + filterState = "closed" 962 + } 963 + } 964 + 965 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 966 + LoggedInUser: rp.oauth.GetMultiAccountUser(r), 967 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 968 + Issues: issues, 969 + IssueCount: totalIssues, 970 + LabelDefs: defs, 971 + FilterState: filterState, 972 + FilterQuery: query.String(), 973 + Page: page, 974 }) 975 } 976 + 977 + func ptrBool(b bool) *bool { return &b } 978 979 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 980 l := rp.logger.With("handler", "NewIssue")
+35 -17
appview/models/search.go
··· 3 import "tangled.org/core/appview/pagination" 4 5 type IssueSearchOptions struct { 6 - Keyword string 7 - RepoAt string 8 - IsOpen bool 9 10 Page pagination.Page 11 } 12 13 type PullSearchOptions struct { 14 - Keyword string 15 - RepoAt string 16 - State PullState 17 18 Page pagination.Page 19 } 20 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 - // }
··· 3 import "tangled.org/core/appview/pagination" 4 5 type IssueSearchOptions struct { 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 17 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 26 + } 27 + 28 type PullSearchOptions struct { 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 40 41 Page pagination.Page 42 } 43 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 } 980 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 991 } 992 993 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 1117 RepoInfo repoinfo.RepoInfo 1118 Pulls []*models.Pull 1119 Active string 1120 - FilteringBy models.PullState 1121 FilterQuery string 1122 Stacks map[string]models.Stack 1123 Pipelines map[string]models.Pipeline
··· 979 } 980 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 + FilterState string 990 + FilterQuery string 991 } 992 993 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 1117 RepoInfo repoinfo.RepoInfo 1118 Pulls []*models.Pull 1119 Active string 1120 + FilterState string 1121 FilterQuery string 1122 Stacks map[string]models.Stack 1123 Pipelines map[string]models.Pipeline
+23 -1
appview/pages/templates/fragments/tabSelector.html
··· 3 {{ $all := .Values }} 4 {{ $active := .Active }} 5 {{ $include := .Include }} 6 <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 {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 8 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 9 {{ range $index, $value := $all }} 10 {{ $isActive := eq $value.Key $active }} 11 <a href="?{{ $name }}={{ $value.Key }}" 12 {{ if $include }} 13 hx-get="?{{ $name }}={{ $value.Key }}" ··· 27 28 {{ $value.Value }} 29 </a> 30 {{ end }} 31 </div> 32 {{ end }} 33 -
··· 3 {{ $all := .Values }} 4 {{ $active := .Active }} 5 {{ $include := .Include }} 6 + {{ $form := .Form }} 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"> 8 {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 9 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 10 {{ range $index, $value := $all }} 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 }} 33 <a href="?{{ $name }}={{ $value.Key }}" 34 {{ if $include }} 35 hx-get="?{{ $name }}={{ $value.Key }}" ··· 49 50 {{ $value.Value }} 51 </a> 52 + {{ end }} 53 {{ end }} 54 </div> 55 {{ end }}
+10 -18
appview/pages/templates/repo/issues/issues.html
··· 8 {{ end }} 9 10 {{ define "repoContent" }} 11 - {{ $active := "closed" }} 12 - {{ if .FilteringByOpen }} 13 - {{ $active = "open" }} 14 - {{ end }} 15 16 - {{ $open := 17 (dict 18 "Key" "open" 19 "Value" "open" 20 "Icon" "circle-dot" 21 "Meta" (string .RepoInfo.Stats.IssueCount.Open)) }} 22 - {{ $closed := 23 (dict 24 "Key" "closed" 25 "Value" "closed" ··· 28 {{ $values := list $open $closed }} 29 30 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 31 - <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 32 - <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 33 <div class="flex-1 flex relative"> 34 <input 35 id="search-q" ··· 40 placeholder="search issues..." 41 > 42 <a 43 - href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}" 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" 45 > 46 {{ i "x" "w-4 h-4" }} ··· 54 </button> 55 </form> 56 <div class="sm:row-start-1"> 57 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }} 58 </div> 59 <a 60 href="/{{ .RepoInfo.FullName }}/issues/new" ··· 72 {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 73 </div> 74 {{if gt .IssueCount .Page.Limit }} 75 - {{ $state := "closed" }} 76 - {{ if .FilteringByOpen }} 77 - {{ $state = "open" }} 78 - {{ end }} 79 - {{ template "fragments/pagination" (dict 80 - "Page" .Page 81 - "TotalCount" .IssueCount 82 "BasePath" (printf "/%s/issues" .RepoInfo.FullName) 83 - "QueryParams" (queryParams "state" $state "q" .FilterQuery) 84 ) }} 85 {{ end }} 86 {{ end }}
··· 8 {{ end }} 9 10 {{ define "repoContent" }} 11 + {{ $active := .FilterState }} 12 13 + {{ $open := 14 (dict 15 "Key" "open" 16 "Value" "open" 17 "Icon" "circle-dot" 18 "Meta" (string .RepoInfo.Stats.IssueCount.Open)) }} 19 + {{ $closed := 20 (dict 21 "Key" "closed" 22 "Value" "closed" ··· 25 {{ $values := list $open $closed }} 26 27 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 28 + <form id="search-form" class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 29 <div class="flex-1 flex relative"> 30 <input 31 id="search-q" ··· 36 placeholder="search issues..." 37 > 38 <a 39 + {{ if $active }}href="?q=state:{{ $active }}"{{ else }}href="?"{{ end }} 40 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 > 42 {{ i "x" "w-4 h-4" }} ··· 50 </button> 51 </form> 52 <div class="sm:row-start-1"> 53 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q" "Form" "search-form") }} 54 </div> 55 <a 56 href="/{{ .RepoInfo.FullName }}/issues/new" ··· 68 {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 69 </div> 70 {{if gt .IssueCount .Page.Limit }} 71 + {{ template "fragments/pagination" (dict 72 + "Page" .Page 73 + "TotalCount" .IssueCount 74 "BasePath" (printf "/%s/issues" .RepoInfo.FullName) 75 + "QueryParams" (queryParams "q" .FilterQuery) 76 ) }} 77 {{ end }} 78 {{ end }}
+11 -17
appview/pages/templates/repo/pulls/pulls.html
··· 8 {{ end }} 9 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 := 18 (dict 19 "Key" "open" 20 "Value" "open" 21 "Icon" "git-pull-request" 22 "Meta" (string .RepoInfo.Stats.PullCount.Open)) }} 23 - {{ $merged := 24 (dict 25 "Key" "merged" 26 "Value" "merged" 27 "Icon" "git-merge" 28 "Meta" (string .RepoInfo.Stats.PullCount.Merged)) }} 29 - {{ $closed := 30 (dict 31 "Key" "closed" 32 "Value" "closed" ··· 34 "Meta" (string .RepoInfo.Stats.PullCount.Closed)) }} 35 {{ $values := list $open $merged $closed }} 36 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 37 - <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 38 - <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 39 <div class="flex-1 flex relative"> 40 <input 41 id="search-q" ··· 46 placeholder="search pulls..." 47 > 48 <a 49 - href="?state={{ .FilteringBy.String }}" 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" 51 > 52 {{ i "x" "w-4 h-4" }} ··· 60 </button> 61 </form> 62 <div class="sm:row-start-1"> 63 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }} 64 </div> 65 <a 66 href="/{{ .RepoInfo.FullName }}/pulls/new" ··· 162 {{ end }} 163 </div> 164 {{if gt .PullCount .Page.Limit }} 165 - {{ template "fragments/pagination" (dict 166 - "Page" .Page 167 - "TotalCount" .PullCount 168 "BasePath" (printf "/%s/pulls" .RepoInfo.FullName) 169 - "QueryParams" (queryParams "state" .FilteringBy.String "q" .FilterQuery) 170 ) }} 171 {{ end }} 172 {{ end }}
··· 8 {{ end }} 9 10 {{ define "repoContent" }} 11 + {{ $active := .FilterState }} 12 + {{ $open := 13 (dict 14 "Key" "open" 15 "Value" "open" 16 "Icon" "git-pull-request" 17 "Meta" (string .RepoInfo.Stats.PullCount.Open)) }} 18 + {{ $merged := 19 (dict 20 "Key" "merged" 21 "Value" "merged" 22 "Icon" "git-merge" 23 "Meta" (string .RepoInfo.Stats.PullCount.Merged)) }} 24 + {{ $closed := 25 (dict 26 "Key" "closed" 27 "Value" "closed" ··· 29 "Meta" (string .RepoInfo.Stats.PullCount.Closed)) }} 30 {{ $values := list $open $merged $closed }} 31 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 32 + <form id="search-form" class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 33 <div class="flex-1 flex relative"> 34 <input 35 id="search-q" ··· 40 placeholder="search pulls..." 41 > 42 <a 43 + {{ if $active }}href="?q=state:{{ $active }}"{{ else }}href="?"{{ end }} 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" 45 > 46 {{ i "x" "w-4 h-4" }} ··· 54 </button> 55 </form> 56 <div class="sm:row-start-1"> 57 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q" "Form" "search-form") }} 58 </div> 59 <a 60 href="/{{ .RepoInfo.FullName }}/pulls/new" ··· 156 {{ end }} 157 </div> 158 {{if gt .PullCount .Page.Limit }} 159 + {{ template "fragments/pagination" (dict 160 + "Page" .Page 161 + "TotalCount" .PullCount 162 "BasePath" (printf "/%s/pulls" .RepoInfo.FullName) 163 + "QueryParams" (queryParams "q" .FilterQuery) 164 ) }} 165 {{ end }} 166 {{ end }}
+122 -67
appview/pulls/pulls.go
··· 31 "tangled.org/core/appview/pages/repoinfo" 32 "tangled.org/core/appview/pagination" 33 "tangled.org/core/appview/reporesolver" 34 "tangled.org/core/appview/validator" 35 "tangled.org/core/appview/xrpcclient" 36 "tangled.org/core/idresolver" ··· 524 525 user := s.oauth.GetMultiAccountUser(r) 526 params := r.URL.Query() 527 - 528 - state := models.PullOpen 529 - switch params.Get("state") { 530 - case "closed": 531 - state = models.PullClosed 532 - case "merged": 533 - state = models.PullMerged 534 - } 535 - 536 page := pagination.FromContext(r.Context()) 537 538 f, err := s.repoResolver.Resolve(r) ··· 541 return 542 } 543 544 - var totalPulls int 545 - switch state { 546 - case models.PullOpen: 547 - totalPulls = f.RepoStats.PullCount.Open 548 - case models.PullMerged: 549 - totalPulls = f.RepoStats.PullCount.Merged 550 - case models.PullClosed: 551 - totalPulls = f.RepoStats.PullCount.Closed 552 } 553 554 - keyword := params.Get("q") 555 556 - repoInfo := s.repoResolver.GetRepoInfo(r, user) 557 558 - var pulls []*models.Pull 559 searchOpts := models.PullSearchOptions{ 560 - Keyword: keyword, 561 - RepoAt: f.RepoAt().String(), 562 - State: state, 563 - Page: page, 564 } 565 - l.Debug("searching with", "searchOpts", searchOpts) 566 - if keyword != "" { 567 res, err := s.indexer.Search(r.Context(), searchOpts) 568 if err != nil { 569 l.Error("failed to search for pulls", "err", err) ··· 572 totalPulls = int(res.Total) 573 l.Debug("searched pulls with indexer", "count", len(res.Hits)) 574 575 - // count matching pulls in the other states to display correct counts 576 - for _, other := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} { 577 - if other == state { 578 - continue 579 - } 580 - countRes, err := s.indexer.Search(r.Context(), models.PullSearchOptions{ 581 - Keyword: keyword, RepoAt: f.RepoAt().String(), State: other, 582 - Page: pagination.Page{Limit: 1}, 583 - }) 584 if err != nil { 585 - continue 586 - } 587 - switch other { 588 - case models.PullOpen: 589 - repoInfo.Stats.PullCount.Open = int(countRes.Total) 590 - case models.PullMerged: 591 - repoInfo.Stats.PullCount.Merged = int(countRes.Total) 592 - case models.PullClosed: 593 - repoInfo.Stats.PullCount.Closed = int(countRes.Total) 594 } 595 } 596 - switch state { 597 - case models.PullOpen: 598 - repoInfo.Stats.PullCount.Open = int(res.Total) 599 - case models.PullMerged: 600 - repoInfo.Stats.PullCount.Merged = int(res.Total) 601 - case models.PullClosed: 602 - repoInfo.Stats.PullCount.Closed = int(res.Total) 603 } 604 - 605 - pulls, err = db.GetPulls( 606 - s.db, 607 - orm.FilterIn("id", res.Hits), 608 - ) 609 - if err != nil { 610 - log.Println("failed to get pulls", err) 611 - s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 612 - return 613 } 614 - } else { 615 pulls, err = db.GetPullsPaginated( 616 s.db, 617 page, 618 - orm.FilterEq("repo_at", f.RepoAt()), 619 - orm.FilterEq("state", searchOpts.State), 620 ) 621 if err != nil { 622 - log.Println("failed to get pulls", err) 623 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 624 return 625 } ··· 688 orm.FilterContains("scope", tangled.RepoPullNSID), 689 ) 690 if err != nil { 691 - log.Println("failed to fetch labels", err) 692 s.pages.Error503(w) 693 return 694 } ··· 698 defs[l.AtUri().String()] = &l 699 } 700 701 s.pages.RepoPulls(w, pages.RepoPullsParams{ 702 LoggedInUser: s.oauth.GetMultiAccountUser(r), 703 RepoInfo: repoInfo, 704 Pulls: pulls, 705 LabelDefs: defs, 706 - FilteringBy: state, 707 - FilterQuery: keyword, 708 Stacks: stacks, 709 Pipelines: m, 710 Page: page, ··· 2485 w.Close() 2486 return &b 2487 }
··· 31 "tangled.org/core/appview/pages/repoinfo" 32 "tangled.org/core/appview/pagination" 33 "tangled.org/core/appview/reporesolver" 34 + "tangled.org/core/appview/searchquery" 35 "tangled.org/core/appview/validator" 36 "tangled.org/core/appview/xrpcclient" 37 "tangled.org/core/idresolver" ··· 525 526 user := s.oauth.GetMultiAccountUser(r) 527 params := r.URL.Query() 528 page := pagination.FromContext(r.Context()) 529 530 f, err := s.repoResolver.Resolve(r) ··· 533 return 534 } 535 536 + query := searchquery.Parse(params.Get("q")) 537 + 538 + var state *models.PullState 539 + if urlState := params.Get("state"); urlState != "" { 540 + switch urlState { 541 + case "open": 542 + state = ptrPullState(models.PullOpen) 543 + case "closed": 544 + state = ptrPullState(models.PullClosed) 545 + case "merged": 546 + state = ptrPullState(models.PullMerged) 547 + } 548 + query.Set("state", urlState) 549 + } else if queryState := query.Get("state"); queryState != nil { 550 + switch *queryState { 551 + case "open": 552 + state = ptrPullState(models.PullOpen) 553 + case "closed": 554 + state = ptrPullState(models.PullClosed) 555 + case "merged": 556 + state = ptrPullState(models.PullMerged) 557 + } 558 + } else if _, hasQ := params["q"]; !hasQ { 559 + state = ptrPullState(models.PullOpen) 560 + query.Set("state", "open") 561 + } 562 + 563 + var authorDid string 564 + if authorHandle := query.Get("author"); authorHandle != nil { 565 + identity, err := s.idResolver.ResolveIdent(r.Context(), *authorHandle) 566 + if err != nil { 567 + l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 568 + } else { 569 + authorDid = identity.DID.String() 570 + } 571 + } 572 + 573 + var negatedAuthorDid string 574 + if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 { 575 + identity, err := s.idResolver.ResolveIdent(r.Context(), negatedAuthors[0]) 576 + if err != nil { 577 + l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err) 578 + } else { 579 + negatedAuthorDid = identity.DID.String() 580 + } 581 } 582 583 + labels := query.GetAll("label") 584 + negatedLabels := query.GetAllNegated("label") 585 586 + var keywords, negatedKeywords []string 587 + var phrases, negatedPhrases []string 588 + for _, item := range query.Items() { 589 + switch item.Kind { 590 + case searchquery.KindKeyword: 591 + if item.Negated { 592 + negatedKeywords = append(negatedKeywords, item.Value) 593 + } else { 594 + keywords = append(keywords, item.Value) 595 + } 596 + case searchquery.KindQuoted: 597 + if item.Negated { 598 + negatedPhrases = append(negatedPhrases, item.Value) 599 + } else { 600 + phrases = append(phrases, item.Value) 601 + } 602 + } 603 + } 604 605 searchOpts := models.PullSearchOptions{ 606 + Keywords: keywords, 607 + Phrases: phrases, 608 + RepoAt: f.RepoAt().String(), 609 + State: state, 610 + AuthorDid: authorDid, 611 + Labels: labels, 612 + NegatedKeywords: negatedKeywords, 613 + NegatedPhrases: negatedPhrases, 614 + NegatedLabels: negatedLabels, 615 + NegatedAuthorDid: negatedAuthorDid, 616 + Page: page, 617 } 618 + 619 + var totalPulls int 620 + if state == nil { 621 + totalPulls = f.RepoStats.PullCount.Open + f.RepoStats.PullCount.Merged + f.RepoStats.PullCount.Closed 622 + } else { 623 + switch *state { 624 + case models.PullOpen: 625 + totalPulls = f.RepoStats.PullCount.Open 626 + case models.PullMerged: 627 + totalPulls = f.RepoStats.PullCount.Merged 628 + case models.PullClosed: 629 + totalPulls = f.RepoStats.PullCount.Closed 630 + } 631 + } 632 + 633 + repoInfo := s.repoResolver.GetRepoInfo(r, user) 634 + 635 + var pulls []*models.Pull 636 + 637 + if searchOpts.HasSearchFilters() { 638 res, err := s.indexer.Search(r.Context(), searchOpts) 639 if err != nil { 640 l.Error("failed to search for pulls", "err", err) ··· 643 totalPulls = int(res.Total) 644 l.Debug("searched pulls with indexer", "count", len(res.Hits)) 645 646 + if len(res.Hits) > 0 { 647 + pulls, err = db.GetPulls( 648 + s.db, 649 + orm.FilterIn("id", res.Hits), 650 + ) 651 if err != nil { 652 + l.Error("failed to get pulls", "err", err) 653 + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 654 + return 655 } 656 } 657 + } else { 658 + filters := []orm.Filter{ 659 + orm.FilterEq("repo_at", f.RepoAt()), 660 } 661 + if state != nil { 662 + filters = append(filters, orm.FilterEq("state", *state)) 663 } 664 pulls, err = db.GetPullsPaginated( 665 s.db, 666 page, 667 + filters..., 668 ) 669 if err != nil { 670 + l.Error("failed to get pulls", "err", err) 671 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 672 return 673 } ··· 736 orm.FilterContains("scope", tangled.RepoPullNSID), 737 ) 738 if err != nil { 739 + l.Error("failed to fetch labels", "err", err) 740 s.pages.Error503(w) 741 return 742 } ··· 746 defs[l.AtUri().String()] = &l 747 } 748 749 + filterState := "" 750 + if state != nil { 751 + filterState = state.String() 752 + } 753 + 754 s.pages.RepoPulls(w, pages.RepoPullsParams{ 755 LoggedInUser: s.oauth.GetMultiAccountUser(r), 756 RepoInfo: repoInfo, 757 Pulls: pulls, 758 LabelDefs: defs, 759 + FilterState: filterState, 760 + FilterQuery: query.String(), 761 Stacks: stacks, 762 Pipelines: m, 763 Page: page, ··· 2538 w.Close() 2539 return &b 2540 } 2541 + 2542 + func ptrPullState(s models.PullState) *models.PullState { return &s }