A vibe coded tangled fork which supports pijul.
at e702dd6e7c25926a9b170a6c821f7c49b11eb20b 1118 lines 31 kB view raw
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/mentions" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/notify" 25 "tangled.org/core/appview/oauth" 26 "tangled.org/core/appview/pages" 27 "tangled.org/core/appview/pages/repoinfo" 28 "tangled.org/core/appview/pagination" 29 "tangled.org/core/appview/reporesolver" 30 "tangled.org/core/appview/searchquery" 31 "tangled.org/core/idresolver" 32 "tangled.org/core/orm" 33 "tangled.org/core/rbac" 34 "tangled.org/core/tid" 35) 36 37type Issues struct { 38 oauth *oauth.OAuth 39 repoResolver *reporesolver.RepoResolver 40 enforcer *rbac.Enforcer 41 pages *pages.Pages 42 idResolver *idresolver.Resolver 43 mentionsResolver *mentions.Resolver 44 db *db.DB 45 config *config.Config 46 notifier notify.Notifier 47 logger *slog.Logger 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 mentionsResolver *mentions.Resolver, 58 db *db.DB, 59 config *config.Config, 60 notifier notify.Notifier, 61 indexer *issues_indexer.Indexer, 62 logger *slog.Logger, 63) *Issues { 64 return &Issues{ 65 oauth: oauth, 66 repoResolver: repoResolver, 67 enforcer: enforcer, 68 pages: pages, 69 idResolver: idResolver, 70 mentionsResolver: mentionsResolver, 71 db: db, 72 config: config, 73 notifier: notifier, 74 logger: logger, 75 indexer: indexer, 76 } 77} 78 79func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 80 l := rp.logger.With("handler", "RepoSingleIssue") 81 user := rp.oauth.GetMultiAccountUser(r) 82 f, err := rp.repoResolver.Resolve(r) 83 if err != nil { 84 l.Error("failed to get repo and knot", "err", err) 85 return 86 } 87 88 issue, ok := r.Context().Value("issue").(*models.Issue) 89 if !ok { 90 l.Error("failed to get issue") 91 rp.pages.Error404(w) 92 return 93 } 94 95 reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 96 if err != nil { 97 l.Error("failed to get issue reactions", "err", err) 98 } 99 100 userReactions := map[models.ReactionKind]bool{} 101 if user != nil { 102 userReactions, err = db.GetReactionStatusMap(rp.db, user.Active.Did, issue.AtUri()) 103 if err != nil { 104 l.Error("failed to get issue reaction status", "err", err) 105 } 106 } 107 108 backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) 109 if err != nil { 110 l.Error("failed to fetch backlinks", "err", err) 111 rp.pages.Error503(w) 112 return 113 } 114 115 labelDefs, err := db.GetLabelDefinitions( 116 rp.db, 117 orm.FilterIn("at_uri", f.Labels), 118 orm.FilterContains("scope", tangled.RepoIssueNSID), 119 ) 120 if err != nil { 121 l.Error("failed to fetch labels", "err", err) 122 rp.pages.Error503(w) 123 return 124 } 125 126 defs := make(map[string]*models.LabelDefinition) 127 for _, l := range labelDefs { 128 defs[l.AtUri().String()] = &l 129 } 130 131 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 132 LoggedInUser: user, 133 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 134 Issue: issue, 135 CommentList: issue.CommentList(), 136 Backlinks: backlinks, 137 Reactions: reactionMap, 138 UserReacted: userReactions, 139 LabelDefs: defs, 140 }) 141} 142 143func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 144 l := rp.logger.With("handler", "EditIssue") 145 user := rp.oauth.GetMultiAccountUser(r) 146 147 issue, ok := r.Context().Value("issue").(*models.Issue) 148 if !ok { 149 l.Error("failed to get issue") 150 rp.pages.Error404(w) 151 return 152 } 153 154 switch r.Method { 155 case http.MethodGet: 156 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 157 LoggedInUser: user, 158 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 159 Issue: issue, 160 }) 161 case http.MethodPost: 162 noticeId := "issues" 163 newIssue := issue 164 newIssue.Title = r.FormValue("title") 165 newIssue.Body = r.FormValue("body") 166 newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 167 168 if err := newIssue.Validate(); err != nil { 169 l.Error("validation error", "err", err) 170 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 171 return 172 } 173 174 newRecord := newIssue.AsRecord() 175 176 // edit an atproto record 177 client, err := rp.oauth.AuthorizedClient(r) 178 if err != nil { 179 l.Error("failed to get authorized client", "err", err) 180 rp.pages.Notice(w, noticeId, "Failed to edit issue.") 181 return 182 } 183 184 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Active.Did, newIssue.Rkey) 185 if err != nil { 186 l.Error("failed to get record", "err", err) 187 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 188 return 189 } 190 191 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 192 Collection: tangled.RepoIssueNSID, 193 Repo: user.Active.Did, 194 Rkey: newIssue.Rkey, 195 SwapRecord: ex.Cid, 196 Record: &lexutil.LexiconTypeDecoder{ 197 Val: &newRecord, 198 }, 199 }) 200 if err != nil { 201 l.Error("failed to edit record on PDS", "err", err) 202 rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") 203 return 204 } 205 206 // modify on DB -- TODO: transact this cleverly 207 tx, err := rp.db.Begin() 208 if err != nil { 209 l.Error("failed to edit issue on DB", "err", err) 210 rp.pages.Notice(w, noticeId, "Failed to edit issue.") 211 return 212 } 213 defer tx.Rollback() 214 215 err = db.PutIssue(tx, newIssue) 216 if err != nil { 217 l.Error("failed to edit issue", "err", err) 218 rp.pages.Notice(w, "issues", "Failed to edit issue.") 219 return 220 } 221 222 if err = tx.Commit(); err != nil { 223 l.Error("failed to edit issue", "err", err) 224 rp.pages.Notice(w, "issues", "Failed to cedit issue.") 225 return 226 } 227 228 rp.pages.HxRefresh(w) 229 } 230} 231 232func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 233 l := rp.logger.With("handler", "DeleteIssue") 234 noticeId := "issue-actions-error" 235 236 f, err := rp.repoResolver.Resolve(r) 237 if err != nil { 238 l.Error("failed to get repo and knot", "err", err) 239 return 240 } 241 242 issue, ok := r.Context().Value("issue").(*models.Issue) 243 if !ok { 244 l.Error("failed to get issue") 245 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 246 return 247 } 248 l = l.With("did", issue.Did, "rkey", issue.Rkey) 249 250 tx, err := rp.db.Begin() 251 if err != nil { 252 l.Error("failed to start transaction", "err", err) 253 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 254 return 255 } 256 defer tx.Rollback() 257 258 // delete from PDS 259 client, err := rp.oauth.AuthorizedClient(r) 260 if err != nil { 261 l.Error("failed to get authorized client", "err", err) 262 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 263 return 264 } 265 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 266 Collection: tangled.RepoIssueNSID, 267 Repo: issue.Did, 268 Rkey: issue.Rkey, 269 }) 270 if err != nil { 271 // TODO: transact this better 272 l.Error("failed to delete issue from PDS", "err", err) 273 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 274 return 275 } 276 277 // delete from db 278 if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { 279 l.Error("failed to delete issue", "err", err) 280 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 281 return 282 } 283 tx.Commit() 284 285 rp.notifier.DeleteIssue(r.Context(), issue) 286 287 // return to all issues page 288 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 289 rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues") 290} 291 292func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 293 l := rp.logger.With("handler", "CloseIssue") 294 user := rp.oauth.GetMultiAccountUser(r) 295 f, err := rp.repoResolver.Resolve(r) 296 if err != nil { 297 l.Error("failed to get repo and knot", "err", err) 298 return 299 } 300 301 issue, ok := r.Context().Value("issue").(*models.Issue) 302 if !ok { 303 l.Error("failed to get issue") 304 rp.pages.Error404(w) 305 return 306 } 307 308 roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 309 isRepoOwner := roles.IsOwner() 310 isCollaborator := roles.IsCollaborator() 311 isIssueOwner := user.Active.Did == issue.Did 312 313 // TODO: make this more granular 314 if isIssueOwner || isRepoOwner || isCollaborator { 315 err = db.CloseIssues( 316 rp.db, 317 orm.FilterEq("id", issue.Id), 318 ) 319 if err != nil { 320 l.Error("failed to close issue", "err", err) 321 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 322 return 323 } 324 // change the issue state (this will pass down to the notifiers) 325 issue.Open = false 326 327 // notify about the issue closure 328 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Active.Did), issue) 329 330 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 331 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 332 return 333 } else { 334 l.Error("user is not permitted to close issue") 335 http.Error(w, "for biden", http.StatusUnauthorized) 336 return 337 } 338} 339 340func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 341 l := rp.logger.With("handler", "ReopenIssue") 342 user := rp.oauth.GetMultiAccountUser(r) 343 f, err := rp.repoResolver.Resolve(r) 344 if err != nil { 345 l.Error("failed to get repo and knot", "err", err) 346 return 347 } 348 349 issue, ok := r.Context().Value("issue").(*models.Issue) 350 if !ok { 351 l.Error("failed to get issue") 352 rp.pages.Error404(w) 353 return 354 } 355 356 roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 357 isRepoOwner := roles.IsOwner() 358 isCollaborator := roles.IsCollaborator() 359 isIssueOwner := user.Active.Did == issue.Did 360 361 if isCollaborator || isRepoOwner || isIssueOwner { 362 err := db.ReopenIssues( 363 rp.db, 364 orm.FilterEq("id", issue.Id), 365 ) 366 if err != nil { 367 l.Error("failed to reopen issue", "err", err) 368 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 369 return 370 } 371 // change the issue state (this will pass down to the notifiers) 372 issue.Open = true 373 374 // notify about the issue reopen 375 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Active.Did), issue) 376 377 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 378 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 379 return 380 } else { 381 l.Error("user is not the owner of the repo") 382 http.Error(w, "forbidden", http.StatusUnauthorized) 383 return 384 } 385} 386 387func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 388 l := rp.logger.With("handler", "NewIssueComment") 389 user := rp.oauth.GetMultiAccountUser(r) 390 f, err := rp.repoResolver.Resolve(r) 391 if err != nil { 392 l.Error("failed to get repo and knot", "err", err) 393 return 394 } 395 396 issue, ok := r.Context().Value("issue").(*models.Issue) 397 if !ok { 398 l.Error("failed to get issue") 399 rp.pages.Error404(w) 400 return 401 } 402 403 body := r.FormValue("body") 404 if body == "" { 405 rp.pages.Notice(w, "issue", "Body is required") 406 return 407 } 408 409 replyToUri := r.FormValue("reply-to") 410 var replyTo *string 411 if replyToUri != "" { 412 replyTo = &replyToUri 413 } 414 415 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 416 417 comment := models.IssueComment{ 418 Did: user.Active.Did, 419 Rkey: tid.TID(), 420 IssueAt: issue.AtUri().String(), 421 ReplyTo: replyTo, 422 Body: body, 423 Created: time.Now(), 424 Mentions: mentions, 425 References: references, 426 } 427 if err = comment.Validate(); err != nil { 428 l.Error("failed to validate comment", "err", err) 429 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 430 return 431 } 432 record := comment.AsRecord() 433 434 client, err := rp.oauth.AuthorizedClient(r) 435 if err != nil { 436 l.Error("failed to get authorized client", "err", err) 437 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 438 return 439 } 440 441 // create a record first 442 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 443 Collection: tangled.RepoIssueCommentNSID, 444 Repo: comment.Did, 445 Rkey: comment.Rkey, 446 Record: &lexutil.LexiconTypeDecoder{ 447 Val: &record, 448 }, 449 }) 450 if err != nil { 451 l.Error("failed to create comment", "err", err) 452 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 453 return 454 } 455 atUri := resp.Uri 456 defer func() { 457 if err := rollbackRecord(context.Background(), atUri, client); err != nil { 458 l.Error("rollback failed", "err", err) 459 } 460 }() 461 462 tx, err := rp.db.Begin() 463 if err != nil { 464 l.Error("failed to start transaction", "err", err) 465 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 466 return 467 } 468 defer tx.Rollback() 469 470 commentId, err := db.AddIssueComment(tx, comment) 471 if err != nil { 472 l.Error("failed to create comment", "err", err) 473 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 474 return 475 } 476 err = tx.Commit() 477 if err != nil { 478 l.Error("failed to commit transaction", "err", err) 479 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 480 return 481 } 482 483 // reset atUri to make rollback a no-op 484 atUri = "" 485 486 // notify about the new comment 487 comment.Id = commentId 488 489 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 490 491 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 492 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 493} 494 495func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 496 l := rp.logger.With("handler", "IssueComment") 497 user := rp.oauth.GetMultiAccountUser(r) 498 499 issue, ok := r.Context().Value("issue").(*models.Issue) 500 if !ok { 501 l.Error("failed to get issue") 502 rp.pages.Error404(w) 503 return 504 } 505 506 commentId := chi.URLParam(r, "commentId") 507 comments, err := db.GetIssueComments( 508 rp.db, 509 orm.FilterEq("id", commentId), 510 ) 511 if err != nil { 512 l.Error("failed to fetch comment", "id", commentId) 513 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 514 return 515 } 516 if len(comments) != 1 { 517 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 518 http.Error(w, "invalid comment id", http.StatusBadRequest) 519 return 520 } 521 comment := comments[0] 522 523 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 524 LoggedInUser: user, 525 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 526 Issue: issue, 527 Comment: &comment, 528 }) 529} 530 531func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 532 l := rp.logger.With("handler", "EditIssueComment") 533 user := rp.oauth.GetMultiAccountUser(r) 534 535 issue, ok := r.Context().Value("issue").(*models.Issue) 536 if !ok { 537 l.Error("failed to get issue") 538 rp.pages.Error404(w) 539 return 540 } 541 542 commentId := chi.URLParam(r, "commentId") 543 comments, err := db.GetIssueComments( 544 rp.db, 545 orm.FilterEq("id", commentId), 546 ) 547 if err != nil { 548 l.Error("failed to fetch comment", "id", commentId) 549 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 550 return 551 } 552 if len(comments) != 1 { 553 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 554 http.Error(w, "invalid comment id", http.StatusBadRequest) 555 return 556 } 557 comment := comments[0] 558 559 if comment.Did != user.Active.Did { 560 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) 561 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 562 return 563 } 564 565 switch r.Method { 566 case http.MethodGet: 567 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 568 LoggedInUser: user, 569 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 570 Issue: issue, 571 Comment: &comment, 572 }) 573 case http.MethodPost: 574 // extract form value 575 newBody := r.FormValue("body") 576 client, err := rp.oauth.AuthorizedClient(r) 577 if err != nil { 578 l.Error("failed to get authorized client", "err", err) 579 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 580 return 581 } 582 583 now := time.Now() 584 newComment := comment 585 newComment.Body = newBody 586 newComment.Edited = &now 587 newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 588 589 record := newComment.AsRecord() 590 591 tx, err := rp.db.Begin() 592 if err != nil { 593 l.Error("failed to start transaction", "err", err) 594 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 595 return 596 } 597 defer tx.Rollback() 598 599 _, err = db.AddIssueComment(tx, newComment) 600 if err != nil { 601 l.Error("failed to perferom update-description query", "err", err) 602 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 603 return 604 } 605 tx.Commit() 606 607 // rkey is optional, it was introduced later 608 if newComment.Rkey != "" { 609 // update the record on pds 610 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Active.Did, comment.Rkey) 611 if err != nil { 612 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 613 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 614 return 615 } 616 617 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 618 Collection: tangled.RepoIssueCommentNSID, 619 Repo: user.Active.Did, 620 Rkey: newComment.Rkey, 621 SwapRecord: ex.Cid, 622 Record: &lexutil.LexiconTypeDecoder{ 623 Val: &record, 624 }, 625 }) 626 if err != nil { 627 l.Error("failed to update record on PDS", "err", err) 628 } 629 } 630 631 // return new comment body with htmx 632 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 633 LoggedInUser: user, 634 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 635 Issue: issue, 636 Comment: &newComment, 637 }) 638 } 639} 640 641func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 642 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 643 user := rp.oauth.GetMultiAccountUser(r) 644 645 issue, ok := r.Context().Value("issue").(*models.Issue) 646 if !ok { 647 l.Error("failed to get issue") 648 rp.pages.Error404(w) 649 return 650 } 651 652 commentId := chi.URLParam(r, "commentId") 653 comments, err := db.GetIssueComments( 654 rp.db, 655 orm.FilterEq("id", commentId), 656 ) 657 if err != nil { 658 l.Error("failed to fetch comment", "id", commentId) 659 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 660 return 661 } 662 if len(comments) != 1 { 663 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 664 http.Error(w, "invalid comment id", http.StatusBadRequest) 665 return 666 } 667 comment := comments[0] 668 669 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 670 LoggedInUser: user, 671 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 672 Issue: issue, 673 Comment: &comment, 674 }) 675} 676 677func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 678 l := rp.logger.With("handler", "ReplyIssueComment") 679 user := rp.oauth.GetMultiAccountUser(r) 680 681 issue, ok := r.Context().Value("issue").(*models.Issue) 682 if !ok { 683 l.Error("failed to get issue") 684 rp.pages.Error404(w) 685 return 686 } 687 688 commentId := chi.URLParam(r, "commentId") 689 comments, err := db.GetIssueComments( 690 rp.db, 691 orm.FilterEq("id", commentId), 692 ) 693 if err != nil { 694 l.Error("failed to fetch comment", "id", commentId) 695 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 696 return 697 } 698 if len(comments) != 1 { 699 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 700 http.Error(w, "invalid comment id", http.StatusBadRequest) 701 return 702 } 703 comment := comments[0] 704 705 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 706 LoggedInUser: user, 707 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 708 Issue: issue, 709 Comment: &comment, 710 }) 711} 712 713func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 714 l := rp.logger.With("handler", "DeleteIssueComment") 715 user := rp.oauth.GetMultiAccountUser(r) 716 717 issue, ok := r.Context().Value("issue").(*models.Issue) 718 if !ok { 719 l.Error("failed to get issue") 720 rp.pages.Error404(w) 721 return 722 } 723 724 commentId := chi.URLParam(r, "commentId") 725 comments, err := db.GetIssueComments( 726 rp.db, 727 orm.FilterEq("id", commentId), 728 ) 729 if err != nil { 730 l.Error("failed to fetch comment", "id", commentId) 731 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 732 return 733 } 734 if len(comments) != 1 { 735 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 736 http.Error(w, "invalid comment id", http.StatusBadRequest) 737 return 738 } 739 comment := comments[0] 740 741 if comment.Did != user.Active.Did { 742 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) 743 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 744 return 745 } 746 747 if comment.Deleted != nil { 748 http.Error(w, "comment already deleted", http.StatusBadRequest) 749 return 750 } 751 752 // optimistic deletion 753 deleted := time.Now() 754 err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 755 if err != nil { 756 l.Error("failed to delete comment", "err", err) 757 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 758 return 759 } 760 761 // delete from pds 762 if comment.Rkey != "" { 763 client, err := rp.oauth.AuthorizedClient(r) 764 if err != nil { 765 l.Error("failed to get authorized client", "err", err) 766 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 767 return 768 } 769 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 770 Collection: tangled.RepoIssueCommentNSID, 771 Repo: user.Active.Did, 772 Rkey: comment.Rkey, 773 }) 774 if err != nil { 775 l.Error("failed to delete from PDS", "err", err) 776 } 777 } 778 779 // optimistic update for htmx 780 comment.Body = "" 781 comment.Deleted = &deleted 782 783 // htmx fragment of comment after deletion 784 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 785 LoggedInUser: user, 786 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 787 Issue: issue, 788 Comment: &comment, 789 }) 790} 791 792func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 793 l := rp.logger.With("handler", "RepoIssues") 794 795 params := r.URL.Query() 796 page := pagination.FromContext(r.Context()) 797 798 user := rp.oauth.GetMultiAccountUser(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 query := searchquery.Parse(params.Get("q")) 806 807 var isOpen *bool 808 if urlState := params.Get("state"); urlState != "" { 809 switch urlState { 810 case "open": 811 isOpen = ptrBool(true) 812 case "closed": 813 isOpen = ptrBool(false) 814 } 815 query.Set("state", urlState) 816 } else if queryState := query.Get("state"); queryState != nil { 817 switch *queryState { 818 case "open": 819 isOpen = ptrBool(true) 820 case "closed": 821 isOpen = ptrBool(false) 822 } 823 } else if _, hasQ := params["q"]; !hasQ { 824 // no q param at all -- default to open 825 isOpen = ptrBool(true) 826 query.Set("state", "open") 827 } 828 829 var authorDid string 830 if authorHandle := query.Get("author"); authorHandle != nil { 831 identity, err := rp.idResolver.ResolveIdent(r.Context(), *authorHandle) 832 if err != nil { 833 l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 834 } else { 835 authorDid = identity.DID.String() 836 } 837 } 838 839 var negatedAuthorDid string 840 if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 { 841 identity, err := rp.idResolver.ResolveIdent(r.Context(), negatedAuthors[0]) 842 if err != nil { 843 l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err) 844 } else { 845 negatedAuthorDid = identity.DID.String() 846 } 847 } 848 849 labels := query.GetAll("label") 850 negatedLabels := query.GetAllNegated("label") 851 852 var keywords, negatedKeywords []string 853 var phrases, negatedPhrases []string 854 for _, item := range query.Items() { 855 switch item.Kind { 856 case searchquery.KindKeyword: 857 if item.Negated { 858 negatedKeywords = append(negatedKeywords, item.Value) 859 } else { 860 keywords = append(keywords, item.Value) 861 } 862 case searchquery.KindQuoted: 863 if item.Negated { 864 negatedPhrases = append(negatedPhrases, item.Value) 865 } else { 866 phrases = append(phrases, item.Value) 867 } 868 } 869 } 870 871 searchOpts := models.IssueSearchOptions{ 872 Keywords: keywords, 873 Phrases: phrases, 874 RepoAt: f.RepoAt().String(), 875 IsOpen: isOpen, 876 AuthorDid: authorDid, 877 Labels: labels, 878 NegatedKeywords: negatedKeywords, 879 NegatedPhrases: negatedPhrases, 880 NegatedLabels: negatedLabels, 881 NegatedAuthorDid: negatedAuthorDid, 882 Page: page, 883 } 884 885 totalIssues := 0 886 if isOpen == nil { 887 totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed 888 } else if *isOpen { 889 totalIssues = f.RepoStats.IssueCount.Open 890 } else { 891 totalIssues = f.RepoStats.IssueCount.Closed 892 } 893 894 repoInfo := rp.repoResolver.GetRepoInfo(r, user) 895 896 var issues []models.Issue 897 898 if searchOpts.HasSearchFilters() { 899 res, err := rp.indexer.Search(r.Context(), searchOpts) 900 if err != nil { 901 l.Error("failed to search for issues", "err", err) 902 return 903 } 904 l.Debug("searched issues with indexer", "count", len(res.Hits)) 905 totalIssues = int(res.Total) 906 907 // update tab counts to reflect filtered results 908 countOpts := searchOpts 909 countOpts.Page = pagination.Page{Limit: 1} 910 countOpts.IsOpen = ptrBool(true) 911 if openRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 912 repoInfo.Stats.IssueCount.Open = int(openRes.Total) 913 } 914 countOpts.IsOpen = ptrBool(false) 915 if closedRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 916 repoInfo.Stats.IssueCount.Closed = int(closedRes.Total) 917 } 918 919 if len(res.Hits) > 0 { 920 issues, err = db.GetIssues( 921 rp.db, 922 orm.FilterIn("id", res.Hits), 923 ) 924 if err != nil { 925 l.Error("failed to get issues", "err", err) 926 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 927 return 928 } 929 } 930 } else { 931 filters := []orm.Filter{ 932 orm.FilterEq("repo_at", f.RepoAt()), 933 } 934 if isOpen != nil { 935 openInt := 0 936 if *isOpen { 937 openInt = 1 938 } 939 filters = append(filters, orm.FilterEq("open", openInt)) 940 } 941 issues, err = db.GetIssuesPaginated( 942 rp.db, 943 page, 944 filters..., 945 ) 946 if err != nil { 947 l.Error("failed to get issues", "err", err) 948 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 949 return 950 } 951 } 952 953 labelDefs, err := db.GetLabelDefinitions( 954 rp.db, 955 orm.FilterIn("at_uri", f.Labels), 956 orm.FilterContains("scope", tangled.RepoIssueNSID), 957 ) 958 if err != nil { 959 l.Error("failed to fetch labels", "err", err) 960 rp.pages.Error503(w) 961 return 962 } 963 964 defs := make(map[string]*models.LabelDefinition) 965 for _, l := range labelDefs { 966 defs[l.AtUri().String()] = &l 967 } 968 969 filterState := "" 970 if isOpen != nil { 971 if *isOpen { 972 filterState = "open" 973 } else { 974 filterState = "closed" 975 } 976 } 977 978 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 979 LoggedInUser: rp.oauth.GetMultiAccountUser(r), 980 RepoInfo: repoInfo, 981 Issues: issues, 982 IssueCount: totalIssues, 983 LabelDefs: defs, 984 FilterState: filterState, 985 FilterQuery: query.String(), 986 Page: page, 987 }) 988} 989 990func ptrBool(b bool) *bool { return &b } 991 992func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 993 l := rp.logger.With("handler", "NewIssue") 994 user := rp.oauth.GetMultiAccountUser(r) 995 996 f, err := rp.repoResolver.Resolve(r) 997 if err != nil { 998 l.Error("failed to get repo and knot", "err", err) 999 return 1000 } 1001 1002 switch r.Method { 1003 case http.MethodGet: 1004 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1005 LoggedInUser: user, 1006 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1007 }) 1008 case http.MethodPost: 1009 body := r.FormValue("body") 1010 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 1011 1012 issue := &models.Issue{ 1013 RepoAt: f.RepoAt(), 1014 Rkey: tid.TID(), 1015 Title: r.FormValue("title"), 1016 Body: body, 1017 Open: true, 1018 Did: user.Active.Did, 1019 Created: time.Now(), 1020 Mentions: mentions, 1021 References: references, 1022 Repo: f, 1023 } 1024 1025 if err := issue.Validate(); err != nil { 1026 l.Error("validation error", "err", err) 1027 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 1028 return 1029 } 1030 1031 record := issue.AsRecord() 1032 1033 // create an atproto record 1034 client, err := rp.oauth.AuthorizedClient(r) 1035 if err != nil { 1036 l.Error("failed to get authorized client", "err", err) 1037 rp.pages.Notice(w, "issues", "Failed to create issue.") 1038 return 1039 } 1040 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1041 Collection: tangled.RepoIssueNSID, 1042 Repo: user.Active.Did, 1043 Rkey: issue.Rkey, 1044 Record: &lexutil.LexiconTypeDecoder{ 1045 Val: &record, 1046 }, 1047 }) 1048 if err != nil { 1049 l.Error("failed to create issue", "err", err) 1050 rp.pages.Notice(w, "issues", "Failed to create issue.") 1051 return 1052 } 1053 atUri := resp.Uri 1054 1055 tx, err := rp.db.BeginTx(r.Context(), nil) 1056 if err != nil { 1057 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 1058 return 1059 } 1060 rollback := func() { 1061 err1 := tx.Rollback() 1062 err2 := rollbackRecord(context.Background(), atUri, client) 1063 1064 if errors.Is(err1, sql.ErrTxDone) { 1065 err1 = nil 1066 } 1067 1068 if err := errors.Join(err1, err2); err != nil { 1069 l.Error("failed to rollback txn", "err", err) 1070 } 1071 } 1072 defer rollback() 1073 1074 err = db.PutIssue(tx, issue) 1075 if err != nil { 1076 l.Error("failed to create issue", "err", err) 1077 rp.pages.Notice(w, "issues", "Failed to create issue.") 1078 return 1079 } 1080 1081 if err = tx.Commit(); err != nil { 1082 l.Error("failed to create issue", "err", err) 1083 rp.pages.Notice(w, "issues", "Failed to create issue.") 1084 return 1085 } 1086 1087 // everything is successful, do not rollback the atproto record 1088 atUri = "" 1089 1090 rp.notifier.NewIssue(r.Context(), issue, mentions) 1091 1092 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 1093 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 1094 return 1095 } 1096} 1097 1098// this is used to rollback changes made to the PDS 1099// 1100// it is a no-op if the provided ATURI is empty 1101func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1102 if aturi == "" { 1103 return nil 1104 } 1105 1106 parsed := syntax.ATURI(aturi) 1107 1108 collection := parsed.Collection().String() 1109 repo := parsed.Authority().String() 1110 rkey := parsed.RecordKey().String() 1111 1112 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 1113 Collection: collection, 1114 Repo: repo, 1115 Rkey: rkey, 1116 }) 1117 return err 1118}