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