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