Monorepo for Tangled tangled.org
10

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