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