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