Monorepo for Tangled tangled.org
4

Configure Feed

Select the types of activity you want to include in your feed.

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