| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package search |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | |
| 9 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 10 | ) |
| 11 | |
| 12 | // SearchIssues runs an issue search visible to actor. Issues |
| 13 | // inherit visibility from their repo via the same predicate. |
| 14 | // |
| 15 | // Ranking: `ts_rank_cd * state_weight` where open weighs 1.5× |
| 16 | // over closed (the spec doesn't pin a number; 1.5 is a sensible |
| 17 | // default that surfaces actionable issues first without burying |
| 18 | // the closed history). |
| 19 | // |
| 20 | // kindFilter is optional: pass "issue", "pr", or "" for both. The |
| 21 | // dropdown / Issues tab passes "issue"; the PR tab would pass "pr". |
| 22 | func SearchIssues(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQuery, kindFilter string, limit, offset int) ([]IssueResult, int64, error) { |
| 23 | if !q.HasContent() { |
| 24 | return nil, 0, ErrEmptyQuery |
| 25 | } |
| 26 | tsText, tsCtor, hasFTS := tsQueryBindAndCtor(q) |
| 27 | |
| 28 | // At least one signal must drive the query: the FTS payload, a |
| 29 | // repo: filter, an author: filter, or a state: filter. |
| 30 | if !hasFTS && q.RepoFilter == nil && q.AuthorFilter == "" && q.StateFilter == "" && kindFilter == "" { |
| 31 | return nil, 0, nil |
| 32 | } |
| 33 | |
| 34 | args := []any{} |
| 35 | tsPlaceholder := 0 |
| 36 | if hasFTS { |
| 37 | args = append(args, tsText) |
| 38 | tsPlaceholder = len(args) |
| 39 | } |
| 40 | visClause, visArgs := policy.VisibilityPredicate(actor, "r", len(args)+1) |
| 41 | args = append(args, visArgs...) |
| 42 | |
| 43 | whereExtras := "" |
| 44 | |
| 45 | if q.RepoFilter != nil { |
| 46 | ownerPos := len(args) + 1 |
| 47 | namePos := len(args) + 2 |
| 48 | args = append(args, q.RepoFilter.Owner, q.RepoFilter.Name) |
| 49 | whereExtras += repoFilterByOwnerName("r", ownerPos, namePos) |
| 50 | } |
| 51 | if q.StateFilter != "" { |
| 52 | statePos := len(args) + 1 |
| 53 | args = append(args, q.StateFilter) |
| 54 | whereExtras += fmt.Sprintf(" AND s.state::text = $%d", statePos) |
| 55 | } |
| 56 | if kindFilter != "" { |
| 57 | kindPos := len(args) + 1 |
| 58 | args = append(args, kindFilter) |
| 59 | whereExtras += fmt.Sprintf(" AND s.kind::text = $%d", kindPos) |
| 60 | } |
| 61 | if q.AuthorFilter != "" { |
| 62 | authorPos := len(args) + 1 |
| 63 | args = append(args, q.AuthorFilter) |
| 64 | whereExtras += fmt.Sprintf( |
| 65 | " AND s.author_user_id = (SELECT id FROM users WHERE username = $%d)", |
| 66 | authorPos, |
| 67 | ) |
| 68 | } |
| 69 | |
| 70 | whereFTS := "TRUE" |
| 71 | rankExpr := "1.0" |
| 72 | if hasFTS { |
| 73 | whereFTS = fmt.Sprintf("s.tsv @@ %s('shithub_search', $%d)", tsCtor, tsPlaceholder) |
| 74 | rankExpr = fmt.Sprintf("ts_rank_cd(s.tsv, %s('shithub_search', $%d))", tsCtor, tsPlaceholder) |
| 75 | } |
| 76 | |
| 77 | limPos := len(args) + 1 |
| 78 | offPos := len(args) + 2 |
| 79 | args = append(args, limit, offset) |
| 80 | |
| 81 | queryStr := fmt.Sprintf(` |
| 82 | SELECT i.id, r.id, %[7]s, r.name, i.number, i.title, |
| 83 | i.state::text, i.kind::text, |
| 84 | coalesce(au.username, '') AS author_name, |
| 85 | i.updated_at, |
| 86 | %[1]s * CASE WHEN s.state = 'open' THEN 1.5 ELSE 1.0 END AS rank |
| 87 | FROM issues_search s |
| 88 | JOIN issues i ON i.id = s.issue_id |
| 89 | JOIN repos r ON r.id = s.repo_id |
| 90 | %[8]s |
| 91 | LEFT JOIN users au ON au.id = s.author_user_id |
| 92 | WHERE %[2]s |
| 93 | AND %[3]s |
| 94 | %[4]s |
| 95 | ORDER BY rank DESC, i.updated_at DESC |
| 96 | LIMIT $%[5]d OFFSET $%[6]d |
| 97 | `, rankExpr, whereFTS, visClause, whereExtras, limPos, offPos, repoOwnerNameExpr("u", "o"), repoOwnerJoin("r", "u", "o")) |
| 98 | |
| 99 | rows, err := deps.Pool.Query(ctx, queryStr, args...) |
| 100 | if err != nil { |
| 101 | return nil, 0, fmt.Errorf("search issues: %w", err) |
| 102 | } |
| 103 | defer rows.Close() |
| 104 | out := make([]IssueResult, 0, limit) |
| 105 | for rows.Next() { |
| 106 | var r IssueResult |
| 107 | if err := rows.Scan(&r.ID, &r.RepoID, &r.OwnerUsername, &r.RepoName, |
| 108 | &r.Number, &r.Title, &r.State, &r.Kind, &r.AuthorName, |
| 109 | &r.UpdatedAt, &r.Rank); err != nil { |
| 110 | return nil, 0, err |
| 111 | } |
| 112 | out = append(out, r) |
| 113 | } |
| 114 | if err := rows.Err(); err != nil { |
| 115 | return nil, 0, err |
| 116 | } |
| 117 | |
| 118 | countQuery := fmt.Sprintf(` |
| 119 | SELECT count(*) |
| 120 | FROM issues_search s |
| 121 | JOIN issues i ON i.id = s.issue_id |
| 122 | JOIN repos r ON r.id = s.repo_id |
| 123 | WHERE %[1]s AND %[2]s %[3]s |
| 124 | `, whereFTS, visClause, whereExtras) |
| 125 | var total int64 |
| 126 | if err := deps.Pool.QueryRow(ctx, countQuery, args[:len(args)-2]...).Scan(&total); err != nil { |
| 127 | return nil, 0, fmt.Errorf("count issues: %w", err) |
| 128 | } |
| 129 | return out, total, nil |
| 130 | } |
| 131 |