This repository has no description
0

Configure Feed

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

core / appview / issues / issues.go
23 kB 816 lines
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 if user == nil { 333 l.Error("nil user") 334 rp.pages.Notice(w, "issue-action", "You must be logged in to close this issue.") 335 return 336 } 337 f, err := rp.repoResolver.Resolve(r) 338 if err != nil { 339 l.Error("failed to get repo and knot", "err", err) 340 return 341 } 342 343 issue, ok := r.Context().Value("issue").(*models.Issue) 344 if !ok { 345 l.Error("failed to get issue") 346 rp.pages.Error404(w) 347 return 348 } 349 350 roles := rp.acl.RolesInRepo(r.Context(), f, user.Did) 351 isRepoOwner := roles.IsOwner() 352 isCollaborator := roles.IsCollaborator() 353 isIssueOwner := user.Did == issue.Did 354 355 // TODO: make this more granular 356 if isIssueOwner || isRepoOwner || isCollaborator { 357 if err := rp.writeIssueStateRecord(r, user.Did, issue.AtUri(), models.StateClosed); err != nil { 358 l.Error("failed to write issue state record", "err", err) 359 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 360 return 361 } 362 363 err = db.CloseIssues( 364 rp.db, 365 orm.FilterEq("id", issue.Id), 366 ) 367 if err != nil { 368 l.Error("failed to close issue", "err", err) 369 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 370 return 371 } 372 // change the issue state (this will pass down to the notifiers) 373 issue.Open = false 374 375 // notify about the issue closure 376 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 377 378 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 379 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 380 return 381 } else { 382 l.Error("user is not permitted to close issue") 383 http.Error(w, "for biden", http.StatusUnauthorized) 384 return 385 } 386} 387 388func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 389 l := rp.logger.With("handler", "ReopenIssue") 390 user := rp.oauth.GetMultiAccountUser(r) 391 if user == nil { 392 l.Error("nil user") 393 rp.pages.Notice(w, "issue-action", "You must be logged in to reopen this issue.") 394 return 395 } 396 f, err := rp.repoResolver.Resolve(r) 397 if err != nil { 398 l.Error("failed to get repo and knot", "err", err) 399 return 400 } 401 402 issue, ok := r.Context().Value("issue").(*models.Issue) 403 if !ok { 404 l.Error("failed to get issue") 405 rp.pages.Error404(w) 406 return 407 } 408 409 roles := rp.acl.RolesInRepo(r.Context(), f, user.Did) 410 isRepoOwner := roles.IsOwner() 411 isCollaborator := roles.IsCollaborator() 412 isIssueOwner := user.Did == issue.Did 413 414 if isCollaborator || isRepoOwner || isIssueOwner { 415 if err := rp.writeIssueStateRecord(r, user.Did, issue.AtUri(), models.StateOpen); err != nil { 416 l.Error("failed to write issue state record", "err", err) 417 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 418 return 419 } 420 421 err := db.ReopenIssues( 422 rp.db, 423 orm.FilterEq("id", issue.Id), 424 ) 425 if err != nil { 426 l.Error("failed to reopen issue", "err", err) 427 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 428 return 429 } 430 // change the issue state (this will pass down to the notifiers) 431 issue.Open = true 432 433 // notify about the issue reopen 434 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 435 436 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 437 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 438 return 439 } else { 440 l.Error("user is not the owner of the repo") 441 http.Error(w, "forbidden", http.StatusUnauthorized) 442 return 443 } 444} 445 446func (rp *Issues) writeIssueStateRecord(r *http.Request, actorDid string, subject syntax.ATURI, value models.StateValue) error { 447 client, err := rp.oauth.AuthorizedClient(r) 448 if err != nil { 449 return err 450 } 451 452 record, err := models.AsIssueStateRecord(subject, value, time.Now()) 453 if err != nil { 454 return err 455 } 456 457 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 458 Collection: tangled.RepoIssueStateNSID, 459 Repo: actorDid, 460 Rkey: tid.TID(), 461 Record: &lexutil.LexiconTypeDecoder{ 462 Val: &record, 463 }, 464 }) 465 return err 466} 467 468func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 469 l := rp.logger.With("handler", "RepoIssues") 470 471 params := r.URL.Query() 472 page := pagination.FromContext(r.Context()) 473 474 user := rp.oauth.GetMultiAccountUser(r) 475 f, err := rp.repoResolver.Resolve(r) 476 if err != nil { 477 l.Error("failed to get repo and knot", "err", err) 478 return 479 } 480 481 query := searchquery.Parse(params.Get("q")) 482 483 var isOpen *bool 484 if urlState := params.Get("state"); urlState != "" { 485 switch urlState { 486 case "open": 487 isOpen = ptrBool(true) 488 case "closed": 489 isOpen = ptrBool(false) 490 } 491 query.Set("state", urlState) 492 } else if queryState := query.Get("state"); queryState != nil { 493 switch *queryState { 494 case "open": 495 isOpen = ptrBool(true) 496 case "closed": 497 isOpen = ptrBool(false) 498 } 499 } else if _, hasQ := params["q"]; !hasQ { 500 // no q param at all -- default to open 501 isOpen = ptrBool(true) 502 query.Set("state", "open") 503 } 504 505 resolve := func(ctx context.Context, ident string) (string, error) { 506 id, err := rp.idResolver.ResolveIdent(ctx, ident) 507 if err != nil { 508 return "", err 509 } 510 return id.DID.String(), nil 511 } 512 513 authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 514 515 labels := query.GetAll("label") 516 negatedLabels := query.GetAllNegated("label") 517 labelValues := query.GetDynamicTags() 518 negatedLabelValues := query.GetNegatedDynamicTags() 519 520 // resolve DID-format label values: if a dynamic tag's label 521 // definition has format "did", resolve the handle to a DID 522 if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 523 labelDefs, err := db.GetLabelDefinitions( 524 rp.db, 525 orm.FilterIn("at_uri", f.Labels), 526 orm.FilterContains("scope", tangled.RepoIssueNSID), 527 ) 528 if err == nil { 529 didLabels := make(map[string]bool) 530 for _, def := range labelDefs { 531 if def.ValueType.Format == models.ValueTypeFormatDid { 532 didLabels[def.Name] = true 533 } 534 } 535 labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 536 negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 537 } else { 538 l.Debug("failed to fetch label definitions for DID resolution", "err", err) 539 } 540 } 541 542 tf := searchquery.ExtractTextFilters(query) 543 544 searchOpts := models.IssueSearchOptions{ 545 Keywords: tf.Keywords, 546 Phrases: tf.Phrases, 547 RepoDid: f.RepoDid, 548 IsOpen: isOpen, 549 AuthorDid: authorDid, 550 Labels: labels, 551 LabelValues: labelValues, 552 NegatedKeywords: tf.NegatedKeywords, 553 NegatedPhrases: tf.NegatedPhrases, 554 NegatedLabels: negatedLabels, 555 NegatedLabelValues: negatedLabelValues, 556 NegatedAuthorDids: negatedAuthorDids, 557 Page: page, 558 } 559 560 totalIssues := 0 561 if isOpen == nil { 562 totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed 563 } else if *isOpen { 564 totalIssues = f.RepoStats.IssueCount.Open 565 } else { 566 totalIssues = f.RepoStats.IssueCount.Closed 567 } 568 569 repoInfo := rp.repoResolver.GetRepoInfo(r, user) 570 571 var issues []models.Issue 572 573 if searchOpts.HasSearchFilters() { 574 res, err := rp.indexer.Search(r.Context(), searchOpts) 575 if err != nil { 576 l.Error("failed to search for issues", "err", err) 577 return 578 } 579 l.Debug("searched issues with indexer", "count", len(res.Hits)) 580 totalIssues = int(res.Total) 581 582 // update tab counts to reflect filtered results 583 countOpts := searchOpts 584 countOpts.Page = pagination.Page{Limit: 1} 585 countOpts.IsOpen = ptrBool(true) 586 if openRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 587 repoInfo.Stats.IssueCount.Open = int(openRes.Total) 588 } 589 countOpts.IsOpen = ptrBool(false) 590 if closedRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 591 repoInfo.Stats.IssueCount.Closed = int(closedRes.Total) 592 } 593 594 if len(res.Hits) > 0 { 595 issues, err = db.GetIssues( 596 rp.db, 597 orm.FilterIn("id", res.Hits), 598 ) 599 if err != nil { 600 l.Error("failed to get issues", "err", err) 601 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 602 return 603 } 604 } 605 } else { 606 filters := []orm.Filter{ 607 orm.FilterEq("repo_did", f.RepoDid), 608 } 609 if isOpen != nil { 610 openInt := 0 611 if *isOpen { 612 openInt = 1 613 } 614 filters = append(filters, orm.FilterEq("open", openInt)) 615 } 616 issues, err = db.GetIssuesPaginated( 617 rp.db, 618 page, 619 filters..., 620 ) 621 if err != nil { 622 l.Error("failed to get issues", "err", err) 623 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 624 return 625 } 626 } 627 628 labelDefs, err := db.GetLabelDefinitions( 629 rp.db, 630 orm.FilterIn("at_uri", f.Labels), 631 orm.FilterContains("scope", tangled.RepoIssueNSID), 632 ) 633 if err != nil { 634 l.Error("failed to fetch labels", "err", err) 635 rp.pages.Error503(w) 636 return 637 } 638 639 defs := make(map[string]*models.LabelDefinition) 640 for _, l := range labelDefs { 641 defs[l.AtUri().String()] = &l 642 } 643 644 filterState := "" 645 if isOpen != nil { 646 if *isOpen { 647 filterState = "open" 648 } else { 649 filterState = "closed" 650 } 651 } 652 653 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 654 if user != nil { 655 dids := make([]syntax.DID, len(issues)) 656 for i, u := range issues { 657 dids[i] = syntax.DID(u.Did) 658 } 659 vouchRelationships, err = db.GetVouchRelationshipsBatch(rp.db, syntax.DID(user.Did), dids) 660 if err != nil { 661 l.Error("failed to fetch vouch relationships", "err", err) 662 } 663 } 664 baseFilterParts := make([]string, 0, len(query.Items())) 665 for _, item := range query.Items() { 666 if item.Kind == searchquery.KindTagValue { 667 if item.Key == "label" || !searchquery.KnownTags[item.Key] { 668 continue 669 } 670 } 671 baseFilterParts = append(baseFilterParts, item.Raw) 672 } 673 baseFilterQuery := strings.Join(baseFilterParts, " ") 674 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 675 BaseParams: pages.BaseParamsFromContext(r.Context()), 676 RepoInfo: repoInfo, 677 Issues: issues, 678 IssueCount: totalIssues, 679 LabelDefs: defs, 680 FilterState: filterState, 681 FilterQuery: query.String(), 682 BaseFilterQuery: baseFilterQuery, 683 Page: page, 684 VouchRelationships: vouchRelationships, 685 }) 686} 687 688func ptrBool(b bool) *bool { return &b } 689 690func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 691 l := rp.logger.With("handler", "NewIssue") 692 user := rp.oauth.GetMultiAccountUser(r) 693 694 f, err := rp.repoResolver.Resolve(r) 695 if err != nil { 696 l.Error("failed to get repo and knot", "err", err) 697 return 698 } 699 700 switch r.Method { 701 case http.MethodGet: 702 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 703 BaseParams: pages.BaseParamsFromContext(r.Context()), 704 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 705 }) 706 case http.MethodPost: 707 body := r.FormValue("body") 708 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 709 710 issue := &models.Issue{ 711 RepoDid: syntax.DID(f.RepoDid), 712 Rkey: tid.TID(), 713 Title: r.FormValue("title"), 714 Body: body, 715 Open: true, 716 Did: user.Did, 717 Created: time.Now(), 718 Mentions: mentions, 719 References: references, 720 Repo: f, 721 } 722 723 if err := issue.Validate(); err != nil { 724 l.Error("validation error", "err", err) 725 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 726 return 727 } 728 729 record := issue.AsRecord() 730 731 // create an atproto record 732 client, err := rp.oauth.AuthorizedClient(r) 733 if err != nil { 734 l.Error("failed to get authorized client", "err", err) 735 rp.pages.Notice(w, "issues", "Failed to create issue.") 736 return 737 } 738 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 739 Collection: tangled.RepoIssueNSID, 740 Repo: user.Did, 741 Rkey: issue.Rkey, 742 Record: &lexutil.LexiconTypeDecoder{ 743 Val: &record, 744 }, 745 }) 746 if err != nil { 747 l.Error("failed to create issue", "err", err) 748 rp.pages.Notice(w, "issues", "Failed to create issue.") 749 return 750 } 751 atUri := resp.Uri 752 753 tx, err := rp.db.BeginTx(r.Context(), nil) 754 if err != nil { 755 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 756 return 757 } 758 rollback := func() { 759 err1 := tx.Rollback() 760 err2 := rollbackRecord(context.Background(), atUri, client) 761 762 if errors.Is(err1, sql.ErrTxDone) { 763 err1 = nil 764 } 765 766 if err := errors.Join(err1, err2); err != nil { 767 l.Error("failed to rollback txn", "err", err) 768 } 769 } 770 defer rollback() 771 772 err = db.PutIssue(tx, issue) 773 if err != nil { 774 l.Error("failed to create issue", "err", err) 775 rp.pages.Notice(w, "issues", "Failed to create issue.") 776 return 777 } 778 779 if err = tx.Commit(); err != nil { 780 l.Error("failed to create issue", "err", err) 781 rp.pages.Notice(w, "issues", "Failed to create issue.") 782 return 783 } 784 785 // everything is successful, do not rollback the atproto record 786 atUri = "" 787 788 rp.notifier.NewIssue(r.Context(), issue, mentions) 789 790 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 791 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 792 return 793 } 794} 795 796// this is used to rollback changes made to the PDS 797// 798// it is a no-op if the provided ATURI is empty 799func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 800 if aturi == "" { 801 return nil 802 } 803 804 parsed := syntax.ATURI(aturi) 805 806 collection := parsed.Collection().String() 807 repo := parsed.Authority().String() 808 rkey := parsed.RecordKey().String() 809 810 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 811 Collection: collection, 812 Repo: repo, 813 Rkey: rkey, 814 }) 815 return err 816}