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
25 kB 886 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 var isSubscribed *bool 165 if user != nil { 166 sub, found, err2 := db.GetIssueSubscription(rp.db, user.Did, issue.Id) 167 if err2 == nil { 168 if found { 169 isSubscribed = &sub 170 } else { 171 // Implicitly subscribed when you're the author or a participant. 172 isAuthorOrParticipant := issue.Did == user.Did 173 if !isAuthorOrParticipant { 174 for _, p := range issue.Participants() { 175 if p.String() == user.Did { 176 isAuthorOrParticipant = true 177 break 178 } 179 } 180 } 181 if isAuthorOrParticipant { 182 t := true 183 isSubscribed = &t 184 } 185 } 186 } 187 } 188 189 err = rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 190 BaseParams: pages.BaseParamsFromContext(r.Context()), 191 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 192 Issue: issue, 193 CommentList: models.NewCommentList(issue.Comments), 194 Backlinks: backlinks, 195 Reactions: reactions, 196 UserReacted: userReactions, 197 LabelDefs: defs, 198 VouchRelationships: vouchRelationships, 199 IsSubscribed: isSubscribed, 200 }) 201 if err != nil { 202 l.Error("failed to render issue", "err", err) 203 } 204} 205 206// SubscribeIssue handles subscribe/unsubscribe for a specific issue. 207func (rp *Issues) SubscribeIssue(w http.ResponseWriter, r *http.Request) { 208 l := rp.logger.With("handler", "SubscribeIssue") 209 user := rp.oauth.GetMultiAccountUser(r) 210 if user == nil { 211 w.WriteHeader(http.StatusUnauthorized) 212 return 213 } 214 215 issue, ok := r.Context().Value("issue").(*models.Issue) 216 if !ok { 217 l.Error("failed to get issue from context") 218 w.WriteHeader(http.StatusNotFound) 219 return 220 } 221 222 subscribe := r.FormValue("subscribe") != "false" 223 224 if err := db.UpsertIssueSubscription(rp.db, user.Did, issue.Id, subscribe); err != nil { 225 l.Error("failed to update issue subscription", "err", err) 226 w.WriteHeader(http.StatusInternalServerError) 227 return 228 } 229 230 // Return the updated subscription button fragment. 231 repoInfo := rp.repoResolver.GetRepoInfo(r, user) 232 rp.pages.IssueSubscribeFragment(w, pages.IssueSubscribeParams{ 233 RepoInfo: repoInfo, 234 IssueId: issue.IssueId, 235 IsSubscribed: &subscribe, 236 }) 237} 238 239func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 240 l := rp.logger.With("handler", "EditIssue") 241 user := rp.oauth.GetMultiAccountUser(r) 242 243 issue, ok := r.Context().Value("issue").(*models.Issue) 244 if !ok { 245 l.Error("failed to get issue") 246 rp.pages.Error404(w) 247 return 248 } 249 250 switch r.Method { 251 case http.MethodGet: 252 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 253 BaseParams: pages.BaseParamsFromContext(r.Context()), 254 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 255 Issue: issue, 256 }) 257 case http.MethodPost: 258 noticeId := "issues" 259 newIssue := issue 260 newIssue.Title = r.FormValue("title") 261 newIssue.Body = r.FormValue("body") 262 newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 263 264 if err := newIssue.Validate(); err != nil { 265 l.Error("validation error", "err", err) 266 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 267 return 268 } 269 270 newRecord := newIssue.AsRecord() 271 272 // edit an atproto record 273 client, err := rp.oauth.AuthorizedClient(r) 274 if err != nil { 275 l.Error("failed to get authorized client", "err", err) 276 rp.pages.Notice(w, noticeId, "Failed to edit issue.") 277 return 278 } 279 280 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 281 if err != nil { 282 l.Error("failed to get record", "err", err) 283 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 284 return 285 } 286 287 // merge existing pins with new uploads, dropping any removed from body 288 var existingBlobs []*lexutil.LexBlob 289 if ex.Value != nil { 290 if prev, ok := ex.Value.Val.(*tangled.RepoIssue); ok { 291 existingBlobs = prev.Blobs 292 } 293 } 294 newRecord.Blobs = models.MergeBlobs(existingBlobs, r.PostForm["blobs"], newIssue.Body) 295 296 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 297 Collection: tangled.RepoIssueNSID, 298 Repo: user.Did, 299 Rkey: newIssue.Rkey, 300 SwapRecord: ex.Cid, 301 Record: &lexutil.LexiconTypeDecoder{ 302 Val: &newRecord, 303 }, 304 }) 305 if err != nil { 306 l.Error("failed to edit record on PDS", "err", err) 307 rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") 308 return 309 } 310 311 // modify on DB -- TODO: transact this cleverly 312 tx, err := rp.db.Begin() 313 if err != nil { 314 l.Error("failed to edit issue on DB", "err", err) 315 rp.pages.Notice(w, noticeId, "Failed to edit issue.") 316 return 317 } 318 defer tx.Rollback() 319 320 err = db.PutIssue(tx, newIssue) 321 if err != nil { 322 l.Error("failed to edit issue", "err", err) 323 rp.pages.Notice(w, "issues", "Failed to edit issue.") 324 return 325 } 326 327 if err = tx.Commit(); err != nil { 328 l.Error("failed to edit issue", "err", err) 329 rp.pages.Notice(w, "issues", "Failed to cedit issue.") 330 return 331 } 332 333 rp.pages.HxRefresh(w) 334 } 335} 336 337func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 338 l := rp.logger.With("handler", "DeleteIssue") 339 noticeId := "issue-actions-error" 340 341 f, err := rp.repoResolver.Resolve(r) 342 if err != nil { 343 l.Error("failed to get repo and knot", "err", err) 344 return 345 } 346 347 issue, ok := r.Context().Value("issue").(*models.Issue) 348 if !ok { 349 l.Error("failed to get issue") 350 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 351 return 352 } 353 l = l.With("did", issue.Did, "rkey", issue.Rkey) 354 355 tx, err := rp.db.Begin() 356 if err != nil { 357 l.Error("failed to start transaction", "err", err) 358 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 359 return 360 } 361 defer tx.Rollback() 362 363 // delete from PDS 364 client, err := rp.oauth.AuthorizedClient(r) 365 if err != nil { 366 l.Error("failed to get authorized client", "err", err) 367 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 368 return 369 } 370 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 371 Collection: tangled.RepoIssueNSID, 372 Repo: issue.Did, 373 Rkey: issue.Rkey, 374 }) 375 if err != nil { 376 // TODO: transact this better 377 l.Error("failed to delete issue from PDS", "err", err) 378 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 379 return 380 } 381 382 // delete from db 383 if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { 384 l.Error("failed to delete issue", "err", err) 385 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 386 return 387 } 388 tx.Commit() 389 390 rp.notifier.DeleteIssue(r.Context(), issue) 391 392 // return to all issues page 393 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 394 rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues") 395} 396 397func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 398 l := rp.logger.With("handler", "CloseIssue") 399 user := rp.oauth.GetMultiAccountUser(r) 400 if user == nil { 401 l.Error("nil user") 402 rp.pages.Notice(w, "issue-action", "You must be logged in to close this issue.") 403 return 404 } 405 f, err := rp.repoResolver.Resolve(r) 406 if err != nil { 407 l.Error("failed to get repo and knot", "err", err) 408 return 409 } 410 411 issue, ok := r.Context().Value("issue").(*models.Issue) 412 if !ok { 413 l.Error("failed to get issue") 414 rp.pages.Error404(w) 415 return 416 } 417 418 roles := rp.acl.RolesInRepo(r.Context(), f, user.Did) 419 isRepoOwner := roles.IsOwner() 420 isCollaborator := roles.IsCollaborator() 421 isIssueOwner := user.Did == issue.Did 422 423 // TODO: make this more granular 424 if isIssueOwner || isRepoOwner || isCollaborator { 425 if err := rp.writeIssueStateRecord(r, user.Did, issue.AtUri(), models.StateClosed); err != nil { 426 l.Error("failed to write issue state record", "err", err) 427 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 428 return 429 } 430 431 err = db.CloseIssues( 432 rp.db, 433 orm.FilterEq("id", issue.Id), 434 ) 435 if err != nil { 436 l.Error("failed to close issue", "err", err) 437 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 438 return 439 } 440 // change the issue state (this will pass down to the notifiers) 441 issue.Open = false 442 443 // notify about the issue closure 444 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 445 446 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 447 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 448 return 449 } else { 450 l.Error("user is not permitted to close issue") 451 http.Error(w, "for biden", http.StatusUnauthorized) 452 return 453 } 454} 455 456func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 457 l := rp.logger.With("handler", "ReopenIssue") 458 user := rp.oauth.GetMultiAccountUser(r) 459 if user == nil { 460 l.Error("nil user") 461 rp.pages.Notice(w, "issue-action", "You must be logged in to reopen this issue.") 462 return 463 } 464 f, err := rp.repoResolver.Resolve(r) 465 if err != nil { 466 l.Error("failed to get repo and knot", "err", err) 467 return 468 } 469 470 issue, ok := r.Context().Value("issue").(*models.Issue) 471 if !ok { 472 l.Error("failed to get issue") 473 rp.pages.Error404(w) 474 return 475 } 476 477 roles := rp.acl.RolesInRepo(r.Context(), f, user.Did) 478 isRepoOwner := roles.IsOwner() 479 isCollaborator := roles.IsCollaborator() 480 isIssueOwner := user.Did == issue.Did 481 482 if isCollaborator || isRepoOwner || isIssueOwner { 483 if err := rp.writeIssueStateRecord(r, user.Did, issue.AtUri(), models.StateOpen); err != nil { 484 l.Error("failed to write issue state record", "err", err) 485 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 486 return 487 } 488 489 err := db.ReopenIssues( 490 rp.db, 491 orm.FilterEq("id", issue.Id), 492 ) 493 if err != nil { 494 l.Error("failed to reopen issue", "err", err) 495 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 496 return 497 } 498 // change the issue state (this will pass down to the notifiers) 499 issue.Open = true 500 501 // notify about the issue reopen 502 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 503 504 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 505 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 506 return 507 } else { 508 l.Error("user is not the owner of the repo") 509 http.Error(w, "forbidden", http.StatusUnauthorized) 510 return 511 } 512} 513 514func (rp *Issues) writeIssueStateRecord(r *http.Request, actorDid string, subject syntax.ATURI, value models.StateValue) error { 515 client, err := rp.oauth.AuthorizedClient(r) 516 if err != nil { 517 return err 518 } 519 520 record, err := models.AsIssueStateRecord(subject, value, time.Now()) 521 if err != nil { 522 return err 523 } 524 525 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 526 Collection: tangled.RepoIssueStateNSID, 527 Repo: actorDid, 528 Rkey: tid.TID(), 529 Record: &lexutil.LexiconTypeDecoder{ 530 Val: &record, 531 }, 532 }) 533 return err 534} 535 536func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 537 l := rp.logger.With("handler", "RepoIssues") 538 539 params := r.URL.Query() 540 page := pagination.FromContext(r.Context()) 541 542 user := rp.oauth.GetMultiAccountUser(r) 543 f, err := rp.repoResolver.Resolve(r) 544 if err != nil { 545 l.Error("failed to get repo and knot", "err", err) 546 return 547 } 548 549 query := searchquery.Parse(params.Get("q")) 550 551 var isOpen *bool 552 if urlState := params.Get("state"); urlState != "" { 553 switch urlState { 554 case "open": 555 isOpen = ptrBool(true) 556 case "closed": 557 isOpen = ptrBool(false) 558 } 559 query.Set("state", urlState) 560 } else if queryState := query.Get("state"); queryState != nil { 561 switch *queryState { 562 case "open": 563 isOpen = ptrBool(true) 564 case "closed": 565 isOpen = ptrBool(false) 566 } 567 } else if _, hasQ := params["q"]; !hasQ { 568 // no q param at all -- default to open 569 isOpen = ptrBool(true) 570 query.Set("state", "open") 571 } 572 573 resolve := func(ctx context.Context, ident string) (string, error) { 574 id, err := rp.idResolver.ResolveIdent(ctx, ident) 575 if err != nil { 576 return "", err 577 } 578 return id.DID.String(), nil 579 } 580 581 authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 582 583 labels := query.GetAll("label") 584 negatedLabels := query.GetAllNegated("label") 585 labelValues := query.GetDynamicTags() 586 negatedLabelValues := query.GetNegatedDynamicTags() 587 588 // resolve DID-format label values: if a dynamic tag's label 589 // definition has format "did", resolve the handle to a DID 590 if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 591 labelDefs, err := db.GetLabelDefinitions( 592 rp.db, 593 orm.FilterIn("at_uri", f.Labels), 594 orm.FilterContains("scope", tangled.RepoIssueNSID), 595 ) 596 if err == nil { 597 didLabels := make(map[string]bool) 598 for _, def := range labelDefs { 599 if def.ValueType.Format == models.ValueTypeFormatDid { 600 didLabels[def.Name] = true 601 } 602 } 603 labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 604 negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 605 } else { 606 l.Debug("failed to fetch label definitions for DID resolution", "err", err) 607 } 608 } 609 610 tf := searchquery.ExtractTextFilters(query) 611 612 searchOpts := models.IssueSearchOptions{ 613 Keywords: tf.Keywords, 614 Phrases: tf.Phrases, 615 RepoDid: f.RepoDid, 616 IsOpen: isOpen, 617 AuthorDid: authorDid, 618 Labels: labels, 619 LabelValues: labelValues, 620 NegatedKeywords: tf.NegatedKeywords, 621 NegatedPhrases: tf.NegatedPhrases, 622 NegatedLabels: negatedLabels, 623 NegatedLabelValues: negatedLabelValues, 624 NegatedAuthorDids: negatedAuthorDids, 625 Page: page, 626 } 627 628 totalIssues := 0 629 if isOpen == nil { 630 totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed 631 } else if *isOpen { 632 totalIssues = f.RepoStats.IssueCount.Open 633 } else { 634 totalIssues = f.RepoStats.IssueCount.Closed 635 } 636 637 repoInfo := rp.repoResolver.GetRepoInfo(r, user) 638 639 var issues []models.Issue 640 641 if searchOpts.HasSearchFilters() { 642 res, err := rp.indexer.Search(r.Context(), searchOpts) 643 if err != nil { 644 l.Error("failed to search for issues", "err", err) 645 return 646 } 647 l.Debug("searched issues with indexer", "count", len(res.Hits)) 648 totalIssues = int(res.Total) 649 650 // update tab counts to reflect filtered results 651 countOpts := searchOpts 652 countOpts.Page = pagination.Page{Limit: 1} 653 countOpts.IsOpen = ptrBool(true) 654 if openRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 655 repoInfo.Stats.IssueCount.Open = int(openRes.Total) 656 } 657 countOpts.IsOpen = ptrBool(false) 658 if closedRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 659 repoInfo.Stats.IssueCount.Closed = int(closedRes.Total) 660 } 661 662 if len(res.Hits) > 0 { 663 issues, err = db.GetIssues( 664 rp.db, 665 orm.FilterIn("id", res.Hits), 666 ) 667 if err != nil { 668 l.Error("failed to get issues", "err", err) 669 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 670 return 671 } 672 } 673 } else { 674 filters := []orm.Filter{ 675 orm.FilterEq("repo_did", f.RepoDid), 676 } 677 if isOpen != nil { 678 openInt := 0 679 if *isOpen { 680 openInt = 1 681 } 682 filters = append(filters, orm.FilterEq("open", openInt)) 683 } 684 issues, err = db.GetIssuesPaginated( 685 rp.db, 686 page, 687 filters..., 688 ) 689 if err != nil { 690 l.Error("failed to get issues", "err", err) 691 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 692 return 693 } 694 } 695 696 labelDefs, err := db.GetLabelDefinitions( 697 rp.db, 698 orm.FilterIn("at_uri", f.Labels), 699 orm.FilterContains("scope", tangled.RepoIssueNSID), 700 ) 701 if err != nil { 702 l.Error("failed to fetch labels", "err", err) 703 rp.pages.Error503(w) 704 return 705 } 706 707 defs := make(map[string]*models.LabelDefinition) 708 for _, l := range labelDefs { 709 defs[l.AtUri().String()] = &l 710 } 711 712 filterState := "" 713 if isOpen != nil { 714 if *isOpen { 715 filterState = "open" 716 } else { 717 filterState = "closed" 718 } 719 } 720 721 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 722 if user != nil { 723 dids := make([]syntax.DID, len(issues)) 724 for i, u := range issues { 725 dids[i] = syntax.DID(u.Did) 726 } 727 vouchRelationships, err = db.GetVouchRelationshipsBatch(rp.db, syntax.DID(user.Did), dids) 728 if err != nil { 729 l.Error("failed to fetch vouch relationships", "err", err) 730 } 731 } 732 baseFilterParts := make([]string, 0, len(query.Items())) 733 for _, item := range query.Items() { 734 if item.Kind == searchquery.KindTagValue { 735 if item.Key == "label" || !searchquery.KnownTags[item.Key] { 736 continue 737 } 738 } 739 baseFilterParts = append(baseFilterParts, item.Raw) 740 } 741 baseFilterQuery := strings.Join(baseFilterParts, " ") 742 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 743 BaseParams: pages.BaseParamsFromContext(r.Context()), 744 RepoInfo: repoInfo, 745 Issues: issues, 746 IssueCount: totalIssues, 747 LabelDefs: defs, 748 FilterState: filterState, 749 FilterQuery: query.String(), 750 BaseFilterQuery: baseFilterQuery, 751 Page: page, 752 VouchRelationships: vouchRelationships, 753 }) 754} 755 756func ptrBool(b bool) *bool { return &b } 757 758func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 759 l := rp.logger.With("handler", "NewIssue") 760 user := rp.oauth.GetMultiAccountUser(r) 761 762 f, err := rp.repoResolver.Resolve(r) 763 if err != nil { 764 l.Error("failed to get repo and knot", "err", err) 765 return 766 } 767 768 switch r.Method { 769 case http.MethodGet: 770 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 771 BaseParams: pages.BaseParamsFromContext(r.Context()), 772 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 773 }) 774 case http.MethodPost: 775 body := r.FormValue("body") 776 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 777 778 issue := &models.Issue{ 779 RepoDid: syntax.DID(f.RepoDid), 780 Rkey: tid.TID(), 781 Title: r.FormValue("title"), 782 Body: body, 783 Open: true, 784 Did: user.Did, 785 Created: time.Now(), 786 Mentions: mentions, 787 References: references, 788 Repo: f, 789 } 790 791 if err := issue.Validate(); err != nil { 792 l.Error("validation error", "err", err) 793 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 794 return 795 } 796 797 issue.Blobs = models.ParseBlobs(r.PostForm["blobs"], body) 798 799 record := issue.AsRecord() 800 801 // create an atproto record 802 client, err := rp.oauth.AuthorizedClient(r) 803 if err != nil { 804 l.Error("failed to get authorized client", "err", err) 805 rp.pages.Notice(w, "issues", "Failed to create issue.") 806 return 807 } 808 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 809 Collection: tangled.RepoIssueNSID, 810 Repo: user.Did, 811 Rkey: issue.Rkey, 812 Record: &lexutil.LexiconTypeDecoder{ 813 Val: &record, 814 }, 815 }) 816 if err != nil { 817 l.Error("failed to create issue", "err", err) 818 rp.pages.Notice(w, "issues", "Failed to create issue.") 819 return 820 } 821 atUri := resp.Uri 822 823 tx, err := rp.db.BeginTx(r.Context(), nil) 824 if err != nil { 825 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 826 return 827 } 828 rollback := func() { 829 err1 := tx.Rollback() 830 err2 := rollbackRecord(context.Background(), atUri, client) 831 832 if errors.Is(err1, sql.ErrTxDone) { 833 err1 = nil 834 } 835 836 if err := errors.Join(err1, err2); err != nil { 837 l.Error("failed to rollback txn", "err", err) 838 } 839 } 840 defer rollback() 841 842 err = db.PutIssue(tx, issue) 843 if err != nil { 844 l.Error("failed to create issue", "err", err) 845 rp.pages.Notice(w, "issues", "Failed to create issue.") 846 return 847 } 848 849 if err = tx.Commit(); err != nil { 850 l.Error("failed to create issue", "err", err) 851 rp.pages.Notice(w, "issues", "Failed to create issue.") 852 return 853 } 854 855 // everything is successful, do not rollback the atproto record 856 atUri = "" 857 858 rp.notifier.NewIssue(r.Context(), issue, mentions) 859 860 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 861 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 862 return 863 } 864} 865 866// this is used to rollback changes made to the PDS 867// 868// it is a no-op if the provided ATURI is empty 869func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 870 if aturi == "" { 871 return nil 872 } 873 874 parsed := syntax.ATURI(aturi) 875 876 collection := parsed.Collection().String() 877 repo := parsed.Authority().String() 878 rkey := parsed.RecordKey().String() 879 880 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 881 Collection: collection, 882 Repo: repo, 883 Rkey: rkey, 884 }) 885 return err 886}