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 "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 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 316 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 317 return 318 } else { 319 l.Error("user is not permitted to close issue") 320 http.Error(w, "for biden", http.StatusUnauthorized) 321 return 322 } 323} 324 325func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 326 l := rp.logger.With("handler", "ReopenIssue") 327 user := rp.oauth.GetUser(r) 328 f, err := rp.repoResolver.Resolve(r) 329 if err != nil { 330 l.Error("failed to get repo and knot", "err", err) 331 return 332 } 333 334 issue, ok := r.Context().Value("issue").(*models.Issue) 335 if !ok { 336 l.Error("failed to get issue") 337 rp.pages.Error404(w) 338 return 339 } 340 341 collaborators, err := f.Collaborators(r.Context()) 342 if err != nil { 343 l.Error("failed to fetch repo collaborators", "err", err) 344 } 345 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 346 return user.Did == collab.Did 347 }) 348 isIssueOwner := user.Did == issue.Did 349 350 if isCollaborator || isIssueOwner { 351 err := db.ReopenIssues( 352 rp.db, 353 db.FilterEq("id", issue.Id), 354 ) 355 if err != nil { 356 l.Error("failed to reopen issue", "err", err) 357 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 358 return 359 } 360 // change the issue state (this will pass down to the notifiers) 361 issue.Open = true 362 363 // notify about the issue reopen 364 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 365 366 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 367 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 368 return 369 } else { 370 l.Error("user is not the owner of the repo") 371 http.Error(w, "forbidden", http.StatusUnauthorized) 372 return 373 } 374} 375 376func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 377 l := rp.logger.With("handler", "NewIssueComment") 378 user := rp.oauth.GetUser(r) 379 f, err := rp.repoResolver.Resolve(r) 380 if err != nil { 381 l.Error("failed to get repo and knot", "err", err) 382 return 383 } 384 385 issue, ok := r.Context().Value("issue").(*models.Issue) 386 if !ok { 387 l.Error("failed to get issue") 388 rp.pages.Error404(w) 389 return 390 } 391 392 body := r.FormValue("body") 393 if body == "" { 394 rp.pages.Notice(w, "issue", "Body is required") 395 return 396 } 397 398 replyToUri := r.FormValue("reply-to") 399 var replyTo *string 400 if replyToUri != "" { 401 replyTo = &replyToUri 402 } 403 404 comment := models.IssueComment{ 405 Did: user.Did, 406 Rkey: tid.TID(), 407 IssueAt: issue.AtUri().String(), 408 ReplyTo: replyTo, 409 Body: body, 410 Created: time.Now(), 411 } 412 if err = rp.validator.ValidateIssueComment(&comment); err != nil { 413 l.Error("failed to validate comment", "err", err) 414 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 415 return 416 } 417 record := comment.AsRecord() 418 419 client, err := rp.oauth.AuthorizedClient(r) 420 if err != nil { 421 l.Error("failed to get authorized client", "err", err) 422 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 423 return 424 } 425 426 // create a record first 427 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 428 Collection: tangled.RepoIssueCommentNSID, 429 Repo: comment.Did, 430 Rkey: comment.Rkey, 431 Record: &lexutil.LexiconTypeDecoder{ 432 Val: &record, 433 }, 434 }) 435 if err != nil { 436 l.Error("failed to create comment", "err", err) 437 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 438 return 439 } 440 atUri := resp.Uri 441 defer func() { 442 if err := rollbackRecord(context.Background(), atUri, client); err != nil { 443 l.Error("rollback failed", "err", err) 444 } 445 }() 446 447 commentId, err := db.AddIssueComment(rp.db, comment) 448 if err != nil { 449 l.Error("failed to create comment", "err", err) 450 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 451 return 452 } 453 454 // reset atUri to make rollback a no-op 455 atUri = "" 456 457 // notify about the new comment 458 comment.Id = commentId 459 460 rawMentions := markup.FindUserMentions(comment.Body) 461 idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 462 l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 463 var mentions []syntax.DID 464 for _, ident := range idents { 465 if ident != nil && !ident.Handle.IsInvalidHandle() { 466 mentions = append(mentions, ident.DID) 467 } 468 } 469 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 470 471 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 472 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 473} 474 475func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 476 l := rp.logger.With("handler", "IssueComment") 477 user := rp.oauth.GetUser(r) 478 f, err := rp.repoResolver.Resolve(r) 479 if err != nil { 480 l.Error("failed to get repo and knot", "err", err) 481 return 482 } 483 484 issue, ok := r.Context().Value("issue").(*models.Issue) 485 if !ok { 486 l.Error("failed to get issue") 487 rp.pages.Error404(w) 488 return 489 } 490 491 commentId := chi.URLParam(r, "commentId") 492 comments, err := db.GetIssueComments( 493 rp.db, 494 db.FilterEq("id", commentId), 495 ) 496 if err != nil { 497 l.Error("failed to fetch comment", "id", commentId) 498 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 499 return 500 } 501 if len(comments) != 1 { 502 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 503 http.Error(w, "invalid comment id", http.StatusBadRequest) 504 return 505 } 506 comment := comments[0] 507 508 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 509 LoggedInUser: user, 510 RepoInfo: f.RepoInfo(user), 511 Issue: issue, 512 Comment: &comment, 513 }) 514} 515 516func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 517 l := rp.logger.With("handler", "EditIssueComment") 518 user := rp.oauth.GetUser(r) 519 f, err := rp.repoResolver.Resolve(r) 520 if err != nil { 521 l.Error("failed to get repo and knot", "err", err) 522 return 523 } 524 525 issue, ok := r.Context().Value("issue").(*models.Issue) 526 if !ok { 527 l.Error("failed to get issue") 528 rp.pages.Error404(w) 529 return 530 } 531 532 commentId := chi.URLParam(r, "commentId") 533 comments, err := db.GetIssueComments( 534 rp.db, 535 db.FilterEq("id", commentId), 536 ) 537 if err != nil { 538 l.Error("failed to fetch comment", "id", commentId) 539 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 540 return 541 } 542 if len(comments) != 1 { 543 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 544 http.Error(w, "invalid comment id", http.StatusBadRequest) 545 return 546 } 547 comment := comments[0] 548 549 if comment.Did != user.Did { 550 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 551 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 552 return 553 } 554 555 switch r.Method { 556 case http.MethodGet: 557 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 558 LoggedInUser: user, 559 RepoInfo: f.RepoInfo(user), 560 Issue: issue, 561 Comment: &comment, 562 }) 563 case http.MethodPost: 564 // extract form value 565 newBody := r.FormValue("body") 566 client, err := rp.oauth.AuthorizedClient(r) 567 if err != nil { 568 l.Error("failed to get authorized client", "err", err) 569 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 570 return 571 } 572 573 now := time.Now() 574 newComment := comment 575 newComment.Body = newBody 576 newComment.Edited = &now 577 record := newComment.AsRecord() 578 579 _, err = db.AddIssueComment(rp.db, newComment) 580 if err != nil { 581 l.Error("failed to perferom update-description query", "err", err) 582 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 583 return 584 } 585 586 // rkey is optional, it was introduced later 587 if newComment.Rkey != "" { 588 // update the record on pds 589 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 590 if err != nil { 591 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 592 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 593 return 594 } 595 596 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 597 Collection: tangled.RepoIssueCommentNSID, 598 Repo: user.Did, 599 Rkey: newComment.Rkey, 600 SwapRecord: ex.Cid, 601 Record: &lexutil.LexiconTypeDecoder{ 602 Val: &record, 603 }, 604 }) 605 if err != nil { 606 l.Error("failed to update record on PDS", "err", err) 607 } 608 } 609 610 // return new comment body with htmx 611 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 612 LoggedInUser: user, 613 RepoInfo: f.RepoInfo(user), 614 Issue: issue, 615 Comment: &newComment, 616 }) 617 } 618} 619 620func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 621 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 622 user := rp.oauth.GetUser(r) 623 f, err := rp.repoResolver.Resolve(r) 624 if err != nil { 625 l.Error("failed to get repo and knot", "err", err) 626 return 627 } 628 629 issue, ok := r.Context().Value("issue").(*models.Issue) 630 if !ok { 631 l.Error("failed to get issue") 632 rp.pages.Error404(w) 633 return 634 } 635 636 commentId := chi.URLParam(r, "commentId") 637 comments, err := db.GetIssueComments( 638 rp.db, 639 db.FilterEq("id", commentId), 640 ) 641 if err != nil { 642 l.Error("failed to fetch comment", "id", commentId) 643 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 644 return 645 } 646 if len(comments) != 1 { 647 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 648 http.Error(w, "invalid comment id", http.StatusBadRequest) 649 return 650 } 651 comment := comments[0] 652 653 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 654 LoggedInUser: user, 655 RepoInfo: f.RepoInfo(user), 656 Issue: issue, 657 Comment: &comment, 658 }) 659} 660 661func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 662 l := rp.logger.With("handler", "ReplyIssueComment") 663 user := rp.oauth.GetUser(r) 664 f, err := rp.repoResolver.Resolve(r) 665 if err != nil { 666 l.Error("failed to get repo and knot", "err", err) 667 return 668 } 669 670 issue, ok := r.Context().Value("issue").(*models.Issue) 671 if !ok { 672 l.Error("failed to get issue") 673 rp.pages.Error404(w) 674 return 675 } 676 677 commentId := chi.URLParam(r, "commentId") 678 comments, err := db.GetIssueComments( 679 rp.db, 680 db.FilterEq("id", commentId), 681 ) 682 if err != nil { 683 l.Error("failed to fetch comment", "id", commentId) 684 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 685 return 686 } 687 if len(comments) != 1 { 688 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 689 http.Error(w, "invalid comment id", http.StatusBadRequest) 690 return 691 } 692 comment := comments[0] 693 694 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 695 LoggedInUser: user, 696 RepoInfo: f.RepoInfo(user), 697 Issue: issue, 698 Comment: &comment, 699 }) 700} 701 702func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 703 l := rp.logger.With("handler", "DeleteIssueComment") 704 user := rp.oauth.GetUser(r) 705 f, err := rp.repoResolver.Resolve(r) 706 if err != nil { 707 l.Error("failed to get repo and knot", "err", err) 708 return 709 } 710 711 issue, ok := r.Context().Value("issue").(*models.Issue) 712 if !ok { 713 l.Error("failed to get issue") 714 rp.pages.Error404(w) 715 return 716 } 717 718 commentId := chi.URLParam(r, "commentId") 719 comments, err := db.GetIssueComments( 720 rp.db, 721 db.FilterEq("id", commentId), 722 ) 723 if err != nil { 724 l.Error("failed to fetch comment", "id", commentId) 725 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 726 return 727 } 728 if len(comments) != 1 { 729 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 730 http.Error(w, "invalid comment id", http.StatusBadRequest) 731 return 732 } 733 comment := comments[0] 734 735 if comment.Did != user.Did { 736 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 737 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 738 return 739 } 740 741 if comment.Deleted != nil { 742 http.Error(w, "comment already deleted", http.StatusBadRequest) 743 return 744 } 745 746 // optimistic deletion 747 deleted := time.Now() 748 err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 749 if err != nil { 750 l.Error("failed to delete comment", "err", err) 751 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 752 return 753 } 754 755 // delete from pds 756 if comment.Rkey != "" { 757 client, err := rp.oauth.AuthorizedClient(r) 758 if err != nil { 759 l.Error("failed to get authorized client", "err", err) 760 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 761 return 762 } 763 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 764 Collection: tangled.RepoIssueCommentNSID, 765 Repo: user.Did, 766 Rkey: comment.Rkey, 767 }) 768 if err != nil { 769 l.Error("failed to delete from PDS", "err", err) 770 } 771 } 772 773 // optimistic update for htmx 774 comment.Body = "" 775 comment.Deleted = &deleted 776 777 // htmx fragment of comment after deletion 778 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 779 LoggedInUser: user, 780 RepoInfo: f.RepoInfo(user), 781 Issue: issue, 782 Comment: &comment, 783 }) 784} 785 786func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 787 l := rp.logger.With("handler", "RepoIssues") 788 789 params := r.URL.Query() 790 state := params.Get("state") 791 isOpen := true 792 switch state { 793 case "open": 794 isOpen = true 795 case "closed": 796 isOpen = false 797 default: 798 isOpen = true 799 } 800 801 page := pagination.FromContext(r.Context()) 802 803 user := rp.oauth.GetUser(r) 804 f, err := rp.repoResolver.Resolve(r) 805 if err != nil { 806 l.Error("failed to get repo and knot", "err", err) 807 return 808 } 809 810 totalIssues := 0 811 if isOpen { 812 totalIssues = f.RepoStats.IssueCount.Open 813 } else { 814 totalIssues = f.RepoStats.IssueCount.Closed 815 } 816 817 keyword := params.Get("q") 818 819 var issues []models.Issue 820 searchOpts := models.IssueSearchOptions{ 821 Keyword: keyword, 822 RepoAt: f.RepoAt().String(), 823 IsOpen: isOpen, 824 Page: page, 825 } 826 if keyword != "" { 827 res, err := rp.indexer.Search(r.Context(), searchOpts) 828 if err != nil { 829 l.Error("failed to search for issues", "err", err) 830 return 831 } 832 l.Debug("searched issues with indexer", "count", len(res.Hits)) 833 totalIssues = int(res.Total) 834 835 issues, err = db.GetIssues( 836 rp.db, 837 db.FilterIn("id", res.Hits), 838 ) 839 if err != nil { 840 l.Error("failed to get issues", "err", err) 841 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 842 return 843 } 844 845 } else { 846 openInt := 0 847 if isOpen { 848 openInt = 1 849 } 850 issues, err = db.GetIssuesPaginated( 851 rp.db, 852 page, 853 db.FilterEq("repo_at", f.RepoAt()), 854 db.FilterEq("open", openInt), 855 ) 856 if err != nil { 857 l.Error("failed to get issues", "err", err) 858 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 859 return 860 } 861 } 862 863 labelDefs, err := db.GetLabelDefinitions( 864 rp.db, 865 db.FilterIn("at_uri", f.Repo.Labels), 866 db.FilterContains("scope", tangled.RepoIssueNSID), 867 ) 868 if err != nil { 869 l.Error("failed to fetch labels", "err", err) 870 rp.pages.Error503(w) 871 return 872 } 873 874 defs := make(map[string]*models.LabelDefinition) 875 for _, l := range labelDefs { 876 defs[l.AtUri().String()] = &l 877 } 878 879 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 880 LoggedInUser: rp.oauth.GetUser(r), 881 RepoInfo: f.RepoInfo(user), 882 Issues: issues, 883 IssueCount: totalIssues, 884 LabelDefs: defs, 885 FilteringByOpen: isOpen, 886 FilterQuery: keyword, 887 Page: page, 888 }) 889} 890 891func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 892 l := rp.logger.With("handler", "NewIssue") 893 user := rp.oauth.GetUser(r) 894 895 f, err := rp.repoResolver.Resolve(r) 896 if err != nil { 897 l.Error("failed to get repo and knot", "err", err) 898 return 899 } 900 901 switch r.Method { 902 case http.MethodGet: 903 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 904 LoggedInUser: user, 905 RepoInfo: f.RepoInfo(user), 906 }) 907 case http.MethodPost: 908 issue := &models.Issue{ 909 RepoAt: f.RepoAt(), 910 Rkey: tid.TID(), 911 Title: r.FormValue("title"), 912 Body: r.FormValue("body"), 913 Open: true, 914 Did: user.Did, 915 Created: time.Now(), 916 Repo: &f.Repo, 917 } 918 919 if err := rp.validator.ValidateIssue(issue); err != nil { 920 l.Error("validation error", "err", err) 921 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 922 return 923 } 924 925 record := issue.AsRecord() 926 927 // create an atproto record 928 client, err := rp.oauth.AuthorizedClient(r) 929 if err != nil { 930 l.Error("failed to get authorized client", "err", err) 931 rp.pages.Notice(w, "issues", "Failed to create issue.") 932 return 933 } 934 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 935 Collection: tangled.RepoIssueNSID, 936 Repo: user.Did, 937 Rkey: issue.Rkey, 938 Record: &lexutil.LexiconTypeDecoder{ 939 Val: &record, 940 }, 941 }) 942 if err != nil { 943 l.Error("failed to create issue", "err", err) 944 rp.pages.Notice(w, "issues", "Failed to create issue.") 945 return 946 } 947 atUri := resp.Uri 948 949 tx, err := rp.db.BeginTx(r.Context(), nil) 950 if err != nil { 951 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 952 return 953 } 954 rollback := func() { 955 err1 := tx.Rollback() 956 err2 := rollbackRecord(context.Background(), atUri, client) 957 958 if errors.Is(err1, sql.ErrTxDone) { 959 err1 = nil 960 } 961 962 if err := errors.Join(err1, err2); err != nil { 963 l.Error("failed to rollback txn", "err", err) 964 } 965 } 966 defer rollback() 967 968 err = db.PutIssue(tx, issue) 969 if err != nil { 970 l.Error("failed to create issue", "err", err) 971 rp.pages.Notice(w, "issues", "Failed to create issue.") 972 return 973 } 974 975 if err = tx.Commit(); err != nil { 976 l.Error("failed to create issue", "err", err) 977 rp.pages.Notice(w, "issues", "Failed to create issue.") 978 return 979 } 980 981 // everything is successful, do not rollback the atproto record 982 atUri = "" 983 984 rawMentions := markup.FindUserMentions(issue.Body) 985 idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 986 l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 987 var mentions []syntax.DID 988 for _, ident := range idents { 989 if ident != nil && !ident.Handle.IsInvalidHandle() { 990 mentions = append(mentions, ident.DID) 991 } 992 } 993 rp.notifier.NewIssue(r.Context(), issue, mentions) 994 995 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 996 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 997 return 998 } 999} 1000 1001// this is used to rollback changes made to the PDS 1002// 1003// it is a no-op if the provided ATURI is empty 1004func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1005 if aturi == "" { 1006 return nil 1007 } 1008 1009 parsed := syntax.ATURI(aturi) 1010 1011 collection := parsed.Collection().String() 1012 repo := parsed.Authority().String() 1013 rkey := parsed.RecordKey().String() 1014 1015 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 1016 Collection: collection, 1017 Repo: repo, 1018 Rkey: rkey, 1019 }) 1020 return err 1021}