forked from tangled.org/core
this repo has no description
at master 11 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 args = append(args, pLower.Arg()...) 105 args = append(args, pUpper.Arg()...) 106 pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 107 108 query := fmt.Sprintf( 109 ` 110 select * from ( 111 select 112 id, 113 did, 114 rkey, 115 repo_at, 116 issue_id, 117 title, 118 body, 119 open, 120 created, 121 edited, 122 deleted, 123 row_number() over (order by created desc) as row_num 124 from 125 issues 126 %s 127 ) ranked_issues 128 %s 129 `, 130 whereClause, 131 pagination, 132 ) 133 134 rows, err := e.Query(query, args...) 135 if err != nil { 136 return nil, fmt.Errorf("failed to query issues table: %w", err) 137 } 138 defer rows.Close() 139 140 for rows.Next() { 141 var issue models.Issue 142 var createdAt string 143 var editedAt, deletedAt sql.Null[string] 144 var rowNum int64 145 err := rows.Scan( 146 &issue.Id, 147 &issue.Did, 148 &issue.Rkey, 149 &issue.RepoAt, 150 &issue.IssueId, 151 &issue.Title, 152 &issue.Body, 153 &issue.Open, 154 &createdAt, 155 &editedAt, 156 &deletedAt, 157 &rowNum, 158 ) 159 if err != nil { 160 return nil, fmt.Errorf("failed to scan issue: %w", err) 161 } 162 163 if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 164 issue.Created = t 165 } 166 167 if editedAt.Valid { 168 if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 169 issue.Edited = &t 170 } 171 } 172 173 if deletedAt.Valid { 174 if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 175 issue.Deleted = &t 176 } 177 } 178 179 atUri := issue.AtUri().String() 180 issueMap[atUri] = &issue 181 } 182 183 // collect reverse repos 184 repoAts := make([]string, 0, len(issueMap)) // or just []string{} 185 for _, issue := range issueMap { 186 repoAts = append(repoAts, string(issue.RepoAt)) 187 } 188 189 repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 190 if err != nil { 191 return nil, fmt.Errorf("failed to build repo mappings: %w", err) 192 } 193 194 repoMap := make(map[string]*models.Repo) 195 for i := range repos { 196 repoMap[string(repos[i].RepoAt())] = &repos[i] 197 } 198 199 for issueAt, i := range issueMap { 200 if r, ok := repoMap[string(i.RepoAt)]; ok { 201 i.Repo = r 202 } else { 203 // do not show up the issue if the repo is deleted 204 // TODO: foreign key where? 205 delete(issueMap, issueAt) 206 } 207 } 208 209 // collect comments 210 issueAts := slices.Collect(maps.Keys(issueMap)) 211 212 comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 213 if err != nil { 214 return nil, fmt.Errorf("failed to query comments: %w", err) 215 } 216 for i := range comments { 217 issueAt := comments[i].IssueAt 218 if issue, ok := issueMap[issueAt]; ok { 219 issue.Comments = append(issue.Comments, comments[i]) 220 } 221 } 222 223 // collect allLabels for each issue 224 allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) 225 if err != nil { 226 return nil, fmt.Errorf("failed to query labels: %w", err) 227 } 228 for issueAt, labels := range allLabels { 229 if issue, ok := issueMap[issueAt.String()]; ok { 230 issue.Labels = labels 231 } 232 } 233 234 var issues []models.Issue 235 for _, i := range issueMap { 236 issues = append(issues, *i) 237 } 238 239 sort.Slice(issues, func(i, j int) bool { 240 return issues[i].Created.After(issues[j].Created) 241 }) 242 243 return issues, nil 244} 245 246func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 247 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 248} 249 250func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 251 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 252 row := e.QueryRow(query, repoAt, issueId) 253 254 var issue models.Issue 255 var createdAt string 256 err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 257 if err != nil { 258 return nil, err 259 } 260 261 createdTime, err := time.Parse(time.RFC3339, createdAt) 262 if err != nil { 263 return nil, err 264 } 265 issue.Created = createdTime 266 267 return &issue, nil 268} 269 270func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 271 result, err := e.Exec( 272 `insert into issue_comments ( 273 did, 274 rkey, 275 issue_at, 276 body, 277 reply_to, 278 created, 279 edited 280 ) 281 values (?, ?, ?, ?, ?, ?, null) 282 on conflict(did, rkey) do update set 283 issue_at = excluded.issue_at, 284 body = excluded.body, 285 edited = case 286 when 287 issue_comments.issue_at != excluded.issue_at 288 or issue_comments.body != excluded.body 289 or issue_comments.reply_to != excluded.reply_to 290 then ? 291 else issue_comments.edited 292 end`, 293 c.Did, 294 c.Rkey, 295 c.IssueAt, 296 c.Body, 297 c.ReplyTo, 298 c.Created.Format(time.RFC3339), 299 time.Now().Format(time.RFC3339), 300 ) 301 if err != nil { 302 return 0, err 303 } 304 305 id, err := result.LastInsertId() 306 if err != nil { 307 return 0, err 308 } 309 310 return id, nil 311} 312 313func DeleteIssueComments(e Execer, filters ...filter) error { 314 var conditions []string 315 var args []any 316 for _, filter := range filters { 317 conditions = append(conditions, filter.Condition()) 318 args = append(args, filter.Arg()...) 319 } 320 321 whereClause := "" 322 if conditions != nil { 323 whereClause = " where " + strings.Join(conditions, " and ") 324 } 325 326 query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 327 328 _, err := e.Exec(query, args...) 329 return err 330} 331 332func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 333 var comments []models.IssueComment 334 335 var conditions []string 336 var args []any 337 for _, filter := range filters { 338 conditions = append(conditions, filter.Condition()) 339 args = append(args, filter.Arg()...) 340 } 341 342 whereClause := "" 343 if conditions != nil { 344 whereClause = " where " + strings.Join(conditions, " and ") 345 } 346 347 query := fmt.Sprintf(` 348 select 349 id, 350 did, 351 rkey, 352 issue_at, 353 reply_to, 354 body, 355 created, 356 edited, 357 deleted 358 from 359 issue_comments 360 %s 361 `, whereClause) 362 363 rows, err := e.Query(query, args...) 364 if err != nil { 365 return nil, err 366 } 367 368 for rows.Next() { 369 var comment models.IssueComment 370 var created string 371 var rkey, edited, deleted, replyTo sql.Null[string] 372 err := rows.Scan( 373 &comment.Id, 374 &comment.Did, 375 &rkey, 376 &comment.IssueAt, 377 &replyTo, 378 &comment.Body, 379 &created, 380 &edited, 381 &deleted, 382 ) 383 if err != nil { 384 return nil, err 385 } 386 387 // this is a remnant from old times, newer comments always have rkey 388 if rkey.Valid { 389 comment.Rkey = rkey.V 390 } 391 392 if t, err := time.Parse(time.RFC3339, created); err == nil { 393 comment.Created = t 394 } 395 396 if edited.Valid { 397 if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 398 comment.Edited = &t 399 } 400 } 401 402 if deleted.Valid { 403 if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 404 comment.Deleted = &t 405 } 406 } 407 408 if replyTo.Valid { 409 comment.ReplyTo = &replyTo.V 410 } 411 412 comments = append(comments, comment) 413 } 414 415 if err = rows.Err(); err != nil { 416 return nil, err 417 } 418 419 return comments, nil 420} 421 422func DeleteIssues(e Execer, filters ...filter) error { 423 var conditions []string 424 var args []any 425 for _, filter := range filters { 426 conditions = append(conditions, filter.Condition()) 427 args = append(args, filter.Arg()...) 428 } 429 430 whereClause := "" 431 if conditions != nil { 432 whereClause = " where " + strings.Join(conditions, " and ") 433 } 434 435 query := fmt.Sprintf(`delete from issues %s`, whereClause) 436 _, err := e.Exec(query, args...) 437 return err 438} 439 440func CloseIssues(e Execer, filters ...filter) error { 441 var conditions []string 442 var args []any 443 for _, filter := range filters { 444 conditions = append(conditions, filter.Condition()) 445 args = append(args, filter.Arg()...) 446 } 447 448 whereClause := "" 449 if conditions != nil { 450 whereClause = " where " + strings.Join(conditions, " and ") 451 } 452 453 query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 454 _, err := e.Exec(query, args...) 455 return err 456} 457 458func ReopenIssues(e Execer, filters ...filter) error { 459 var conditions []string 460 var args []any 461 for _, filter := range filters { 462 conditions = append(conditions, filter.Condition()) 463 args = append(args, filter.Arg()...) 464 } 465 466 whereClause := "" 467 if conditions != nil { 468 whereClause = " where " + strings.Join(conditions, " and ") 469 } 470 471 query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause) 472 _, err := e.Exec(query, args...) 473 return err 474} 475 476func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) { 477 row := e.QueryRow(` 478 select 479 count(case when open = 1 then 1 end) as open_count, 480 count(case when open = 0 then 1 end) as closed_count 481 from issues 482 where repo_at = ?`, 483 repoAt, 484 ) 485 486 var count models.IssueCount 487 if err := row.Scan(&count.Open, &count.Closed); err != nil { 488 return models.IssueCount{}, err 489 } 490 491 return count, nil 492}