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