Monorepo for Tangled tangled.org
6

Configure Feed

Select the types of activity you want to include in your feed.

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