A vibe coded tangled fork which supports pijul.
at d861bff51e1597cd6902ff3d8ff601c3b1bf1451 1120 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-comment", "Body is required") 407 return 408 } 409 410 var replyTo *syntax.ATURI 411 replyToRaw := r.FormValue("reply-to") 412 if replyToRaw != "" { 413 aturi, err := syntax.ParseATURI(replyToRaw) 414 if err != nil { 415 rp.pages.Notice(w, "issue-comment", "reply-to should be valid AT-URI") 416 return 417 } 418 replyTo = &aturi 419 } 420 421 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 422 423 comment := models.Comment{ 424 Did: syntax.DID(user.Active.Did), 425 Collection: tangled.CommentNSID, 426 Rkey: tid.TID(), 427 Subject: issue.AtUri(), 428 ReplyTo: replyTo, 429 Body: body, 430 Created: time.Now(), 431 Mentions: mentions, 432 References: references, 433 } 434 if err = comment.Validate(); err != nil { 435 l.Error("failed to validate comment", "err", err) 436 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 437 return 438 } 439 440 client, err := rp.oauth.AuthorizedClient(r) 441 if err != nil { 442 l.Error("failed to get authorized client", "err", err) 443 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 444 return 445 } 446 447 // create a record first 448 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 449 Collection: comment.Collection.String(), 450 Repo: comment.Did.String(), 451 Rkey: comment.Rkey, 452 Record: &lexutil.LexiconTypeDecoder{ 453 Val: comment.AsRecord(), 454 }, 455 }) 456 if err != nil { 457 l.Error("failed to create comment", "err", err) 458 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 459 return 460 } 461 atUri := resp.Uri 462 defer func() { 463 if err := rollbackRecord(context.Background(), atUri, client); err != nil { 464 l.Error("rollback failed", "err", err) 465 } 466 }() 467 468 tx, err := rp.db.Begin() 469 if err != nil { 470 l.Error("failed to start transaction", "err", err) 471 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 472 return 473 } 474 defer tx.Rollback() 475 476 err = db.PutComment(tx, &comment) 477 if err != nil { 478 l.Error("failed to create comment", "err", err) 479 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 480 return 481 } 482 err = tx.Commit() 483 if err != nil { 484 l.Error("failed to commit transaction", "err", err) 485 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 486 return 487 } 488 489 // reset atUri to make rollback a no-op 490 atUri = "" 491 492 rp.notifier.NewComment(r.Context(), &comment) 493 494 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 495 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) 496} 497 498func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 499 l := rp.logger.With("handler", "IssueComment") 500 user := rp.oauth.GetMultiAccountUser(r) 501 502 issue, ok := r.Context().Value("issue").(*models.Issue) 503 if !ok { 504 l.Error("failed to get issue") 505 rp.pages.Error404(w) 506 return 507 } 508 509 commentId := chi.URLParam(r, "commentId") 510 comments, err := db.GetComments( 511 rp.db, 512 orm.FilterEq("id", commentId), 513 ) 514 if err != nil { 515 l.Error("failed to fetch comment", "id", commentId) 516 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 517 return 518 } 519 if len(comments) != 1 { 520 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 521 http.Error(w, "invalid comment id", http.StatusBadRequest) 522 return 523 } 524 comment := comments[0] 525 526 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 527 LoggedInUser: user, 528 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 529 Issue: issue, 530 Comment: &comment, 531 }) 532} 533 534func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 535 l := rp.logger.With("handler", "EditIssueComment") 536 user := rp.oauth.GetMultiAccountUser(r) 537 538 issue, ok := r.Context().Value("issue").(*models.Issue) 539 if !ok { 540 l.Error("failed to get issue") 541 rp.pages.Error404(w) 542 return 543 } 544 545 commentId := chi.URLParam(r, "commentId") 546 comments, err := db.GetComments( 547 rp.db, 548 orm.FilterEq("id", commentId), 549 ) 550 if err != nil { 551 l.Error("failed to fetch comment", "id", commentId) 552 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 553 return 554 } 555 if len(comments) != 1 { 556 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 557 http.Error(w, "invalid comment id", http.StatusBadRequest) 558 return 559 } 560 comment := comments[0] 561 562 if comment.Did.String() != user.Active.Did { 563 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) 564 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 565 return 566 } 567 568 switch r.Method { 569 case http.MethodGet: 570 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 571 LoggedInUser: user, 572 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 573 Issue: issue, 574 Comment: &comment, 575 }) 576 case http.MethodPost: 577 // extract form value 578 newBody := r.FormValue("body") 579 client, err := rp.oauth.AuthorizedClient(r) 580 if err != nil { 581 l.Error("failed to get authorized client", "err", err) 582 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 583 return 584 } 585 586 now := time.Now() 587 newComment := comment 588 newComment.Body = newBody 589 newComment.Edited = &now 590 newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 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.PutComment(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 // TODO: update correct comment 611 612 // update the record on pds 613 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", newComment.Collection.String(), newComment.Did.String(), newComment.Rkey) 614 if err != nil { 615 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 616 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update comment, no record found on PDS.") 617 return 618 } 619 620 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 621 Collection: newComment.Collection.String(), 622 Repo: newComment.Did.String(), 623 Rkey: newComment.Rkey, 624 SwapRecord: ex.Cid, 625 Record: &lexutil.LexiconTypeDecoder{ 626 Val: newComment.AsRecord(), 627 }, 628 }) 629 if err != nil { 630 l.Error("failed to update record on PDS", "err", err) 631 } 632 } 633 634 // return new comment body with htmx 635 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 636 LoggedInUser: user, 637 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 638 Issue: issue, 639 Comment: &newComment, 640 }) 641 } 642} 643 644func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 645 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 646 user := rp.oauth.GetMultiAccountUser(r) 647 648 issue, ok := r.Context().Value("issue").(*models.Issue) 649 if !ok { 650 l.Error("failed to get issue") 651 rp.pages.Error404(w) 652 return 653 } 654 655 commentId := chi.URLParam(r, "commentId") 656 comments, err := db.GetComments( 657 rp.db, 658 orm.FilterEq("id", commentId), 659 ) 660 if err != nil { 661 l.Error("failed to fetch comment", "id", commentId) 662 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 663 return 664 } 665 if len(comments) != 1 { 666 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 667 http.Error(w, "invalid comment id", http.StatusBadRequest) 668 return 669 } 670 comment := comments[0] 671 672 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 673 LoggedInUser: user, 674 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 675 Issue: issue, 676 Comment: &comment, 677 }) 678} 679 680func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 681 l := rp.logger.With("handler", "ReplyIssueComment") 682 user := rp.oauth.GetMultiAccountUser(r) 683 684 issue, ok := r.Context().Value("issue").(*models.Issue) 685 if !ok { 686 l.Error("failed to get issue") 687 rp.pages.Error404(w) 688 return 689 } 690 691 commentId := chi.URLParam(r, "commentId") 692 comments, err := db.GetComments( 693 rp.db, 694 orm.FilterEq("id", commentId), 695 ) 696 if err != nil { 697 l.Error("failed to fetch comment", "id", commentId) 698 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 699 return 700 } 701 if len(comments) != 1 { 702 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 703 http.Error(w, "invalid comment id", http.StatusBadRequest) 704 return 705 } 706 comment := comments[0] 707 708 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 709 LoggedInUser: user, 710 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 711 Issue: issue, 712 Comment: &comment, 713 }) 714} 715 716func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 717 l := rp.logger.With("handler", "DeleteIssueComment") 718 user := rp.oauth.GetMultiAccountUser(r) 719 720 issue, ok := r.Context().Value("issue").(*models.Issue) 721 if !ok { 722 l.Error("failed to get issue") 723 rp.pages.Error404(w) 724 return 725 } 726 727 commentId := chi.URLParam(r, "commentId") 728 comments, err := db.GetComments( 729 rp.db, 730 orm.FilterEq("id", commentId), 731 ) 732 if err != nil { 733 l.Error("failed to fetch comment", "id", commentId) 734 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 735 return 736 } 737 if len(comments) != 1 { 738 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 739 http.Error(w, "invalid comment id", http.StatusBadRequest) 740 return 741 } 742 comment := comments[0] 743 744 if comment.Did.String() != user.Active.Did { 745 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) 746 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 747 return 748 } 749 750 if comment.Deleted != nil { 751 http.Error(w, "comment already deleted", http.StatusBadRequest) 752 return 753 } 754 755 // optimistic deletion 756 deleted := time.Now() 757 err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) 758 if err != nil { 759 l.Error("failed to delete comment", "err", err) 760 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 761 return 762 } 763 764 // delete from pds 765 if comment.Rkey != "" { 766 client, err := rp.oauth.AuthorizedClient(r) 767 if err != nil { 768 l.Error("failed to get authorized client", "err", err) 769 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 770 return 771 } 772 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 773 Collection: comment.Collection.String(), 774 Repo: comment.Did.String(), 775 Rkey: comment.Rkey, 776 }) 777 if err != nil { 778 l.Error("failed to delete from PDS", "err", err) 779 } 780 } 781 782 // optimistic update for htmx 783 comment.Body = "" 784 comment.Deleted = &deleted 785 786 // htmx fragment of comment after deletion 787 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 788 LoggedInUser: user, 789 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 790 Issue: issue, 791 Comment: &comment, 792 }) 793} 794 795func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 796 l := rp.logger.With("handler", "RepoIssues") 797 798 params := r.URL.Query() 799 page := pagination.FromContext(r.Context()) 800 801 user := rp.oauth.GetMultiAccountUser(r) 802 f, err := rp.repoResolver.Resolve(r) 803 if err != nil { 804 l.Error("failed to get repo and knot", "err", err) 805 return 806 } 807 808 query := searchquery.Parse(params.Get("q")) 809 810 var isOpen *bool 811 if urlState := params.Get("state"); urlState != "" { 812 switch urlState { 813 case "open": 814 isOpen = ptrBool(true) 815 case "closed": 816 isOpen = ptrBool(false) 817 } 818 query.Set("state", urlState) 819 } else if queryState := query.Get("state"); queryState != nil { 820 switch *queryState { 821 case "open": 822 isOpen = ptrBool(true) 823 case "closed": 824 isOpen = ptrBool(false) 825 } 826 } else if _, hasQ := params["q"]; !hasQ { 827 // no q param at all -- default to open 828 isOpen = ptrBool(true) 829 query.Set("state", "open") 830 } 831 832 resolve := func(ctx context.Context, ident string) (string, error) { 833 id, err := rp.idResolver.ResolveIdent(ctx, ident) 834 if err != nil { 835 return "", err 836 } 837 return id.DID.String(), nil 838 } 839 840 authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 841 842 labels := query.GetAll("label") 843 negatedLabels := query.GetAllNegated("label") 844 labelValues := query.GetDynamicTags() 845 negatedLabelValues := query.GetNegatedDynamicTags() 846 847 // resolve DID-format label values: if a dynamic tag's label 848 // definition has format "did", resolve the handle to a DID 849 if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 850 labelDefs, err := db.GetLabelDefinitions( 851 rp.db, 852 orm.FilterIn("at_uri", f.Labels), 853 orm.FilterContains("scope", tangled.RepoIssueNSID), 854 ) 855 if err == nil { 856 didLabels := make(map[string]bool) 857 for _, def := range labelDefs { 858 if def.ValueType.Format == models.ValueTypeFormatDid { 859 didLabels[def.Name] = true 860 } 861 } 862 labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 863 negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 864 } else { 865 l.Debug("failed to fetch label definitions for DID resolution", "err", err) 866 } 867 } 868 869 tf := searchquery.ExtractTextFilters(query) 870 871 searchOpts := models.IssueSearchOptions{ 872 Keywords: tf.Keywords, 873 Phrases: tf.Phrases, 874 RepoAt: f.RepoAt().String(), 875 IsOpen: isOpen, 876 AuthorDid: authorDid, 877 Labels: labels, 878 LabelValues: labelValues, 879 NegatedKeywords: tf.NegatedKeywords, 880 NegatedPhrases: tf.NegatedPhrases, 881 NegatedLabels: negatedLabels, 882 NegatedLabelValues: negatedLabelValues, 883 NegatedAuthorDids: negatedAuthorDids, 884 Page: page, 885 } 886 887 totalIssues := 0 888 if isOpen == nil { 889 totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed 890 } else if *isOpen { 891 totalIssues = f.RepoStats.IssueCount.Open 892 } else { 893 totalIssues = f.RepoStats.IssueCount.Closed 894 } 895 896 repoInfo := rp.repoResolver.GetRepoInfo(r, user) 897 898 var issues []models.Issue 899 900 if searchOpts.HasSearchFilters() { 901 res, err := rp.indexer.Search(r.Context(), searchOpts) 902 if err != nil { 903 l.Error("failed to search for issues", "err", err) 904 return 905 } 906 l.Debug("searched issues with indexer", "count", len(res.Hits)) 907 totalIssues = int(res.Total) 908 909 // update tab counts to reflect filtered results 910 countOpts := searchOpts 911 countOpts.Page = pagination.Page{Limit: 1} 912 countOpts.IsOpen = ptrBool(true) 913 if openRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 914 repoInfo.Stats.IssueCount.Open = int(openRes.Total) 915 } 916 countOpts.IsOpen = ptrBool(false) 917 if closedRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 918 repoInfo.Stats.IssueCount.Closed = int(closedRes.Total) 919 } 920 921 if len(res.Hits) > 0 { 922 issues, err = db.GetIssues( 923 rp.db, 924 orm.FilterIn("id", res.Hits), 925 ) 926 if err != nil { 927 l.Error("failed to get issues", "err", err) 928 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 929 return 930 } 931 } 932 } else { 933 filters := []orm.Filter{ 934 orm.FilterEq("repo_at", f.RepoAt()), 935 } 936 if isOpen != nil { 937 openInt := 0 938 if *isOpen { 939 openInt = 1 940 } 941 filters = append(filters, orm.FilterEq("open", openInt)) 942 } 943 issues, err = db.GetIssuesPaginated( 944 rp.db, 945 page, 946 filters..., 947 ) 948 if err != nil { 949 l.Error("failed to get issues", "err", err) 950 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 951 return 952 } 953 } 954 955 labelDefs, err := db.GetLabelDefinitions( 956 rp.db, 957 orm.FilterIn("at_uri", f.Labels), 958 orm.FilterContains("scope", tangled.RepoIssueNSID), 959 ) 960 if err != nil { 961 l.Error("failed to fetch labels", "err", err) 962 rp.pages.Error503(w) 963 return 964 } 965 966 defs := make(map[string]*models.LabelDefinition) 967 for _, l := range labelDefs { 968 defs[l.AtUri().String()] = &l 969 } 970 971 filterState := "" 972 if isOpen != nil { 973 if *isOpen { 974 filterState = "open" 975 } else { 976 filterState = "closed" 977 } 978 } 979 980 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 981 LoggedInUser: rp.oauth.GetMultiAccountUser(r), 982 RepoInfo: repoInfo, 983 Issues: issues, 984 IssueCount: totalIssues, 985 LabelDefs: defs, 986 FilterState: filterState, 987 FilterQuery: query.String(), 988 Page: page, 989 }) 990} 991 992func ptrBool(b bool) *bool { return &b } 993 994func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 995 l := rp.logger.With("handler", "NewIssue") 996 user := rp.oauth.GetMultiAccountUser(r) 997 998 f, err := rp.repoResolver.Resolve(r) 999 if err != nil { 1000 l.Error("failed to get repo and knot", "err", err) 1001 return 1002 } 1003 1004 switch r.Method { 1005 case http.MethodGet: 1006 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1007 LoggedInUser: user, 1008 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1009 }) 1010 case http.MethodPost: 1011 body := r.FormValue("body") 1012 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 1013 1014 issue := &models.Issue{ 1015 RepoAt: f.RepoAt(), 1016 Rkey: tid.TID(), 1017 Title: r.FormValue("title"), 1018 Body: body, 1019 Open: true, 1020 Did: user.Active.Did, 1021 Created: time.Now(), 1022 Mentions: mentions, 1023 References: references, 1024 Repo: f, 1025 } 1026 1027 if err := rp.validator.ValidateIssue(issue); err != nil { 1028 l.Error("validation error", "err", err) 1029 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 1030 return 1031 } 1032 1033 record := issue.AsRecord() 1034 1035 // create an atproto record 1036 client, err := rp.oauth.AuthorizedClient(r) 1037 if err != nil { 1038 l.Error("failed to get authorized client", "err", err) 1039 rp.pages.Notice(w, "issues", "Failed to create issue.") 1040 return 1041 } 1042 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1043 Collection: tangled.RepoIssueNSID, 1044 Repo: user.Active.Did, 1045 Rkey: issue.Rkey, 1046 Record: &lexutil.LexiconTypeDecoder{ 1047 Val: &record, 1048 }, 1049 }) 1050 if err != nil { 1051 l.Error("failed to create issue", "err", err) 1052 rp.pages.Notice(w, "issues", "Failed to create issue.") 1053 return 1054 } 1055 atUri := resp.Uri 1056 1057 tx, err := rp.db.BeginTx(r.Context(), nil) 1058 if err != nil { 1059 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 1060 return 1061 } 1062 rollback := func() { 1063 err1 := tx.Rollback() 1064 err2 := rollbackRecord(context.Background(), atUri, client) 1065 1066 if errors.Is(err1, sql.ErrTxDone) { 1067 err1 = nil 1068 } 1069 1070 if err := errors.Join(err1, err2); err != nil { 1071 l.Error("failed to rollback txn", "err", err) 1072 } 1073 } 1074 defer rollback() 1075 1076 err = db.PutIssue(tx, issue) 1077 if 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 if err = tx.Commit(); err != nil { 1084 l.Error("failed to create issue", "err", err) 1085 rp.pages.Notice(w, "issues", "Failed to create issue.") 1086 return 1087 } 1088 1089 // everything is successful, do not rollback the atproto record 1090 atUri = "" 1091 1092 rp.notifier.NewIssue(r.Context(), issue, mentions) 1093 1094 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 1095 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 1096 return 1097 } 1098} 1099 1100// this is used to rollback changes made to the PDS 1101// 1102// it is a no-op if the provided ATURI is empty 1103func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1104 if aturi == "" { 1105 return nil 1106 } 1107 1108 parsed := syntax.ATURI(aturi) 1109 1110 collection := parsed.Collection().String() 1111 repo := parsed.Authority().String() 1112 rkey := parsed.RecordKey().String() 1113 1114 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 1115 Collection: collection, 1116 Repo: repo, 1117 Rkey: rkey, 1118 }) 1119 return err 1120}