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