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