From 2dfa521fa237fb96026c8cd1e36be31a9501caac Mon Sep 17 00:00:00 2001 From: Cameron Smith Date: Mon, 6 Oct 2025 23:01:36 -0400 Subject: [PATCH] appview: add search bar for issues and pulls with filters and sorts - Implement search with naive title and body issue text search for issues and pulls. - Use has:label syntax for filtering on labels. - Add reaction counts to list pages. Signed-off-by: Cameron Smith --- appview/db/issues.go | 259 ++++++++++++++++++ appview/db/pulls.go | 196 +++++++++++++ appview/db/repos.go | 15 + appview/issues/issues.go | 34 ++- appview/models/issue.go | 7 +- appview/pages/pages.go | 6 + .../templates/repo/fragments/searchBar.html | 185 +++++++++++++ .../repo/issues/fragments/issueListing.html | 10 + .../pages/templates/repo/issues/issues.html | 33 ++- appview/pages/templates/repo/pulls/pulls.html | 11 +- appview/pulls/pulls.go | 28 +- appview/search/query.go | 63 +++++ 12 files changed, 838 insertions(+), 9 deletions(-) create mode 100644 appview/pages/templates/repo/fragments/searchBar.html create mode 100644 appview/search/query.go diff --git a/appview/db/issues.go b/appview/db/issues.go index 028668b0..46bbca90 100644 --- a/appview/db/issues.go +++ b/appview/db/issues.go @@ -237,6 +237,10 @@ func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]mo } sort.Slice(issues, func(i, j int) bool { + if issues[i].Created.Equal(issues[j].Created) { + // Tiebreaker: use issue_id for stable sort + return issues[i].IssueId > issues[j].IssueId + } return issues[i].Created.After(issues[j].Created) }) @@ -490,3 +494,258 @@ func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) { return count, nil } + +func SearchIssues(e Execer, page pagination.Page, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]models.Issue, error) { + var conditions []string + var args []any + + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + if text != "" { + searchPattern := "%" + text + "%" + conditions = append(conditions, "(title like ? or body like ?)") + args = append(args, searchPattern, searchPattern) + } + + whereClause := "" + if len(conditions) > 0 { + whereClause = " where " + strings.Join(conditions, " and ") + } + + pLower := FilterGte("row_num", page.Offset+1) + pUpper := FilterLte("row_num", page.Offset+page.Limit) + args = append(args, pLower.Arg()...) + args = append(args, pUpper.Arg()...) + paginationClause := " where " + pLower.Condition() + " and " + pUpper.Condition() + + query := fmt.Sprintf( + ` + select * from ( + select + id, + did, + rkey, + repo_at, + issue_id, + title, + body, + open, + created, + edited, + deleted, + row_number() over (order by created desc) as row_num + from + issues + %s + ) ranked_issues + %s + `, + whereClause, + paginationClause, + ) + + rows, err := e.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query issues: %w", err) + } + defer rows.Close() + + issueMap := make(map[string]*models.Issue) + for rows.Next() { + var issue models.Issue + var createdAt string + var editedAt, deletedAt sql.Null[string] + var rowNum int64 + + err := rows.Scan( + &issue.Id, + &issue.Did, + &issue.Rkey, + &issue.RepoAt, + &issue.IssueId, + &issue.Title, + &issue.Body, + &issue.Open, + &createdAt, + &editedAt, + &deletedAt, + &rowNum, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan issue: %w", err) + } + + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { + issue.Created = t + } + if editedAt.Valid { + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { + issue.Edited = &t + } + } + if deletedAt.Valid { + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { + issue.Deleted = &t + } + } + + atUri := issue.AtUri().String() + issueMap[atUri] = &issue + } + + repoAts := make([]string, 0, len(issueMap)) + for _, issue := range issueMap { + repoAts = append(repoAts, string(issue.RepoAt)) + } + + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) + if err != nil { + return nil, fmt.Errorf("failed to build repo mappings: %w", err) + } + + repoMap := make(map[string]*models.Repo) + for i := range repos { + repoMap[string(repos[i].RepoAt())] = &repos[i] + } + + for issueAt, i := range issueMap { + if r, ok := repoMap[string(i.RepoAt)]; ok { + i.Repo = r + } else { + delete(issueMap, issueAt) + } + } + + issueAts := slices.Collect(maps.Keys(issueMap)) + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) + if err != nil { + return nil, fmt.Errorf("failed to query comments: %w", err) + } + for i := range comments { + issueAt := comments[i].IssueAt + if issue, ok := issueMap[issueAt]; ok { + issue.Comments = append(issue.Comments, comments[i]) + } + } + + allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) + if err != nil { + return nil, fmt.Errorf("failed to query labels: %w", err) + } + for issueAt, labels := range allLabels { + if issue, ok := issueMap[issueAt.String()]; ok { + issue.Labels = labels + } + } + + reactionCounts := make(map[string]int) + if len(issueAts) > 0 { + reactionArgs := make([]any, len(issueAts)) + for i, v := range issueAts { + reactionArgs[i] = v + } + rows, err := e.Query(` + select thread_at, count(*) as total + from reactions + where thread_at in (`+strings.Repeat("?,", len(issueAts)-1)+"?"+`) + group by thread_at + `, reactionArgs...) + if err == nil { + defer rows.Close() + for rows.Next() { + var threadAt string + var count int + if err := rows.Scan(&threadAt, &count); err == nil { + reactionCounts[threadAt] = count + } + } + } + } + + if len(labels) > 0 { + if len(issueMap) > 0 { + var repoAt string + for _, issue := range issueMap { + repoAt = string(issue.RepoAt) + break + } + + repo, err := GetRepoByAtUri(e, repoAt) + if err == nil && len(repo.Labels) > 0 { + labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels)) + if err == nil { + labelNameToUri := make(map[string]string) + for _, def := range labelDefs { + labelNameToUri[def.Name] = def.AtUri().String() + } + + for issueAt, issue := range issueMap { + hasAllLabels := true + for _, labelName := range labels { + labelUri, found := labelNameToUri[labelName] + if !found { + hasAllLabels = false + break + } + if !issue.Labels.ContainsLabel(labelUri) { + hasAllLabels = false + break + } + } + if !hasAllLabels { + delete(issueMap, issueAt) + } + } + } + } + } + } + + var issues []models.Issue + for _, i := range issueMap { + i.ReactionCount = reactionCounts[i.AtUri().String()] + issues = append(issues, *i) + } + + sort.Slice(issues, func(i, j int) bool { + var less bool + + switch sortBy { + case "comments": + if len(issues[i].Comments) == len(issues[j].Comments) { + // Tiebreaker: use issue_id for stable sort + less = issues[i].IssueId > issues[j].IssueId + } else { + less = len(issues[i].Comments) > len(issues[j].Comments) + } + case "reactions": + iCount := reactionCounts[issues[i].AtUri().String()] + jCount := reactionCounts[issues[j].AtUri().String()] + if iCount == jCount { + // Tiebreaker: use issue_id for stable sort + less = issues[i].IssueId > issues[j].IssueId + } else { + less = iCount > jCount + } + case "created": + fallthrough + default: + if issues[i].Created.Equal(issues[j].Created) { + // Tiebreaker: use issue_id for stable sort + less = issues[i].IssueId > issues[j].IssueId + } else { + less = issues[i].Created.After(issues[j].Created) + } + } + + if sortOrder == "asc" { + return !less + } + return less + }) + + return issues, nil +} diff --git a/appview/db/pulls.go b/appview/db/pulls.go index cacb6c42..82d15d3e 100644 --- a/appview/db/pulls.go +++ b/appview/db/pulls.go @@ -736,3 +736,199 @@ func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) { return pulls, nil } + +func SearchPulls(e Execer, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]*models.Pull, error) { + var conditions []string + var args []any + + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + if text != "" { + searchPattern := "%" + text + "%" + conditions = append(conditions, "title like ?") + args = append(args, searchPattern) + } + + whereClause := "" + if len(conditions) > 0 { + whereClause = " where " + strings.Join(conditions, " and ") + } + + query := fmt.Sprintf(` + select + id, + owner_did, + pull_id, + title, + body, + target_branch, + repo_at, + rkey, + state, + source_branch, + source_repo_at, + stack_id, + change_id, + parent_change_id, + created + from pulls + %s + order by created desc + `, whereClause) + + rows, err := e.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query pulls: %w", err) + } + defer rows.Close() + + pullMap := make(map[string]*models.Pull) + for rows.Next() { + var pull models.Pull + var createdAt string + var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.Null[string] + + err := rows.Scan( + &pull.ID, + &pull.OwnerDid, + &pull.PullId, + &pull.Title, + &pull.Body, + &pull.TargetBranch, + &pull.RepoAt, + &pull.Rkey, + &pull.State, + &sourceBranch, + &sourceRepoAt, + &stackId, + &changeId, + &parentChangeId, + &createdAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan pull: %w", err) + } + + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { + pull.Created = t + } + + if sourceBranch.Valid || sourceRepoAt.Valid { + pull.PullSource = &models.PullSource{} + if sourceBranch.Valid { + pull.PullSource.Branch = sourceBranch.V + } + if sourceRepoAt.Valid { + uri := syntax.ATURI(sourceRepoAt.V) + pull.PullSource.RepoAt = &uri + } + } + + if stackId.Valid { + pull.StackId = stackId.V + } + if changeId.Valid { + pull.ChangeId = changeId.V + } + if parentChangeId.Valid { + pull.ParentChangeId = parentChangeId.V + } + + pullAt := pull.PullAt().String() + pullMap[pullAt] = &pull + } + + // Load submissions and labels + for _, pull := range pullMap { + submissionsMap, err := GetPullSubmissions(e, FilterEq("pull_at", pull.PullAt().String())) + if err != nil { + return nil, fmt.Errorf("failed to query submissions: %w", err) + } + if subs, ok := submissionsMap[pull.PullAt()]; ok { + pull.Submissions = subs + } + } + + // Collect labels + pullAts := slices.Collect(maps.Keys(pullMap)) + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) + if err != nil { + return nil, fmt.Errorf("failed to query labels: %w", err) + } + for pullAt, labels := range allLabels { + if pull, ok := pullMap[pullAt.String()]; ok { + pull.Labels = labels + } + } + + // Filter by labels if specified + if len(labels) > 0 { + if len(pullMap) > 0 { + var repoAt string + for _, pull := range pullMap { + repoAt = string(pull.RepoAt) + break + } + + repo, err := GetRepoByAtUri(e, repoAt) + if err == nil && len(repo.Labels) > 0 { + labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels)) + if err == nil { + labelNameToUri := make(map[string]string) + for _, def := range labelDefs { + labelNameToUri[def.Name] = def.AtUri().String() + } + + for pullAt, pull := range pullMap { + hasAllLabels := true + for _, labelName := range labels { + labelUri, found := labelNameToUri[labelName] + if !found { + hasAllLabels = false + break + } + if !pull.Labels.ContainsLabel(labelUri) { + hasAllLabels = false + break + } + } + if !hasAllLabels { + delete(pullMap, pullAt) + } + } + } + } + } + } + + var pulls []*models.Pull + for _, p := range pullMap { + pulls = append(pulls, p) + } + + sort.Slice(pulls, func(i, j int) bool { + var less bool + + switch sortBy { + case "created": + fallthrough + default: + if pulls[i].Created.Equal(pulls[j].Created) { + // Tiebreaker: use pull_id for stable sort + less = pulls[i].PullId > pulls[j].PullId + } else { + less = pulls[i].Created.After(pulls[j].Created) + } + } + + if sortOrder == "asc" { + return !less + } + return less + }) + + return pulls, nil +} diff --git a/appview/db/repos.go b/appview/db/repos.go index 27a88b66..3e6e5b15 100644 --- a/appview/db/repos.go +++ b/appview/db/repos.go @@ -372,6 +372,21 @@ func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { repo.Description = "" } + // Load labels for this repo + rows, err := e.Query(`select label_at from repo_labels where repo_at = ?`, atUri) + if err != nil { + return nil, fmt.Errorf("failed to load repo labels: %w", err) + } + defer rows.Close() + + for rows.Next() { + var labelAt string + if err := rows.Scan(&labelAt); err != nil { + continue + } + repo.Labels = append(repo.Labels, labelAt) + } + return &repo, nil } diff --git a/appview/issues/issues.go b/appview/issues/issues.go index d454d890..024e9e6b 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -26,6 +26,7 @@ import ( "tangled.org/core/appview/pages" "tangled.org/core/appview/pagination" "tangled.org/core/appview/reporesolver" + "tangled.org/core/appview/search" "tangled.org/core/appview/validator" "tangled.org/core/idresolver" tlog "tangled.org/core/log" @@ -759,6 +760,22 @@ func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { params := r.URL.Query() state := params.Get("state") + searchQuery := params.Get("q") + sortBy := params.Get("sort_by") + sortOrder := params.Get("sort_order") + + // Use for template (preserve empty values) + templateSortBy := sortBy + templateSortOrder := sortOrder + + // Default sort values for queries + if sortBy == "" { + sortBy = "created" + } + if sortOrder == "" { + sortOrder = "desc" + } + isOpen := true switch state { case "open": @@ -786,12 +803,24 @@ func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { if isOpen { openVal = 1 } - issues, err := db.GetIssuesPaginated( + + var issues []models.Issue + + // Parse the search query (even if empty, to handle label filters) + query := search.Parse(searchQuery) + + // Always use search function to handle sorting + issues, err = db.SearchIssues( rp.db, page, + query.Text, + query.Labels, + sortBy, + sortOrder, db.FilterEq("repo_at", f.RepoAt()), db.FilterEq("open", openVal), ) + if err != nil { log.Println("failed to get issues", err) rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") @@ -821,6 +850,9 @@ func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { LabelDefs: defs, FilteringByOpen: isOpen, Page: page, + SearchQuery: searchQuery, + SortBy: templateSortBy, + SortOrder: templateSortOrder, }) } diff --git a/appview/models/issue.go b/appview/models/issue.go index 78b79773..367ec50f 100644 --- a/appview/models/issue.go +++ b/appview/models/issue.go @@ -24,9 +24,10 @@ type Issue struct { // optionally, populate this when querying for reverse mappings // like comment counts, parent repo etc. - Comments []IssueComment - Labels LabelState - Repo *Repo + Comments []IssueComment + ReactionCount int + Labels LabelState + Repo *Repo } func (i *Issue) AtUri() syntax.ATURI { diff --git a/appview/pages/pages.go b/appview/pages/pages.go index f78f3856..38e4e1a8 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -969,6 +969,9 @@ type RepoIssuesParams struct { LabelDefs map[string]*models.LabelDefinition Page pagination.Page FilteringByOpen bool + SearchQuery string + SortBy string + SortOrder string } func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { @@ -1102,6 +1105,9 @@ type RepoPullsParams struct { Stacks map[string]models.Stack Pipelines map[string]models.Pipeline LabelDefs map[string]*models.LabelDefinition + SearchQuery string + SortBy string + SortOrder string } func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { diff --git a/appview/pages/templates/repo/fragments/searchBar.html b/appview/pages/templates/repo/fragments/searchBar.html new file mode 100644 index 00000000..c2524eff --- /dev/null +++ b/appview/pages/templates/repo/fragments/searchBar.html @@ -0,0 +1,185 @@ +{{ define "repo/fragments/searchBar" }} +
+
+
+ + +
+ + + {{ if .State }} + + {{ end }} + + + {{ $sortBy := .SortBy }} + {{ $sortOrder := .SortOrder }} + {{ $defaultSortBy := "created" }} + {{ $defaultSortOrder := "desc" }} + {{ if not $sortBy }} + {{ $sortBy = $defaultSortBy }} + {{ end }} + {{ if not $sortOrder }} + {{ $sortOrder = $defaultSortOrder }} + {{ end }} + + + + + + + +
+
+ + +{{ end }} diff --git a/appview/pages/templates/repo/issues/fragments/issueListing.html b/appview/pages/templates/repo/issues/fragments/issueListing.html index 8f858bbd..a2419597 100644 --- a/appview/pages/templates/repo/issues/fragments/issueListing.html +++ b/appview/pages/templates/repo/issues/fragments/issueListing.html @@ -42,6 +42,16 @@ {{ len .Comments }} comment{{$s}} + {{ if gt .ReactionCount 0 }} + + {{ $s := "s" }} + {{ if eq .ReactionCount 1 }} + {{ $s = "" }} + {{ end }} + {{ .ReactionCount }} reaction{{$s}} + + {{ end }} + {{ $state := .Labels }} {{ range $k, $d := $.LabelDefs }} {{ range $v, $s := $state.GetValSet $d.AtUri.String }} diff --git a/appview/pages/templates/repo/issues/issues.html b/appview/pages/templates/repo/issues/issues.html index 5d073f0e..04f50dad 100644 --- a/appview/pages/templates/repo/issues/issues.html +++ b/appview/pages/templates/repo/issues/issues.html @@ -8,7 +8,7 @@ {{ end }} {{ define "repoContent" }} -
+
+ +{{ $state := "open" }} +{{ if not .FilteringByOpen }} + {{ $state = "closed" }} +{{ end }} + +{{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "issues" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }}
{{ end }} @@ -52,10 +59,20 @@ {{ if gt .Page.Offset 0 }} {{ $prev := .Page.Previous }} + {{ $prevUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $prev.Offset $prev.Limit }} + {{ if .SearchQuery }} + {{ $prevUrl = printf "%s&q=%s" $prevUrl .SearchQuery }} + {{ end }} + {{ if .SortBy }} + {{ $prevUrl = printf "%s&sort_by=%s" $prevUrl .SortBy }} + {{ end }} + {{ if .SortOrder }} + {{ $prevUrl = printf "%s&sort_order=%s" $prevUrl .SortOrder }} + {{ end }} {{ i "chevron-left" "w-4 h-4" }} previous @@ -66,10 +83,20 @@ {{ if eq (len .Issues) .Page.Limit }} {{ $next := .Page.Next }} + {{ $nextUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $next.Offset $next.Limit }} + {{ if .SearchQuery }} + {{ $nextUrl = printf "%s&q=%s" $nextUrl .SearchQuery }} + {{ end }} + {{ if .SortBy }} + {{ $nextUrl = printf "%s&sort_by=%s" $nextUrl .SortBy }} + {{ end }} + {{ if .SortOrder }} + {{ $nextUrl = printf "%s&sort_order=%s" $nextUrl .SortOrder }} + {{ end }} next {{ i "chevron-right" "w-4 h-4" }} diff --git a/appview/pages/templates/repo/pulls/pulls.html b/appview/pages/templates/repo/pulls/pulls.html index 6541a54c..13892783 100644 --- a/appview/pages/templates/repo/pulls/pulls.html +++ b/appview/pages/templates/repo/pulls/pulls.html @@ -8,7 +8,7 @@ {{ end }} {{ define "repoContent" }} -
+
+ + {{ $state := "open" }} + {{ if .FilteringBy.IsMerged }} + {{ $state = "merged" }} + {{ else if .FilteringBy.IsClosed }} + {{ $state = "closed" }} + {{ end }} + + {{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "pulls" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }}
{{ end }} diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index 126202a7..ee15192a 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -21,6 +21,7 @@ import ( "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup" "tangled.org/core/appview/reporesolver" + "tangled.org/core/appview/search" "tangled.org/core/appview/xrpcclient" "tangled.org/core/idresolver" "tangled.org/core/patchutil" @@ -492,6 +493,19 @@ func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) params := r.URL.Query() + searchQuery := params.Get("q") + sortBy := params.Get("sort_by") + sortOrder := params.Get("sort_order") + + templateSortBy := sortBy + templateSortOrder := sortOrder + + if sortBy == "" { + sortBy = "created" + } + if sortOrder == "" { + sortOrder = "desc" + } state := models.PullOpen switch params.Get("state") { @@ -507,11 +521,20 @@ func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { return } - pulls, err := db.GetPulls( + var pulls []*models.Pull + + query := search.Parse(searchQuery) + + pulls, err = db.SearchPulls( s.db, + query.Text, + query.Labels, + sortBy, + sortOrder, db.FilterEq("repo_at", f.RepoAt()), db.FilterEq("state", state), ) + if err != nil { log.Println("failed to get pulls", err) s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") @@ -599,6 +622,9 @@ func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { FilteringBy: state, Stacks: stacks, Pipelines: m, + SearchQuery: searchQuery, + SortBy: templateSortBy, + SortOrder: templateSortOrder, }) } diff --git a/appview/search/query.go b/appview/search/query.go new file mode 100644 index 00000000..bdf6e022 --- /dev/null +++ b/appview/search/query.go @@ -0,0 +1,63 @@ +package search + +import ( + "strings" +) + +// Query represents a parsed search query +type Query struct { + // Text search terms (anything that's not a has: filter) + Text string + // Label filters from has:labelname syntax + Labels []string +} + +// Parse parses a search query string into a Query struct +// Syntax: +// - "has:enhancement" adds a label filter +// - Other text becomes part of the text search +func Parse(queryStr string) Query { + q := Query{ + Labels: []string{}, + } + + // Split query into tokens + tokens := strings.Fields(queryStr) + var textParts []string + + for _, token := range tokens { + // Check if it's a has: filter + if strings.HasPrefix(token, "has:") { + label := strings.TrimPrefix(token, "has:") + if label != "" { + q.Labels = append(q.Labels, label) + } + } else { + // It's a text search term + textParts = append(textParts, token) + } + } + + q.Text = strings.Join(textParts, " ") + return q +} + +// String converts a Query back to a query string +func (q Query) String() string { + var parts []string + + if q.Text != "" { + parts = append(parts, q.Text) + } + + for _, label := range q.Labels { + parts = append(parts, "has:"+label) + } + + return strings.Join(parts, " ") +} + +// HasFilters returns true if the query has any search filters +func (q Query) HasFilters() bool { + return q.Text != "" || len(q.Labels) > 0 +} -- 2.43.0