Monorepo for Tangled tangled.org
9

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