Monorepo for Tangled
0
fork

Configure Feed

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

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