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