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