Monorepo for Tangled tangled.org
8

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