this repo has no description
1package issues 2 3import ( 4 "context" 5 "database/sql" 6 "errors" 7 "fmt" 8 "log" 9 "log/slog" 10 "net/http" 11 "slices" 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 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.sh/tangled.sh/core/api/tangled" 20 "tangled.sh/tangled.sh/core/appview/config" 21 "tangled.sh/tangled.sh/core/appview/db" 22 "tangled.sh/tangled.sh/core/appview/notify" 23 "tangled.sh/tangled.sh/core/appview/oauth" 24 "tangled.sh/tangled.sh/core/appview/pages" 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 "tangled.sh/tangled.sh/core/appview/validator" 28 "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 "tangled.sh/tangled.sh/core/idresolver" 30 tlog "tangled.sh/tangled.sh/core/log" 31 "tangled.sh/tangled.sh/core/tid" 32) 33 34type Issues struct { 35 oauth *oauth.OAuth 36 repoResolver *reporesolver.RepoResolver 37 pages *pages.Pages 38 idResolver *idresolver.Resolver 39 db *db.DB 40 config *config.Config 41 notifier notify.Notifier 42 logger *slog.Logger 43 validator *validator.Validator 44} 45 46func New( 47 oauth *oauth.OAuth, 48 repoResolver *reporesolver.RepoResolver, 49 pages *pages.Pages, 50 idResolver *idresolver.Resolver, 51 db *db.DB, 52 config *config.Config, 53 notifier notify.Notifier, 54 validator *validator.Validator, 55) *Issues { 56 return &Issues{ 57 oauth: oauth, 58 repoResolver: repoResolver, 59 pages: pages, 60 idResolver: idResolver, 61 db: db, 62 config: config, 63 notifier: notifier, 64 logger: tlog.New("issues"), 65 validator: validator, 66 } 67} 68 69func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 70 l := rp.logger.With("handler", "RepoSingleIssue") 71 user := rp.oauth.GetUser(r) 72 f, err := rp.repoResolver.Resolve(r) 73 if err != nil { 74 log.Println("failed to get repo and knot", err) 75 return 76 } 77 78 issue, ok := r.Context().Value("issue").(*db.Issue) 79 if !ok { 80 l.Error("failed to get issue") 81 rp.pages.Error404(w) 82 return 83 } 84 85 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 86 if err != nil { 87 l.Error("failed to get issue reactions", "err", err) 88 } 89 90 userReactions := map[db.ReactionKind]bool{} 91 if user != nil { 92 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 } 94 95 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 96 LoggedInUser: user, 97 RepoInfo: f.RepoInfo(user), 98 Issue: issue, 99 CommentList: issue.CommentList(), 100 OrderedReactionKinds: db.OrderedReactionKinds, 101 Reactions: reactionCountMap, 102 UserReacted: userReactions, 103 }) 104} 105 106func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 107 l := rp.logger.With("handler", "EditIssue") 108 user := rp.oauth.GetUser(r) 109 f, err := rp.repoResolver.Resolve(r) 110 if err != nil { 111 log.Println("failed to get repo and knot", err) 112 return 113 } 114 115 issue, ok := r.Context().Value("issue").(*db.Issue) 116 if !ok { 117 l.Error("failed to get issue") 118 rp.pages.Error404(w) 119 return 120 } 121 122 switch r.Method { 123 case http.MethodGet: 124 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 125 LoggedInUser: user, 126 RepoInfo: f.RepoInfo(user), 127 Issue: issue, 128 }) 129 case http.MethodPost: 130 noticeId := "issues" 131 newIssue := issue 132 newIssue.Title = r.FormValue("title") 133 newIssue.Body = r.FormValue("body") 134 135 if err := rp.validator.ValidateIssue(newIssue); err != nil { 136 l.Error("validation error", "err", err) 137 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 138 return 139 } 140 141 newRecord := newIssue.AsRecord() 142 143 // edit an atproto record 144 client, err := rp.oauth.AuthorizedClient(r) 145 if err != nil { 146 l.Error("failed to get authorized client", "err", err) 147 rp.pages.Notice(w, noticeId, "Failed to edit issue.") 148 return 149 } 150 151 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 152 if err != nil { 153 l.Error("failed to get record", "err", err) 154 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 155 return 156 } 157 158 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 159 Collection: tangled.RepoIssueNSID, 160 Repo: user.Did, 161 Rkey: newIssue.Rkey, 162 SwapRecord: ex.Cid, 163 Record: &lexutil.LexiconTypeDecoder{ 164 Val: &newRecord, 165 }, 166 }) 167 if err != nil { 168 l.Error("failed to edit record on PDS", "err", err) 169 rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") 170 return 171 } 172 173 // modify on DB -- TODO: transact this cleverly 174 tx, err := rp.db.Begin() 175 if err != nil { 176 l.Error("failed to edit issue on DB", "err", err) 177 rp.pages.Notice(w, noticeId, "Failed to edit issue.") 178 return 179 } 180 defer tx.Rollback() 181 182 err = db.PutIssue(tx, newIssue) 183 if err != nil { 184 log.Println("failed to edit issue", err) 185 rp.pages.Notice(w, "issues", "Failed to edit issue.") 186 return 187 } 188 189 if err = tx.Commit(); err != nil { 190 l.Error("failed to edit issue", "err", err) 191 rp.pages.Notice(w, "issues", "Failed to cedit issue.") 192 return 193 } 194 195 rp.pages.HxRefresh(w) 196 } 197} 198 199func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 200 l := rp.logger.With("handler", "DeleteIssue") 201 user := rp.oauth.GetUser(r) 202 f, err := rp.repoResolver.Resolve(r) 203 if err != nil { 204 log.Println("failed to get repo and knot", err) 205 return 206 } 207 208 issue, ok := r.Context().Value("issue").(*db.Issue) 209 if !ok { 210 l.Error("failed to get issue") 211 rp.pages.Error404(w) 212 return 213 } 214 215 switch r.Method { 216 case http.MethodGet: 217 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 218 LoggedInUser: user, 219 RepoInfo: f.RepoInfo(user), 220 Issue: issue, 221 }) 222 case http.MethodPost: 223 } 224} 225 226func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 227 l := rp.logger.With("handler", "CloseIssue") 228 user := rp.oauth.GetUser(r) 229 f, err := rp.repoResolver.Resolve(r) 230 if err != nil { 231 l.Error("failed to get repo and knot", "err", err) 232 return 233 } 234 235 issue, ok := r.Context().Value("issue").(*db.Issue) 236 if !ok { 237 l.Error("failed to get issue") 238 rp.pages.Error404(w) 239 return 240 } 241 242 collaborators, err := f.Collaborators(r.Context()) 243 if err != nil { 244 log.Println("failed to fetch repo collaborators: %w", err) 245 } 246 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 247 return user.Did == collab.Did 248 }) 249 isIssueOwner := user.Did == issue.Did 250 251 // TODO: make this more granular 252 if isIssueOwner || isCollaborator { 253 err = db.CloseIssues( 254 rp.db, 255 db.FilterEq("id", issue.Id), 256 ) 257 if err != nil { 258 log.Println("failed to close issue", err) 259 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 260 return 261 } 262 263 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 264 return 265 } else { 266 log.Println("user is not permitted to close issue") 267 http.Error(w, "for biden", http.StatusUnauthorized) 268 return 269 } 270} 271 272func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 273 l := rp.logger.With("handler", "ReopenIssue") 274 user := rp.oauth.GetUser(r) 275 f, err := rp.repoResolver.Resolve(r) 276 if err != nil { 277 log.Println("failed to get repo and knot", err) 278 return 279 } 280 281 issue, ok := r.Context().Value("issue").(*db.Issue) 282 if !ok { 283 l.Error("failed to get issue") 284 rp.pages.Error404(w) 285 return 286 } 287 288 collaborators, err := f.Collaborators(r.Context()) 289 if err != nil { 290 log.Println("failed to fetch repo collaborators: %w", err) 291 } 292 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 293 return user.Did == collab.Did 294 }) 295 isIssueOwner := user.Did == issue.Did 296 297 if isCollaborator || isIssueOwner { 298 err := db.ReopenIssues( 299 rp.db, 300 db.FilterEq("id", issue.Id), 301 ) 302 if err != nil { 303 log.Println("failed to reopen issue", err) 304 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 305 return 306 } 307 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 308 return 309 } else { 310 log.Println("user is not the owner of the repo") 311 http.Error(w, "forbidden", http.StatusUnauthorized) 312 return 313 } 314} 315 316func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 317 l := rp.logger.With("handler", "NewIssueComment") 318 user := rp.oauth.GetUser(r) 319 f, err := rp.repoResolver.Resolve(r) 320 if err != nil { 321 l.Error("failed to get repo and knot", "err", err) 322 return 323 } 324 325 issue, ok := r.Context().Value("issue").(*db.Issue) 326 if !ok { 327 l.Error("failed to get issue") 328 rp.pages.Error404(w) 329 return 330 } 331 332 body := r.FormValue("body") 333 if body == "" { 334 rp.pages.Notice(w, "issue", "Body is required") 335 return 336 } 337 338 replyToUri := r.FormValue("reply-to") 339 var replyTo *string 340 if replyToUri != "" { 341 uri, err := syntax.ParseATURI(replyToUri) 342 if err != nil { 343 l.Error("failed to get parse replyTo", "err", err, "replyTo", replyToUri) 344 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 345 return 346 } 347 if uri.Collection() != tangled.RepoIssueCommentNSID { 348 l.Error("invalid replyTo collection", "collection", uri.Collection()) 349 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 350 return 351 } 352 u := uri.String() 353 replyTo = &u 354 } 355 356 comment := db.IssueComment{ 357 Did: user.Did, 358 Rkey: tid.TID(), 359 IssueAt: issue.AtUri().String(), 360 ReplyTo: replyTo, 361 Body: body, 362 Created: time.Now(), 363 } 364 if err = rp.validator.ValidateIssueComment(&comment); err != nil { 365 l.Error("failed to validate comment", "err", err) 366 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 367 return 368 } 369 record := comment.AsRecord() 370 371 client, err := rp.oauth.AuthorizedClient(r) 372 if err != nil { 373 l.Error("failed to get authorized client", "err", err) 374 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 375 return 376 } 377 378 // create a record first 379 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 380 Collection: tangled.RepoIssueCommentNSID, 381 Repo: comment.Did, 382 Rkey: comment.Rkey, 383 Record: &lexutil.LexiconTypeDecoder{ 384 Val: &record, 385 }, 386 }) 387 if err != nil { 388 l.Error("failed to create comment", "err", err) 389 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 390 return 391 } 392 atUri := resp.Uri 393 defer func() { 394 if err := rollbackRecord(context.Background(), atUri, client); err != nil { 395 l.Error("rollback failed", "err", err) 396 } 397 }() 398 399 commentId, err := db.AddIssueComment(rp.db, comment) 400 if err != nil { 401 l.Error("failed to create comment", "err", err) 402 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 403 return 404 } 405 406 // reset atUri to make rollback a no-op 407 atUri = "" 408 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 409} 410 411func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 412 l := rp.logger.With("handler", "IssueComment") 413 user := rp.oauth.GetUser(r) 414 f, err := rp.repoResolver.Resolve(r) 415 if err != nil { 416 l.Error("failed to get repo and knot", "err", err) 417 return 418 } 419 420 issue, ok := r.Context().Value("issue").(*db.Issue) 421 if !ok { 422 l.Error("failed to get issue") 423 rp.pages.Error404(w) 424 return 425 } 426 427 commentId := chi.URLParam(r, "commentId") 428 comments, err := db.GetIssueComments( 429 rp.db, 430 db.FilterEq("id", commentId), 431 ) 432 if err != nil { 433 l.Error("failed to fetch comment", "id", commentId) 434 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 435 return 436 } 437 if len(comments) != 1 { 438 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 439 http.Error(w, "invalid comment id", http.StatusBadRequest) 440 return 441 } 442 comment := comments[0] 443 444 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 445 LoggedInUser: user, 446 RepoInfo: f.RepoInfo(user), 447 Issue: issue, 448 Comment: &comment, 449 }) 450} 451 452func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 453 l := rp.logger.With("handler", "EditIssueComment") 454 user := rp.oauth.GetUser(r) 455 f, err := rp.repoResolver.Resolve(r) 456 if err != nil { 457 l.Error("failed to get repo and knot", "err", err) 458 return 459 } 460 461 issue, ok := r.Context().Value("issue").(*db.Issue) 462 if !ok { 463 l.Error("failed to get issue") 464 rp.pages.Error404(w) 465 return 466 } 467 468 commentId := chi.URLParam(r, "commentId") 469 comments, err := db.GetIssueComments( 470 rp.db, 471 db.FilterEq("id", commentId), 472 ) 473 if err != nil { 474 l.Error("failed to fetch comment", "id", commentId) 475 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 476 return 477 } 478 if len(comments) != 1 { 479 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 480 http.Error(w, "invalid comment id", http.StatusBadRequest) 481 return 482 } 483 comment := comments[0] 484 485 if comment.Did != user.Did { 486 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 487 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 488 return 489 } 490 491 switch r.Method { 492 case http.MethodGet: 493 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 494 LoggedInUser: user, 495 RepoInfo: f.RepoInfo(user), 496 Issue: issue, 497 Comment: &comment, 498 }) 499 case http.MethodPost: 500 // extract form value 501 newBody := r.FormValue("body") 502 client, err := rp.oauth.AuthorizedClient(r) 503 if err != nil { 504 log.Println("failed to get authorized client", err) 505 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 506 return 507 } 508 509 now := time.Now() 510 newComment := comment 511 newComment.Body = newBody 512 newComment.Edited = &now 513 record := newComment.AsRecord() 514 515 _, err = db.AddIssueComment(rp.db, newComment) 516 if err != nil { 517 log.Println("failed to perferom update-description query", err) 518 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 519 return 520 } 521 522 // rkey is optional, it was introduced later 523 if newComment.Rkey != "" { 524 // update the record on pds 525 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 526 if err != nil { 527 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 528 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 529 return 530 } 531 532 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 533 Collection: tangled.RepoIssueCommentNSID, 534 Repo: user.Did, 535 Rkey: newComment.Rkey, 536 SwapRecord: ex.Cid, 537 Record: &lexutil.LexiconTypeDecoder{ 538 Val: &record, 539 }, 540 }) 541 if err != nil { 542 l.Error("failed to update record on PDS", "err", err) 543 } 544 } 545 546 // return new comment body with htmx 547 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 548 LoggedInUser: user, 549 RepoInfo: f.RepoInfo(user), 550 Issue: issue, 551 Comment: &newComment, 552 }) 553 } 554} 555 556func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 557 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 558 user := rp.oauth.GetUser(r) 559 f, err := rp.repoResolver.Resolve(r) 560 if err != nil { 561 l.Error("failed to get repo and knot", "err", err) 562 return 563 } 564 565 issue, ok := r.Context().Value("issue").(*db.Issue) 566 if !ok { 567 l.Error("failed to get issue") 568 rp.pages.Error404(w) 569 return 570 } 571 572 commentId := chi.URLParam(r, "commentId") 573 comments, err := db.GetIssueComments( 574 rp.db, 575 db.FilterEq("id", commentId), 576 ) 577 if err != nil { 578 l.Error("failed to fetch comment", "id", commentId) 579 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 580 return 581 } 582 if len(comments) != 1 { 583 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 584 http.Error(w, "invalid comment id", http.StatusBadRequest) 585 return 586 } 587 comment := comments[0] 588 589 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 590 LoggedInUser: user, 591 RepoInfo: f.RepoInfo(user), 592 Issue: issue, 593 Comment: &comment, 594 }) 595} 596 597func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 598 l := rp.logger.With("handler", "ReplyIssueComment") 599 user := rp.oauth.GetUser(r) 600 f, err := rp.repoResolver.Resolve(r) 601 if err != nil { 602 l.Error("failed to get repo and knot", "err", err) 603 return 604 } 605 606 issue, ok := r.Context().Value("issue").(*db.Issue) 607 if !ok { 608 l.Error("failed to get issue") 609 rp.pages.Error404(w) 610 return 611 } 612 613 commentId := chi.URLParam(r, "commentId") 614 comments, err := db.GetIssueComments( 615 rp.db, 616 db.FilterEq("id", commentId), 617 ) 618 if err != nil { 619 l.Error("failed to fetch comment", "id", commentId) 620 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 621 return 622 } 623 if len(comments) != 1 { 624 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 625 http.Error(w, "invalid comment id", http.StatusBadRequest) 626 return 627 } 628 comment := comments[0] 629 630 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 631 LoggedInUser: user, 632 RepoInfo: f.RepoInfo(user), 633 Issue: issue, 634 Comment: &comment, 635 }) 636} 637 638func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 639 l := rp.logger.With("handler", "DeleteIssueComment") 640 user := rp.oauth.GetUser(r) 641 f, err := rp.repoResolver.Resolve(r) 642 if err != nil { 643 l.Error("failed to get repo and knot", "err", err) 644 return 645 } 646 647 issue, ok := r.Context().Value("issue").(*db.Issue) 648 if !ok { 649 l.Error("failed to get issue") 650 rp.pages.Error404(w) 651 return 652 } 653 654 commentId := chi.URLParam(r, "commentId") 655 comments, err := db.GetIssueComments( 656 rp.db, 657 db.FilterEq("id", commentId), 658 ) 659 if err != nil { 660 l.Error("failed to fetch comment", "id", commentId) 661 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 662 return 663 } 664 if len(comments) != 1 { 665 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 666 http.Error(w, "invalid comment id", http.StatusBadRequest) 667 return 668 } 669 comment := comments[0] 670 671 if comment.Did != user.Did { 672 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 673 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 674 return 675 } 676 677 if comment.Deleted != nil { 678 http.Error(w, "comment already deleted", http.StatusBadRequest) 679 return 680 } 681 682 // optimistic deletion 683 deleted := time.Now() 684 err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 685 if err != nil { 686 l.Error("failed to delete comment", "err", err) 687 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 688 return 689 } 690 691 // delete from pds 692 if comment.Rkey != "" { 693 client, err := rp.oauth.AuthorizedClient(r) 694 if err != nil { 695 log.Println("failed to get authorized client", err) 696 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 697 return 698 } 699 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 700 Collection: tangled.GraphFollowNSID, 701 Repo: user.Did, 702 Rkey: comment.Rkey, 703 }) 704 if err != nil { 705 log.Println(err) 706 } 707 } 708 709 // optimistic update for htmx 710 comment.Body = "" 711 comment.Deleted = &deleted 712 713 // htmx fragment of comment after deletion 714 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 715 LoggedInUser: user, 716 RepoInfo: f.RepoInfo(user), 717 Issue: issue, 718 Comment: &comment, 719 }) 720} 721 722func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 723 params := r.URL.Query() 724 state := params.Get("state") 725 isOpen := true 726 switch state { 727 case "open": 728 isOpen = true 729 case "closed": 730 isOpen = false 731 default: 732 isOpen = true 733 } 734 735 page, ok := r.Context().Value("page").(pagination.Page) 736 if !ok { 737 log.Println("failed to get page") 738 page = pagination.FirstPage() 739 } 740 741 user := rp.oauth.GetUser(r) 742 f, err := rp.repoResolver.Resolve(r) 743 if err != nil { 744 log.Println("failed to get repo and knot", err) 745 return 746 } 747 748 openVal := 0 749 if isOpen { 750 openVal = 1 751 } 752 issues, err := db.GetIssuesPaginated( 753 rp.db, 754 page, 755 db.FilterEq("repo_at", f.RepoAt()), 756 db.FilterEq("open", openVal), 757 ) 758 if err != nil { 759 log.Println("failed to get issues", err) 760 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 761 return 762 } 763 764 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 765 LoggedInUser: rp.oauth.GetUser(r), 766 RepoInfo: f.RepoInfo(user), 767 Issues: issues, 768 FilteringByOpen: isOpen, 769 Page: page, 770 }) 771} 772 773func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 774 l := rp.logger.With("handler", "NewIssue") 775 user := rp.oauth.GetUser(r) 776 777 f, err := rp.repoResolver.Resolve(r) 778 if err != nil { 779 l.Error("failed to get repo and knot", "err", err) 780 return 781 } 782 783 switch r.Method { 784 case http.MethodGet: 785 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 786 LoggedInUser: user, 787 RepoInfo: f.RepoInfo(user), 788 }) 789 case http.MethodPost: 790 issue := &db.Issue{ 791 RepoAt: f.RepoAt(), 792 Rkey: tid.TID(), 793 Title: r.FormValue("title"), 794 Body: r.FormValue("body"), 795 Did: user.Did, 796 Created: time.Now(), 797 } 798 799 if err := rp.validator.ValidateIssue(issue); err != nil { 800 l.Error("validation error", "err", err) 801 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 802 return 803 } 804 805 record := issue.AsRecord() 806 807 // create an atproto record 808 client, err := rp.oauth.AuthorizedClient(r) 809 if err != nil { 810 l.Error("failed to get authorized client", "err", err) 811 rp.pages.Notice(w, "issues", "Failed to create issue.") 812 return 813 } 814 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 815 Collection: tangled.RepoIssueNSID, 816 Repo: user.Did, 817 Rkey: issue.Rkey, 818 Record: &lexutil.LexiconTypeDecoder{ 819 Val: &record, 820 }, 821 }) 822 if err != nil { 823 l.Error("failed to create issue", "err", err) 824 rp.pages.Notice(w, "issues", "Failed to create issue.") 825 return 826 } 827 atUri := resp.Uri 828 829 tx, err := rp.db.BeginTx(r.Context(), nil) 830 if err != nil { 831 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 832 return 833 } 834 rollback := func() { 835 err1 := tx.Rollback() 836 err2 := rollbackRecord(context.Background(), atUri, client) 837 838 if errors.Is(err1, sql.ErrTxDone) { 839 err1 = nil 840 } 841 842 if err := errors.Join(err1, err2); err != nil { 843 l.Error("failed to rollback txn", "err", err) 844 } 845 } 846 defer rollback() 847 848 err = db.PutIssue(tx, issue) 849 if err != nil { 850 log.Println("failed to create issue", err) 851 rp.pages.Notice(w, "issues", "Failed to create issue.") 852 return 853 } 854 855 if err = tx.Commit(); err != nil { 856 log.Println("failed to create issue", err) 857 rp.pages.Notice(w, "issues", "Failed to create issue.") 858 return 859 } 860 861 // everything is successful, do not rollback the atproto record 862 atUri = "" 863 rp.notifier.NewIssue(r.Context(), issue) 864 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 865 return 866 } 867} 868 869// this is used to rollback changes made to the PDS 870// 871// it is a no-op if the provided ATURI is empty 872func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 873 if aturi == "" { 874 return nil 875 } 876 877 parsed := syntax.ATURI(aturi) 878 879 collection := parsed.Collection().String() 880 repo := parsed.Authority().String() 881 rkey := parsed.RecordKey().String() 882 883 _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 884 Collection: collection, 885 Repo: repo, 886 Rkey: rkey, 887 }) 888 return err 889}