Monorepo for Tangled tangled.org
5

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 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/knotacl" 23 "tangled.org/core/appview/mentions" 24 "tangled.org/core/appview/models" 25 "tangled.org/core/appview/notify" 26 "tangled.org/core/appview/oauth" 27 "tangled.org/core/appview/pages" 28 "tangled.org/core/appview/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/tid" 36) 37 38type Issues struct { 39 oauth *oauth.OAuth 40 repoResolver *reporesolver.RepoResolver 41 acl *knotacl.Service 42 pages *pages.Pages 43 idResolver *idresolver.Resolver 44 mentionsResolver *mentions.Resolver 45 db *db.DB 46 config *config.Config 47 notifier notify.Notifier 48 logger *slog.Logger 49 validator *validator.Validator 50 indexer *issues_indexer.Indexer 51 ogreClient *ogre.Client 52} 53 54func New( 55 oauth *oauth.OAuth, 56 repoResolver *reporesolver.RepoResolver, 57 acl *knotacl.Service, 58 pages *pages.Pages, 59 idResolver *idresolver.Resolver, 60 mentionsResolver *mentions.Resolver, 61 db *db.DB, 62 config *config.Config, 63 notifier notify.Notifier, 64 validator *validator.Validator, 65 indexer *issues_indexer.Indexer, 66 logger *slog.Logger, 67) *Issues { 68 return &Issues{ 69 oauth: oauth, 70 repoResolver: repoResolver, 71 acl: acl, 72 pages: pages, 73 idResolver: idResolver, 74 mentionsResolver: mentionsResolver, 75 db: db, 76 config: config, 77 notifier: notifier, 78 logger: logger, 79 validator: validator, 80 indexer: indexer, 81 ogreClient: ogre.NewClient(config.Ogre.Host), 82 } 83} 84 85func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 86 l := rp.logger.With("handler", "RepoSingleIssue") 87 user := rp.oauth.GetMultiAccountUser(r) 88 f, err := rp.repoResolver.Resolve(r) 89 if err != nil { 90 l.Error("failed to get repo and knot", "err", err) 91 return 92 } 93 94 issue, ok := r.Context().Value("issue").(*models.Issue) 95 if !ok { 96 l.Error("failed to get issue") 97 rp.pages.Error404(w) 98 return 99 } 100 101 if user != nil { 102 repoDid := f.RepoDid 103 userDid := user.Did 104 issueId := issue.IssueId 105 go func() { 106 if err := db.MarkNotificationsReadForIssue(rp.db, userDid, repoDid, issueId); err != nil { 107 l.Error("failed to mark issue notifications as read", "err", err) 108 } 109 }() 110 } 111 112 entities := []syntax.ATURI{issue.AtUri()} 113 for _, c := range issue.Comments { 114 entities = append(entities, c.FeedCommentAtUri()) 115 } 116 reactions, err := db.ListReactionDisplayDataMap(rp.db, entities, 20) 117 if err != nil { 118 l.Error("failed to get reactions", "err", err) 119 } 120 121 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool 122 if user != nil { 123 userReactions, err = db.ListReactionStatusMap(rp.db, entities, syntax.DID(user.Did)) 124 if err != nil { 125 l.Error("failed to get user reactions", "err", err) 126 } 127 } 128 129 backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) 130 if err != nil { 131 l.Error("failed to fetch backlinks", "err", err) 132 rp.pages.Error503(w) 133 return 134 } 135 136 labelDefs, err := db.GetLabelDefinitions( 137 rp.db, 138 orm.FilterIn("at_uri", f.Labels), 139 orm.FilterContains("scope", tangled.RepoIssueNSID), 140 ) 141 if err != nil { 142 l.Error("failed to fetch labels", "err", err) 143 rp.pages.Error503(w) 144 return 145 } 146 147 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 148 if user != nil { 149 participants := issue.Participants() 150 vouchRelationships, err = db.GetVouchRelationshipsBatch(rp.db, syntax.DID(user.Did), participants) 151 if err != nil { 152 l.Error("failed to fetch vouch relationships", "err", err) 153 } 154 } 155 156 defs := make(map[string]*models.LabelDefinition) 157 for _, l := range labelDefs { 158 defs[l.AtUri().String()] = &l 159 } 160 161 err = rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 162 LoggedInUser: user, 163 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 164 Issue: issue, 165 CommentList: models.NewCommentList(issue.Comments), 166 Backlinks: backlinks, 167 Reactions: reactions, 168 UserReacted: userReactions, 169 LabelDefs: defs, 170 VouchRelationships: vouchRelationships, 171 }) 172 if err != nil { 173 l.Error("failed to render issue", "err", err) 174 } 175} 176 177func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 178 l := rp.logger.With("handler", "EditIssue") 179 user := rp.oauth.GetMultiAccountUser(r) 180 181 issue, ok := r.Context().Value("issue").(*models.Issue) 182 if !ok { 183 l.Error("failed to get issue") 184 rp.pages.Error404(w) 185 return 186 } 187 188 switch r.Method { 189 case http.MethodGet: 190 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 191 LoggedInUser: user, 192 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 193 Issue: issue, 194 }) 195 case http.MethodPost: 196 noticeId := "issues" 197 newIssue := issue 198 newIssue.Title = r.FormValue("title") 199 newIssue.Body = r.FormValue("body") 200 newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 201 202 if err := rp.validator.ValidateIssue(newIssue); err != nil { 203 l.Error("validation error", "err", err) 204 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 205 return 206 } 207 208 newRecord := newIssue.AsRecord() 209 210 // edit an atproto record 211 client, err := rp.oauth.AuthorizedClient(r) 212 if err != nil { 213 l.Error("failed to get authorized client", "err", err) 214 rp.pages.Notice(w, noticeId, "Failed to edit issue.") 215 return 216 } 217 218 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 219 if err != nil { 220 l.Error("failed to get record", "err", err) 221 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 222 return 223 } 224 225 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 226 Collection: tangled.RepoIssueNSID, 227 Repo: user.Did, 228 Rkey: newIssue.Rkey, 229 SwapRecord: ex.Cid, 230 Record: &lexutil.LexiconTypeDecoder{ 231 Val: &newRecord, 232 }, 233 }) 234 if err != nil { 235 l.Error("failed to edit record on PDS", "err", err) 236 rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") 237 return 238 } 239 240 // modify on DB -- TODO: transact this cleverly 241 tx, err := rp.db.Begin() 242 if err != nil { 243 l.Error("failed to edit issue on DB", "err", err) 244 rp.pages.Notice(w, noticeId, "Failed to edit issue.") 245 return 246 } 247 defer tx.Rollback() 248 249 err = db.PutIssue(tx, newIssue) 250 if err != nil { 251 l.Error("failed to edit issue", "err", err) 252 rp.pages.Notice(w, "issues", "Failed to edit issue.") 253 return 254 } 255 256 if err = tx.Commit(); err != nil { 257 l.Error("failed to edit issue", "err", err) 258 rp.pages.Notice(w, "issues", "Failed to cedit issue.") 259 return 260 } 261 262 rp.pages.HxRefresh(w) 263 } 264} 265 266func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 267 l := rp.logger.With("handler", "DeleteIssue") 268 noticeId := "issue-actions-error" 269 270 f, err := rp.repoResolver.Resolve(r) 271 if err != nil { 272 l.Error("failed to get repo and knot", "err", err) 273 return 274 } 275 276 issue, ok := r.Context().Value("issue").(*models.Issue) 277 if !ok { 278 l.Error("failed to get issue") 279 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 280 return 281 } 282 l = l.With("did", issue.Did, "rkey", issue.Rkey) 283 284 tx, err := rp.db.Begin() 285 if err != nil { 286 l.Error("failed to start transaction", "err", err) 287 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 288 return 289 } 290 defer tx.Rollback() 291 292 // delete from PDS 293 client, err := rp.oauth.AuthorizedClient(r) 294 if err != nil { 295 l.Error("failed to get authorized client", "err", err) 296 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 297 return 298 } 299 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 300 Collection: tangled.RepoIssueNSID, 301 Repo: issue.Did, 302 Rkey: issue.Rkey, 303 }) 304 if err != nil { 305 // TODO: transact this better 306 l.Error("failed to delete issue from PDS", "err", err) 307 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 308 return 309 } 310 311 // delete from db 312 if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { 313 l.Error("failed to delete issue", "err", err) 314 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 315 return 316 } 317 tx.Commit() 318 319 rp.notifier.DeleteIssue(r.Context(), issue) 320 321 // return to all issues page 322 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 323 rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues") 324} 325 326func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 327 l := rp.logger.With("handler", "CloseIssue") 328 user := rp.oauth.GetMultiAccountUser(r) 329 f, err := rp.repoResolver.Resolve(r) 330 if err != nil { 331 l.Error("failed to get repo and knot", "err", err) 332 return 333 } 334 335 issue, ok := r.Context().Value("issue").(*models.Issue) 336 if !ok { 337 l.Error("failed to get issue") 338 rp.pages.Error404(w) 339 return 340 } 341 342 roles := rp.acl.RolesInRepo(r.Context(), f, user.Did) 343 isRepoOwner := roles.IsOwner() 344 isCollaborator := roles.IsCollaborator() 345 isIssueOwner := user.Did == issue.Did 346 347 // TODO: make this more granular 348 if isIssueOwner || isRepoOwner || isCollaborator { 349 err = db.CloseIssues( 350 rp.db, 351 orm.FilterEq("id", issue.Id), 352 ) 353 if err != nil { 354 l.Error("failed to close issue", "err", err) 355 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 356 return 357 } 358 // change the issue state (this will pass down to the notifiers) 359 issue.Open = false 360 361 // notify about the issue closure 362 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 363 364 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 365 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 366 return 367 } else { 368 l.Error("user is not permitted to close issue") 369 http.Error(w, "for biden", http.StatusUnauthorized) 370 return 371 } 372} 373 374func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 375 l := rp.logger.With("handler", "ReopenIssue") 376 user := rp.oauth.GetMultiAccountUser(r) 377 f, err := rp.repoResolver.Resolve(r) 378 if err != nil { 379 l.Error("failed to get repo and knot", "err", err) 380 return 381 } 382 383 issue, ok := r.Context().Value("issue").(*models.Issue) 384 if !ok { 385 l.Error("failed to get issue") 386 rp.pages.Error404(w) 387 return 388 } 389 390 roles := rp.acl.RolesInRepo(r.Context(), f, user.Did) 391 isRepoOwner := roles.IsOwner() 392 isCollaborator := roles.IsCollaborator() 393 isIssueOwner := user.Did == issue.Did 394 395 if isCollaborator || isRepoOwner || isIssueOwner { 396 err := db.ReopenIssues( 397 rp.db, 398 orm.FilterEq("id", issue.Id), 399 ) 400 if err != nil { 401 l.Error("failed to reopen issue", "err", err) 402 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 403 return 404 } 405 // change the issue state (this will pass down to the notifiers) 406 issue.Open = true 407 408 // notify about the issue reopen 409 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 410 411 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 412 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 413 return 414 } else { 415 l.Error("user is not the owner of the repo") 416 http.Error(w, "forbidden", http.StatusUnauthorized) 417 return 418 } 419} 420 421func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 422 l := rp.logger.With("handler", "RepoIssues") 423 424 params := r.URL.Query() 425 page := pagination.FromContext(r.Context()) 426 427 user := rp.oauth.GetMultiAccountUser(r) 428 f, err := rp.repoResolver.Resolve(r) 429 if err != nil { 430 l.Error("failed to get repo and knot", "err", err) 431 return 432 } 433 434 query := searchquery.Parse(params.Get("q")) 435 436 var isOpen *bool 437 if urlState := params.Get("state"); urlState != "" { 438 switch urlState { 439 case "open": 440 isOpen = ptrBool(true) 441 case "closed": 442 isOpen = ptrBool(false) 443 } 444 query.Set("state", urlState) 445 } else if queryState := query.Get("state"); queryState != nil { 446 switch *queryState { 447 case "open": 448 isOpen = ptrBool(true) 449 case "closed": 450 isOpen = ptrBool(false) 451 } 452 } else if _, hasQ := params["q"]; !hasQ { 453 // no q param at all -- default to open 454 isOpen = ptrBool(true) 455 query.Set("state", "open") 456 } 457 458 resolve := func(ctx context.Context, ident string) (string, error) { 459 id, err := rp.idResolver.ResolveIdent(ctx, ident) 460 if err != nil { 461 return "", err 462 } 463 return id.DID.String(), nil 464 } 465 466 authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 467 468 labels := query.GetAll("label") 469 negatedLabels := query.GetAllNegated("label") 470 labelValues := query.GetDynamicTags() 471 negatedLabelValues := query.GetNegatedDynamicTags() 472 473 // resolve DID-format label values: if a dynamic tag's label 474 // definition has format "did", resolve the handle to a DID 475 if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 476 labelDefs, err := db.GetLabelDefinitions( 477 rp.db, 478 orm.FilterIn("at_uri", f.Labels), 479 orm.FilterContains("scope", tangled.RepoIssueNSID), 480 ) 481 if err == nil { 482 didLabels := make(map[string]bool) 483 for _, def := range labelDefs { 484 if def.ValueType.Format == models.ValueTypeFormatDid { 485 didLabels[def.Name] = true 486 } 487 } 488 labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 489 negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 490 } else { 491 l.Debug("failed to fetch label definitions for DID resolution", "err", err) 492 } 493 } 494 495 tf := searchquery.ExtractTextFilters(query) 496 497 searchOpts := models.IssueSearchOptions{ 498 Keywords: tf.Keywords, 499 Phrases: tf.Phrases, 500 RepoDid: f.RepoDid, 501 IsOpen: isOpen, 502 AuthorDid: authorDid, 503 Labels: labels, 504 LabelValues: labelValues, 505 NegatedKeywords: tf.NegatedKeywords, 506 NegatedPhrases: tf.NegatedPhrases, 507 NegatedLabels: negatedLabels, 508 NegatedLabelValues: negatedLabelValues, 509 NegatedAuthorDids: negatedAuthorDids, 510 Page: page, 511 } 512 513 totalIssues := 0 514 if isOpen == nil { 515 totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed 516 } else if *isOpen { 517 totalIssues = f.RepoStats.IssueCount.Open 518 } else { 519 totalIssues = f.RepoStats.IssueCount.Closed 520 } 521 522 repoInfo := rp.repoResolver.GetRepoInfo(r, user) 523 524 var issues []models.Issue 525 526 if searchOpts.HasSearchFilters() { 527 res, err := rp.indexer.Search(r.Context(), searchOpts) 528 if err != nil { 529 l.Error("failed to search for issues", "err", err) 530 return 531 } 532 l.Debug("searched issues with indexer", "count", len(res.Hits)) 533 totalIssues = int(res.Total) 534 535 // update tab counts to reflect filtered results 536 countOpts := searchOpts 537 countOpts.Page = pagination.Page{Limit: 1} 538 countOpts.IsOpen = ptrBool(true) 539 if openRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 540 repoInfo.Stats.IssueCount.Open = int(openRes.Total) 541 } 542 countOpts.IsOpen = ptrBool(false) 543 if closedRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 544 repoInfo.Stats.IssueCount.Closed = int(closedRes.Total) 545 } 546 547 if len(res.Hits) > 0 { 548 issues, err = db.GetIssues( 549 rp.db, 550 orm.FilterIn("id", res.Hits), 551 ) 552 if err != nil { 553 l.Error("failed to get issues", "err", err) 554 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 555 return 556 } 557 } 558 } else { 559 filters := []orm.Filter{ 560 orm.FilterEq("repo_did", f.RepoDid), 561 } 562 if isOpen != nil { 563 openInt := 0 564 if *isOpen { 565 openInt = 1 566 } 567 filters = append(filters, orm.FilterEq("open", openInt)) 568 } 569 issues, err = db.GetIssuesPaginated( 570 rp.db, 571 page, 572 filters..., 573 ) 574 if err != nil { 575 l.Error("failed to get issues", "err", err) 576 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 577 return 578 } 579 } 580 581 labelDefs, err := db.GetLabelDefinitions( 582 rp.db, 583 orm.FilterIn("at_uri", f.Labels), 584 orm.FilterContains("scope", tangled.RepoIssueNSID), 585 ) 586 if err != nil { 587 l.Error("failed to fetch labels", "err", err) 588 rp.pages.Error503(w) 589 return 590 } 591 592 defs := make(map[string]*models.LabelDefinition) 593 for _, l := range labelDefs { 594 defs[l.AtUri().String()] = &l 595 } 596 597 filterState := "" 598 if isOpen != nil { 599 if *isOpen { 600 filterState = "open" 601 } else { 602 filterState = "closed" 603 } 604 } 605 606 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 607 if user != nil { 608 dids := make([]syntax.DID, len(issues)) 609 for i, u := range issues { 610 dids[i] = syntax.DID(u.Did) 611 } 612 vouchRelationships, err = db.GetVouchRelationshipsBatch(rp.db, syntax.DID(user.Did), dids) 613 if err != nil { 614 l.Error("failed to fetch vouch relationships", "err", err) 615 } 616 } 617 baseFilterParts := make([]string, 0, len(query.Items())) 618 for _, item := range query.Items() { 619 if item.Kind == searchquery.KindTagValue { 620 if item.Key == "label" || !searchquery.KnownTags[item.Key] { 621 continue 622 } 623 } 624 baseFilterParts = append(baseFilterParts, item.Raw) 625 } 626 baseFilterQuery := strings.Join(baseFilterParts, " ") 627 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 628 LoggedInUser: rp.oauth.GetMultiAccountUser(r), 629 RepoInfo: repoInfo, 630 Issues: issues, 631 IssueCount: totalIssues, 632 LabelDefs: defs, 633 FilterState: filterState, 634 FilterQuery: query.String(), 635 BaseFilterQuery: baseFilterQuery, 636 Page: page, 637 VouchRelationships: vouchRelationships, 638 }) 639} 640 641func ptrBool(b bool) *bool { return &b } 642 643func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 644 l := rp.logger.With("handler", "NewIssue") 645 user := rp.oauth.GetMultiAccountUser(r) 646 647 f, err := rp.repoResolver.Resolve(r) 648 if err != nil { 649 l.Error("failed to get repo and knot", "err", err) 650 return 651 } 652 653 switch r.Method { 654 case http.MethodGet: 655 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 656 LoggedInUser: user, 657 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 658 }) 659 case http.MethodPost: 660 body := r.FormValue("body") 661 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 662 663 issue := &models.Issue{ 664 RepoDid: syntax.DID(f.RepoDid), 665 Rkey: tid.TID(), 666 Title: r.FormValue("title"), 667 Body: body, 668 Open: true, 669 Did: user.Did, 670 Created: time.Now(), 671 Mentions: mentions, 672 References: references, 673 Repo: f, 674 } 675 676 if err := rp.validator.ValidateIssue(issue); err != nil { 677 l.Error("validation error", "err", err) 678 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 679 return 680 } 681 682 record := issue.AsRecord() 683 684 // create an atproto record 685 client, err := rp.oauth.AuthorizedClient(r) 686 if err != nil { 687 l.Error("failed to get authorized client", "err", err) 688 rp.pages.Notice(w, "issues", "Failed to create issue.") 689 return 690 } 691 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 692 Collection: tangled.RepoIssueNSID, 693 Repo: user.Did, 694 Rkey: issue.Rkey, 695 Record: &lexutil.LexiconTypeDecoder{ 696 Val: &record, 697 }, 698 }) 699 if err != nil { 700 l.Error("failed to create issue", "err", err) 701 rp.pages.Notice(w, "issues", "Failed to create issue.") 702 return 703 } 704 atUri := resp.Uri 705 706 tx, err := rp.db.BeginTx(r.Context(), nil) 707 if err != nil { 708 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 709 return 710 } 711 rollback := func() { 712 err1 := tx.Rollback() 713 err2 := rollbackRecord(context.Background(), atUri, client) 714 715 if errors.Is(err1, sql.ErrTxDone) { 716 err1 = nil 717 } 718 719 if err := errors.Join(err1, err2); err != nil { 720 l.Error("failed to rollback txn", "err", err) 721 } 722 } 723 defer rollback() 724 725 err = db.PutIssue(tx, issue) 726 if err != nil { 727 l.Error("failed to create issue", "err", err) 728 rp.pages.Notice(w, "issues", "Failed to create issue.") 729 return 730 } 731 732 if err = tx.Commit(); err != nil { 733 l.Error("failed to create issue", "err", err) 734 rp.pages.Notice(w, "issues", "Failed to create issue.") 735 return 736 } 737 738 // everything is successful, do not rollback the atproto record 739 atUri = "" 740 741 rp.notifier.NewIssue(r.Context(), issue, mentions) 742 743 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 744 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 745 return 746 } 747} 748 749// this is used to rollback changes made to the PDS 750// 751// it is a no-op if the provided ATURI is empty 752func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 753 if aturi == "" { 754 return nil 755 } 756 757 parsed := syntax.ATURI(aturi) 758 759 collection := parsed.Collection().String() 760 repo := parsed.Authority().String() 761 rkey := parsed.RecordKey().String() 762 763 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 764 Collection: collection, 765 Repo: repo, 766 Rkey: rkey, 767 }) 768 return err 769}