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