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 defer tx.Rollback()
175
176 _, err := tx.Exec(`
177 insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
178 values (?, 1)
179 `, issue.RepoAt)
180 if err != nil {
181 return err
182 }
183
184 var nextId int
185 err = tx.QueryRow(`
186 update repo_issue_seqs
187 set next_issue_id = next_issue_id + 1
188 where repo_at = ?
189 returning next_issue_id - 1
190 `, issue.RepoAt).Scan(&nextId)
191 if err != nil {
192 return err
193 }
194
195 issue.IssueId = nextId
196
197 res, err := tx.Exec(`
198 insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body)
199 values (?, ?, ?, ?, ?, ?, ?)
200 `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body)
201 if err != nil {
202 return err
203 }
204
205 lastID, err := res.LastInsertId()
206 if err != nil {
207 return err
208 }
209 issue.ID = lastID
210
211 if err := tx.Commit(); err != nil {
212 return err
213 }
214
215 return nil
216}
217
218func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
219 var issueAt string
220 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
221 return issueAt, err
222}
223
224func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
225 var ownerDid string
226 err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid)
227 return ownerDid, err
228}
229
230func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
231 var issues []Issue
232 openValue := 0
233 if isOpen {
234 openValue = 1
235 }
236
237 rows, err := e.Query(
238 `
239 with numbered_issue as (
240 select
241 i.id,
242 i.owner_did,
243 i.rkey,
244 i.issue_id,
245 i.created,
246 i.title,
247 i.body,
248 i.open,
249 count(c.id) as comment_count,
250 row_number() over (order by i.created desc) as row_num
251 from
252 issues i
253 left join
254 comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
255 where
256 i.repo_at = ? and i.open = ?
257 group by
258 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
259 )
260 select
261 id,
262 owner_did,
263 rkey,
264 issue_id,
265 created,
266 title,
267 body,
268 open,
269 comment_count
270 from
271 numbered_issue
272 where
273 row_num between ? and ?`,
274 repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
275 if err != nil {
276 return nil, err
277 }
278 defer rows.Close()
279
280 for rows.Next() {
281 var issue Issue
282 var createdAt string
283 var metadata IssueMetadata
284 err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
285 if err != nil {
286 return nil, err
287 }
288
289 createdTime, err := time.Parse(time.RFC3339, createdAt)
290 if err != nil {
291 return nil, err
292 }
293 issue.Created = createdTime
294 issue.Metadata = &metadata
295
296 issues = append(issues, issue)
297 }
298
299 if err := rows.Err(); err != nil {
300 return nil, err
301 }
302
303 return issues, nil
304}
305
306func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) {
307 issues := make([]Issue, 0, limit)
308
309 var conditions []string
310 var args []any
311 for _, filter := range filters {
312 conditions = append(conditions, filter.Condition())
313 args = append(args, filter.Arg()...)
314 }
315
316 whereClause := ""
317 if conditions != nil {
318 whereClause = " where " + strings.Join(conditions, " and ")
319 }
320 limitClause := ""
321 if limit != 0 {
322 limitClause = fmt.Sprintf(" limit %d ", limit)
323 }
324
325 query := fmt.Sprintf(
326 `select
327 i.id,
328 i.owner_did,
329 i.repo_at,
330 i.issue_id,
331 i.created,
332 i.title,
333 i.body,
334 i.open
335 from
336 issues i
337 %s
338 order by
339 i.created desc
340 %s`,
341 whereClause, limitClause)
342
343 rows, err := e.Query(query, args...)
344 if err != nil {
345 return nil, err
346 }
347 defer rows.Close()
348
349 for rows.Next() {
350 var issue Issue
351 var issueCreatedAt string
352 err := rows.Scan(
353 &issue.ID,
354 &issue.OwnerDid,
355 &issue.RepoAt,
356 &issue.IssueId,
357 &issueCreatedAt,
358 &issue.Title,
359 &issue.Body,
360 &issue.Open,
361 )
362 if err != nil {
363 return nil, err
364 }
365
366 issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
367 if err != nil {
368 return nil, err
369 }
370 issue.Created = issueCreatedTime
371
372 issues = append(issues, issue)
373 }
374
375 if err := rows.Err(); err != nil {
376 return nil, err
377 }
378
379 return issues, nil
380}
381
382func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
383 return GetIssuesWithLimit(e, 0, filters...)
384}
385
386// timeframe here is directly passed into the sql query filter, and any
387// timeframe in the past should be negative; e.g.: "-3 months"
388func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
389 var issues []Issue
390
391 rows, err := e.Query(
392 `select
393 i.id,
394 i.owner_did,
395 i.rkey,
396 i.repo_at,
397 i.issue_id,
398 i.created,
399 i.title,
400 i.body,
401 i.open,
402 r.did,
403 r.name,
404 r.knot,
405 r.rkey,
406 r.created
407 from
408 issues i
409 join
410 repos r on i.repo_at = r.at_uri
411 where
412 i.owner_did = ? and i.created >= date ('now', ?)
413 order by
414 i.created desc`,
415 ownerDid, timeframe)
416 if err != nil {
417 return nil, err
418 }
419 defer rows.Close()
420
421 for rows.Next() {
422 var issue Issue
423 var issueCreatedAt, repoCreatedAt string
424 var repo Repo
425 err := rows.Scan(
426 &issue.ID,
427 &issue.OwnerDid,
428 &issue.Rkey,
429 &issue.RepoAt,
430 &issue.IssueId,
431 &issueCreatedAt,
432 &issue.Title,
433 &issue.Body,
434 &issue.Open,
435 &repo.Did,
436 &repo.Name,
437 &repo.Knot,
438 &repo.Rkey,
439 &repoCreatedAt,
440 )
441 if err != nil {
442 return nil, err
443 }
444
445 issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
446 if err != nil {
447 return nil, err
448 }
449 issue.Created = issueCreatedTime
450
451 repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
452 if err != nil {
453 return nil, err
454 }
455 repo.Created = repoCreatedTime
456
457 issue.Metadata = &IssueMetadata{
458 Repo: &repo,
459 }
460
461 issues = append(issues, issue)
462 }
463
464 if err := rows.Err(); err != nil {
465 return nil, err
466 }
467
468 return issues, nil
469}
470
471func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
472 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
473 row := e.QueryRow(query, repoAt, issueId)
474
475 var issue Issue
476 var createdAt string
477 err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
478 if err != nil {
479 return nil, err
480 }
481
482 createdTime, err := time.Parse(time.RFC3339, createdAt)
483 if err != nil {
484 return nil, err
485 }
486 issue.Created = createdTime
487
488 return &issue, nil
489}
490
491func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
492 query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
493 row := e.QueryRow(query, repoAt, issueId)
494
495 var issue Issue
496 var createdAt string
497 err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
498 if err != nil {
499 return nil, nil, err
500 }
501
502 createdTime, err := time.Parse(time.RFC3339, createdAt)
503 if err != nil {
504 return nil, nil, err
505 }
506 issue.Created = createdTime
507
508 comments, err := GetComments(e, repoAt, issueId)
509 if err != nil {
510 return nil, nil, err
511 }
512
513 return &issue, comments, nil
514}
515
516func NewIssueComment(e Execer, comment *Comment) error {
517 query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
518 _, err := e.Exec(
519 query,
520 comment.OwnerDid,
521 comment.RepoAt,
522 comment.Rkey,
523 comment.Issue,
524 comment.CommentId,
525 comment.Body,
526 )
527 return err
528}
529
530func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
531 var comments []Comment
532
533 rows, err := e.Query(`
534 select
535 owner_did,
536 issue_id,
537 comment_id,
538 rkey,
539 body,
540 created,
541 edited,
542 deleted
543 from
544 comments
545 where
546 repo_at = ? and issue_id = ?
547 order by
548 created asc`,
549 repoAt,
550 issueId,
551 )
552 if err == sql.ErrNoRows {
553 return []Comment{}, nil
554 }
555 if err != nil {
556 return nil, err
557 }
558 defer rows.Close()
559
560 for rows.Next() {
561 var comment Comment
562 var createdAt string
563 var deletedAt, editedAt, rkey sql.NullString
564 err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt)
565 if err != nil {
566 return nil, err
567 }
568
569 createdAtTime, err := time.Parse(time.RFC3339, createdAt)
570 if err != nil {
571 return nil, err
572 }
573 comment.Created = &createdAtTime
574
575 if deletedAt.Valid {
576 deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
577 if err != nil {
578 return nil, err
579 }
580 comment.Deleted = &deletedTime
581 }
582
583 if editedAt.Valid {
584 editedTime, err := time.Parse(time.RFC3339, editedAt.String)
585 if err != nil {
586 return nil, err
587 }
588 comment.Edited = &editedTime
589 }
590
591 if rkey.Valid {
592 comment.Rkey = rkey.String
593 }
594
595 comments = append(comments, comment)
596 }
597
598 if err := rows.Err(); err != nil {
599 return nil, err
600 }
601
602 return comments, nil
603}
604
605func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
606 query := `
607 select
608 owner_did, body, rkey, created, deleted, edited
609 from
610 comments where repo_at = ? and issue_id = ? and comment_id = ?
611 `
612 row := e.QueryRow(query, repoAt, issueId, commentId)
613
614 var comment Comment
615 var createdAt string
616 var deletedAt, editedAt, rkey sql.NullString
617 err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt)
618 if err != nil {
619 return nil, err
620 }
621
622 createdTime, err := time.Parse(time.RFC3339, createdAt)
623 if err != nil {
624 return nil, err
625 }
626 comment.Created = &createdTime
627
628 if deletedAt.Valid {
629 deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
630 if err != nil {
631 return nil, err
632 }
633 comment.Deleted = &deletedTime
634 }
635
636 if editedAt.Valid {
637 editedTime, err := time.Parse(time.RFC3339, editedAt.String)
638 if err != nil {
639 return nil, err
640 }
641 comment.Edited = &editedTime
642 }
643
644 if rkey.Valid {
645 comment.Rkey = rkey.String
646 }
647
648 comment.RepoAt = repoAt
649 comment.Issue = issueId
650 comment.CommentId = commentId
651
652 return &comment, nil
653}
654
655func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
656 _, err := e.Exec(
657 `
658 update comments
659 set body = ?,
660 edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
661 where repo_at = ? and issue_id = ? and comment_id = ?
662 `, newBody, repoAt, issueId, commentId)
663 return err
664}
665
666func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
667 _, err := e.Exec(
668 `
669 update comments
670 set body = "",
671 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
672 where repo_at = ? and issue_id = ? and comment_id = ?
673 `, repoAt, issueId, commentId)
674 return err
675}
676
677func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error {
678 _, err := e.Exec(
679 `
680 update comments
681 set body = ?,
682 edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
683 where owner_did = ? and rkey = ?
684 `, newBody, ownerDid, rkey)
685 return err
686}
687
688func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error {
689 _, err := e.Exec(
690 `
691 update comments
692 set body = "",
693 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
694 where owner_did = ? and rkey = ?
695 `, ownerDid, rkey)
696 return err
697}
698
699func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error {
700 _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey)
701 return err
702}
703
704func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error {
705 _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey)
706 return err
707}
708
709func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
710 _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId)
711 return err
712}
713
714func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
715 _, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId)
716 return err
717}
718
719type IssueCount struct {
720 Open int
721 Closed int
722}
723
724func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) {
725 row := e.QueryRow(`
726 select
727 count(case when open = 1 then 1 end) as open_count,
728 count(case when open = 0 then 1 end) as closed_count
729 from issues
730 where repo_at = ?`,
731 repoAt,
732 )
733
734 var count IssueCount
735 if err := row.Scan(&count.Open, &count.Closed); err != nil {
736 return IssueCount{0, 0}, err
737 }
738
739 return count, nil
740}