forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
1package db
2
3import (
4 "database/sql"
5 "fmt"
6 "maps"
7 "slices"
8 "sort"
9 "strings"
10 "time"
11
12 "github.com/bluesky-social/indigo/atproto/syntax"
13 "tangled.org/core/appview/models"
14 "tangled.org/core/appview/pagination"
15)
16
17func PutIssue(tx *sql.Tx, issue *models.Issue) error {
18 // ensure sequence exists
19 _, err := tx.Exec(`
20 insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
21 values (?, 1)
22 `, issue.RepoAt)
23 if err != nil {
24 return err
25 }
26
27 issues, err := GetIssues(
28 tx,
29 FilterEq("did", issue.Did),
30 FilterEq("rkey", issue.Rkey),
31 )
32 switch {
33 case err != nil:
34 return err
35 case len(issues) == 0:
36 return createNewIssue(tx, issue)
37 case len(issues) != 1: // should be unreachable
38 return fmt.Errorf("invalid number of issues returned: %d", len(issues))
39 default:
40 // if content is identical, do not edit
41 existingIssue := issues[0]
42 if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body {
43 return nil
44 }
45
46 issue.Id = existingIssue.Id
47 issue.IssueId = existingIssue.IssueId
48 return updateIssue(tx, issue)
49 }
50}
51
52func createNewIssue(tx *sql.Tx, issue *models.Issue) error {
53 // get next issue_id
54 var newIssueId int
55 err := tx.QueryRow(`
56 update repo_issue_seqs
57 set next_issue_id = next_issue_id + 1
58 where repo_at = ?
59 returning next_issue_id - 1
60 `, issue.RepoAt).Scan(&newIssueId)
61 if err != nil {
62 return err
63 }
64
65 // insert new issue
66 row := tx.QueryRow(`
67 insert into issues (repo_at, did, rkey, issue_id, title, body)
68 values (?, ?, ?, ?, ?, ?)
69 returning rowid, issue_id
70 `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
71
72 return row.Scan(&issue.Id, &issue.IssueId)
73}
74
75func updateIssue(tx *sql.Tx, issue *models.Issue) error {
76 // update existing issue
77 _, err := tx.Exec(`
78 update issues
79 set title = ?, body = ?, edited = ?
80 where did = ? and rkey = ?
81 `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
82 return err
83}
84
85func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
86 issueMap := make(map[string]*models.Issue) // at-uri -> issue
87
88 var conditions []string
89 var args []any
90
91 for _, filter := range filters {
92 conditions = append(conditions, filter.Condition())
93 args = append(args, filter.Arg()...)
94 }
95
96 whereClause := ""
97 if conditions != nil {
98 whereClause = " where " + strings.Join(conditions, " and ")
99 }
100
101 pLower := FilterGte("row_num", page.Offset+1)
102 pUpper := FilterLte("row_num", page.Offset+page.Limit)
103
104 pageClause := ""
105 if page.Limit > 0 {
106 args = append(args, pLower.Arg()...)
107 args = append(args, pUpper.Arg()...)
108 pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition()
109 }
110
111 query := fmt.Sprintf(
112 `
113 select * from (
114 select
115 id,
116 did,
117 rkey,
118 repo_at,
119 issue_id,
120 title,
121 body,
122 open,
123 created,
124 edited,
125 deleted,
126 row_number() over (order by created desc) as row_num
127 from
128 issues
129 %s
130 ) ranked_issues
131 %s
132 `,
133 whereClause,
134 pageClause,
135 )
136
137 rows, err := e.Query(query, args...)
138 if err != nil {
139 return nil, fmt.Errorf("failed to query issues table: %w", err)
140 }
141 defer rows.Close()
142
143 for rows.Next() {
144 var issue models.Issue
145 var createdAt string
146 var editedAt, deletedAt sql.Null[string]
147 var rowNum int64
148 err := rows.Scan(
149 &issue.Id,
150 &issue.Did,
151 &issue.Rkey,
152 &issue.RepoAt,
153 &issue.IssueId,
154 &issue.Title,
155 &issue.Body,
156 &issue.Open,
157 &createdAt,
158 &editedAt,
159 &deletedAt,
160 &rowNum,
161 )
162 if err != nil {
163 return nil, fmt.Errorf("failed to scan issue: %w", err)
164 }
165
166 if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
167 issue.Created = t
168 }
169
170 if editedAt.Valid {
171 if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil {
172 issue.Edited = &t
173 }
174 }
175
176 if deletedAt.Valid {
177 if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
178 issue.Deleted = &t
179 }
180 }
181
182 atUri := issue.AtUri().String()
183 issueMap[atUri] = &issue
184 }
185
186 // collect reverse repos
187 repoAts := make([]string, 0, len(issueMap)) // or just []string{}
188 for _, issue := range issueMap {
189 repoAts = append(repoAts, string(issue.RepoAt))
190 }
191
192 repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
193 if err != nil {
194 return nil, fmt.Errorf("failed to build repo mappings: %w", err)
195 }
196
197 repoMap := make(map[string]*models.Repo)
198 for i := range repos {
199 repoMap[string(repos[i].RepoAt())] = &repos[i]
200 }
201
202 for issueAt, i := range issueMap {
203 if r, ok := repoMap[string(i.RepoAt)]; ok {
204 i.Repo = r
205 } else {
206 // do not show up the issue if the repo is deleted
207 // TODO: foreign key where?
208 delete(issueMap, issueAt)
209 }
210 }
211
212 // collect comments
213 issueAts := slices.Collect(maps.Keys(issueMap))
214
215 comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
216 if err != nil {
217 return nil, fmt.Errorf("failed to query comments: %w", err)
218 }
219 for i := range comments {
220 issueAt := comments[i].IssueAt
221 if issue, ok := issueMap[issueAt]; ok {
222 issue.Comments = append(issue.Comments, comments[i])
223 }
224 }
225
226 // collect allLabels for each issue
227 allLabels, err := GetLabels(e, FilterIn("subject", issueAts))
228 if err != nil {
229 return nil, fmt.Errorf("failed to query labels: %w", err)
230 }
231 for issueAt, labels := range allLabels {
232 if issue, ok := issueMap[issueAt.String()]; ok {
233 issue.Labels = labels
234 }
235 }
236
237 var issues []models.Issue
238 for _, i := range issueMap {
239 issues = append(issues, *i)
240 }
241
242 sort.Slice(issues, func(i, j int) bool {
243 return issues[i].Created.After(issues[j].Created)
244 })
245
246 return issues, nil
247}
248
249func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
250 return GetIssuesPaginated(e, pagination.Page{}, filters...)
251}
252
253// GetIssueIDs gets list of all existing issue's IDs
254func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) {
255 var ids []int64
256
257 var filters []filter
258 openValue := 0
259 if opts.IsOpen {
260 openValue = 1
261 }
262 filters = append(filters, FilterEq("open", openValue))
263 if opts.RepoAt != "" {
264 filters = append(filters, FilterEq("repo_at", opts.RepoAt))
265 }
266
267 var conditions []string
268 var args []any
269
270 for _, filter := range filters {
271 conditions = append(conditions, filter.Condition())
272 args = append(args, filter.Arg()...)
273 }
274
275 whereClause := ""
276 if conditions != nil {
277 whereClause = " where " + strings.Join(conditions, " and ")
278 }
279 query := fmt.Sprintf(
280 `
281 select
282 id
283 from
284 issues
285 %s
286 limit ? offset ?`,
287 whereClause,
288 )
289 args = append(args, opts.Page.Limit, opts.Page.Offset)
290 rows, err := e.Query(query, args...)
291 if err != nil {
292 return nil, err
293 }
294 defer rows.Close()
295
296 for rows.Next() {
297 var id int64
298 err := rows.Scan(&id)
299 if err != nil {
300 return nil, err
301 }
302
303 ids = append(ids, id)
304 }
305
306 return ids, nil
307}
308
309func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
310 result, err := e.Exec(
311 `insert into issue_comments (
312 did,
313 rkey,
314 issue_at,
315 body,
316 reply_to,
317 created,
318 edited
319 )
320 values (?, ?, ?, ?, ?, ?, null)
321 on conflict(did, rkey) do update set
322 issue_at = excluded.issue_at,
323 body = excluded.body,
324 edited = case
325 when
326 issue_comments.issue_at != excluded.issue_at
327 or issue_comments.body != excluded.body
328 or issue_comments.reply_to != excluded.reply_to
329 then ?
330 else issue_comments.edited
331 end`,
332 c.Did,
333 c.Rkey,
334 c.IssueAt,
335 c.Body,
336 c.ReplyTo,
337 c.Created.Format(time.RFC3339),
338 time.Now().Format(time.RFC3339),
339 )
340 if err != nil {
341 return 0, err
342 }
343
344 id, err := result.LastInsertId()
345 if err != nil {
346 return 0, err
347 }
348
349 return id, nil
350}
351
352func DeleteIssueComments(e Execer, filters ...filter) error {
353 var conditions []string
354 var args []any
355 for _, filter := range filters {
356 conditions = append(conditions, filter.Condition())
357 args = append(args, filter.Arg()...)
358 }
359
360 whereClause := ""
361 if conditions != nil {
362 whereClause = " where " + strings.Join(conditions, " and ")
363 }
364
365 query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
366
367 _, err := e.Exec(query, args...)
368 return err
369}
370
371func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
372 var comments []models.IssueComment
373
374 var conditions []string
375 var args []any
376 for _, filter := range filters {
377 conditions = append(conditions, filter.Condition())
378 args = append(args, filter.Arg()...)
379 }
380
381 whereClause := ""
382 if conditions != nil {
383 whereClause = " where " + strings.Join(conditions, " and ")
384 }
385
386 query := fmt.Sprintf(`
387 select
388 id,
389 did,
390 rkey,
391 issue_at,
392 reply_to,
393 body,
394 created,
395 edited,
396 deleted
397 from
398 issue_comments
399 %s
400 `, whereClause)
401
402 rows, err := e.Query(query, args...)
403 if err != nil {
404 return nil, err
405 }
406
407 for rows.Next() {
408 var comment models.IssueComment
409 var created string
410 var rkey, edited, deleted, replyTo sql.Null[string]
411 err := rows.Scan(
412 &comment.Id,
413 &comment.Did,
414 &rkey,
415 &comment.IssueAt,
416 &replyTo,
417 &comment.Body,
418 &created,
419 &edited,
420 &deleted,
421 )
422 if err != nil {
423 return nil, err
424 }
425
426 // this is a remnant from old times, newer comments always have rkey
427 if rkey.Valid {
428 comment.Rkey = rkey.V
429 }
430
431 if t, err := time.Parse(time.RFC3339, created); err == nil {
432 comment.Created = t
433 }
434
435 if edited.Valid {
436 if t, err := time.Parse(time.RFC3339, edited.V); err == nil {
437 comment.Edited = &t
438 }
439 }
440
441 if deleted.Valid {
442 if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
443 comment.Deleted = &t
444 }
445 }
446
447 if replyTo.Valid {
448 comment.ReplyTo = &replyTo.V
449 }
450
451 comments = append(comments, comment)
452 }
453
454 if err = rows.Err(); err != nil {
455 return nil, err
456 }
457
458 return comments, nil
459}
460
461func DeleteIssues(e Execer, filters ...filter) error {
462 var conditions []string
463 var args []any
464 for _, filter := range filters {
465 conditions = append(conditions, filter.Condition())
466 args = append(args, filter.Arg()...)
467 }
468
469 whereClause := ""
470 if conditions != nil {
471 whereClause = " where " + strings.Join(conditions, " and ")
472 }
473
474 query := fmt.Sprintf(`delete from issues %s`, whereClause)
475 _, err := e.Exec(query, args...)
476 return err
477}
478
479func CloseIssues(e Execer, filters ...filter) error {
480 var conditions []string
481 var args []any
482 for _, filter := range filters {
483 conditions = append(conditions, filter.Condition())
484 args = append(args, filter.Arg()...)
485 }
486
487 whereClause := ""
488 if conditions != nil {
489 whereClause = " where " + strings.Join(conditions, " and ")
490 }
491
492 query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause)
493 _, err := e.Exec(query, args...)
494 return err
495}
496
497func ReopenIssues(e Execer, filters ...filter) error {
498 var conditions []string
499 var args []any
500 for _, filter := range filters {
501 conditions = append(conditions, filter.Condition())
502 args = append(args, filter.Arg()...)
503 }
504
505 whereClause := ""
506 if conditions != nil {
507 whereClause = " where " + strings.Join(conditions, " and ")
508 }
509
510 query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause)
511 _, err := e.Exec(query, args...)
512 return err
513}
514
515func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) {
516 row := e.QueryRow(`
517 select
518 count(case when open = 1 then 1 end) as open_count,
519 count(case when open = 0 then 1 end) as closed_count
520 from issues
521 where repo_at = ?`,
522 repoAt,
523 )
524
525 var count models.IssueCount
526 if err := row.Scan(&count.Open, &count.Closed); err != nil {
527 return models.IssueCount{}, err
528 }
529
530 return count, nil
531}