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