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