forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
at master 12 kB view raw
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/appview/models" 14 "tangled.org/core/appview/pagination" 15) 16 17func PutIssue(tx *sql.Tx, issue *models.Issue) error { 18 // ensure sequence exists 19 _, err := tx.Exec(` 20 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 21 values (?, 1) 22 `, issue.RepoAt) 23 if err != nil { 24 return err 25 } 26 27 issues, err := GetIssues( 28 tx, 29 FilterEq("did", issue.Did), 30 FilterEq("rkey", issue.Rkey), 31 ) 32 switch { 33 case err != nil: 34 return err 35 case len(issues) == 0: 36 return createNewIssue(tx, issue) 37 case len(issues) != 1: // should be unreachable 38 return fmt.Errorf("invalid number of issues returned: %d", len(issues)) 39 default: 40 // if content is identical, do not edit 41 existingIssue := issues[0] 42 if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body { 43 return nil 44 } 45 46 issue.Id = existingIssue.Id 47 issue.IssueId = existingIssue.IssueId 48 return updateIssue(tx, issue) 49 } 50} 51 52func createNewIssue(tx *sql.Tx, issue *models.Issue) error { 53 // get next issue_id 54 var newIssueId int 55 err := tx.QueryRow(` 56 update repo_issue_seqs 57 set next_issue_id = next_issue_id + 1 58 where repo_at = ? 59 returning next_issue_id - 1 60 `, issue.RepoAt).Scan(&newIssueId) 61 if err != nil { 62 return err 63 } 64 65 // insert new issue 66 row := tx.QueryRow(` 67 insert into issues (repo_at, did, rkey, issue_id, title, body) 68 values (?, ?, ?, ?, ?, ?) 69 returning rowid, issue_id 70 `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 71 72 return row.Scan(&issue.Id, &issue.IssueId) 73} 74 75func updateIssue(tx *sql.Tx, issue *models.Issue) error { 76 // update existing issue 77 _, err := tx.Exec(` 78 update issues 79 set title = ?, body = ?, edited = ? 80 where did = ? and rkey = ? 81 `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 82 return err 83} 84 85func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 86 issueMap := make(map[string]*models.Issue) // at-uri -> issue 87 88 var conditions []string 89 var args []any 90 91 for _, filter := range filters { 92 conditions = append(conditions, filter.Condition()) 93 args = append(args, filter.Arg()...) 94 } 95 96 whereClause := "" 97 if conditions != nil { 98 whereClause = " where " + strings.Join(conditions, " and ") 99 } 100 101 pLower := FilterGte("row_num", page.Offset+1) 102 pUpper := FilterLte("row_num", page.Offset+page.Limit) 103 104 pageClause := "" 105 if page.Limit > 0 { 106 args = append(args, pLower.Arg()...) 107 args = append(args, pUpper.Arg()...) 108 pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition() 109 } 110 111 query := fmt.Sprintf( 112 ` 113 select * from ( 114 select 115 id, 116 did, 117 rkey, 118 repo_at, 119 issue_id, 120 title, 121 body, 122 open, 123 created, 124 edited, 125 deleted, 126 row_number() over (order by created desc) as row_num 127 from 128 issues 129 %s 130 ) ranked_issues 131 %s 132 `, 133 whereClause, 134 pageClause, 135 ) 136 137 rows, err := e.Query(query, args...) 138 if err != nil { 139 return nil, fmt.Errorf("failed to query issues table: %w", err) 140 } 141 defer rows.Close() 142 143 for rows.Next() { 144 var issue models.Issue 145 var createdAt string 146 var editedAt, deletedAt sql.Null[string] 147 var rowNum int64 148 err := rows.Scan( 149 &issue.Id, 150 &issue.Did, 151 &issue.Rkey, 152 &issue.RepoAt, 153 &issue.IssueId, 154 &issue.Title, 155 &issue.Body, 156 &issue.Open, 157 &createdAt, 158 &editedAt, 159 &deletedAt, 160 &rowNum, 161 ) 162 if err != nil { 163 return nil, fmt.Errorf("failed to scan issue: %w", err) 164 } 165 166 if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 167 issue.Created = t 168 } 169 170 if editedAt.Valid { 171 if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 172 issue.Edited = &t 173 } 174 } 175 176 if deletedAt.Valid { 177 if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 178 issue.Deleted = &t 179 } 180 } 181 182 atUri := issue.AtUri().String() 183 issueMap[atUri] = &issue 184 } 185 186 // collect reverse repos 187 repoAts := make([]string, 0, len(issueMap)) // or just []string{} 188 for _, issue := range issueMap { 189 repoAts = append(repoAts, string(issue.RepoAt)) 190 } 191 192 repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 193 if err != nil { 194 return nil, fmt.Errorf("failed to build repo mappings: %w", err) 195 } 196 197 repoMap := make(map[string]*models.Repo) 198 for i := range repos { 199 repoMap[string(repos[i].RepoAt())] = &repos[i] 200 } 201 202 for issueAt, i := range issueMap { 203 if r, ok := repoMap[string(i.RepoAt)]; ok { 204 i.Repo = r 205 } else { 206 // do not show up the issue if the repo is deleted 207 // TODO: foreign key where? 208 delete(issueMap, issueAt) 209 } 210 } 211 212 // collect comments 213 issueAts := slices.Collect(maps.Keys(issueMap)) 214 215 comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 216 if err != nil { 217 return nil, fmt.Errorf("failed to query comments: %w", err) 218 } 219 for i := range comments { 220 issueAt := comments[i].IssueAt 221 if issue, ok := issueMap[issueAt]; ok { 222 issue.Comments = append(issue.Comments, comments[i]) 223 } 224 } 225 226 // collect allLabels for each issue 227 allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) 228 if err != nil { 229 return nil, fmt.Errorf("failed to query labels: %w", err) 230 } 231 for issueAt, labels := range allLabels { 232 if issue, ok := issueMap[issueAt.String()]; ok { 233 issue.Labels = labels 234 } 235 } 236 237 var issues []models.Issue 238 for _, i := range issueMap { 239 issues = append(issues, *i) 240 } 241 242 sort.Slice(issues, func(i, j int) bool { 243 return issues[i].Created.After(issues[j].Created) 244 }) 245 246 return issues, nil 247} 248 249func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 250 issues, err := GetIssuesPaginated( 251 e, 252 pagination.Page{}, 253 FilterEq("repo_at", repoAt), 254 FilterEq("issue_id", issueId), 255 ) 256 if err != nil { 257 return nil, err 258 } 259 if len(issues) != 1 { 260 return nil, sql.ErrNoRows 261 } 262 263 return &issues[0], nil 264} 265 266func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 267 return GetIssuesPaginated(e, pagination.Page{}, filters...) 268} 269 270// GetIssueIDs gets list of all existing issue's IDs 271func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) { 272 var ids []int64 273 274 var filters []filter 275 openValue := 0 276 if opts.IsOpen { 277 openValue = 1 278 } 279 filters = append(filters, FilterEq("open", openValue)) 280 if opts.RepoAt != "" { 281 filters = append(filters, FilterEq("repo_at", opts.RepoAt)) 282 } 283 284 var conditions []string 285 var args []any 286 287 for _, filter := range filters { 288 conditions = append(conditions, filter.Condition()) 289 args = append(args, filter.Arg()...) 290 } 291 292 whereClause := "" 293 if conditions != nil { 294 whereClause = " where " + strings.Join(conditions, " and ") 295 } 296 query := fmt.Sprintf( 297 ` 298 select 299 id 300 from 301 issues 302 %s 303 limit ? offset ?`, 304 whereClause, 305 ) 306 args = append(args, opts.Page.Limit, opts.Page.Offset) 307 rows, err := e.Query(query, args...) 308 if err != nil { 309 return nil, err 310 } 311 defer rows.Close() 312 313 for rows.Next() { 314 var id int64 315 err := rows.Scan(&id) 316 if err != nil { 317 return nil, err 318 } 319 320 ids = append(ids, id) 321 } 322 323 return ids, nil 324} 325 326func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 327 result, err := e.Exec( 328 `insert into issue_comments ( 329 did, 330 rkey, 331 issue_at, 332 body, 333 reply_to, 334 created, 335 edited 336 ) 337 values (?, ?, ?, ?, ?, ?, null) 338 on conflict(did, rkey) do update set 339 issue_at = excluded.issue_at, 340 body = excluded.body, 341 edited = case 342 when 343 issue_comments.issue_at != excluded.issue_at 344 or issue_comments.body != excluded.body 345 or issue_comments.reply_to != excluded.reply_to 346 then ? 347 else issue_comments.edited 348 end`, 349 c.Did, 350 c.Rkey, 351 c.IssueAt, 352 c.Body, 353 c.ReplyTo, 354 c.Created.Format(time.RFC3339), 355 time.Now().Format(time.RFC3339), 356 ) 357 if err != nil { 358 return 0, err 359 } 360 361 id, err := result.LastInsertId() 362 if err != nil { 363 return 0, err 364 } 365 366 return id, nil 367} 368 369func DeleteIssueComments(e Execer, filters ...filter) error { 370 var conditions []string 371 var args []any 372 for _, filter := range filters { 373 conditions = append(conditions, filter.Condition()) 374 args = append(args, filter.Arg()...) 375 } 376 377 whereClause := "" 378 if conditions != nil { 379 whereClause = " where " + strings.Join(conditions, " and ") 380 } 381 382 query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 383 384 _, err := e.Exec(query, args...) 385 return err 386} 387 388func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 389 var comments []models.IssueComment 390 391 var conditions []string 392 var args []any 393 for _, filter := range filters { 394 conditions = append(conditions, filter.Condition()) 395 args = append(args, filter.Arg()...) 396 } 397 398 whereClause := "" 399 if conditions != nil { 400 whereClause = " where " + strings.Join(conditions, " and ") 401 } 402 403 query := fmt.Sprintf(` 404 select 405 id, 406 did, 407 rkey, 408 issue_at, 409 reply_to, 410 body, 411 created, 412 edited, 413 deleted 414 from 415 issue_comments 416 %s 417 `, whereClause) 418 419 rows, err := e.Query(query, args...) 420 if err != nil { 421 return nil, err 422 } 423 424 for rows.Next() { 425 var comment models.IssueComment 426 var created string 427 var rkey, edited, deleted, replyTo sql.Null[string] 428 err := rows.Scan( 429 &comment.Id, 430 &comment.Did, 431 &rkey, 432 &comment.IssueAt, 433 &replyTo, 434 &comment.Body, 435 &created, 436 &edited, 437 &deleted, 438 ) 439 if err != nil { 440 return nil, err 441 } 442 443 // this is a remnant from old times, newer comments always have rkey 444 if rkey.Valid { 445 comment.Rkey = rkey.V 446 } 447 448 if t, err := time.Parse(time.RFC3339, created); err == nil { 449 comment.Created = t 450 } 451 452 if edited.Valid { 453 if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 454 comment.Edited = &t 455 } 456 } 457 458 if deleted.Valid { 459 if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 460 comment.Deleted = &t 461 } 462 } 463 464 if replyTo.Valid { 465 comment.ReplyTo = &replyTo.V 466 } 467 468 comments = append(comments, comment) 469 } 470 471 if err = rows.Err(); err != nil { 472 return nil, err 473 } 474 475 return comments, nil 476} 477 478func DeleteIssues(e Execer, filters ...filter) error { 479 var conditions []string 480 var args []any 481 for _, filter := range filters { 482 conditions = append(conditions, filter.Condition()) 483 args = append(args, filter.Arg()...) 484 } 485 486 whereClause := "" 487 if conditions != nil { 488 whereClause = " where " + strings.Join(conditions, " and ") 489 } 490 491 query := fmt.Sprintf(`delete from issues %s`, whereClause) 492 _, err := e.Exec(query, args...) 493 return err 494} 495 496func CloseIssues(e Execer, filters ...filter) error { 497 var conditions []string 498 var args []any 499 for _, filter := range filters { 500 conditions = append(conditions, filter.Condition()) 501 args = append(args, filter.Arg()...) 502 } 503 504 whereClause := "" 505 if conditions != nil { 506 whereClause = " where " + strings.Join(conditions, " and ") 507 } 508 509 query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 510 _, err := e.Exec(query, args...) 511 return err 512} 513 514func ReopenIssues(e Execer, filters ...filter) error { 515 var conditions []string 516 var args []any 517 for _, filter := range filters { 518 conditions = append(conditions, filter.Condition()) 519 args = append(args, filter.Arg()...) 520 } 521 522 whereClause := "" 523 if conditions != nil { 524 whereClause = " where " + strings.Join(conditions, " and ") 525 } 526 527 query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause) 528 _, err := e.Exec(query, args...) 529 return err 530} 531 532func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) { 533 row := e.QueryRow(` 534 select 535 count(case when open = 1 then 1 end) as open_count, 536 count(case when open = 0 then 1 end) as closed_count 537 from issues 538 where repo_at = ?`, 539 repoAt, 540 ) 541 542 var count models.IssueCount 543 if err := row.Scan(&count.Open, &count.Closed); err != nil { 544 return models.IssueCount{}, err 545 } 546 547 return count, nil 548}