A vibe coded tangled fork which supports pijul.
at 1a0ca28ea1d5d462569a254c38a851832e549cb2 1025 lines 28 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/validator" 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 validator *validator.Validator 49 indexer *issues_indexer.Indexer 50} 51 52func New( 53 oauth *oauth.OAuth, 54 repoResolver *reporesolver.RepoResolver, 55 enforcer *rbac.Enforcer, 56 pages *pages.Pages, 57 idResolver *idresolver.Resolver, 58 mentionsResolver *mentions.Resolver, 59 db *db.DB, 60 config *config.Config, 61 notifier notify.Notifier, 62 validator *validator.Validator, 63 indexer *issues_indexer.Indexer, 64 logger *slog.Logger, 65) *Issues { 66 return &Issues{ 67 oauth: oauth, 68 repoResolver: repoResolver, 69 enforcer: enforcer, 70 pages: pages, 71 idResolver: idResolver, 72 mentionsResolver: mentionsResolver, 73 db: db, 74 config: config, 75 notifier: notifier, 76 logger: logger, 77 validator: validator, 78 indexer: indexer, 79 } 80} 81 82func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 83 l := rp.logger.With("handler", "RepoSingleIssue") 84 user := rp.oauth.GetMultiAccountUser(r) 85 f, err := rp.repoResolver.Resolve(r) 86 if err != nil { 87 l.Error("failed to get repo and knot", "err", err) 88 return 89 } 90 91 issue, ok := r.Context().Value("issue").(*models.Issue) 92 if !ok { 93 l.Error("failed to get issue") 94 rp.pages.Error404(w) 95 return 96 } 97 98 reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 99 if err != nil { 100 l.Error("failed to get issue reactions", "err", err) 101 } 102 103 userReactions := map[models.ReactionKind]bool{} 104 if user != nil { 105 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 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 := rp.validator.ValidateIssue(newIssue); 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.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.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.Did, f.Knot, f.DidSlashRepo())} 309 isRepoOwner := roles.IsOwner() 310 isCollaborator := roles.IsCollaborator() 311 isIssueOwner := user.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.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.Did, f.Knot, f.DidSlashRepo())} 357 isRepoOwner := roles.IsOwner() 358 isCollaborator := roles.IsCollaborator() 359 isIssueOwner := user.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.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-comment", "Body is required") 406 return 407 } 408 409 var replyTo *syntax.ATURI 410 replyToRaw := r.FormValue("reply-to") 411 if replyToRaw != "" { 412 aturi, err := syntax.ParseATURI(replyToRaw) 413 if err != nil { 414 rp.pages.Notice(w, "issue-comment", "reply-to should be valid AT-URI") 415 return 416 } 417 replyTo = &aturi 418 } 419 420 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 421 422 comment := models.Comment{ 423 Did: syntax.DID(user.Did), 424 Collection: tangled.CommentNSID, 425 Rkey: tid.TID(), 426 Subject: issue.AtUri(), 427 ReplyTo: replyTo, 428 Body: body, 429 Created: time.Now(), 430 Mentions: mentions, 431 References: references, 432 } 433 if err = comment.Validate(); err != nil { 434 l.Error("failed to validate comment", "err", err) 435 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 436 return 437 } 438 439 client, err := rp.oauth.AuthorizedClient(r) 440 if err != nil { 441 l.Error("failed to get authorized client", "err", err) 442 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 443 return 444 } 445 446 // create a record first 447 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 448 Collection: comment.Collection.String(), 449 Repo: comment.Did.String(), 450 Rkey: comment.Rkey, 451 Record: &lexutil.LexiconTypeDecoder{ 452 Val: comment.AsRecord(), 453 }, 454 }) 455 if err != nil { 456 l.Error("failed to create comment", "err", err) 457 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 458 return 459 } 460 atUri := resp.Uri 461 defer func() { 462 if err := rollbackRecord(context.Background(), atUri, client); err != nil { 463 l.Error("rollback failed", "err", err) 464 } 465 }() 466 467 tx, err := rp.db.Begin() 468 if err != nil { 469 l.Error("failed to start transaction", "err", err) 470 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 471 return 472 } 473 defer tx.Rollback() 474 475 err = db.PutComment(tx, &comment) 476 if err != nil { 477 l.Error("failed to create comment", "err", err) 478 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 479 return 480 } 481 err = tx.Commit() 482 if err != nil { 483 l.Error("failed to commit transaction", "err", err) 484 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 485 return 486 } 487 488 // reset atUri to make rollback a no-op 489 atUri = "" 490 491 rp.notifier.NewComment(r.Context(), &comment) 492 493 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 494 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) 495} 496 497func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 498 l := rp.logger.With("handler", "IssueComment") 499 user := rp.oauth.GetMultiAccountUser(r) 500 501 issue, ok := r.Context().Value("issue").(*models.Issue) 502 if !ok { 503 l.Error("failed to get issue") 504 rp.pages.Error404(w) 505 return 506 } 507 508 commentId := chi.URLParam(r, "commentId") 509 comments, err := db.GetComments( 510 rp.db, 511 orm.FilterEq("id", commentId), 512 ) 513 if err != nil { 514 l.Error("failed to fetch comment", "id", commentId) 515 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 516 return 517 } 518 if len(comments) != 1 { 519 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 520 http.Error(w, "invalid comment id", http.StatusBadRequest) 521 return 522 } 523 comment := comments[0] 524 525 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 526 LoggedInUser: user, 527 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 528 Issue: issue, 529 Comment: &comment, 530 }) 531} 532 533func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 534 l := rp.logger.With("handler", "EditIssueComment") 535 user := rp.oauth.GetMultiAccountUser(r) 536 537 issue, ok := r.Context().Value("issue").(*models.Issue) 538 if !ok { 539 l.Error("failed to get issue") 540 rp.pages.Error404(w) 541 return 542 } 543 544 commentId := chi.URLParam(r, "commentId") 545 comments, err := db.GetComments( 546 rp.db, 547 orm.FilterEq("id", commentId), 548 ) 549 if err != nil { 550 l.Error("failed to fetch comment", "id", commentId) 551 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 552 return 553 } 554 if len(comments) != 1 { 555 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 556 http.Error(w, "invalid comment id", http.StatusBadRequest) 557 return 558 } 559 comment := comments[0] 560 561 if comment.Did.String() != user.Did { 562 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 563 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 564 return 565 } 566 567 switch r.Method { 568 case http.MethodGet: 569 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 570 LoggedInUser: user, 571 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 572 Issue: issue, 573 Comment: &comment, 574 }) 575 case http.MethodPost: 576 // extract form value 577 newBody := r.FormValue("body") 578 client, err := rp.oauth.AuthorizedClient(r) 579 if err != nil { 580 l.Error("failed to get authorized client", "err", err) 581 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 582 return 583 } 584 585 now := time.Now() 586 newComment := comment 587 newComment.Body = newBody 588 newComment.Edited = &now 589 newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 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.PutComment(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 // TODO: update correct comment 610 611 // update the record on pds 612 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", newComment.Collection.String(), newComment.Did.String(), newComment.Rkey) 613 if err != nil { 614 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 615 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update comment, no record found on PDS.") 616 return 617 } 618 619 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 620 Collection: newComment.Collection.String(), 621 Repo: newComment.Did.String(), 622 Rkey: newComment.Rkey, 623 SwapRecord: ex.Cid, 624 Record: &lexutil.LexiconTypeDecoder{ 625 Val: newComment.AsRecord(), 626 }, 627 }) 628 if err != nil { 629 l.Error("failed to update record on PDS", "err", err) 630 } 631 } 632 633 // return new comment body with htmx 634 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 635 LoggedInUser: user, 636 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 637 Issue: issue, 638 Comment: &newComment, 639 }) 640 } 641} 642 643func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 644 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 645 user := rp.oauth.GetMultiAccountUser(r) 646 647 issue, ok := r.Context().Value("issue").(*models.Issue) 648 if !ok { 649 l.Error("failed to get issue") 650 rp.pages.Error404(w) 651 return 652 } 653 654 commentId := chi.URLParam(r, "commentId") 655 comments, err := db.GetComments( 656 rp.db, 657 orm.FilterEq("id", commentId), 658 ) 659 if err != nil { 660 l.Error("failed to fetch comment", "id", commentId) 661 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 662 return 663 } 664 if len(comments) != 1 { 665 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 666 http.Error(w, "invalid comment id", http.StatusBadRequest) 667 return 668 } 669 comment := comments[0] 670 671 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 672 LoggedInUser: user, 673 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 674 Issue: issue, 675 Comment: &comment, 676 }) 677} 678 679func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 680 l := rp.logger.With("handler", "ReplyIssueComment") 681 user := rp.oauth.GetMultiAccountUser(r) 682 683 issue, ok := r.Context().Value("issue").(*models.Issue) 684 if !ok { 685 l.Error("failed to get issue") 686 rp.pages.Error404(w) 687 return 688 } 689 690 commentId := chi.URLParam(r, "commentId") 691 comments, err := db.GetComments( 692 rp.db, 693 orm.FilterEq("id", commentId), 694 ) 695 if err != nil { 696 l.Error("failed to fetch comment", "id", commentId) 697 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 698 return 699 } 700 if len(comments) != 1 { 701 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 702 http.Error(w, "invalid comment id", http.StatusBadRequest) 703 return 704 } 705 comment := comments[0] 706 707 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 708 LoggedInUser: user, 709 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 710 Issue: issue, 711 Comment: &comment, 712 }) 713} 714 715func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 716 l := rp.logger.With("handler", "DeleteIssueComment") 717 user := rp.oauth.GetMultiAccountUser(r) 718 719 issue, ok := r.Context().Value("issue").(*models.Issue) 720 if !ok { 721 l.Error("failed to get issue") 722 rp.pages.Error404(w) 723 return 724 } 725 726 commentId := chi.URLParam(r, "commentId") 727 comments, err := db.GetComments( 728 rp.db, 729 orm.FilterEq("id", commentId), 730 ) 731 if err != nil { 732 l.Error("failed to fetch comment", "id", commentId) 733 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 734 return 735 } 736 if len(comments) != 1 { 737 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 738 http.Error(w, "invalid comment id", http.StatusBadRequest) 739 return 740 } 741 comment := comments[0] 742 743 if comment.Did.String() != user.Did { 744 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 745 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 746 return 747 } 748 749 if comment.Deleted != nil { 750 http.Error(w, "comment already deleted", http.StatusBadRequest) 751 return 752 } 753 754 // optimistic deletion 755 deleted := time.Now() 756 err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) 757 if err != nil { 758 l.Error("failed to delete comment", "err", err) 759 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 760 return 761 } 762 763 // delete from pds 764 if comment.Rkey != "" { 765 client, err := rp.oauth.AuthorizedClient(r) 766 if err != nil { 767 l.Error("failed to get authorized client", "err", err) 768 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 769 return 770 } 771 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 772 Collection: comment.Collection.String(), 773 Repo: comment.Did.String(), 774 Rkey: comment.Rkey, 775 }) 776 if err != nil { 777 l.Error("failed to delete from PDS", "err", err) 778 } 779 } 780 781 // optimistic update for htmx 782 comment.Body = "" 783 comment.Deleted = &deleted 784 785 // htmx fragment of comment after deletion 786 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 787 LoggedInUser: user, 788 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 789 Issue: issue, 790 Comment: &comment, 791 }) 792} 793 794func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 795 l := rp.logger.With("handler", "RepoIssues") 796 797 params := r.URL.Query() 798 state := params.Get("state") 799 isOpen := true 800 switch state { 801 case "open": 802 isOpen = true 803 case "closed": 804 isOpen = false 805 default: 806 isOpen = true 807 } 808 809 page := pagination.FromContext(r.Context()) 810 811 user := rp.oauth.GetMultiAccountUser(r) 812 f, err := rp.repoResolver.Resolve(r) 813 if err != nil { 814 l.Error("failed to get repo and knot", "err", err) 815 return 816 } 817 818 totalIssues := 0 819 if isOpen { 820 totalIssues = f.RepoStats.IssueCount.Open 821 } else { 822 totalIssues = f.RepoStats.IssueCount.Closed 823 } 824 825 keyword := params.Get("q") 826 827 var issues []models.Issue 828 searchOpts := models.IssueSearchOptions{ 829 Keyword: keyword, 830 RepoAt: f.RepoAt().String(), 831 IsOpen: isOpen, 832 Page: page, 833 } 834 if keyword != "" { 835 res, err := rp.indexer.Search(r.Context(), searchOpts) 836 if err != nil { 837 l.Error("failed to search for issues", "err", err) 838 return 839 } 840 l.Debug("searched issues with indexer", "count", len(res.Hits)) 841 totalIssues = int(res.Total) 842 843 issues, err = db.GetIssues( 844 rp.db, 845 orm.FilterIn("id", res.Hits), 846 ) 847 if err != nil { 848 l.Error("failed to get issues", "err", err) 849 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 850 return 851 } 852 853 } else { 854 openInt := 0 855 if isOpen { 856 openInt = 1 857 } 858 issues, err = db.GetIssuesPaginated( 859 rp.db, 860 page, 861 orm.FilterEq("repo_at", f.RepoAt()), 862 orm.FilterEq("open", openInt), 863 ) 864 if err != nil { 865 l.Error("failed to get issues", "err", err) 866 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 867 return 868 } 869 } 870 871 labelDefs, err := db.GetLabelDefinitions( 872 rp.db, 873 orm.FilterIn("at_uri", f.Labels), 874 orm.FilterContains("scope", tangled.RepoIssueNSID), 875 ) 876 if err != nil { 877 l.Error("failed to fetch labels", "err", err) 878 rp.pages.Error503(w) 879 return 880 } 881 882 defs := make(map[string]*models.LabelDefinition) 883 for _, l := range labelDefs { 884 defs[l.AtUri().String()] = &l 885 } 886 887 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 888 LoggedInUser: rp.oauth.GetMultiAccountUser(r), 889 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 890 Issues: issues, 891 IssueCount: totalIssues, 892 LabelDefs: defs, 893 FilteringByOpen: isOpen, 894 FilterQuery: keyword, 895 Page: page, 896 }) 897} 898 899func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 900 l := rp.logger.With("handler", "NewIssue") 901 user := rp.oauth.GetMultiAccountUser(r) 902 903 f, err := rp.repoResolver.Resolve(r) 904 if err != nil { 905 l.Error("failed to get repo and knot", "err", err) 906 return 907 } 908 909 switch r.Method { 910 case http.MethodGet: 911 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 912 LoggedInUser: user, 913 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 914 }) 915 case http.MethodPost: 916 body := r.FormValue("body") 917 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 918 919 issue := &models.Issue{ 920 RepoAt: f.RepoAt(), 921 Rkey: tid.TID(), 922 Title: r.FormValue("title"), 923 Body: body, 924 Open: true, 925 Did: user.Did, 926 Created: time.Now(), 927 Mentions: mentions, 928 References: references, 929 Repo: f, 930 } 931 932 if err := rp.validator.ValidateIssue(issue); err != nil { 933 l.Error("validation error", "err", err) 934 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 935 return 936 } 937 938 record := issue.AsRecord() 939 940 // create an atproto record 941 client, err := rp.oauth.AuthorizedClient(r) 942 if err != nil { 943 l.Error("failed to get authorized client", "err", err) 944 rp.pages.Notice(w, "issues", "Failed to create issue.") 945 return 946 } 947 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 948 Collection: tangled.RepoIssueNSID, 949 Repo: user.Did, 950 Rkey: issue.Rkey, 951 Record: &lexutil.LexiconTypeDecoder{ 952 Val: &record, 953 }, 954 }) 955 if err != nil { 956 l.Error("failed to create issue", "err", err) 957 rp.pages.Notice(w, "issues", "Failed to create issue.") 958 return 959 } 960 atUri := resp.Uri 961 962 tx, err := rp.db.BeginTx(r.Context(), nil) 963 if err != nil { 964 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 965 return 966 } 967 rollback := func() { 968 err1 := tx.Rollback() 969 err2 := rollbackRecord(context.Background(), atUri, client) 970 971 if errors.Is(err1, sql.ErrTxDone) { 972 err1 = nil 973 } 974 975 if err := errors.Join(err1, err2); err != nil { 976 l.Error("failed to rollback txn", "err", err) 977 } 978 } 979 defer rollback() 980 981 err = db.PutIssue(tx, issue) 982 if err != nil { 983 l.Error("failed to create issue", "err", err) 984 rp.pages.Notice(w, "issues", "Failed to create issue.") 985 return 986 } 987 988 if err = tx.Commit(); err != nil { 989 l.Error("failed to create issue", "err", err) 990 rp.pages.Notice(w, "issues", "Failed to create issue.") 991 return 992 } 993 994 // everything is successful, do not rollback the atproto record 995 atUri = "" 996 997 rp.notifier.NewIssue(r.Context(), issue, mentions) 998 999 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 1000 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 1001 return 1002 } 1003} 1004 1005// this is used to rollback changes made to the PDS 1006// 1007// it is a no-op if the provided ATURI is empty 1008func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1009 if aturi == "" { 1010 return nil 1011 } 1012 1013 parsed := syntax.ATURI(aturi) 1014 1015 collection := parsed.Collection().String() 1016 repo := parsed.Authority().String() 1017 rkey := parsed.RecordKey().String() 1018 1019 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 1020 Collection: collection, 1021 Repo: repo, 1022 Rkey: rkey, 1023 }) 1024 return err 1025}