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