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