forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
1package db 2 3import ( 4 "database/sql" 5 "fmt" 6 "time" 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 "tangled.sh/tangled.sh/core/api/tangled" 10 "tangled.sh/tangled.sh/core/appview/pagination" 11) 12 13type Issue struct { 14 ID int64 15 RepoAt syntax.ATURI 16 OwnerDid string 17 IssueId int 18 Rkey string 19 Created time.Time 20 Title string 21 Body string 22 Open bool 23 24 // optionally, populate this when querying for reverse mappings 25 // like comment counts, parent repo etc. 26 Metadata *IssueMetadata 27} 28 29type IssueMetadata struct { 30 CommentCount int 31 Repo *Repo 32 // labels, assignee etc. 33} 34 35type Comment struct { 36 OwnerDid string 37 RepoAt syntax.ATURI 38 Rkey string 39 Issue int 40 CommentId int 41 Body string 42 Created *time.Time 43 Deleted *time.Time 44 Edited *time.Time 45} 46 47func (i *Issue) AtUri() syntax.ATURI { 48 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 49} 50 51func NewIssue(tx *sql.Tx, issue *Issue) error { 52 defer tx.Rollback() 53 54 _, err := tx.Exec(` 55 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 56 values (?, 1) 57 `, issue.RepoAt) 58 if err != nil { 59 return err 60 } 61 62 var nextId int 63 err = tx.QueryRow(` 64 update repo_issue_seqs 65 set next_issue_id = next_issue_id + 1 66 where repo_at = ? 67 returning next_issue_id - 1 68 `, issue.RepoAt).Scan(&nextId) 69 if err != nil { 70 return err 71 } 72 73 issue.IssueId = nextId 74 75 res, err := tx.Exec(` 76 insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body) 77 values (?, ?, ?, ?, ?, ?, ?) 78 `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body) 79 if err != nil { 80 return err 81 } 82 83 lastID, err := res.LastInsertId() 84 if err != nil { 85 return err 86 } 87 issue.ID = lastID 88 89 if err := tx.Commit(); err != nil { 90 return err 91 } 92 93 return nil 94} 95 96func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 97 var issueAt string 98 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 99 return issueAt, err 100} 101 102func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 103 var ownerDid string 104 err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 105 return ownerDid, err 106} 107 108func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 109 var issues []Issue 110 openValue := 0 111 if isOpen { 112 openValue = 1 113 } 114 115 rows, err := e.Query( 116 ` 117 with numbered_issue as ( 118 select 119 i.id, 120 i.owner_did, 121 i.rkey, 122 i.issue_id, 123 i.created, 124 i.title, 125 i.body, 126 i.open, 127 count(c.id) as comment_count, 128 row_number() over (order by i.created desc) as row_num 129 from 130 issues i 131 left join 132 comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 133 where 134 i.repo_at = ? and i.open = ? 135 group by 136 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 137 ) 138 select 139 id, 140 owner_did, 141 rkey, 142 issue_id, 143 created, 144 title, 145 body, 146 open, 147 comment_count 148 from 149 numbered_issue 150 where 151 row_num between ? and ?`, 152 repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 153 if err != nil { 154 return nil, err 155 } 156 defer rows.Close() 157 158 for rows.Next() { 159 var issue Issue 160 var createdAt string 161 var metadata IssueMetadata 162 err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 163 if err != nil { 164 return nil, err 165 } 166 167 createdTime, err := time.Parse(time.RFC3339, createdAt) 168 if err != nil { 169 return nil, err 170 } 171 issue.Created = createdTime 172 issue.Metadata = &metadata 173 174 issues = append(issues, issue) 175 } 176 177 if err := rows.Err(); err != nil { 178 return nil, err 179 } 180 181 return issues, nil 182} 183 184// timeframe here is directly passed into the sql query filter, and any 185// timeframe in the past should be negative; e.g.: "-3 months" 186func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { 187 var issues []Issue 188 189 rows, err := e.Query( 190 `select 191 i.id, 192 i.owner_did, 193 i.rkey, 194 i.repo_at, 195 i.issue_id, 196 i.created, 197 i.title, 198 i.body, 199 i.open, 200 r.did, 201 r.name, 202 r.knot, 203 r.rkey, 204 r.created 205 from 206 issues i 207 join 208 repos r on i.repo_at = r.at_uri 209 where 210 i.owner_did = ? and i.created >= date ('now', ?) 211 order by 212 i.created desc`, 213 ownerDid, timeframe) 214 if err != nil { 215 return nil, err 216 } 217 defer rows.Close() 218 219 for rows.Next() { 220 var issue Issue 221 var issueCreatedAt, repoCreatedAt string 222 var repo Repo 223 err := rows.Scan( 224 &issue.ID, 225 &issue.OwnerDid, 226 &issue.Rkey, 227 &issue.RepoAt, 228 &issue.IssueId, 229 &issueCreatedAt, 230 &issue.Title, 231 &issue.Body, 232 &issue.Open, 233 &repo.Did, 234 &repo.Name, 235 &repo.Knot, 236 &repo.Rkey, 237 &repoCreatedAt, 238 ) 239 if err != nil { 240 return nil, err 241 } 242 243 issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 244 if err != nil { 245 return nil, err 246 } 247 issue.Created = issueCreatedTime 248 249 repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 250 if err != nil { 251 return nil, err 252 } 253 repo.Created = repoCreatedTime 254 255 issue.Metadata = &IssueMetadata{ 256 Repo: &repo, 257 } 258 259 issues = append(issues, issue) 260 } 261 262 if err := rows.Err(); err != nil { 263 return nil, err 264 } 265 266 return issues, nil 267} 268 269func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 270 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 271 row := e.QueryRow(query, repoAt, issueId) 272 273 var issue Issue 274 var createdAt string 275 err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 276 if err != nil { 277 return nil, err 278 } 279 280 createdTime, err := time.Parse(time.RFC3339, createdAt) 281 if err != nil { 282 return nil, err 283 } 284 issue.Created = createdTime 285 286 return &issue, nil 287} 288 289func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 290 query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 291 row := e.QueryRow(query, repoAt, issueId) 292 293 var issue Issue 294 var createdAt string 295 err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 296 if err != nil { 297 return nil, nil, err 298 } 299 300 createdTime, err := time.Parse(time.RFC3339, createdAt) 301 if err != nil { 302 return nil, nil, err 303 } 304 issue.Created = createdTime 305 306 comments, err := GetComments(e, repoAt, issueId) 307 if err != nil { 308 return nil, nil, err 309 } 310 311 return &issue, comments, nil 312} 313 314func NewIssueComment(e Execer, comment *Comment) error { 315 query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` 316 _, err := e.Exec( 317 query, 318 comment.OwnerDid, 319 comment.RepoAt, 320 comment.Rkey, 321 comment.Issue, 322 comment.CommentId, 323 comment.Body, 324 ) 325 return err 326} 327 328func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 329 var comments []Comment 330 331 rows, err := e.Query(` 332 select 333 owner_did, 334 issue_id, 335 comment_id, 336 rkey, 337 body, 338 created, 339 edited, 340 deleted 341 from 342 comments 343 where 344 repo_at = ? and issue_id = ? 345 order by 346 created asc`, 347 repoAt, 348 issueId, 349 ) 350 if err == sql.ErrNoRows { 351 return []Comment{}, nil 352 } 353 if err != nil { 354 return nil, err 355 } 356 defer rows.Close() 357 358 for rows.Next() { 359 var comment Comment 360 var createdAt string 361 var deletedAt, editedAt, rkey sql.NullString 362 err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt) 363 if err != nil { 364 return nil, err 365 } 366 367 createdAtTime, err := time.Parse(time.RFC3339, createdAt) 368 if err != nil { 369 return nil, err 370 } 371 comment.Created = &createdAtTime 372 373 if deletedAt.Valid { 374 deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 375 if err != nil { 376 return nil, err 377 } 378 comment.Deleted = &deletedTime 379 } 380 381 if editedAt.Valid { 382 editedTime, err := time.Parse(time.RFC3339, editedAt.String) 383 if err != nil { 384 return nil, err 385 } 386 comment.Edited = &editedTime 387 } 388 389 if rkey.Valid { 390 comment.Rkey = rkey.String 391 } 392 393 comments = append(comments, comment) 394 } 395 396 if err := rows.Err(); err != nil { 397 return nil, err 398 } 399 400 return comments, nil 401} 402 403func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) { 404 query := ` 405 select 406 owner_did, body, rkey, created, deleted, edited 407 from 408 comments where repo_at = ? and issue_id = ? and comment_id = ? 409 ` 410 row := e.QueryRow(query, repoAt, issueId, commentId) 411 412 var comment Comment 413 var createdAt string 414 var deletedAt, editedAt, rkey sql.NullString 415 err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt) 416 if err != nil { 417 return nil, err 418 } 419 420 createdTime, err := time.Parse(time.RFC3339, createdAt) 421 if err != nil { 422 return nil, err 423 } 424 comment.Created = &createdTime 425 426 if deletedAt.Valid { 427 deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 428 if err != nil { 429 return nil, err 430 } 431 comment.Deleted = &deletedTime 432 } 433 434 if editedAt.Valid { 435 editedTime, err := time.Parse(time.RFC3339, editedAt.String) 436 if err != nil { 437 return nil, err 438 } 439 comment.Edited = &editedTime 440 } 441 442 if rkey.Valid { 443 comment.Rkey = rkey.String 444 } 445 446 comment.RepoAt = repoAt 447 comment.Issue = issueId 448 comment.CommentId = commentId 449 450 return &comment, nil 451} 452 453func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error { 454 _, err := e.Exec( 455 ` 456 update comments 457 set body = ?, 458 edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 459 where repo_at = ? and issue_id = ? and comment_id = ? 460 `, newBody, repoAt, issueId, commentId) 461 return err 462} 463 464func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error { 465 _, err := e.Exec( 466 ` 467 update comments 468 set body = "", 469 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 470 where repo_at = ? and issue_id = ? and comment_id = ? 471 `, repoAt, issueId, commentId) 472 return err 473} 474 475func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 476 _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId) 477 return err 478} 479 480func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 481 _, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId) 482 return err 483} 484 485type IssueCount struct { 486 Open int 487 Closed int 488} 489 490func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) { 491 row := e.QueryRow(` 492 select 493 count(case when open = 1 then 1 end) as open_count, 494 count(case when open = 0 then 1 end) as closed_count 495 from issues 496 where repo_at = ?`, 497 repoAt, 498 ) 499 500 var count IssueCount 501 if err := row.Scan(&count.Open, &count.Closed); err != nil { 502 return IssueCount{0, 0}, err 503 } 504 505 return count, nil 506}