Go · 4022 bytes Raw Blame History
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