appview: add issue search endpoint #496

merged
opened by boltless.me targeting master from boltless.me/core: feat/search
Changed files
+124 -40
appview
db
issues
pages
templates
repo
issues
+87 -37
appview/db/issues.go
··· 2 3 import ( 4 "database/sql" 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 "tangled.sh/tangled.sh/core/appview/pagination" 9 ) 10 ··· 160 return issues, nil 161 } 162 163 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 164 - var issues []Issue 165 openValue := 0 166 - if isOpen { 167 openValue = 1 168 } 169 170 - rows, err := e.Query( 171 ` 172 - with numbered_issue as ( 173 - select 174 - i.id, 175 - i.owner_did, 176 - i.issue_id, 177 - i.created, 178 - i.title, 179 - i.body, 180 - i.open, 181 - count(c.id) as comment_count, 182 - row_number() over (order by i.created desc) as row_num 183 - from 184 - issues i 185 - left join 186 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 187 - where 188 - i.repo_at = ? and i.open = ? 189 - group by 190 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 191 - ) 192 select 193 - id, 194 - owner_did, 195 - issue_id, 196 - created, 197 - title, 198 - body, 199 - open, 200 - comment_count 201 - from 202 - numbered_issue 203 - where 204 - row_num between ? and ?`, 205 - repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 206 if err != nil { 207 return nil, err 208 }
··· 2 3 import ( 4 "database/sql" 5 + "fmt" 6 + "strconv" 7 + "strings" 8 "time" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.sh/tangled.sh/core/appview/models" 12 "tangled.sh/tangled.sh/core/appview/pagination" 13 ) 14 ··· 164 return issues, nil 165 } 166 167 + // GetIssueIDs gets list of all existing issue's IDs 168 + func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) { 169 + var ids []int64 170 + 171 + var filters []filter 172 openValue := 0 173 + if opts.IsOpen { 174 openValue = 1 175 } 176 + filters = append(filters, FilterEq("open", openValue)) 177 + if opts.RepoAt != "" { 178 + filters = append(filters, FilterEq("repo_at", opts.RepoAt)) 179 + } 180 181 + var conditions []string 182 + var args []any 183 + 184 + for _, filter := range filters { 185 + conditions = append(conditions, filter.Condition()) 186 + args = append(args, filter.Arg()...) 187 + } 188 + 189 + whereClause := "" 190 + if conditions != nil { 191 + whereClause = " where " + strings.Join(conditions, " and ") 192 + } 193 + query := fmt.Sprintf( 194 ` 195 select 196 + id 197 + from 198 + issues 199 + %s 200 + limit ? offset ?`, 201 + whereClause, 202 + ) 203 + args = append(args, opts.Page.Limit, opts.Page.Offset) 204 + rows, err := e.Query(query, args...) 205 + if err != nil { 206 + return nil, err 207 + } 208 + defer rows.Close() 209 + 210 + for rows.Next() { 211 + var id int64 212 + err := rows.Scan(&id) 213 + if err != nil { 214 + return nil, err 215 + } 216 + 217 + ids = append(ids, id) 218 + } 219 + 220 + return ids, nil 221 + } 222 + 223 + // GetIssuesByIDs gets list of issues from given IDs 224 + func GetIssuesByIDs(e Execer, ids []int64) ([]Issue, error) { 225 + var issues []Issue 226 + 227 + // HACK: would be better to create "?,?,?,..." or use something like sqlx 228 + idStrings := make([]string, len(ids)) 229 + for i, id := range ids { 230 + idStrings[i] = strconv.FormatInt(id, 10) 231 + } 232 + idList := strings.Join(idStrings, ",") 233 + query := fmt.Sprintf( 234 + ` 235 + select 236 + i.id, 237 + i.owner_did, 238 + i.issue_id, 239 + i.created, 240 + i.title, 241 + i.body, 242 + i.open, 243 + count(c.id) as comment_count 244 + from 245 + issues i 246 + left join 247 + comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 248 + where 249 + i.id in (%s) 250 + group by 251 + i.id 252 + order by i.created desc`, 253 + idList, 254 + ) 255 + rows, err := e.Query(query) 256 if err != nil { 257 return nil, err 258 }
+28 -1
appview/issues/issues.go
··· 19 "tangled.sh/tangled.sh/core/appview/config" 20 "tangled.sh/tangled.sh/core/appview/db" 21 issues_indexer "tangled.sh/tangled.sh/core/appview/indexer/issues" 22 "tangled.sh/tangled.sh/core/appview/notify" 23 "tangled.sh/tangled.sh/core/appview/oauth" 24 "tangled.sh/tangled.sh/core/appview/pages" ··· 607 return 608 } 609 610 - issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 611 if err != nil { 612 log.Println("failed to get issues", err) 613 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 619 RepoInfo: f.RepoInfo(user), 620 Issues: issues, 621 FilteringByOpen: isOpen, 622 Page: page, 623 }) 624 }
··· 19 "tangled.sh/tangled.sh/core/appview/config" 20 "tangled.sh/tangled.sh/core/appview/db" 21 issues_indexer "tangled.sh/tangled.sh/core/appview/indexer/issues" 22 + "tangled.sh/tangled.sh/core/appview/models" 23 "tangled.sh/tangled.sh/core/appview/notify" 24 "tangled.sh/tangled.sh/core/appview/oauth" 25 "tangled.sh/tangled.sh/core/appview/pages" ··· 608 return 609 } 610 611 + keyword := params.Get("q") 612 + 613 + var ids []int64 614 + searchOpts := models.IssueSearchOptions{ 615 + Keyword: keyword, 616 + RepoAt: f.RepoAt.String(), 617 + IsOpen: isOpen, 618 + Page: page, 619 + } 620 + if keyword != "" { 621 + res, err := rp.indexer.Search(r.Context(), searchOpts) 622 + if err != nil { 623 + log.Println("failed to search for issues", err) 624 + return 625 + } 626 + log.Println("searched issues:", res.Hits) 627 + ids = res.Hits 628 + } else { 629 + ids, err = db.GetIssueIDs(rp.db, searchOpts) 630 + if err != nil { 631 + log.Println("failed to search for issues", err) 632 + return 633 + } 634 + } 635 + 636 + issues, err := db.GetIssuesByIDs(rp.db, ids) 637 if err != nil { 638 log.Println("failed to get issues", err) 639 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 645 RepoInfo: f.RepoInfo(user), 646 Issues: issues, 647 FilteringByOpen: isOpen, 648 + FilterQuery: keyword, 649 Page: page, 650 }) 651 }
+9 -2
appview/pages/templates/repo/issues/issues.html
··· 24 {{ i "ban" "w-4 h-4" }} 25 <span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span> 26 </a> 27 </div> 28 <a 29 href="/{{ .RepoInfo.FullName }}/issues/new" ··· 100 <a 101 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 102 hx-boost="true" 103 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 104 > 105 {{ i "chevron-left" "w-4 h-4" }} 106 previous ··· 114 <a 115 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 116 hx-boost="true" 117 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 118 > 119 next 120 {{ i "chevron-right" "w-4 h-4" }}
··· 24 {{ i "ban" "w-4 h-4" }} 25 <span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span> 26 </a> 27 + <form class="flex gap-4" method="GET"> 28 + <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 29 + <input class="" type="text" name="q" value="{{ .FilterQuery }}"> 30 + <button class="btn" type="submit"> 31 + search 32 + </button> 33 + </form> 34 </div> 35 <a 36 href="/{{ .RepoInfo.FullName }}/issues/new" ··· 107 <a 108 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 109 hx-boost="true" 110 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 111 > 112 {{ i "chevron-left" "w-4 h-4" }} 113 previous ··· 121 <a 122 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 123 hx-boost="true" 124 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 125 > 126 next 127 {{ i "chevron-right" "w-4 h-4" }}