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