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