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