this repo has no description
1package issues 2 3import ( 4 "context" 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 atpclient "github.com/bluesky-social/indigo/atproto/client" 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" 17 18 "tangled.org/core/api/tangled" 19 "tangled.org/core/appview/config" 20 "tangled.org/core/appview/db" 21 issues_indexer "tangled.org/core/appview/indexer/issues" 22 "tangled.org/core/appview/models" 23 "tangled.org/core/appview/notify" 24 "tangled.org/core/appview/oauth" 25 "tangled.org/core/appview/pages" 26 "tangled.org/core/appview/pages/markup" 27 "tangled.org/core/appview/pages/repoinfo" 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/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 db *db.DB 43 config *config.Config 44 notifier notify.Notifier 45 logger *slog.Logger 46 validator *validator.Validator 47 indexer *issues_indexer.Indexer 48} 49 50func New( 51 oauth *oauth.OAuth, 52 repoResolver *reporesolver.RepoResolver, 53 enforcer *rbac.Enforcer, 54 pages *pages.Pages, 55 idResolver *idresolver.Resolver, 56 db *db.DB, 57 config *config.Config, 58 notifier notify.Notifier, 59 validator *validator.Validator, 60 indexer *issues_indexer.Indexer, 61 logger *slog.Logger, 62) *Issues { 63 return &Issues{ 64 oauth: oauth, 65 repoResolver: repoResolver, 66 enforcer: enforcer, 67 pages: pages, 68 idResolver: idResolver, 69 db: db, 70 config: config, 71 notifier: notifier, 72 logger: logger, 73 validator: validator, 74 indexer: indexer, 75 } 76} 77 78func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 79 l := rp.logger.With("handler", "RepoSingleIssue") 80 user := rp.oauth.GetUser(r) 81 f, err := rp.repoResolver.Resolve(r) 82 if err != nil { 83 l.Error("failed to get repo and knot", "err", err) 84 return 85 } 86 87 issue, ok := r.Context().Value("issue").(*models.Issue) 88 if !ok { 89 l.Error("failed to get issue") 90 rp.pages.Error404(w) 91 return 92 } 93 94 reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 95 if err != nil { 96 l.Error("failed to get issue reactions", "err", err) 97 } 98 99 userReactions := map[models.ReactionKind]bool{} 100 if user != nil { 101 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 102 } 103 104 labelDefs, err := db.GetLabelDefinitions( 105 rp.db, 106 db.FilterIn("at_uri", f.Repo.Labels), 107 db.FilterContains("scope", tangled.RepoIssueNSID), 108 ) 109 if err != nil { 110 l.Error("failed to fetch labels", "err", err) 111 rp.pages.Error503(w) 112 return 113 } 114 115 defs := make(map[string]*models.LabelDefinition) 116 for _, l := range labelDefs { 117 defs[l.AtUri().String()] = &l 118 } 119 120 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 121 LoggedInUser: user, 122 RepoInfo: f.RepoInfo(user), 123 Issue: issue, 124 CommentList: issue.CommentList(), 125 OrderedReactionKinds: models.OrderedReactionKinds, 126 Reactions: reactionMap, 127 UserReacted: userReactions, 128 LabelDefs: defs, 129 }) 130} 131 132func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 133 l := rp.logger.With("handler", "EditIssue") 134 user := rp.oauth.GetUser(r) 135 f, err := rp.repoResolver.Resolve(r) 136 if err != nil { 137 l.Error("failed to get repo and knot", "err", err) 138 return 139 } 140 141 issue, ok := r.Context().Value("issue").(*models.Issue) 142 if !ok { 143 l.Error("failed to get issue") 144 rp.pages.Error404(w) 145 return 146 } 147 148 switch r.Method { 149 case http.MethodGet: 150 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 151 LoggedInUser: user, 152 RepoInfo: f.RepoInfo(user), 153 Issue: issue, 154 }) 155 case http.MethodPost: 156 noticeId := "issues" 157 newIssue := issue 158 newIssue.Title = r.FormValue("title") 159 newIssue.Body = r.FormValue("body") 160 161 if err := rp.validator.ValidateIssue(newIssue); err != nil { 162 l.Error("validation error", "err", err) 163 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 164 return 165 } 166 167 newRecord := newIssue.AsRecord() 168 169 // edit an atproto record 170 client, err := rp.oauth.AuthorizedClient(r) 171 if err != nil { 172 l.Error("failed to get authorized client", "err", err) 173 rp.pages.Notice(w, noticeId, "Failed to edit issue.") 174 return 175 } 176 177 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 178 if err != nil { 179 l.Error("failed to get record", "err", err) 180 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 181 return 182 } 183 184 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 185 Collection: tangled.RepoIssueNSID, 186 Repo: user.Did, 187 Rkey: newIssue.Rkey, 188 SwapRecord: ex.Cid, 189 Record: &lexutil.LexiconTypeDecoder{ 190 Val: &newRecord, 191 }, 192 }) 193 if err != nil { 194 l.Error("failed to edit record on PDS", "err", err) 195 rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") 196 return 197 } 198 199 // modify on DB -- TODO: transact this cleverly 200 tx, err := rp.db.Begin() 201 if err != nil { 202 l.Error("failed to edit issue on DB", "err", err) 203 rp.pages.Notice(w, noticeId, "Failed to edit issue.") 204 return 205 } 206 defer tx.Rollback() 207 208 err = db.PutIssue(tx, newIssue) 209 if err != nil { 210 l.Error("failed to edit issue", "err", err) 211 rp.pages.Notice(w, "issues", "Failed to edit issue.") 212 return 213 } 214 215 if err = tx.Commit(); err != nil { 216 l.Error("failed to edit issue", "err", err) 217 rp.pages.Notice(w, "issues", "Failed to cedit issue.") 218 return 219 } 220 221 rp.pages.HxRefresh(w) 222 } 223} 224 225func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 226 l := rp.logger.With("handler", "DeleteIssue") 227 noticeId := "issue-actions-error" 228 229 f, err := rp.repoResolver.Resolve(r) 230 if err != nil { 231 l.Error("failed to get repo and knot", "err", err) 232 return 233 } 234 235 issue, ok := r.Context().Value("issue").(*models.Issue) 236 if !ok { 237 l.Error("failed to get issue") 238 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 239 return 240 } 241 l = l.With("did", issue.Did, "rkey", issue.Rkey) 242 243 // delete from PDS 244 client, err := rp.oauth.AuthorizedClient(r) 245 if err != nil { 246 l.Error("failed to get authorized client", "err", err) 247 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 248 return 249 } 250 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 251 Collection: tangled.RepoIssueNSID, 252 Repo: issue.Did, 253 Rkey: issue.Rkey, 254 }) 255 if err != nil { 256 // TODO: transact this better 257 l.Error("failed to delete issue from PDS", "err", err) 258 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 259 return 260 } 261 262 // delete from db 263 if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil { 264 l.Error("failed to delete issue", "err", err) 265 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 266 return 267 } 268 269 rp.notifier.DeleteIssue(r.Context(), issue) 270 271 // return to all issues page 272 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 273 rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues") 274} 275 276func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 277 l := rp.logger.With("handler", "CloseIssue") 278 user := rp.oauth.GetUser(r) 279 f, err := rp.repoResolver.Resolve(r) 280 if err != nil { 281 l.Error("failed to get repo and knot", "err", err) 282 return 283 } 284 285 issue, ok := r.Context().Value("issue").(*models.Issue) 286 if !ok { 287 l.Error("failed to get issue") 288 rp.pages.Error404(w) 289 return 290 } 291 292 roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 293 isRepoOwner := roles.IsOwner() 294 isCollaborator := roles.IsCollaborator() 295 isIssueOwner := user.Did == issue.Did 296 297 // TODO: make this more granular 298 if isIssueOwner || isRepoOwner || isCollaborator { 299 err = db.CloseIssues( 300 rp.db, 301 db.FilterEq("id", issue.Id), 302 ) 303 if err != nil { 304 l.Error("failed to close issue", "err", err) 305 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 306 return 307 } 308 // change the issue state (this will pass down to the notifiers) 309 issue.Open = false 310 311 // notify about the issue closure 312 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 313 314 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 315 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", 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 roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 341 isRepoOwner := roles.IsOwner() 342 isCollaborator := roles.IsCollaborator() 343 isIssueOwner := user.Did == issue.Did 344 345 if isCollaborator || isRepoOwner || isIssueOwner { 346 err := db.ReopenIssues( 347 rp.db, 348 db.FilterEq("id", issue.Id), 349 ) 350 if err != nil { 351 l.Error("failed to reopen issue", "err", err) 352 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 353 return 354 } 355 // change the issue state (this will pass down to the notifiers) 356 issue.Open = true 357 358 // notify about the issue reopen 359 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 360 361 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 362 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 363 return 364 } else { 365 l.Error("user is not the owner of the repo") 366 http.Error(w, "forbidden", http.StatusUnauthorized) 367 return 368 } 369} 370 371func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 372 l := rp.logger.With("handler", "NewIssueComment") 373 user := rp.oauth.GetUser(r) 374 f, err := rp.repoResolver.Resolve(r) 375 if err != nil { 376 l.Error("failed to get repo and knot", "err", err) 377 return 378 } 379 380 issue, ok := r.Context().Value("issue").(*models.Issue) 381 if !ok { 382 l.Error("failed to get issue") 383 rp.pages.Error404(w) 384 return 385 } 386 387 body := r.FormValue("body") 388 if body == "" { 389 rp.pages.Notice(w, "issue", "Body is required") 390 return 391 } 392 393 replyToUri := r.FormValue("reply-to") 394 var replyTo *string 395 if replyToUri != "" { 396 replyTo = &replyToUri 397 } 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 rawMentions := markup.FindUserMentions(comment.Body) 456 idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 457 l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 458 var mentions []syntax.DID 459 for _, ident := range idents { 460 if ident != nil && !ident.Handle.IsInvalidHandle() { 461 mentions = append(mentions, ident.DID) 462 } 463 } 464 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 465 466 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 467 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 468} 469 470func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 471 l := rp.logger.With("handler", "IssueComment") 472 user := rp.oauth.GetUser(r) 473 f, err := rp.repoResolver.Resolve(r) 474 if err != nil { 475 l.Error("failed to get repo and knot", "err", err) 476 return 477 } 478 479 issue, ok := r.Context().Value("issue").(*models.Issue) 480 if !ok { 481 l.Error("failed to get issue") 482 rp.pages.Error404(w) 483 return 484 } 485 486 commentId := chi.URLParam(r, "commentId") 487 comments, err := db.GetIssueComments( 488 rp.db, 489 db.FilterEq("id", commentId), 490 ) 491 if err != nil { 492 l.Error("failed to fetch comment", "id", commentId) 493 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 494 return 495 } 496 if len(comments) != 1 { 497 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 498 http.Error(w, "invalid comment id", http.StatusBadRequest) 499 return 500 } 501 comment := comments[0] 502 503 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 504 LoggedInUser: user, 505 RepoInfo: f.RepoInfo(user), 506 Issue: issue, 507 Comment: &comment, 508 }) 509} 510 511func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 512 l := rp.logger.With("handler", "EditIssueComment") 513 user := rp.oauth.GetUser(r) 514 f, err := rp.repoResolver.Resolve(r) 515 if err != nil { 516 l.Error("failed to get repo and knot", "err", err) 517 return 518 } 519 520 issue, ok := r.Context().Value("issue").(*models.Issue) 521 if !ok { 522 l.Error("failed to get issue") 523 rp.pages.Error404(w) 524 return 525 } 526 527 commentId := chi.URLParam(r, "commentId") 528 comments, err := db.GetIssueComments( 529 rp.db, 530 db.FilterEq("id", commentId), 531 ) 532 if err != nil { 533 l.Error("failed to fetch comment", "id", commentId) 534 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 535 return 536 } 537 if len(comments) != 1 { 538 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 539 http.Error(w, "invalid comment id", http.StatusBadRequest) 540 return 541 } 542 comment := comments[0] 543 544 if comment.Did != user.Did { 545 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 546 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 547 return 548 } 549 550 switch r.Method { 551 case http.MethodGet: 552 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 553 LoggedInUser: user, 554 RepoInfo: f.RepoInfo(user), 555 Issue: issue, 556 Comment: &comment, 557 }) 558 case http.MethodPost: 559 // extract form value 560 newBody := r.FormValue("body") 561 client, err := rp.oauth.AuthorizedClient(r) 562 if err != nil { 563 l.Error("failed to get authorized client", "err", err) 564 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 565 return 566 } 567 568 now := time.Now() 569 newComment := comment 570 newComment.Body = newBody 571 newComment.Edited = &now 572 record := newComment.AsRecord() 573 574 _, err = db.AddIssueComment(rp.db, newComment) 575 if err != nil { 576 l.Error("failed to perferom update-description query", "err", err) 577 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 578 return 579 } 580 581 // rkey is optional, it was introduced later 582 if newComment.Rkey != "" { 583 // update the record on pds 584 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 585 if err != nil { 586 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 587 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 588 return 589 } 590 591 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 592 Collection: tangled.RepoIssueCommentNSID, 593 Repo: user.Did, 594 Rkey: newComment.Rkey, 595 SwapRecord: ex.Cid, 596 Record: &lexutil.LexiconTypeDecoder{ 597 Val: &record, 598 }, 599 }) 600 if err != nil { 601 l.Error("failed to update record on PDS", "err", err) 602 } 603 } 604 605 // return new comment body with htmx 606 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 607 LoggedInUser: user, 608 RepoInfo: f.RepoInfo(user), 609 Issue: issue, 610 Comment: &newComment, 611 }) 612 } 613} 614 615func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 616 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 617 user := rp.oauth.GetUser(r) 618 f, err := rp.repoResolver.Resolve(r) 619 if err != nil { 620 l.Error("failed to get repo and knot", "err", err) 621 return 622 } 623 624 issue, ok := r.Context().Value("issue").(*models.Issue) 625 if !ok { 626 l.Error("failed to get issue") 627 rp.pages.Error404(w) 628 return 629 } 630 631 commentId := chi.URLParam(r, "commentId") 632 comments, err := db.GetIssueComments( 633 rp.db, 634 db.FilterEq("id", commentId), 635 ) 636 if err != nil { 637 l.Error("failed to fetch comment", "id", commentId) 638 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 639 return 640 } 641 if len(comments) != 1 { 642 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 643 http.Error(w, "invalid comment id", http.StatusBadRequest) 644 return 645 } 646 comment := comments[0] 647 648 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 649 LoggedInUser: user, 650 RepoInfo: f.RepoInfo(user), 651 Issue: issue, 652 Comment: &comment, 653 }) 654} 655 656func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 657 l := rp.logger.With("handler", "ReplyIssueComment") 658 user := rp.oauth.GetUser(r) 659 f, err := rp.repoResolver.Resolve(r) 660 if err != nil { 661 l.Error("failed to get repo and knot", "err", err) 662 return 663 } 664 665 issue, ok := r.Context().Value("issue").(*models.Issue) 666 if !ok { 667 l.Error("failed to get issue") 668 rp.pages.Error404(w) 669 return 670 } 671 672 commentId := chi.URLParam(r, "commentId") 673 comments, err := db.GetIssueComments( 674 rp.db, 675 db.FilterEq("id", commentId), 676 ) 677 if err != nil { 678 l.Error("failed to fetch comment", "id", commentId) 679 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 680 return 681 } 682 if len(comments) != 1 { 683 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 684 http.Error(w, "invalid comment id", http.StatusBadRequest) 685 return 686 } 687 comment := comments[0] 688 689 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 690 LoggedInUser: user, 691 RepoInfo: f.RepoInfo(user), 692 Issue: issue, 693 Comment: &comment, 694 }) 695} 696 697func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 698 l := rp.logger.With("handler", "DeleteIssueComment") 699 user := rp.oauth.GetUser(r) 700 f, err := rp.repoResolver.Resolve(r) 701 if err != nil { 702 l.Error("failed to get repo and knot", "err", err) 703 return 704 } 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: f.RepoInfo(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.Repo.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: f.RepoInfo(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: f.RepoInfo(user), 901 }) 902 case http.MethodPost: 903 issue := &models.Issue{ 904 RepoAt: f.RepoAt(), 905 Rkey: tid.TID(), 906 Title: r.FormValue("title"), 907 Body: r.FormValue("body"), 908 Open: true, 909 Did: user.Did, 910 Created: time.Now(), 911 Repo: &f.Repo, 912 } 913 914 if err := rp.validator.ValidateIssue(issue); err != nil { 915 l.Error("validation error", "err", err) 916 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 917 return 918 } 919 920 record := issue.AsRecord() 921 922 // create an atproto record 923 client, err := rp.oauth.AuthorizedClient(r) 924 if err != nil { 925 l.Error("failed to get authorized client", "err", err) 926 rp.pages.Notice(w, "issues", "Failed to create issue.") 927 return 928 } 929 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 930 Collection: tangled.RepoIssueNSID, 931 Repo: user.Did, 932 Rkey: issue.Rkey, 933 Record: &lexutil.LexiconTypeDecoder{ 934 Val: &record, 935 }, 936 }) 937 if err != nil { 938 l.Error("failed to create issue", "err", err) 939 rp.pages.Notice(w, "issues", "Failed to create issue.") 940 return 941 } 942 atUri := resp.Uri 943 944 tx, err := rp.db.BeginTx(r.Context(), nil) 945 if err != nil { 946 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 947 return 948 } 949 rollback := func() { 950 err1 := tx.Rollback() 951 err2 := rollbackRecord(context.Background(), atUri, client) 952 953 if errors.Is(err1, sql.ErrTxDone) { 954 err1 = nil 955 } 956 957 if err := errors.Join(err1, err2); err != nil { 958 l.Error("failed to rollback txn", "err", err) 959 } 960 } 961 defer rollback() 962 963 err = db.PutIssue(tx, issue) 964 if err != nil { 965 l.Error("failed to create issue", "err", err) 966 rp.pages.Notice(w, "issues", "Failed to create issue.") 967 return 968 } 969 970 if err = tx.Commit(); err != nil { 971 l.Error("failed to create issue", "err", err) 972 rp.pages.Notice(w, "issues", "Failed to create issue.") 973 return 974 } 975 976 // everything is successful, do not rollback the atproto record 977 atUri = "" 978 979 rawMentions := markup.FindUserMentions(issue.Body) 980 idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 981 l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 982 var mentions []syntax.DID 983 for _, ident := range idents { 984 if ident != nil && !ident.Handle.IsInvalidHandle() { 985 mentions = append(mentions, ident.DID) 986 } 987 } 988 rp.notifier.NewIssue(r.Context(), issue, mentions) 989 990 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 991 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 992 return 993 } 994} 995 996// this is used to rollback changes made to the PDS 997// 998// it is a no-op if the provided ATURI is empty 999func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1000 if aturi == "" { 1001 return nil 1002 } 1003 1004 parsed := syntax.ATURI(aturi) 1005 1006 collection := parsed.Collection().String() 1007 repo := parsed.Authority().String() 1008 rkey := parsed.RecordKey().String() 1009 1010 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 1011 Collection: collection, 1012 Repo: repo, 1013 Rkey: rkey, 1014 }) 1015 return err 1016}