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