A vibe coded tangled fork which supports pijul.
at 401bc223b6b5cc3a4b7332639f64d7e6409f273e 1114 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 resolve := func(ctx context.Context, ident string) (string, error) { 827 id, err := rp.idResolver.ResolveIdent(ctx, ident) 828 if err != nil { 829 return "", err 830 } 831 return id.DID.String(), nil 832 } 833 834 authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 835 836 labels := query.GetAll("label") 837 negatedLabels := query.GetAllNegated("label") 838 labelValues := query.GetDynamicTags() 839 negatedLabelValues := query.GetNegatedDynamicTags() 840 841 // resolve DID-format label values: if a dynamic tag's label 842 // definition has format "did", resolve the handle to a DID 843 if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 844 labelDefs, err := db.GetLabelDefinitions( 845 rp.db, 846 orm.FilterIn("at_uri", f.Labels), 847 orm.FilterContains("scope", tangled.RepoIssueNSID), 848 ) 849 if err == nil { 850 didLabels := make(map[string]bool) 851 for _, def := range labelDefs { 852 if def.ValueType.Format == models.ValueTypeFormatDid { 853 didLabels[def.Name] = true 854 } 855 } 856 labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 857 negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 858 } else { 859 l.Debug("failed to fetch label definitions for DID resolution", "err", err) 860 } 861 } 862 863 tf := searchquery.ExtractTextFilters(query) 864 865 searchOpts := models.IssueSearchOptions{ 866 Keywords: tf.Keywords, 867 Phrases: tf.Phrases, 868 RepoAt: f.RepoAt().String(), 869 IsOpen: isOpen, 870 AuthorDid: authorDid, 871 Labels: labels, 872 LabelValues: labelValues, 873 NegatedKeywords: tf.NegatedKeywords, 874 NegatedPhrases: tf.NegatedPhrases, 875 NegatedLabels: negatedLabels, 876 NegatedLabelValues: negatedLabelValues, 877 NegatedAuthorDids: negatedAuthorDids, 878 Page: page, 879 } 880 881 totalIssues := 0 882 if isOpen == nil { 883 totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed 884 } else if *isOpen { 885 totalIssues = f.RepoStats.IssueCount.Open 886 } else { 887 totalIssues = f.RepoStats.IssueCount.Closed 888 } 889 890 repoInfo := rp.repoResolver.GetRepoInfo(r, user) 891 892 var issues []models.Issue 893 894 if searchOpts.HasSearchFilters() { 895 res, err := rp.indexer.Search(r.Context(), searchOpts) 896 if err != nil { 897 l.Error("failed to search for issues", "err", err) 898 return 899 } 900 l.Debug("searched issues with indexer", "count", len(res.Hits)) 901 totalIssues = int(res.Total) 902 903 // update tab counts to reflect filtered results 904 countOpts := searchOpts 905 countOpts.Page = pagination.Page{Limit: 1} 906 countOpts.IsOpen = ptrBool(true) 907 if openRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 908 repoInfo.Stats.IssueCount.Open = int(openRes.Total) 909 } 910 countOpts.IsOpen = ptrBool(false) 911 if closedRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 912 repoInfo.Stats.IssueCount.Closed = int(closedRes.Total) 913 } 914 915 if len(res.Hits) > 0 { 916 issues, err = db.GetIssues( 917 rp.db, 918 orm.FilterIn("id", res.Hits), 919 ) 920 if err != nil { 921 l.Error("failed to get issues", "err", err) 922 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 923 return 924 } 925 } 926 } else { 927 filters := []orm.Filter{ 928 orm.FilterEq("repo_at", f.RepoAt()), 929 } 930 if isOpen != nil { 931 openInt := 0 932 if *isOpen { 933 openInt = 1 934 } 935 filters = append(filters, orm.FilterEq("open", openInt)) 936 } 937 issues, err = db.GetIssuesPaginated( 938 rp.db, 939 page, 940 filters..., 941 ) 942 if err != nil { 943 l.Error("failed to get issues", "err", err) 944 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 945 return 946 } 947 } 948 949 labelDefs, err := db.GetLabelDefinitions( 950 rp.db, 951 orm.FilterIn("at_uri", f.Labels), 952 orm.FilterContains("scope", tangled.RepoIssueNSID), 953 ) 954 if err != nil { 955 l.Error("failed to fetch labels", "err", err) 956 rp.pages.Error503(w) 957 return 958 } 959 960 defs := make(map[string]*models.LabelDefinition) 961 for _, l := range labelDefs { 962 defs[l.AtUri().String()] = &l 963 } 964 965 filterState := "" 966 if isOpen != nil { 967 if *isOpen { 968 filterState = "open" 969 } else { 970 filterState = "closed" 971 } 972 } 973 974 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 975 LoggedInUser: rp.oauth.GetMultiAccountUser(r), 976 RepoInfo: repoInfo, 977 Issues: issues, 978 IssueCount: totalIssues, 979 LabelDefs: defs, 980 FilterState: filterState, 981 FilterQuery: query.String(), 982 Page: page, 983 }) 984} 985 986func ptrBool(b bool) *bool { return &b } 987 988func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 989 l := rp.logger.With("handler", "NewIssue") 990 user := rp.oauth.GetMultiAccountUser(r) 991 992 f, err := rp.repoResolver.Resolve(r) 993 if err != nil { 994 l.Error("failed to get repo and knot", "err", err) 995 return 996 } 997 998 switch r.Method { 999 case http.MethodGet: 1000 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1001 LoggedInUser: user, 1002 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1003 }) 1004 case http.MethodPost: 1005 body := r.FormValue("body") 1006 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 1007 1008 issue := &models.Issue{ 1009 RepoAt: f.RepoAt(), 1010 Rkey: tid.TID(), 1011 Title: r.FormValue("title"), 1012 Body: body, 1013 Open: true, 1014 Did: user.Active.Did, 1015 Created: time.Now(), 1016 Mentions: mentions, 1017 References: references, 1018 Repo: f, 1019 } 1020 1021 if err := issue.Validate(); err != nil { 1022 l.Error("validation error", "err", err) 1023 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 1024 return 1025 } 1026 1027 record := issue.AsRecord() 1028 1029 // create an atproto record 1030 client, err := rp.oauth.AuthorizedClient(r) 1031 if err != nil { 1032 l.Error("failed to get authorized client", "err", err) 1033 rp.pages.Notice(w, "issues", "Failed to create issue.") 1034 return 1035 } 1036 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1037 Collection: tangled.RepoIssueNSID, 1038 Repo: user.Active.Did, 1039 Rkey: issue.Rkey, 1040 Record: &lexutil.LexiconTypeDecoder{ 1041 Val: &record, 1042 }, 1043 }) 1044 if err != nil { 1045 l.Error("failed to create issue", "err", err) 1046 rp.pages.Notice(w, "issues", "Failed to create issue.") 1047 return 1048 } 1049 atUri := resp.Uri 1050 1051 tx, err := rp.db.BeginTx(r.Context(), nil) 1052 if err != nil { 1053 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 1054 return 1055 } 1056 rollback := func() { 1057 err1 := tx.Rollback() 1058 err2 := rollbackRecord(context.Background(), atUri, client) 1059 1060 if errors.Is(err1, sql.ErrTxDone) { 1061 err1 = nil 1062 } 1063 1064 if err := errors.Join(err1, err2); err != nil { 1065 l.Error("failed to rollback txn", "err", err) 1066 } 1067 } 1068 defer rollback() 1069 1070 err = db.PutIssue(tx, issue) 1071 if err != nil { 1072 l.Error("failed to create issue", "err", err) 1073 rp.pages.Notice(w, "issues", "Failed to create issue.") 1074 return 1075 } 1076 1077 if err = tx.Commit(); err != nil { 1078 l.Error("failed to create issue", "err", err) 1079 rp.pages.Notice(w, "issues", "Failed to create issue.") 1080 return 1081 } 1082 1083 // everything is successful, do not rollback the atproto record 1084 atUri = "" 1085 1086 rp.notifier.NewIssue(r.Context(), issue, mentions) 1087 1088 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 1089 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 1090 return 1091 } 1092} 1093 1094// this is used to rollback changes made to the PDS 1095// 1096// it is a no-op if the provided ATURI is empty 1097func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1098 if aturi == "" { 1099 return nil 1100 } 1101 1102 parsed := syntax.ATURI(aturi) 1103 1104 collection := parsed.Collection().String() 1105 repo := parsed.Authority().String() 1106 rkey := parsed.RecordKey().String() 1107 1108 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 1109 Collection: collection, 1110 Repo: repo, 1111 Rkey: rkey, 1112 }) 1113 return err 1114}