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