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