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