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