this repo has no description
1package issues 2 3import ( 4 "context" 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "slices" 11 "time" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 atpclient "github.com/bluesky-social/indigo/atproto/client" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" 18 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/config" 21 "tangled.org/core/appview/db" 22 issues_indexer "tangled.org/core/appview/indexer/issues" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/notify" 25 "tangled.org/core/appview/oauth" 26 "tangled.org/core/appview/pages" 27 "tangled.org/core/appview/pagination" 28 "tangled.org/core/appview/reporesolver" 29 "tangled.org/core/appview/validator" 30 "tangled.org/core/idresolver" 31 "tangled.org/core/tid" 32) 33 34type Issues struct { 35 oauth *oauth.OAuth 36 repoResolver *reporesolver.RepoResolver 37 pages *pages.Pages 38 idResolver *idresolver.Resolver 39 db *db.DB 40 config *config.Config 41 notifier notify.Notifier 42 logger *slog.Logger 43 validator *validator.Validator 44 indexer *issues_indexer.Indexer 45} 46 47func New( 48 oauth *oauth.OAuth, 49 repoResolver *reporesolver.RepoResolver, 50 pages *pages.Pages, 51 idResolver *idresolver.Resolver, 52 db *db.DB, 53 config *config.Config, 54 notifier notify.Notifier, 55 validator *validator.Validator, 56 indexer *issues_indexer.Indexer, 57 logger *slog.Logger, 58) *Issues { 59 return &Issues{ 60 oauth: oauth, 61 repoResolver: repoResolver, 62 pages: pages, 63 idResolver: idResolver, 64 db: db, 65 config: config, 66 notifier: notifier, 67 logger: logger, 68 validator: validator, 69 indexer: indexer, 70 } 71} 72 73func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 74 l := rp.logger.With("handler", "RepoSingleIssue") 75 user := rp.oauth.GetUser(r) 76 f, err := rp.repoResolver.Resolve(r) 77 if err != nil { 78 l.Error("failed to get repo and knot", "err", err) 79 return 80 } 81 82 issue, ok := r.Context().Value("issue").(*models.Issue) 83 if !ok { 84 l.Error("failed to get issue") 85 rp.pages.Error404(w) 86 return 87 } 88 89 reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 90 if err != nil { 91 l.Error("failed to get issue reactions", "err", err) 92 } 93 94 userReactions := map[models.ReactionKind]bool{} 95 if user != nil { 96 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 97 } 98 99 labelDefs, err := db.GetLabelDefinitions( 100 rp.db, 101 db.FilterIn("at_uri", f.Repo.Labels), 102 db.FilterContains("scope", tangled.RepoIssueNSID), 103 ) 104 if err != nil { 105 l.Error("failed to fetch labels", "err", err) 106 rp.pages.Error503(w) 107 return 108 } 109 110 defs := make(map[string]*models.LabelDefinition) 111 for _, l := range labelDefs { 112 defs[l.AtUri().String()] = &l 113 } 114 115 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 116 LoggedInUser: user, 117 RepoInfo: f.RepoInfo(user), 118 Issue: issue, 119 CommentList: issue.CommentList(), 120 OrderedReactionKinds: models.OrderedReactionKinds, 121 Reactions: reactionMap, 122 UserReacted: userReactions, 123 LabelDefs: defs, 124 }) 125} 126 127func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 128 l := rp.logger.With("handler", "EditIssue") 129 user := rp.oauth.GetUser(r) 130 f, err := rp.repoResolver.Resolve(r) 131 if err != nil { 132 l.Error("failed to get repo and knot", "err", err) 133 return 134 } 135 136 issue, ok := r.Context().Value("issue").(*models.Issue) 137 if !ok { 138 l.Error("failed to get issue") 139 rp.pages.Error404(w) 140 return 141 } 142 143 switch r.Method { 144 case http.MethodGet: 145 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 146 LoggedInUser: user, 147 RepoInfo: f.RepoInfo(user), 148 Issue: issue, 149 }) 150 case http.MethodPost: 151 noticeId := "issues" 152 newIssue := issue 153 newIssue.Title = r.FormValue("title") 154 newIssue.Body = r.FormValue("body") 155 156 if err := rp.validator.ValidateIssue(newIssue); err != nil { 157 l.Error("validation error", "err", err) 158 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 159 return 160 } 161 162 newRecord := newIssue.AsRecord() 163 164 // edit an atproto record 165 client, err := rp.oauth.AuthorizedClient(r) 166 if err != nil { 167 l.Error("failed to get authorized client", "err", err) 168 rp.pages.Notice(w, noticeId, "Failed to edit issue.") 169 return 170 } 171 172 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 173 if err != nil { 174 l.Error("failed to get record", "err", err) 175 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 176 return 177 } 178 179 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 180 Collection: tangled.RepoIssueNSID, 181 Repo: user.Did, 182 Rkey: newIssue.Rkey, 183 SwapRecord: ex.Cid, 184 Record: &lexutil.LexiconTypeDecoder{ 185 Val: &newRecord, 186 }, 187 }) 188 if err != nil { 189 l.Error("failed to edit record on PDS", "err", err) 190 rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") 191 return 192 } 193 194 // modify on DB -- TODO: transact this cleverly 195 tx, err := rp.db.Begin() 196 if err != nil { 197 l.Error("failed to edit issue on DB", "err", err) 198 rp.pages.Notice(w, noticeId, "Failed to edit issue.") 199 return 200 } 201 defer tx.Rollback() 202 203 err = db.PutIssue(tx, newIssue) 204 if err != nil { 205 l.Error("failed to edit issue", "err", err) 206 rp.pages.Notice(w, "issues", "Failed to edit issue.") 207 return 208 } 209 210 if err = tx.Commit(); err != nil { 211 l.Error("failed to edit issue", "err", err) 212 rp.pages.Notice(w, "issues", "Failed to cedit issue.") 213 return 214 } 215 216 rp.pages.HxRefresh(w) 217 } 218} 219 220func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 221 l := rp.logger.With("handler", "DeleteIssue") 222 noticeId := "issue-actions-error" 223 224 user := rp.oauth.GetUser(r) 225 226 f, err := rp.repoResolver.Resolve(r) 227 if err != nil { 228 l.Error("failed to get repo and knot", "err", err) 229 return 230 } 231 232 issue, ok := r.Context().Value("issue").(*models.Issue) 233 if !ok { 234 l.Error("failed to get issue") 235 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 236 return 237 } 238 l = l.With("did", issue.Did, "rkey", issue.Rkey) 239 240 // delete from PDS 241 client, err := rp.oauth.AuthorizedClient(r) 242 if err != nil { 243 l.Error("failed to get authorized client", "err", err) 244 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 245 return 246 } 247 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 248 Collection: tangled.RepoIssueNSID, 249 Repo: issue.Did, 250 Rkey: issue.Rkey, 251 }) 252 if err != nil { 253 // TODO: transact this better 254 l.Error("failed to delete issue from PDS", "err", err) 255 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 256 return 257 } 258 259 // delete from db 260 if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil { 261 l.Error("failed to delete issue", "err", err) 262 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 263 return 264 } 265 266 // return to all issues page 267 rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 268} 269 270func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 271 l := rp.logger.With("handler", "CloseIssue") 272 user := rp.oauth.GetUser(r) 273 f, err := rp.repoResolver.Resolve(r) 274 if err != nil { 275 l.Error("failed to get repo and knot", "err", err) 276 return 277 } 278 279 issue, ok := r.Context().Value("issue").(*models.Issue) 280 if !ok { 281 l.Error("failed to get issue") 282 rp.pages.Error404(w) 283 return 284 } 285 286 collaborators, err := f.Collaborators(r.Context()) 287 if err != nil { 288 l.Error("failed to fetch repo collaborators", "err", err) 289 } 290 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 291 return user.Did == collab.Did 292 }) 293 isIssueOwner := user.Did == issue.Did 294 295 // TODO: make this more granular 296 if isIssueOwner || isCollaborator { 297 err = db.CloseIssues( 298 rp.db, 299 db.FilterEq("id", issue.Id), 300 ) 301 if err != nil { 302 l.Error("failed to close issue", "err", err) 303 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 304 return 305 } 306 307 // notify about the issue closure 308 rp.notifier.NewIssueClosed(r.Context(), issue) 309 310 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 311 return 312 } else { 313 l.Error("user is not permitted to close issue") 314 http.Error(w, "for biden", http.StatusUnauthorized) 315 return 316 } 317} 318 319func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 320 l := rp.logger.With("handler", "ReopenIssue") 321 user := rp.oauth.GetUser(r) 322 f, err := rp.repoResolver.Resolve(r) 323 if err != nil { 324 l.Error("failed to get repo and knot", "err", err) 325 return 326 } 327 328 issue, ok := r.Context().Value("issue").(*models.Issue) 329 if !ok { 330 l.Error("failed to get issue") 331 rp.pages.Error404(w) 332 return 333 } 334 335 collaborators, err := f.Collaborators(r.Context()) 336 if err != nil { 337 l.Error("failed to fetch repo collaborators", "err", err) 338 } 339 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 340 return user.Did == collab.Did 341 }) 342 isIssueOwner := user.Did == issue.Did 343 344 if isCollaborator || isIssueOwner { 345 err := db.ReopenIssues( 346 rp.db, 347 db.FilterEq("id", issue.Id), 348 ) 349 if err != nil { 350 l.Error("failed to reopen issue", "err", err) 351 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 352 return 353 } 354 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 355 return 356 } else { 357 l.Error("user is not the owner of the repo") 358 http.Error(w, "forbidden", http.StatusUnauthorized) 359 return 360 } 361} 362 363func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 364 l := rp.logger.With("handler", "NewIssueComment") 365 user := rp.oauth.GetUser(r) 366 f, err := rp.repoResolver.Resolve(r) 367 if err != nil { 368 l.Error("failed to get repo and knot", "err", err) 369 return 370 } 371 372 issue, ok := r.Context().Value("issue").(*models.Issue) 373 if !ok { 374 l.Error("failed to get issue") 375 rp.pages.Error404(w) 376 return 377 } 378 379 body := r.FormValue("body") 380 if body == "" { 381 rp.pages.Notice(w, "issue", "Body is required") 382 return 383 } 384 385 replyToUri := r.FormValue("reply-to") 386 var replyTo *string 387 if replyToUri != "" { 388 replyTo = &replyToUri 389 } 390 391 comment := models.IssueComment{ 392 Did: user.Did, 393 Rkey: tid.TID(), 394 IssueAt: issue.AtUri().String(), 395 ReplyTo: replyTo, 396 Body: body, 397 Created: time.Now(), 398 } 399 if err = rp.validator.ValidateIssueComment(&comment); err != nil { 400 l.Error("failed to validate comment", "err", err) 401 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 402 return 403 } 404 record := comment.AsRecord() 405 406 client, err := rp.oauth.AuthorizedClient(r) 407 if err != nil { 408 l.Error("failed to get authorized client", "err", err) 409 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 410 return 411 } 412 413 // create a record first 414 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 415 Collection: tangled.RepoIssueCommentNSID, 416 Repo: comment.Did, 417 Rkey: comment.Rkey, 418 Record: &lexutil.LexiconTypeDecoder{ 419 Val: &record, 420 }, 421 }) 422 if err != nil { 423 l.Error("failed to create comment", "err", err) 424 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 425 return 426 } 427 atUri := resp.Uri 428 defer func() { 429 if err := rollbackRecord(context.Background(), atUri, client); err != nil { 430 l.Error("rollback failed", "err", err) 431 } 432 }() 433 434 commentId, err := db.AddIssueComment(rp.db, comment) 435 if err != nil { 436 l.Error("failed to create comment", "err", err) 437 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 438 return 439 } 440 441 // reset atUri to make rollback a no-op 442 atUri = "" 443 444 // notify about the new comment 445 comment.Id = commentId 446 rp.notifier.NewIssueComment(r.Context(), &comment) 447 448 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 449} 450 451func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 452 l := rp.logger.With("handler", "IssueComment") 453 user := rp.oauth.GetUser(r) 454 f, err := rp.repoResolver.Resolve(r) 455 if err != nil { 456 l.Error("failed to get repo and knot", "err", err) 457 return 458 } 459 460 issue, ok := r.Context().Value("issue").(*models.Issue) 461 if !ok { 462 l.Error("failed to get issue") 463 rp.pages.Error404(w) 464 return 465 } 466 467 commentId := chi.URLParam(r, "commentId") 468 comments, err := db.GetIssueComments( 469 rp.db, 470 db.FilterEq("id", commentId), 471 ) 472 if err != nil { 473 l.Error("failed to fetch comment", "id", commentId) 474 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 475 return 476 } 477 if len(comments) != 1 { 478 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 479 http.Error(w, "invalid comment id", http.StatusBadRequest) 480 return 481 } 482 comment := comments[0] 483 484 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 485 LoggedInUser: user, 486 RepoInfo: f.RepoInfo(user), 487 Issue: issue, 488 Comment: &comment, 489 }) 490} 491 492func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 493 l := rp.logger.With("handler", "EditIssueComment") 494 user := rp.oauth.GetUser(r) 495 f, err := rp.repoResolver.Resolve(r) 496 if err != nil { 497 l.Error("failed to get repo and knot", "err", err) 498 return 499 } 500 501 issue, ok := r.Context().Value("issue").(*models.Issue) 502 if !ok { 503 l.Error("failed to get issue") 504 rp.pages.Error404(w) 505 return 506 } 507 508 commentId := chi.URLParam(r, "commentId") 509 comments, err := db.GetIssueComments( 510 rp.db, 511 db.FilterEq("id", commentId), 512 ) 513 if err != nil { 514 l.Error("failed to fetch comment", "id", commentId) 515 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 516 return 517 } 518 if len(comments) != 1 { 519 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 520 http.Error(w, "invalid comment id", http.StatusBadRequest) 521 return 522 } 523 comment := comments[0] 524 525 if comment.Did != user.Did { 526 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 527 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 528 return 529 } 530 531 switch r.Method { 532 case http.MethodGet: 533 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 534 LoggedInUser: user, 535 RepoInfo: f.RepoInfo(user), 536 Issue: issue, 537 Comment: &comment, 538 }) 539 case http.MethodPost: 540 // extract form value 541 newBody := r.FormValue("body") 542 client, err := rp.oauth.AuthorizedClient(r) 543 if err != nil { 544 l.Error("failed to get authorized client", "err", err) 545 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 546 return 547 } 548 549 now := time.Now() 550 newComment := comment 551 newComment.Body = newBody 552 newComment.Edited = &now 553 record := newComment.AsRecord() 554 555 _, err = db.AddIssueComment(rp.db, newComment) 556 if err != nil { 557 l.Error("failed to perferom update-description query", "err", err) 558 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 559 return 560 } 561 562 // rkey is optional, it was introduced later 563 if newComment.Rkey != "" { 564 // update the record on pds 565 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 566 if err != nil { 567 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 568 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 569 return 570 } 571 572 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 573 Collection: tangled.RepoIssueCommentNSID, 574 Repo: user.Did, 575 Rkey: newComment.Rkey, 576 SwapRecord: ex.Cid, 577 Record: &lexutil.LexiconTypeDecoder{ 578 Val: &record, 579 }, 580 }) 581 if err != nil { 582 l.Error("failed to update record on PDS", "err", err) 583 } 584 } 585 586 // return new comment body with htmx 587 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 588 LoggedInUser: user, 589 RepoInfo: f.RepoInfo(user), 590 Issue: issue, 591 Comment: &newComment, 592 }) 593 } 594} 595 596func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 597 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 598 user := rp.oauth.GetUser(r) 599 f, err := rp.repoResolver.Resolve(r) 600 if err != nil { 601 l.Error("failed to get repo and knot", "err", err) 602 return 603 } 604 605 issue, ok := r.Context().Value("issue").(*models.Issue) 606 if !ok { 607 l.Error("failed to get issue") 608 rp.pages.Error404(w) 609 return 610 } 611 612 commentId := chi.URLParam(r, "commentId") 613 comments, err := db.GetIssueComments( 614 rp.db, 615 db.FilterEq("id", commentId), 616 ) 617 if err != nil { 618 l.Error("failed to fetch comment", "id", commentId) 619 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 620 return 621 } 622 if len(comments) != 1 { 623 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 624 http.Error(w, "invalid comment id", http.StatusBadRequest) 625 return 626 } 627 comment := comments[0] 628 629 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 630 LoggedInUser: user, 631 RepoInfo: f.RepoInfo(user), 632 Issue: issue, 633 Comment: &comment, 634 }) 635} 636 637func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 638 l := rp.logger.With("handler", "ReplyIssueComment") 639 user := rp.oauth.GetUser(r) 640 f, err := rp.repoResolver.Resolve(r) 641 if err != nil { 642 l.Error("failed to get repo and knot", "err", err) 643 return 644 } 645 646 issue, ok := r.Context().Value("issue").(*models.Issue) 647 if !ok { 648 l.Error("failed to get issue") 649 rp.pages.Error404(w) 650 return 651 } 652 653 commentId := chi.URLParam(r, "commentId") 654 comments, err := db.GetIssueComments( 655 rp.db, 656 db.FilterEq("id", commentId), 657 ) 658 if err != nil { 659 l.Error("failed to fetch comment", "id", commentId) 660 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 661 return 662 } 663 if len(comments) != 1 { 664 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 665 http.Error(w, "invalid comment id", http.StatusBadRequest) 666 return 667 } 668 comment := comments[0] 669 670 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 671 LoggedInUser: user, 672 RepoInfo: f.RepoInfo(user), 673 Issue: issue, 674 Comment: &comment, 675 }) 676} 677 678func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 679 l := rp.logger.With("handler", "DeleteIssueComment") 680 user := rp.oauth.GetUser(r) 681 f, err := rp.repoResolver.Resolve(r) 682 if err != nil { 683 l.Error("failed to get repo and knot", "err", err) 684 return 685 } 686 687 issue, ok := r.Context().Value("issue").(*models.Issue) 688 if !ok { 689 l.Error("failed to get issue") 690 rp.pages.Error404(w) 691 return 692 } 693 694 commentId := chi.URLParam(r, "commentId") 695 comments, err := db.GetIssueComments( 696 rp.db, 697 db.FilterEq("id", commentId), 698 ) 699 if err != nil { 700 l.Error("failed to fetch comment", "id", commentId) 701 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 702 return 703 } 704 if len(comments) != 1 { 705 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 706 http.Error(w, "invalid comment id", http.StatusBadRequest) 707 return 708 } 709 comment := comments[0] 710 711 if comment.Did != user.Did { 712 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 713 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 714 return 715 } 716 717 if comment.Deleted != nil { 718 http.Error(w, "comment already deleted", http.StatusBadRequest) 719 return 720 } 721 722 // optimistic deletion 723 deleted := time.Now() 724 err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 725 if err != nil { 726 l.Error("failed to delete comment", "err", err) 727 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 728 return 729 } 730 731 // delete from pds 732 if comment.Rkey != "" { 733 client, err := rp.oauth.AuthorizedClient(r) 734 if err != nil { 735 l.Error("failed to get authorized client", "err", err) 736 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 737 return 738 } 739 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 740 Collection: tangled.RepoIssueCommentNSID, 741 Repo: user.Did, 742 Rkey: comment.Rkey, 743 }) 744 if err != nil { 745 l.Error("failed to delete from PDS", "err", err) 746 } 747 } 748 749 // optimistic update for htmx 750 comment.Body = "" 751 comment.Deleted = &deleted 752 753 // htmx fragment of comment after deletion 754 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 755 LoggedInUser: user, 756 RepoInfo: f.RepoInfo(user), 757 Issue: issue, 758 Comment: &comment, 759 }) 760} 761 762func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 763 l := rp.logger.With("handler", "RepoIssues") 764 765 params := r.URL.Query() 766 state := params.Get("state") 767 isOpen := true 768 switch state { 769 case "open": 770 isOpen = true 771 case "closed": 772 isOpen = false 773 default: 774 isOpen = true 775 } 776 777 page := pagination.FromContext(r.Context()) 778 779 user := rp.oauth.GetUser(r) 780 f, err := rp.repoResolver.Resolve(r) 781 if err != nil { 782 l.Error("failed to get repo and knot", "err", err) 783 return 784 } 785 786 openVal := 0 787 if isOpen { 788 openVal = 1 789 } 790 issues, err := db.GetIssuesPaginated( 791 rp.db, 792 page, 793 db.FilterEq("repo_at", f.RepoAt()), 794 db.FilterEq("open", openVal), 795 ) 796 if err != nil { 797 l.Error("failed to get issues", "err", err) 798 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 799 return 800 } 801 802 labelDefs, err := db.GetLabelDefinitions( 803 rp.db, 804 db.FilterIn("at_uri", f.Repo.Labels), 805 db.FilterContains("scope", tangled.RepoIssueNSID), 806 ) 807 if err != nil { 808 l.Error("failed to fetch labels", "err", err) 809 rp.pages.Error503(w) 810 return 811 } 812 813 defs := make(map[string]*models.LabelDefinition) 814 for _, l := range labelDefs { 815 defs[l.AtUri().String()] = &l 816 } 817 818 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 819 LoggedInUser: rp.oauth.GetUser(r), 820 RepoInfo: f.RepoInfo(user), 821 Issues: issues, 822 LabelDefs: defs, 823 FilteringByOpen: isOpen, 824 Page: page, 825 }) 826} 827 828func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 829 l := rp.logger.With("handler", "NewIssue") 830 user := rp.oauth.GetUser(r) 831 832 f, err := rp.repoResolver.Resolve(r) 833 if err != nil { 834 l.Error("failed to get repo and knot", "err", err) 835 return 836 } 837 838 switch r.Method { 839 case http.MethodGet: 840 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 841 LoggedInUser: user, 842 RepoInfo: f.RepoInfo(user), 843 }) 844 case http.MethodPost: 845 issue := &models.Issue{ 846 RepoAt: f.RepoAt(), 847 Rkey: tid.TID(), 848 Title: r.FormValue("title"), 849 Body: r.FormValue("body"), 850 Open: true, 851 Did: user.Did, 852 Created: time.Now(), 853 Repo: &f.Repo, 854 } 855 856 if err := rp.validator.ValidateIssue(issue); err != nil { 857 l.Error("validation error", "err", err) 858 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 859 return 860 } 861 862 record := issue.AsRecord() 863 864 // create an atproto record 865 client, err := rp.oauth.AuthorizedClient(r) 866 if err != nil { 867 l.Error("failed to get authorized client", "err", err) 868 rp.pages.Notice(w, "issues", "Failed to create issue.") 869 return 870 } 871 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 872 Collection: tangled.RepoIssueNSID, 873 Repo: user.Did, 874 Rkey: issue.Rkey, 875 Record: &lexutil.LexiconTypeDecoder{ 876 Val: &record, 877 }, 878 }) 879 if err != nil { 880 l.Error("failed to create issue", "err", err) 881 rp.pages.Notice(w, "issues", "Failed to create issue.") 882 return 883 } 884 atUri := resp.Uri 885 886 tx, err := rp.db.BeginTx(r.Context(), nil) 887 if err != nil { 888 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 889 return 890 } 891 rollback := func() { 892 err1 := tx.Rollback() 893 err2 := rollbackRecord(context.Background(), atUri, client) 894 895 if errors.Is(err1, sql.ErrTxDone) { 896 err1 = nil 897 } 898 899 if err := errors.Join(err1, err2); err != nil { 900 l.Error("failed to rollback txn", "err", err) 901 } 902 } 903 defer rollback() 904 905 err = db.PutIssue(tx, issue) 906 if err != nil { 907 l.Error("failed to create issue", "err", err) 908 rp.pages.Notice(w, "issues", "Failed to create issue.") 909 return 910 } 911 912 if err = tx.Commit(); err != nil { 913 l.Error("failed to create issue", "err", err) 914 rp.pages.Notice(w, "issues", "Failed to create issue.") 915 return 916 } 917 918 // everything is successful, do not rollback the atproto record 919 atUri = "" 920 rp.notifier.NewIssue(r.Context(), issue) 921 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 922 return 923 } 924} 925 926// this is used to rollback changes made to the PDS 927// 928// it is a no-op if the provided ATURI is empty 929func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 930 if aturi == "" { 931 return nil 932 } 933 934 parsed := syntax.ATURI(aturi) 935 936 collection := parsed.Collection().String() 937 repo := parsed.Authority().String() 938 rkey := parsed.RecordKey().String() 939 940 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 941 Collection: collection, 942 Repo: repo, 943 Rkey: rkey, 944 }) 945 return err 946}