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