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