this repo has no description
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.sh/tangled.sh/core/api/tangled"
14 "tangled.sh/tangled.sh/core/appview/pagination"
15)
16
17type Issue struct {
18 Id int64
19 Did string
20 Rkey string
21 RepoAt syntax.ATURI
22 IssueId int
23 Created time.Time
24 Edited *time.Time
25 Deleted *time.Time
26 Title string
27 Body string
28 Open bool
29
30 // optionally, populate this when querying for reverse mappings
31 // like comment counts, parent repo etc.
32 Comments []IssueComment
33 Repo *Repo
34}
35
36func (i *Issue) AtUri() syntax.ATURI {
37 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
38}
39
40func (i *Issue) AsRecord() tangled.RepoIssue {
41 return tangled.RepoIssue{
42 Repo: i.RepoAt.String(),
43 Title: i.Title,
44 Body: &i.Body,
45 CreatedAt: i.Created.Format(time.RFC3339),
46 }
47}
48
49type CommentListItem struct {
50 Self *IssueComment
51 Replies []*IssueComment
52}
53
54func (i *Issue) CommentList() []CommentListItem {
55 // Create a map to quickly find comments by their aturi
56 toplevel := make(map[string]*CommentListItem)
57 var replies []*IssueComment
58
59 // collect top level comments into the map
60 for _, comment := range i.Comments {
61 if comment.IsTopLevel() {
62 toplevel[comment.AtUri().String()] = &CommentListItem{
63 Self: &comment,
64 }
65 } else {
66 replies = append(replies, &comment)
67 }
68 }
69
70 for _, r := range replies {
71 parentAt := *r.ReplyTo
72 if parent, exists := toplevel[parentAt]; exists {
73 parent.Replies = append(parent.Replies, r)
74 }
75 }
76
77 var listing []CommentListItem
78 for _, v := range toplevel {
79 listing = append(listing, *v)
80 }
81
82 // sort everything
83 sortFunc := func(a, b *IssueComment) bool {
84 return a.Created.Before(b.Created)
85 }
86 sort.Slice(listing, func(i, j int) bool {
87 return sortFunc(listing[i].Self, listing[j].Self)
88 })
89 for _, r := range listing {
90 sort.Slice(r.Replies, func(i, j int) bool {
91 return sortFunc(r.Replies[i], r.Replies[j])
92 })
93 }
94
95 return listing
96}
97
98func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
99 created, err := time.Parse(time.RFC3339, record.CreatedAt)
100 if err != nil {
101 created = time.Now()
102 }
103
104 body := ""
105 if record.Body != nil {
106 body = *record.Body
107 }
108
109 return Issue{
110 RepoAt: syntax.ATURI(record.Repo),
111 Did: did,
112 Rkey: rkey,
113 Created: created,
114 Title: record.Title,
115 Body: body,
116 Open: true, // new issues are open by default
117 }
118}
119
120type IssueComment struct {
121 Id int64
122 Did string
123 Rkey string
124 IssueAt string
125 ReplyTo *string
126 Body string
127 Created time.Time
128 Edited *time.Time
129 Deleted *time.Time
130}
131
132func (i *IssueComment) AtUri() syntax.ATURI {
133 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
134}
135
136func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
137 return tangled.RepoIssueComment{
138 Body: i.Body,
139 Issue: i.IssueAt,
140 CreatedAt: i.Created.Format(time.RFC3339),
141 ReplyTo: i.ReplyTo,
142 }
143}
144
145func (i *IssueComment) IsTopLevel() bool {
146 return i.ReplyTo == nil
147}
148
149func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
150 created, err := time.Parse(time.RFC3339, record.CreatedAt)
151 if err != nil {
152 created = time.Now()
153 }
154
155 ownerDid := did
156
157 if _, err = syntax.ParseATURI(record.Issue); err != nil {
158 return nil, err
159 }
160
161 comment := IssueComment{
162 Did: ownerDid,
163 Rkey: rkey,
164 Body: record.Body,
165 IssueAt: record.Issue,
166 ReplyTo: record.ReplyTo,
167 Created: created,
168 }
169
170 return &comment, nil
171}
172
173func NewIssue(tx *sql.Tx, issue *Issue) error {
174 // ensure sequence exists
175 _, err := tx.Exec(`
176 insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
177 values (?, 1)
178 `, issue.RepoAt)
179 if err != nil {
180 return err
181 }
182
183 // check if issue already exists
184 var existingRowId, existingIssueId sql.NullInt64
185 err = tx.QueryRow(`
186 select rowid, issue_id from issues
187 where did = ? and rkey = ?
188 `, issue.Did, issue.Rkey).Scan(&existingRowId, &existingIssueId)
189
190 switch {
191 case err == sql.ErrNoRows:
192 return createNewIssue(tx, issue)
193
194 case err != nil:
195 return err
196
197 default:
198 // Case 3: Issue exists - update it
199 return updateIssue(tx, issue, existingRowId.Int64, int(existingIssueId.Int64))
200 }
201}
202
203func createNewIssue(tx *sql.Tx, issue *Issue) error {
204 // get next issue_id
205 var newIssueId int
206 err := tx.QueryRow(`
207 update repo_issue_seqs
208 set next_issue_id = next_issue_id + 1
209 where repo_at = ?
210 returning next_issue_id - 1
211 `, issue.RepoAt).Scan(&newIssueId)
212 if err != nil {
213 return err
214 }
215
216 // insert new issue
217 row := tx.QueryRow(`
218 insert into issues (repo_at, did, rkey, issue_id, title, body)
219 values (?, ?, ?, ?, ?, ?)
220 returning rowid, issue_id
221 `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
222
223 return row.Scan(&issue.Id, &issue.IssueId)
224}
225
226func updateIssue(tx *sql.Tx, issue *Issue, existingRowId int64, existingIssueId int) error {
227 // update existing issue
228 _, err := tx.Exec(`
229 update issues
230 set title = ?, body = ?
231 where did = ? and rkey = ?
232 `, issue.Title, issue.Body, issue.Did, issue.Rkey)
233 if err != nil {
234 return err
235 }
236
237 // set the values from existing record
238 issue.Id = existingRowId
239 issue.IssueId = existingIssueId
240 return nil
241}
242
243func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
244 var issueAt string
245 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
246 return issueAt, err
247}
248
249func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
250 var ownerDid string
251 err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid)
252 return ownerDid, err
253}
254
255func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
256 var issues []Issue
257 openValue := 0
258 if isOpen {
259 openValue = 1
260 }
261
262 rows, err := e.Query(
263 `
264 with numbered_issue as (
265 select
266 i.id,
267 i.owner_did,
268 i.rkey,
269 i.issue_id,
270 i.created,
271 i.title,
272 i.body,
273 i.open,
274 count(c.id) as comment_count,
275 row_number() over (order by i.created desc) as row_num
276 from
277 issues i
278 left join
279 comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
280 where
281 i.repo_at = ? and i.open = ?
282 group by
283 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
284 )
285 select
286 id,
287 owner_did,
288 rkey,
289 issue_id,
290 created,
291 title,
292 body,
293 open,
294 comment_count
295 from
296 numbered_issue
297 where
298 row_num between ? and ?`,
299 repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
300 if err != nil {
301 return nil, err
302 }
303 defer rows.Close()
304
305 for rows.Next() {
306 var issue Issue
307 var createdAt string
308 var metadata IssueMetadata
309 err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
310 if err != nil {
311 return nil, err
312 }
313
314 createdTime, err := time.Parse(time.RFC3339, createdAt)
315 if err != nil {
316 return nil, err
317 }
318 issue.Created = createdTime
319 issue.Metadata = &metadata
320
321 issues = append(issues, issue)
322 }
323
324 if err := rows.Err(); err != nil {
325 return nil, err
326 }
327
328 return issues, nil
329}
330
331func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) {
332 issues := make([]Issue, 0, limit)
333
334 var conditions []string
335 var args []any
336 for _, filter := range filters {
337 conditions = append(conditions, filter.Condition())
338 args = append(args, filter.Arg()...)
339 }
340
341 whereClause := ""
342 if conditions != nil {
343 whereClause = " where " + strings.Join(conditions, " and ")
344 }
345 limitClause := ""
346 if limit != 0 {
347 limitClause = fmt.Sprintf(" limit %d ", limit)
348 }
349
350 query := fmt.Sprintf(
351 `select
352 i.id,
353 i.owner_did,
354 i.repo_at,
355 i.issue_id,
356 i.created,
357 i.title,
358 i.body,
359 i.open
360 from
361 issues i
362 %s
363 order by
364 i.created desc
365 %s`,
366 whereClause, limitClause)
367
368 rows, err := e.Query(query, args...)
369 if err != nil {
370 return nil, err
371 }
372 defer rows.Close()
373
374 for rows.Next() {
375 var issue Issue
376 var issueCreatedAt string
377 err := rows.Scan(
378 &issue.ID,
379 &issue.OwnerDid,
380 &issue.RepoAt,
381 &issue.IssueId,
382 &issueCreatedAt,
383 &issue.Title,
384 &issue.Body,
385 &issue.Open,
386 )
387 if err != nil {
388 return nil, err
389 }
390
391 issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
392 if err != nil {
393 return nil, err
394 }
395 issue.Created = issueCreatedTime
396
397 issues = append(issues, issue)
398 }
399
400 if err := rows.Err(); err != nil {
401 return nil, err
402 }
403
404 return issues, nil
405}
406
407func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
408 return GetIssuesWithLimit(e, 0, filters...)
409}
410
411// timeframe here is directly passed into the sql query filter, and any
412// timeframe in the past should be negative; e.g.: "-3 months"
413func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
414 var issues []Issue
415
416 rows, err := e.Query(
417 `select
418 i.id,
419 i.owner_did,
420 i.rkey,
421 i.repo_at,
422 i.issue_id,
423 i.created,
424 i.title,
425 i.body,
426 i.open,
427 r.did,
428 r.name,
429 r.knot,
430 r.rkey,
431 r.created
432 from
433 issues i
434 join
435 repos r on i.repo_at = r.at_uri
436 where
437 i.owner_did = ? and i.created >= date ('now', ?)
438 order by
439 i.created desc`,
440 ownerDid, timeframe)
441 if err != nil {
442 return nil, err
443 }
444 defer rows.Close()
445
446 for rows.Next() {
447 var issue Issue
448 var issueCreatedAt, repoCreatedAt string
449 var repo Repo
450 err := rows.Scan(
451 &issue.ID,
452 &issue.OwnerDid,
453 &issue.Rkey,
454 &issue.RepoAt,
455 &issue.IssueId,
456 &issueCreatedAt,
457 &issue.Title,
458 &issue.Body,
459 &issue.Open,
460 &repo.Did,
461 &repo.Name,
462 &repo.Knot,
463 &repo.Rkey,
464 &repoCreatedAt,
465 )
466 if err != nil {
467 return nil, err
468 }
469
470 issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
471 if err != nil {
472 return nil, err
473 }
474 issue.Created = issueCreatedTime
475
476 repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
477 if err != nil {
478 return nil, err
479 }
480 repo.Created = repoCreatedTime
481
482 issue.Metadata = &IssueMetadata{
483 Repo: &repo,
484 }
485
486 issues = append(issues, issue)
487 }
488
489 if err := rows.Err(); err != nil {
490 return nil, err
491 }
492
493 return issues, nil
494}
495
496func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
497 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
498 row := e.QueryRow(query, repoAt, issueId)
499
500 var issue Issue
501 var createdAt string
502 err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
503 if err != nil {
504 return nil, err
505 }
506
507 createdTime, err := time.Parse(time.RFC3339, createdAt)
508 if err != nil {
509 return nil, err
510 }
511 issue.Created = createdTime
512
513 return &issue, nil
514}
515
516func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
517 query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
518 row := e.QueryRow(query, repoAt, issueId)
519
520 var issue Issue
521 var createdAt string
522 err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
523 if err != nil {
524 return nil, nil, err
525 }
526
527 createdTime, err := time.Parse(time.RFC3339, createdAt)
528 if err != nil {
529 return nil, nil, err
530 }
531 issue.Created = createdTime
532
533 comments, err := GetComments(e, repoAt, issueId)
534 if err != nil {
535 return nil, nil, err
536 }
537
538 return &issue, comments, nil
539}
540
541func NewIssueComment(e Execer, comment *Comment) error {
542 query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
543 _, err := e.Exec(
544 query,
545 comment.OwnerDid,
546 comment.RepoAt,
547 comment.Rkey,
548 comment.Issue,
549 comment.CommentId,
550 comment.Body,
551 )
552 return err
553}
554
555func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
556 var comments []Comment
557
558 rows, err := e.Query(`
559 select
560 owner_did,
561 issue_id,
562 comment_id,
563 rkey,
564 body,
565 created,
566 edited,
567 deleted
568 from
569 comments
570 where
571 repo_at = ? and issue_id = ?
572 order by
573 created asc`,
574 repoAt,
575 issueId,
576 )
577 if err == sql.ErrNoRows {
578 return []Comment{}, nil
579 }
580 if err != nil {
581 return nil, err
582 }
583 defer rows.Close()
584
585 for rows.Next() {
586 var comment Comment
587 var createdAt string
588 var deletedAt, editedAt, rkey sql.NullString
589 err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt)
590 if err != nil {
591 return nil, err
592 }
593
594 createdAtTime, err := time.Parse(time.RFC3339, createdAt)
595 if err != nil {
596 return nil, err
597 }
598 comment.Created = &createdAtTime
599
600 if deletedAt.Valid {
601 deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
602 if err != nil {
603 return nil, err
604 }
605 comment.Deleted = &deletedTime
606 }
607
608 if editedAt.Valid {
609 editedTime, err := time.Parse(time.RFC3339, editedAt.String)
610 if err != nil {
611 return nil, err
612 }
613 comment.Edited = &editedTime
614 }
615
616 if rkey.Valid {
617 comment.Rkey = rkey.String
618 }
619
620 comments = append(comments, comment)
621 }
622
623 if err := rows.Err(); err != nil {
624 return nil, err
625 }
626
627 return comments, nil
628}
629
630func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
631 query := `
632 select
633 owner_did, body, rkey, created, deleted, edited
634 from
635 comments where repo_at = ? and issue_id = ? and comment_id = ?
636 `
637 row := e.QueryRow(query, repoAt, issueId, commentId)
638
639 var comment Comment
640 var createdAt string
641 var deletedAt, editedAt, rkey sql.NullString
642 err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt)
643 if err != nil {
644 return nil, err
645 }
646
647 createdTime, err := time.Parse(time.RFC3339, createdAt)
648 if err != nil {
649 return nil, err
650 }
651 comment.Created = &createdTime
652
653 if deletedAt.Valid {
654 deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
655 if err != nil {
656 return nil, err
657 }
658 comment.Deleted = &deletedTime
659 }
660
661 if editedAt.Valid {
662 editedTime, err := time.Parse(time.RFC3339, editedAt.String)
663 if err != nil {
664 return nil, err
665 }
666 comment.Edited = &editedTime
667 }
668
669 if rkey.Valid {
670 comment.Rkey = rkey.String
671 }
672
673 comment.RepoAt = repoAt
674 comment.Issue = issueId
675 comment.CommentId = commentId
676
677 return &comment, nil
678}
679
680func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
681 _, err := e.Exec(
682 `
683 update comments
684 set body = ?,
685 edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
686 where repo_at = ? and issue_id = ? and comment_id = ?
687 `, newBody, repoAt, issueId, commentId)
688 return err
689}
690
691func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
692 _, err := e.Exec(
693 `
694 update comments
695 set body = "",
696 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
697 where repo_at = ? and issue_id = ? and comment_id = ?
698 `, repoAt, issueId, commentId)
699 return err
700}
701
702func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error {
703 _, err := e.Exec(
704 `
705 update comments
706 set body = ?,
707 edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
708 where owner_did = ? and rkey = ?
709 `, newBody, ownerDid, rkey)
710 return err
711}
712
713func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error {
714 _, err := e.Exec(
715 `
716 update comments
717 set body = "",
718 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
719 where owner_did = ? and rkey = ?
720 `, ownerDid, rkey)
721 return err
722}
723
724func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error {
725 _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey)
726 return err
727}
728
729func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error {
730 _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey)
731 return err
732}
733
734func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
735 _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId)
736 return err
737}
738
739func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
740 _, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId)
741 return err
742}
743
744type IssueCount struct {
745 Open int
746 Closed int
747}
748
749func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) {
750 row := e.QueryRow(`
751 select
752 count(case when open = 1 then 1 end) as open_count,
753 count(case when open = 0 then 1 end) as closed_count
754 from issues
755 where repo_at = ?`,
756 repoAt,
757 )
758
759 var count IssueCount
760 if err := row.Scan(&count.Open, &count.Closed); err != nil {
761 return IssueCount{0, 0}, err
762 }
763
764 return count, nil
765}