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 rp.notifier.DeleteIssue(r.Context(), issue) 267 268 // return to all issues page 269 rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 270} 271 272func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 273 l := rp.logger.With("handler", "CloseIssue") 274 user := rp.oauth.GetUser(r) 275 f, err := rp.repoResolver.Resolve(r) 276 if err != nil { 277 l.Error("failed to get repo and knot", "err", err) 278 return 279 } 280 281 issue, ok := r.Context().Value("issue").(*models.Issue) 282 if !ok { 283 l.Error("failed to get issue") 284 rp.pages.Error404(w) 285 return 286 } 287 288 collaborators, err := f.Collaborators(r.Context()) 289 if err != nil { 290 l.Error("failed to fetch repo collaborators", "err", err) 291 } 292 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 293 return user.Did == collab.Did 294 }) 295 isIssueOwner := user.Did == issue.Did 296 297 // TODO: make this more granular 298 if isIssueOwner || isCollaborator { 299 err = db.CloseIssues( 300 rp.db, 301 db.FilterEq("id", issue.Id), 302 ) 303 if err != nil { 304 l.Error("failed to close issue", "err", err) 305 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 306 return 307 } 308 // change the issue state (this will pass down to the notifiers) 309 issue.Open = false 310 311 // notify about the issue closure 312 rp.notifier.NewIssueClosed(r.Context(), issue) 313 314 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 315 return 316 } else { 317 l.Error("user is not permitted to close issue") 318 http.Error(w, "for biden", http.StatusUnauthorized) 319 return 320 } 321} 322 323func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 324 l := rp.logger.With("handler", "ReopenIssue") 325 user := rp.oauth.GetUser(r) 326 f, err := rp.repoResolver.Resolve(r) 327 if err != nil { 328 l.Error("failed to get repo and knot", "err", err) 329 return 330 } 331 332 issue, ok := r.Context().Value("issue").(*models.Issue) 333 if !ok { 334 l.Error("failed to get issue") 335 rp.pages.Error404(w) 336 return 337 } 338 339 collaborators, err := f.Collaborators(r.Context()) 340 if err != nil { 341 l.Error("failed to fetch repo collaborators", "err", err) 342 } 343 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 344 return user.Did == collab.Did 345 }) 346 isIssueOwner := user.Did == issue.Did 347 348 if isCollaborator || isIssueOwner { 349 err := db.ReopenIssues( 350 rp.db, 351 db.FilterEq("id", issue.Id), 352 ) 353 if err != nil { 354 l.Error("failed to reopen issue", "err", err) 355 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 356 return 357 } 358 // change the issue state (this will pass down to the notifiers) 359 issue.Open = true 360 361 // // notify about the issue reopen 362 // rp.notifier.NewIssueReopen(r.Context(), issue) 363 364 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 365 return 366 } else { 367 l.Error("user is not the owner of the repo") 368 http.Error(w, "forbidden", http.StatusUnauthorized) 369 return 370 } 371} 372 373func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 374 l := rp.logger.With("handler", "NewIssueComment") 375 user := rp.oauth.GetUser(r) 376 f, err := rp.repoResolver.Resolve(r) 377 if err != nil { 378 l.Error("failed to get repo and knot", "err", err) 379 return 380 } 381 382 issue, ok := r.Context().Value("issue").(*models.Issue) 383 if !ok { 384 l.Error("failed to get issue") 385 rp.pages.Error404(w) 386 return 387 } 388 389 body := r.FormValue("body") 390 if body == "" { 391 rp.pages.Notice(w, "issue", "Body is required") 392 return 393 } 394 395 replyToUri := r.FormValue("reply-to") 396 var replyTo *string 397 if replyToUri != "" { 398 replyTo = &replyToUri 399 } 400 401 comment := models.IssueComment{ 402 Did: user.Did, 403 Rkey: tid.TID(), 404 IssueAt: issue.AtUri().String(), 405 ReplyTo: replyTo, 406 Body: body, 407 Created: time.Now(), 408 } 409 if err = rp.validator.ValidateIssueComment(&comment); err != nil { 410 l.Error("failed to validate comment", "err", err) 411 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 412 return 413 } 414 record := comment.AsRecord() 415 416 client, err := rp.oauth.AuthorizedClient(r) 417 if err != nil { 418 l.Error("failed to get authorized client", "err", err) 419 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 420 return 421 } 422 423 // create a record first 424 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 425 Collection: tangled.RepoIssueCommentNSID, 426 Repo: comment.Did, 427 Rkey: comment.Rkey, 428 Record: &lexutil.LexiconTypeDecoder{ 429 Val: &record, 430 }, 431 }) 432 if err != nil { 433 l.Error("failed to create comment", "err", err) 434 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 435 return 436 } 437 atUri := resp.Uri 438 defer func() { 439 if err := rollbackRecord(context.Background(), atUri, client); err != nil { 440 l.Error("rollback failed", "err", err) 441 } 442 }() 443 444 commentId, err := db.AddIssueComment(rp.db, comment) 445 if err != nil { 446 l.Error("failed to create comment", "err", err) 447 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 448 return 449 } 450 451 // reset atUri to make rollback a no-op 452 atUri = "" 453 454 // notify about the new comment 455 comment.Id = commentId 456 rp.notifier.NewIssueComment(r.Context(), &comment) 457 458 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 459} 460 461func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 462 l := rp.logger.With("handler", "IssueComment") 463 user := rp.oauth.GetUser(r) 464 f, err := rp.repoResolver.Resolve(r) 465 if err != nil { 466 l.Error("failed to get repo and knot", "err", err) 467 return 468 } 469 470 issue, ok := r.Context().Value("issue").(*models.Issue) 471 if !ok { 472 l.Error("failed to get issue") 473 rp.pages.Error404(w) 474 return 475 } 476 477 commentId := chi.URLParam(r, "commentId") 478 comments, err := db.GetIssueComments( 479 rp.db, 480 db.FilterEq("id", commentId), 481 ) 482 if err != nil { 483 l.Error("failed to fetch comment", "id", commentId) 484 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 485 return 486 } 487 if len(comments) != 1 { 488 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 489 http.Error(w, "invalid comment id", http.StatusBadRequest) 490 return 491 } 492 comment := comments[0] 493 494 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 495 LoggedInUser: user, 496 RepoInfo: f.RepoInfo(user), 497 Issue: issue, 498 Comment: &comment, 499 }) 500} 501 502func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 503 l := rp.logger.With("handler", "EditIssueComment") 504 user := rp.oauth.GetUser(r) 505 f, err := rp.repoResolver.Resolve(r) 506 if err != nil { 507 l.Error("failed to get repo and knot", "err", err) 508 return 509 } 510 511 issue, ok := r.Context().Value("issue").(*models.Issue) 512 if !ok { 513 l.Error("failed to get issue") 514 rp.pages.Error404(w) 515 return 516 } 517 518 commentId := chi.URLParam(r, "commentId") 519 comments, err := db.GetIssueComments( 520 rp.db, 521 db.FilterEq("id", commentId), 522 ) 523 if err != nil { 524 l.Error("failed to fetch comment", "id", commentId) 525 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 526 return 527 } 528 if len(comments) != 1 { 529 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 530 http.Error(w, "invalid comment id", http.StatusBadRequest) 531 return 532 } 533 comment := comments[0] 534 535 if comment.Did != user.Did { 536 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 537 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 538 return 539 } 540 541 switch r.Method { 542 case http.MethodGet: 543 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 544 LoggedInUser: user, 545 RepoInfo: f.RepoInfo(user), 546 Issue: issue, 547 Comment: &comment, 548 }) 549 case http.MethodPost: 550 // extract form value 551 newBody := r.FormValue("body") 552 client, err := rp.oauth.AuthorizedClient(r) 553 if err != nil { 554 l.Error("failed to get authorized client", "err", err) 555 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 556 return 557 } 558 559 now := time.Now() 560 newComment := comment 561 newComment.Body = newBody 562 newComment.Edited = &now 563 record := newComment.AsRecord() 564 565 _, err = db.AddIssueComment(rp.db, newComment) 566 if err != nil { 567 l.Error("failed to perferom update-description query", "err", err) 568 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 569 return 570 } 571 572 // rkey is optional, it was introduced later 573 if newComment.Rkey != "" { 574 // update the record on pds 575 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 576 if err != nil { 577 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 578 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 579 return 580 } 581 582 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 583 Collection: tangled.RepoIssueCommentNSID, 584 Repo: user.Did, 585 Rkey: newComment.Rkey, 586 SwapRecord: ex.Cid, 587 Record: &lexutil.LexiconTypeDecoder{ 588 Val: &record, 589 }, 590 }) 591 if err != nil { 592 l.Error("failed to update record on PDS", "err", err) 593 } 594 } 595 596 // return new comment body with htmx 597 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 598 LoggedInUser: user, 599 RepoInfo: f.RepoInfo(user), 600 Issue: issue, 601 Comment: &newComment, 602 }) 603 } 604} 605 606func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 607 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 608 user := rp.oauth.GetUser(r) 609 f, err := rp.repoResolver.Resolve(r) 610 if err != nil { 611 l.Error("failed to get repo and knot", "err", err) 612 return 613 } 614 615 issue, ok := r.Context().Value("issue").(*models.Issue) 616 if !ok { 617 l.Error("failed to get issue") 618 rp.pages.Error404(w) 619 return 620 } 621 622 commentId := chi.URLParam(r, "commentId") 623 comments, err := db.GetIssueComments( 624 rp.db, 625 db.FilterEq("id", commentId), 626 ) 627 if err != nil { 628 l.Error("failed to fetch comment", "id", commentId) 629 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 630 return 631 } 632 if len(comments) != 1 { 633 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 634 http.Error(w, "invalid comment id", http.StatusBadRequest) 635 return 636 } 637 comment := comments[0] 638 639 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 640 LoggedInUser: user, 641 RepoInfo: f.RepoInfo(user), 642 Issue: issue, 643 Comment: &comment, 644 }) 645} 646 647func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 648 l := rp.logger.With("handler", "ReplyIssueComment") 649 user := rp.oauth.GetUser(r) 650 f, err := rp.repoResolver.Resolve(r) 651 if err != nil { 652 l.Error("failed to get repo and knot", "err", err) 653 return 654 } 655 656 issue, ok := r.Context().Value("issue").(*models.Issue) 657 if !ok { 658 l.Error("failed to get issue") 659 rp.pages.Error404(w) 660 return 661 } 662 663 commentId := chi.URLParam(r, "commentId") 664 comments, err := db.GetIssueComments( 665 rp.db, 666 db.FilterEq("id", commentId), 667 ) 668 if err != nil { 669 l.Error("failed to fetch comment", "id", commentId) 670 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 671 return 672 } 673 if len(comments) != 1 { 674 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 675 http.Error(w, "invalid comment id", http.StatusBadRequest) 676 return 677 } 678 comment := comments[0] 679 680 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 681 LoggedInUser: user, 682 RepoInfo: f.RepoInfo(user), 683 Issue: issue, 684 Comment: &comment, 685 }) 686} 687 688func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 689 l := rp.logger.With("handler", "DeleteIssueComment") 690 user := rp.oauth.GetUser(r) 691 f, err := rp.repoResolver.Resolve(r) 692 if err != nil { 693 l.Error("failed to get repo and knot", "err", err) 694 return 695 } 696 697 issue, ok := r.Context().Value("issue").(*models.Issue) 698 if !ok { 699 l.Error("failed to get issue") 700 rp.pages.Error404(w) 701 return 702 } 703 704 commentId := chi.URLParam(r, "commentId") 705 comments, err := db.GetIssueComments( 706 rp.db, 707 db.FilterEq("id", commentId), 708 ) 709 if err != nil { 710 l.Error("failed to fetch comment", "id", commentId) 711 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 712 return 713 } 714 if len(comments) != 1 { 715 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 716 http.Error(w, "invalid comment id", http.StatusBadRequest) 717 return 718 } 719 comment := comments[0] 720 721 if comment.Did != user.Did { 722 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 723 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 724 return 725 } 726 727 if comment.Deleted != nil { 728 http.Error(w, "comment already deleted", http.StatusBadRequest) 729 return 730 } 731 732 // optimistic deletion 733 deleted := time.Now() 734 err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 735 if err != nil { 736 l.Error("failed to delete comment", "err", err) 737 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 738 return 739 } 740 741 // delete from pds 742 if comment.Rkey != "" { 743 client, err := rp.oauth.AuthorizedClient(r) 744 if err != nil { 745 l.Error("failed to get authorized client", "err", err) 746 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 747 return 748 } 749 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 750 Collection: tangled.RepoIssueCommentNSID, 751 Repo: user.Did, 752 Rkey: comment.Rkey, 753 }) 754 if err != nil { 755 l.Error("failed to delete from PDS", "err", err) 756 } 757 } 758 759 // optimistic update for htmx 760 comment.Body = "" 761 comment.Deleted = &deleted 762 763 // htmx fragment of comment after deletion 764 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 765 LoggedInUser: user, 766 RepoInfo: f.RepoInfo(user), 767 Issue: issue, 768 Comment: &comment, 769 }) 770} 771 772func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 773 l := rp.logger.With("handler", "RepoIssues") 774 775 params := r.URL.Query() 776 state := params.Get("state") 777 isOpen := true 778 switch state { 779 case "open": 780 isOpen = true 781 case "closed": 782 isOpen = false 783 default: 784 isOpen = true 785 } 786 787 page := pagination.FromContext(r.Context()) 788 789 user := rp.oauth.GetUser(r) 790 f, err := rp.repoResolver.Resolve(r) 791 if err != nil { 792 l.Error("failed to get repo and knot", "err", err) 793 return 794 } 795 796 keyword := params.Get("q") 797 798 var ids []int64 799 searchOpts := models.IssueSearchOptions{ 800 Keyword: keyword, 801 RepoAt: f.RepoAt().String(), 802 IsOpen: isOpen, 803 Page: page, 804 } 805 if keyword != "" { 806 res, err := rp.indexer.Search(r.Context(), searchOpts) 807 if err != nil { 808 l.Error("failed to search for issues", "err", err) 809 return 810 } 811 ids = res.Hits 812 l.Debug("searched issues with indexer", "count", len(ids)) 813 } else { 814 ids, err = db.GetIssueIDs(rp.db, searchOpts) 815 if err != nil { 816 l.Error("failed to search for issues", "err", err) 817 return 818 } 819 l.Debug("indexed all issues from the db", "count", len(ids)) 820 } 821 822 issues, err := db.GetIssues( 823 rp.db, 824 db.FilterIn("id", ids), 825 ) 826 if err != nil { 827 l.Error("failed to get issues", "err", err) 828 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 829 return 830 } 831 832 labelDefs, err := db.GetLabelDefinitions( 833 rp.db, 834 db.FilterIn("at_uri", f.Repo.Labels), 835 db.FilterContains("scope", tangled.RepoIssueNSID), 836 ) 837 if err != nil { 838 l.Error("failed to fetch labels", "err", err) 839 rp.pages.Error503(w) 840 return 841 } 842 843 defs := make(map[string]*models.LabelDefinition) 844 for _, l := range labelDefs { 845 defs[l.AtUri().String()] = &l 846 } 847 848 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 849 LoggedInUser: rp.oauth.GetUser(r), 850 RepoInfo: f.RepoInfo(user), 851 Issues: issues, 852 LabelDefs: defs, 853 FilteringByOpen: isOpen, 854 FilterQuery: keyword, 855 Page: page, 856 }) 857} 858 859func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 860 l := rp.logger.With("handler", "NewIssue") 861 user := rp.oauth.GetUser(r) 862 863 f, err := rp.repoResolver.Resolve(r) 864 if err != nil { 865 l.Error("failed to get repo and knot", "err", err) 866 return 867 } 868 869 switch r.Method { 870 case http.MethodGet: 871 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 872 LoggedInUser: user, 873 RepoInfo: f.RepoInfo(user), 874 }) 875 case http.MethodPost: 876 issue := &models.Issue{ 877 RepoAt: f.RepoAt(), 878 Rkey: tid.TID(), 879 Title: r.FormValue("title"), 880 Body: r.FormValue("body"), 881 Open: true, 882 Did: user.Did, 883 Created: time.Now(), 884 Repo: &f.Repo, 885 } 886 887 if err := rp.validator.ValidateIssue(issue); err != nil { 888 l.Error("validation error", "err", err) 889 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 890 return 891 } 892 893 record := issue.AsRecord() 894 895 // create an atproto record 896 client, err := rp.oauth.AuthorizedClient(r) 897 if err != nil { 898 l.Error("failed to get authorized client", "err", err) 899 rp.pages.Notice(w, "issues", "Failed to create issue.") 900 return 901 } 902 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 903 Collection: tangled.RepoIssueNSID, 904 Repo: user.Did, 905 Rkey: issue.Rkey, 906 Record: &lexutil.LexiconTypeDecoder{ 907 Val: &record, 908 }, 909 }) 910 if err != nil { 911 l.Error("failed to create issue", "err", err) 912 rp.pages.Notice(w, "issues", "Failed to create issue.") 913 return 914 } 915 atUri := resp.Uri 916 917 tx, err := rp.db.BeginTx(r.Context(), nil) 918 if err != nil { 919 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 920 return 921 } 922 rollback := func() { 923 err1 := tx.Rollback() 924 err2 := rollbackRecord(context.Background(), atUri, client) 925 926 if errors.Is(err1, sql.ErrTxDone) { 927 err1 = nil 928 } 929 930 if err := errors.Join(err1, err2); err != nil { 931 l.Error("failed to rollback txn", "err", err) 932 } 933 } 934 defer rollback() 935 936 err = db.PutIssue(tx, issue) 937 if err != nil { 938 l.Error("failed to create issue", "err", err) 939 rp.pages.Notice(w, "issues", "Failed to create issue.") 940 return 941 } 942 943 if err = tx.Commit(); err != nil { 944 l.Error("failed to create issue", "err", err) 945 rp.pages.Notice(w, "issues", "Failed to create issue.") 946 return 947 } 948 949 // everything is successful, do not rollback the atproto record 950 atUri = "" 951 rp.notifier.NewIssue(r.Context(), issue) 952 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 953 return 954 } 955} 956 957// this is used to rollback changes made to the PDS 958// 959// it is a no-op if the provided ATURI is empty 960func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 961 if aturi == "" { 962 return nil 963 } 964 965 parsed := syntax.ATURI(aturi) 966 967 collection := parsed.Collection().String() 968 repo := parsed.Authority().String() 969 rkey := parsed.RecordKey().String() 970 971 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 972 Collection: collection, 973 Repo: repo, 974 Rkey: rkey, 975 }) 976 return err 977}