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