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