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