Monorepo for Tangled tangled.org

Enable filtering by key-value labels in search box using format mycustomlabel:value #1102

merged opened by octet-stream.net targeting master from octet-stream.net/core: thombles/label-values

This extends PR #1065 to include filtering of key-value pairs, a request on Discord from @oppi.li.

In short, if you use include a tag:value in your search query:

  1. It tries to match it to a "system" tag like "label" or "author"
  2. If it's not, "tag" is taken to be a custom label with an associated value (in the code, a "dynamic tag")
  3. If it's a label that has a DID value, then the query will resolve the value as a handle before querying the index

To make them indexable all labels are serialised into the format "mycustomlabel:somevalue", the same way they would be typed in a search, and stored as distinct keywords in bleve. That is, for the purposes of queries they are all rendered into flat strings.

This was threatening to make the duplication worse between the issues and pulls search handlers so I've made an effort to factor out some common logic. I have a feeling this needs a much broader refactor reflecting how similar those two handlers are, but in the meantime this should limit the complexity somewhat.

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:txurc6ueald5d7462bpvzdby/sh.tangled.repo.pull/3mg2hctaerh22
+430 -148
Diff #0
+31 -20
appview/indexer/issues/indexer.go
··· 18 18 "github.com/blevesearch/bleve/v2/search/query" 19 19 "tangled.org/core/appview/db" 20 20 "tangled.org/core/appview/indexer/base36" 21 - "tangled.org/core/appview/indexer/bleve" 21 + bleveutil "tangled.org/core/appview/indexer/bleve" 22 22 "tangled.org/core/appview/models" 23 23 "tangled.org/core/appview/pagination" 24 24 tlog "tangled.org/core/log" ··· 31 31 unicodeNormalizeName = "uicodeNormalize" 32 32 33 33 // Bump this when the index mapping changes to trigger a rebuild. 34 - issueIndexerVersion = 2 34 + issueIndexerVersion = 3 35 35 ) 36 36 37 37 type Indexer struct { ··· 89 89 docMapping.AddFieldMappingsAt("is_open", boolFieldMapping) 90 90 docMapping.AddFieldMappingsAt("author_did", keywordFieldMapping) 91 91 docMapping.AddFieldMappingsAt("labels", keywordFieldMapping) 92 + docMapping.AddFieldMappingsAt("label_values", keywordFieldMapping) 92 93 93 94 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 94 95 "type": unicodenorm.Name, ··· 183 184 } 184 185 185 186 type issueData struct { 186 - ID int64 `json:"id"` 187 - RepoAt string `json:"repo_at"` 188 - IssueID int `json:"issue_id"` 189 - Title string `json:"title"` 190 - Body string `json:"body"` 191 - IsOpen bool `json:"is_open"` 192 - AuthorDid string `json:"author_did"` 193 - Labels []string `json:"labels"` 187 + ID int64 `json:"id"` 188 + RepoAt string `json:"repo_at"` 189 + IssueID int `json:"issue_id"` 190 + Title string `json:"title"` 191 + Body string `json:"body"` 192 + IsOpen bool `json:"is_open"` 193 + AuthorDid string `json:"author_did"` 194 + Labels []string `json:"labels"` 195 + LabelValues []string `json:"label_values"` 194 196 195 197 Comments []IssueCommentData `json:"comments"` 196 198 } 197 199 198 200 func makeIssueData(issue *models.Issue) *issueData { 199 201 return &issueData{ 200 - ID: issue.Id, 201 - RepoAt: issue.RepoAt.String(), 202 - IssueID: issue.IssueId, 203 - Title: issue.Title, 204 - Body: issue.Body, 205 - IsOpen: issue.Open, 206 - AuthorDid: issue.Did, 207 - Labels: issue.Labels.LabelNames(), 202 + ID: issue.Id, 203 + RepoAt: issue.RepoAt.String(), 204 + IssueID: issue.IssueId, 205 + Title: issue.Title, 206 + Body: issue.Body, 207 + IsOpen: issue.Open, 208 + AuthorDid: issue.Did, 209 + Labels: issue.Labels.LabelNames(), 210 + LabelValues: issue.Labels.LabelNameValues(), 208 211 } 209 212 } 210 213 ··· 284 287 musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) 285 288 } 286 289 287 - if opts.NegatedAuthorDid != "" { 288 - mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", opts.NegatedAuthorDid)) 290 + for _, did := range opts.NegatedAuthorDids { 291 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", did)) 289 292 } 290 293 291 294 for _, label := range opts.NegatedLabels { 292 295 mustNots = append(mustNots, bleveutil.KeywordFieldQuery("labels", label)) 296 + } 297 + 298 + for _, lv := range opts.LabelValues { 299 + musts = append(musts, bleveutil.KeywordFieldQuery("label_values", lv)) 300 + } 301 + 302 + for _, lv := range opts.NegatedLabelValues { 303 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("label_values", lv)) 293 304 } 294 305 295 306 indexerQuery := bleve.NewBooleanQuery()
+84 -4
appview/indexer/issues/indexer_test.go
··· 3 3 import ( 4 4 "context" 5 5 "os" 6 + "strings" 6 7 "testing" 7 8 8 9 "github.com/blevesearch/bleve/v2" ··· 38 39 39 40 func boolPtr(b bool) *bool { return &b } 40 41 41 - func makeLabelState(labels ...string) models.LabelState { 42 + // makeLabelState creates a LabelState for testing. Each entry is either 43 + // "name" (null-type label) or "name=val" (valued label). 44 + func makeLabelState(entries ...string) models.LabelState { 42 45 state := models.NewLabelState() 43 - for _, label := range labels { 44 - state.Inner()[label] = make(map[string]struct{}) 45 - state.SetName(label, label) 46 + for _, entry := range entries { 47 + if eqIdx := strings.Index(entry, "="); eqIdx > 0 { 48 + name := entry[:eqIdx] 49 + val := entry[eqIdx+1:] 50 + if state.Inner()[name] == nil { 51 + state.Inner()[name] = make(map[string]struct{}) 52 + } 53 + state.Inner()[name][val] = struct{}{} 54 + state.SetName(name, name) 55 + } else { 56 + state.Inner()[entry] = make(map[string]struct{}) 57 + state.SetName(entry, entry) 58 + } 46 59 } 47 60 return state 48 61 } ··· 262 275 assert.Equal(t, uint64(0), result.Total) 263 276 assert.Empty(t, result.Hits) 264 277 } 278 + 279 + func TestSearchLabelValues(t *testing.T) { 280 + ix, cleanup := setupTestIndexer(t) 281 + defer cleanup() 282 + 283 + ctx := context.Background() 284 + 285 + err := ix.Index(ctx, 286 + models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "High priority bug", Body: "Urgent", Open: true, Did: "did:plc:alice", 287 + Labels: makeLabelState("bug", "priority=high")}, 288 + models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Low priority feature", Body: "Nice to have", Open: true, Did: "did:plc:bob", 289 + Labels: makeLabelState("feature", "priority=low")}, 290 + models.Issue{Id: 3, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "High priority feature", Body: "Important", Open: true, Did: "did:plc:alice", 291 + Labels: makeLabelState("feature", "priority=high")}, 292 + ) 293 + require.NoError(t, err) 294 + 295 + opts := func() models.IssueSearchOptions { 296 + return models.IssueSearchOptions{ 297 + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", 298 + IsOpen: boolPtr(true), 299 + Page: pagination.Page{Limit: 10}, 300 + } 301 + } 302 + 303 + o := opts() 304 + o.LabelValues = []string{"priority:high"} 305 + result, err := ix.Search(ctx, o) 306 + require.NoError(t, err) 307 + assert.Equal(t, uint64(2), result.Total) 308 + assert.Contains(t, result.Hits, int64(1)) 309 + assert.Contains(t, result.Hits, int64(3)) 310 + 311 + o = opts() 312 + o.LabelValues = []string{"priority:low"} 313 + result, err = ix.Search(ctx, o) 314 + require.NoError(t, err) 315 + assert.Equal(t, uint64(1), result.Total) 316 + assert.Contains(t, result.Hits, int64(2)) 317 + 318 + // Combined: plain label + label value 319 + o = opts() 320 + o.Labels = []string{"feature"} 321 + o.LabelValues = []string{"priority:high"} 322 + result, err = ix.Search(ctx, o) 323 + require.NoError(t, err) 324 + assert.Equal(t, uint64(1), result.Total) 325 + assert.Contains(t, result.Hits, int64(3)) 326 + 327 + // Negated label value 328 + o = opts() 329 + o.NegatedLabelValues = []string{"priority:low"} 330 + result, err = ix.Search(ctx, o) 331 + require.NoError(t, err) 332 + assert.Equal(t, uint64(2), result.Total) 333 + assert.Contains(t, result.Hits, int64(1)) 334 + assert.Contains(t, result.Hits, int64(3)) 335 + 336 + // Label value + negated plain label 337 + o = opts() 338 + o.LabelValues = []string{"priority:high"} 339 + o.NegatedLabels = []string{"feature"} 340 + result, err = ix.Search(ctx, o) 341 + require.NoError(t, err) 342 + assert.Equal(t, uint64(1), result.Total) 343 + assert.Contains(t, result.Hits, int64(1)) 344 + }
+31 -20
appview/indexer/pulls/indexer.go
··· 18 18 "github.com/blevesearch/bleve/v2/search/query" 19 19 "tangled.org/core/appview/db" 20 20 "tangled.org/core/appview/indexer/base36" 21 - "tangled.org/core/appview/indexer/bleve" 21 + bleveutil "tangled.org/core/appview/indexer/bleve" 22 22 "tangled.org/core/appview/models" 23 23 tlog "tangled.org/core/log" 24 24 ) ··· 30 30 unicodeNormalizeName = "uicodeNormalize" 31 31 32 32 // Bump this when the index mapping changes to trigger a rebuild. 33 - pullIndexerVersion = 2 33 + pullIndexerVersion = 3 34 34 ) 35 35 36 36 type Indexer struct { ··· 84 84 docMapping.AddFieldMappingsAt("state", keywordFieldMapping) 85 85 docMapping.AddFieldMappingsAt("author_did", keywordFieldMapping) 86 86 docMapping.AddFieldMappingsAt("labels", keywordFieldMapping) 87 + docMapping.AddFieldMappingsAt("label_values", keywordFieldMapping) 87 88 88 89 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 89 90 "type": unicodenorm.Name, ··· 178 179 } 179 180 180 181 type pullData struct { 181 - ID int64 `json:"id"` 182 - RepoAt string `json:"repo_at"` 183 - PullID int `json:"pull_id"` 184 - Title string `json:"title"` 185 - Body string `json:"body"` 186 - State string `json:"state"` 187 - AuthorDid string `json:"author_did"` 188 - Labels []string `json:"labels"` 182 + ID int64 `json:"id"` 183 + RepoAt string `json:"repo_at"` 184 + PullID int `json:"pull_id"` 185 + Title string `json:"title"` 186 + Body string `json:"body"` 187 + State string `json:"state"` 188 + AuthorDid string `json:"author_did"` 189 + Labels []string `json:"labels"` 190 + LabelValues []string `json:"label_values"` 189 191 190 192 Comments []pullCommentData `json:"comments"` 191 193 } 192 194 193 195 func makePullData(pull *models.Pull) *pullData { 194 196 return &pullData{ 195 - ID: int64(pull.ID), 196 - RepoAt: pull.RepoAt.String(), 197 - PullID: pull.PullId, 198 - Title: pull.Title, 199 - Body: pull.Body, 200 - State: pull.State.String(), 201 - AuthorDid: pull.OwnerDid, 202 - Labels: pull.Labels.LabelNames(), 197 + ID: int64(pull.ID), 198 + RepoAt: pull.RepoAt.String(), 199 + PullID: pull.PullId, 200 + Title: pull.Title, 201 + Body: pull.Body, 202 + State: pull.State.String(), 203 + AuthorDid: pull.OwnerDid, 204 + Labels: pull.Labels.LabelNames(), 205 + LabelValues: pull.Labels.LabelNameValues(), 203 206 } 204 207 } 205 208 ··· 285 288 musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) 286 289 } 287 290 288 - if opts.NegatedAuthorDid != "" { 289 - mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", opts.NegatedAuthorDid)) 291 + for _, did := range opts.NegatedAuthorDids { 292 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", did)) 290 293 } 291 294 292 295 for _, label := range opts.NegatedLabels { 293 296 mustNots = append(mustNots, bleveutil.KeywordFieldQuery("labels", label)) 297 + } 298 + 299 + for _, lv := range opts.LabelValues { 300 + musts = append(musts, bleveutil.KeywordFieldQuery("label_values", lv)) 301 + } 302 + 303 + for _, lv := range opts.NegatedLabelValues { 304 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("label_values", lv)) 294 305 } 295 306 296 307 indexerQuery := bleve.NewBooleanQuery()
+40 -41
appview/issues/issues.go
··· 827 827 query.Set("state", "open") 828 828 } 829 829 830 - var authorDid string 831 - if authorHandle := query.Get("author"); authorHandle != nil { 832 - identity, err := rp.idResolver.ResolveIdent(r.Context(), *authorHandle) 830 + resolve := func(ctx context.Context, ident string) (string, error) { 831 + id, err := rp.idResolver.ResolveIdent(ctx, ident) 833 832 if err != nil { 834 - l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 835 - } else { 836 - authorDid = identity.DID.String() 833 + return "", err 837 834 } 835 + return id.DID.String(), nil 838 836 } 839 837 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 - } 838 + authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 849 839 850 840 labels := query.GetAll("label") 851 841 negatedLabels := query.GetAllNegated("label") 842 + labelValues := query.GetDynamicTags() 843 + negatedLabelValues := query.GetNegatedDynamicTags() 852 844 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) 845 + // resolve DID-format label values: if a dynamic tag's label 846 + // definition has format "did", resolve the handle to a DID 847 + if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 848 + labelDefs, err := db.GetLabelDefinitions( 849 + rp.db, 850 + orm.FilterIn("at_uri", f.Labels), 851 + orm.FilterContains("scope", tangled.RepoIssueNSID), 852 + ) 853 + if err == nil { 854 + didLabels := make(map[string]bool) 855 + for _, def := range labelDefs { 856 + if def.ValueType.Format == models.ValueTypeFormatDid { 857 + didLabels[def.Name] = true 858 + } 868 859 } 860 + labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 861 + negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 862 + } else { 863 + l.Debug("failed to fetch label definitions for DID resolution", "err", err) 869 864 } 870 865 } 871 866 867 + tf := searchquery.ExtractTextFilters(query) 868 + 872 869 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, 870 + Keywords: tf.Keywords, 871 + Phrases: tf.Phrases, 872 + RepoAt: f.RepoAt().String(), 873 + IsOpen: isOpen, 874 + AuthorDid: authorDid, 875 + Labels: labels, 876 + LabelValues: labelValues, 877 + NegatedKeywords: tf.NegatedKeywords, 878 + NegatedPhrases: tf.NegatedPhrases, 879 + NegatedLabels: negatedLabels, 880 + NegatedLabelValues: negatedLabelValues, 881 + NegatedAuthorDids: negatedAuthorDids, 882 + Page: page, 884 883 } 885 884 886 885 totalIssues := 0
+21
appview/models/label.go
··· 314 314 return result 315 315 } 316 316 317 + // LabelNameValues returns composite "name:value" strings for all labels 318 + // that have non-empty values. 319 + func (s LabelState) LabelNameValues() []string { 320 + var result []string 321 + for key, valset := range s.inner { 322 + if valset == nil { 323 + continue 324 + } 325 + name, ok := s.names[key] 326 + if !ok { 327 + continue 328 + } 329 + for val := range valset { 330 + if val != "" { 331 + result = append(result, name+":"+val) 332 + } 333 + } 334 + } 335 + return result 336 + } 337 + 317 338 func (s LabelState) Inner() map[string]set { 318 339 return s.inner 319 340 }
+28 -22
appview/models/search.go
··· 3 3 import "tangled.org/core/appview/pagination" 4 4 5 5 type IssueSearchOptions struct { 6 - Keywords []string 7 - Phrases []string 8 - RepoAt string 9 - IsOpen *bool 10 - AuthorDid string 11 - Labels []string 6 + Keywords []string 7 + Phrases []string 8 + RepoAt string 9 + IsOpen *bool 10 + AuthorDid string 11 + Labels []string 12 + LabelValues []string 12 13 13 - NegatedKeywords []string 14 - NegatedPhrases []string 15 - NegatedLabels []string 16 - NegatedAuthorDid string 14 + NegatedKeywords []string 15 + NegatedPhrases []string 16 + NegatedLabels []string 17 + NegatedLabelValues []string 18 + NegatedAuthorDids []string 17 19 18 20 Page pagination.Page 19 21 } 20 22 21 23 func (o *IssueSearchOptions) HasSearchFilters() bool { 22 24 return len(o.Keywords) > 0 || len(o.Phrases) > 0 || 23 - o.AuthorDid != "" || o.NegatedAuthorDid != "" || 25 + o.AuthorDid != "" || len(o.NegatedAuthorDids) > 0 || 24 26 len(o.Labels) > 0 || len(o.NegatedLabels) > 0 || 27 + len(o.LabelValues) > 0 || len(o.NegatedLabelValues) > 0 || 25 28 len(o.NegatedKeywords) > 0 || len(o.NegatedPhrases) > 0 26 29 } 27 30 28 31 type PullSearchOptions struct { 29 - Keywords []string 30 - Phrases []string 31 - RepoAt string 32 - State *PullState 33 - AuthorDid string 34 - Labels []string 32 + Keywords []string 33 + Phrases []string 34 + RepoAt string 35 + State *PullState 36 + AuthorDid string 37 + Labels []string 38 + LabelValues []string 35 39 36 - NegatedKeywords []string 37 - NegatedPhrases []string 38 - NegatedLabels []string 39 - NegatedAuthorDid string 40 + NegatedKeywords []string 41 + NegatedPhrases []string 42 + NegatedLabels []string 43 + NegatedLabelValues []string 44 + NegatedAuthorDids []string 40 45 41 46 Page pagination.Page 42 47 } 43 48 44 49 func (o *PullSearchOptions) HasSearchFilters() bool { 45 50 return len(o.Keywords) > 0 || len(o.Phrases) > 0 || 46 - o.AuthorDid != "" || o.NegatedAuthorDid != "" || 51 + o.AuthorDid != "" || len(o.NegatedAuthorDids) > 0 || 47 52 len(o.Labels) > 0 || len(o.NegatedLabels) > 0 || 53 + len(o.LabelValues) > 0 || len(o.NegatedLabelValues) > 0 || 48 54 len(o.NegatedKeywords) > 0 || len(o.NegatedPhrases) > 0 49 55 }
+40 -41
appview/pulls/pulls.go
··· 560 560 query.Set("state", "open") 561 561 } 562 562 563 - var authorDid string 564 - if authorHandle := query.Get("author"); authorHandle != nil { 565 - identity, err := s.idResolver.ResolveIdent(r.Context(), *authorHandle) 563 + resolve := func(ctx context.Context, ident string) (string, error) { 564 + id, err := s.idResolver.ResolveIdent(ctx, ident) 566 565 if err != nil { 567 - l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 568 - } else { 569 - authorDid = identity.DID.String() 566 + return "", err 570 567 } 568 + return id.DID.String(), nil 571 569 } 572 570 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 - } 571 + authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 582 572 583 573 labels := query.GetAll("label") 584 574 negatedLabels := query.GetAllNegated("label") 575 + labelValues := query.GetDynamicTags() 576 + negatedLabelValues := query.GetNegatedDynamicTags() 585 577 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) 578 + // resolve DID-format label values: if a dynamic tag's label 579 + // definition has format "did", resolve the handle to a DID 580 + if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 581 + labelDefs, err := db.GetLabelDefinitions( 582 + s.db, 583 + orm.FilterIn("at_uri", f.Labels), 584 + orm.FilterContains("scope", tangled.RepoPullNSID), 585 + ) 586 + if err == nil { 587 + didLabels := make(map[string]bool) 588 + for _, def := range labelDefs { 589 + if def.ValueType.Format == models.ValueTypeFormatDid { 590 + didLabels[def.Name] = true 591 + } 601 592 } 593 + labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 594 + negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 595 + } else { 596 + l.Debug("failed to fetch label definitions for DID resolution", "err", err) 602 597 } 603 598 } 604 599 600 + tf := searchquery.ExtractTextFilters(query) 601 + 605 602 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, 603 + Keywords: tf.Keywords, 604 + Phrases: tf.Phrases, 605 + RepoAt: f.RepoAt().String(), 606 + State: state, 607 + AuthorDid: authorDid, 608 + Labels: labels, 609 + LabelValues: labelValues, 610 + NegatedKeywords: tf.NegatedKeywords, 611 + NegatedPhrases: tf.NegatedPhrases, 612 + NegatedLabels: negatedLabels, 613 + NegatedLabelValues: negatedLabelValues, 614 + NegatedAuthorDids: negatedAuthorDids, 615 + Page: page, 617 616 } 618 617 619 618 var totalPulls int
+99
appview/searchquery/resolve.go
··· 1 + package searchquery 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "strings" 7 + ) 8 + 9 + // IdentResolver converts a handle/identifier string to a DID string. 10 + type IdentResolver func(ctx context.Context, ident string) (string, error) 11 + 12 + // ResolveAuthor extracts the "author" tag from the query and resolves 13 + // both the positive and negated values to DIDs using the provided resolver. 14 + // Returns empty string / nil on missing tags or resolution failure. 15 + func ResolveAuthor(ctx context.Context, q *Query, resolve IdentResolver, log *slog.Logger) (authorDid string, negatedAuthorDids []string) { 16 + if authorHandle := q.Get("author"); authorHandle != nil { 17 + did, err := resolve(ctx, *authorHandle) 18 + if err != nil { 19 + log.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 20 + } else { 21 + authorDid = did 22 + } 23 + } 24 + 25 + for _, handle := range q.GetAllNegated("author") { 26 + did, err := resolve(ctx, handle) 27 + if err != nil { 28 + log.Debug("failed to resolve negated author handle", "handle", handle, "err", err) 29 + continue 30 + } 31 + negatedAuthorDids = append(negatedAuthorDids, did) 32 + } 33 + 34 + return authorDid, negatedAuthorDids 35 + } 36 + 37 + // TextFilters holds the keyword and phrase filters extracted from a query. 38 + type TextFilters struct { 39 + Keywords []string 40 + NegatedKeywords []string 41 + Phrases []string 42 + NegatedPhrases []string 43 + } 44 + 45 + // ExtractTextFilters extracts keyword and quoted-phrase items from the query, 46 + // separated into positive and negated slices. 47 + func ExtractTextFilters(q *Query) TextFilters { 48 + var tf TextFilters 49 + for _, item := range q.Items() { 50 + switch item.Kind { 51 + case KindKeyword: 52 + if item.Negated { 53 + tf.NegatedKeywords = append(tf.NegatedKeywords, item.Value) 54 + } else { 55 + tf.Keywords = append(tf.Keywords, item.Value) 56 + } 57 + case KindQuoted: 58 + if item.Negated { 59 + tf.NegatedPhrases = append(tf.NegatedPhrases, item.Value) 60 + } else { 61 + tf.Phrases = append(tf.Phrases, item.Value) 62 + } 63 + } 64 + } 65 + return tf 66 + } 67 + 68 + // ResolveDIDLabelValues resolves handle values to DIDs for dynamic label 69 + // tags whose name appears in didLabels. Tags not in didLabels are returned 70 + // unchanged. On resolution failure the original value is kept. 71 + func ResolveDIDLabelValues( 72 + ctx context.Context, 73 + tags []string, 74 + didLabels map[string]bool, 75 + resolve IdentResolver, 76 + log *slog.Logger, 77 + ) []string { 78 + resolved := make([]string, 0, len(tags)) 79 + for _, tag := range tags { 80 + idx := strings.Index(tag, ":") 81 + if idx < 0 { 82 + resolved = append(resolved, tag) 83 + continue 84 + } 85 + name, val := tag[:idx], tag[idx+1:] 86 + if didLabels[name] { 87 + did, err := resolve(ctx, val) 88 + if err != nil { 89 + log.Debug("failed to resolve DID label value", "label", name, "value", val, "err", err) 90 + resolved = append(resolved, tag) 91 + continue 92 + } 93 + resolved = append(resolved, name+":"+did) 94 + } else { 95 + resolved = append(resolved, tag) 96 + } 97 + } 98 + return resolved 99 + }
+33
appview/searchquery/searchquery.go
··· 158 158 return q.Get(key) != nil 159 159 } 160 160 161 + // KnownTags is the set of tag keys with special system-defined handling. 162 + // Any tag:value pair whose key is not in this set is treated as a dynamic 163 + // label filter. 164 + var KnownTags = map[string]bool{ 165 + "state": true, 166 + "author": true, 167 + "label": true, 168 + } 169 + 170 + // GetDynamicTags returns composite "key:value" strings for all non-negated 171 + // tag:value items whose key is not a known system tag. 172 + func (q *Query) GetDynamicTags() []string { 173 + var result []string 174 + for _, item := range q.items { 175 + if item.Kind == KindTagValue && !item.Negated && !KnownTags[item.Key] { 176 + result = append(result, item.Key+":"+item.Value) 177 + } 178 + } 179 + return result 180 + } 181 + 182 + // GetNegatedDynamicTags returns composite "key:value" strings for all negated 183 + // tag:value items whose key is not a known system tag. 184 + func (q *Query) GetNegatedDynamicTags() []string { 185 + var result []string 186 + for _, item := range q.items { 187 + if item.Kind == KindTagValue && item.Negated && !KnownTags[item.Key] { 188 + result = append(result, item.Key+":"+item.Value) 189 + } 190 + } 191 + return result 192 + } 193 + 161 194 func (q *Query) Set(key, value string) { 162 195 raw := key + ":" + value 163 196 found := false
+23
appview/searchquery/searchquery_test.go
··· 250 250 assert.Equal(t, "-label", items[0].Key) 251 251 assert.Equal(t, "bug", items[0].Value) 252 252 } 253 + 254 + func TestDynamicTags(t *testing.T) { 255 + q := Parse("state:open label:bug priority:high severity:critical -priority:low author:alice -severity:minor keyword") 256 + 257 + // Known tags are not included 258 + dynamic := q.GetDynamicTags() 259 + assert.Equal(t, []string{"priority:high", "severity:critical"}, dynamic) 260 + 261 + negated := q.GetNegatedDynamicTags() 262 + assert.Equal(t, []string{"priority:low", "severity:minor"}, negated) 263 + 264 + // Known tags still work as before 265 + assert.Equal(t, []string{"bug"}, q.GetAll("label")) 266 + val := q.Get("state") 267 + assert.NotNil(t, val) 268 + assert.Equal(t, "open", *val) 269 + } 270 + 271 + func TestDynamicTagsEmpty(t *testing.T) { 272 + q := Parse("state:open label:bug author:alice keyword") 273 + assert.Equal(t, 0, len(q.GetDynamicTags())) 274 + assert.Equal(t, 0, len(q.GetNegatedDynamicTags())) 275 + }

History

1 round 1 comment
sign up or login to add to the discussion
octet-stream.net submitted #0
3 commits
expand
appview/searchquery: add dynamic tag extraction and shared resolution helpers
appview: add label-value search support to models and indexers
appview: integrate label-value search and DID resolution in handlers
expand 1 comment

works like a charm, had a read through the code itself, no notes! thanks so much for this contribution!

pull request successfully merged