this repo has no description
1package issues 2 3import ( 4 "context" 5 "database/sql" 6 "errors" 7 "fmt" 8 "log" 9 "log/slog" 10 "net/http" 11 "slices" 12 "strings" 13 "time" 14 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/go-chi/chi/v5" 19 20 "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview/config" 22 "tangled.sh/tangled.sh/core/appview/db" 23 "tangled.sh/tangled.sh/core/appview/notify" 24 "tangled.sh/tangled.sh/core/appview/oauth" 25 "tangled.sh/tangled.sh/core/appview/pages" 26 "tangled.sh/tangled.sh/core/appview/pages/markup" 27 "tangled.sh/tangled.sh/core/appview/pagination" 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 29 "tangled.sh/tangled.sh/core/appview/validator" 30 "tangled.sh/tangled.sh/core/appview/xrpcclient" 31 "tangled.sh/tangled.sh/core/idresolver" 32 tlog "tangled.sh/tangled.sh/core/log" 33 "tangled.sh/tangled.sh/core/tid" 34) 35 36type Issues struct { 37 oauth *oauth.OAuth 38 repoResolver *reporesolver.RepoResolver 39 pages *pages.Pages 40 idResolver *idresolver.Resolver 41 db *db.DB 42 config *config.Config 43 notifier notify.Notifier 44 logger *slog.Logger 45 validator *validator.Validator 46} 47 48func New( 49 oauth *oauth.OAuth, 50 repoResolver *reporesolver.RepoResolver, 51 pages *pages.Pages, 52 idResolver *idresolver.Resolver, 53 db *db.DB, 54 config *config.Config, 55 notifier notify.Notifier, 56) *Issues { 57 return &Issues{ 58 oauth: oauth, 59 repoResolver: repoResolver, 60 pages: pages, 61 idResolver: idResolver, 62 db: db, 63 config: config, 64 notifier: notifier, 65 logger: tlog.New("issues"), 66 validator: validator, 67 } 68} 69 70func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 71 l := rp.logger.With("handler", "RepoSingleIssue") 72 user := rp.oauth.GetUser(r) 73 f, err := rp.repoResolver.Resolve(r) 74 if err != nil { 75 log.Println("failed to get repo and knot", err) 76 return 77 } 78 79 issue, ok := r.Context().Value("issue").(*db.Issue) 80 if !ok { 81 l.Error("failed to get issue") 82 rp.pages.Error404(w) 83 return 84 } 85 86 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 87 if err != nil { 88 l.Error("failed to get issue reactions", "err", err) 89 } 90 91 userReactions := map[db.ReactionKind]bool{} 92 if user != nil { 93 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 94 } 95 96 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 97 LoggedInUser: user, 98 RepoInfo: f.RepoInfo(user), 99 Issue: issue, 100 CommentList: issue.CommentList(), 101 OrderedReactionKinds: db.OrderedReactionKinds, 102 Reactions: reactionCountMap, 103 UserReacted: userReactions, 104 }) 105 106} 107 108func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 109 l := rp.logger.With("handler", "CloseIssue") 110 user := rp.oauth.GetUser(r) 111 f, err := rp.repoResolver.Resolve(r) 112 if err != nil { 113 l.Error("failed to get repo and knot", "err", err) 114 return 115 } 116 117 issue, ok := r.Context().Value("issue").(*db.Issue) 118 if !ok { 119 l.Error("failed to get issue") 120 rp.pages.Error404(w) 121 return 122 } 123 124 collaborators, err := f.Collaborators(r.Context()) 125 if err != nil { 126 log.Println("failed to fetch repo collaborators: %w", err) 127 } 128 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 129 return user.Did == collab.Did 130 }) 131 isIssueOwner := user.Did == issue.Did 132 133 // TODO: make this more granular 134 if isIssueOwner || isCollaborator { 135 err = db.CloseIssues( 136 rp.db, 137 db.FilterEq("id", issue.Id), 138 ) 139 if err != nil { 140 log.Println("failed to close issue", err) 141 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 142 return 143 } 144 145 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 146 return 147 } else { 148 log.Println("user is not permitted to close issue") 149 http.Error(w, "for biden", http.StatusUnauthorized) 150 return 151 } 152} 153 154func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 155 l := rp.logger.With("handler", "ReopenIssue") 156 user := rp.oauth.GetUser(r) 157 f, err := rp.repoResolver.Resolve(r) 158 if err != nil { 159 log.Println("failed to get repo and knot", err) 160 return 161 } 162 163 issue, ok := r.Context().Value("issue").(*db.Issue) 164 if !ok { 165 l.Error("failed to get issue") 166 rp.pages.Error404(w) 167 return 168 } 169 170 collaborators, err := f.Collaborators(r.Context()) 171 if err != nil { 172 log.Println("failed to fetch repo collaborators: %w", err) 173 } 174 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 175 return user.Did == collab.Did 176 }) 177 isIssueOwner := user.Did == issue.Did 178 179 if isCollaborator || isIssueOwner { 180 err := db.ReopenIssues( 181 rp.db, 182 db.FilterEq("id", issue.Id), 183 ) 184 if err != nil { 185 log.Println("failed to reopen issue", err) 186 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 187 return 188 } 189 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 190 return 191 } else { 192 log.Println("user is not the owner of the repo") 193 http.Error(w, "forbidden", http.StatusUnauthorized) 194 return 195 } 196} 197 198func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 199 l := rp.logger.With("handler", "NewIssueComment") 200 user := rp.oauth.GetUser(r) 201 f, err := rp.repoResolver.Resolve(r) 202 if err != nil { 203 l.Error("failed to get repo and knot", "err", err) 204 return 205 } 206 207 issue, ok := r.Context().Value("issue").(*db.Issue) 208 if !ok { 209 l.Error("failed to get issue") 210 rp.pages.Error404(w) 211 return 212 } 213 214 body := r.FormValue("body") 215 if body == "" { 216 rp.pages.Notice(w, "issue", "Body is required") 217 return 218 } 219 220 replyToUri := r.FormValue("reply-to") 221 var replyTo *string 222 if replyToUri != "" { 223 uri, err := syntax.ParseATURI(replyToUri) 224 if err != nil { 225 l.Error("failed to get parse replyTo", "err", err, "replyTo", replyToUri) 226 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 227 return 228 } 229 if uri.Collection() != tangled.RepoIssueCommentNSID { 230 l.Error("invalid replyTo collection", "collection", uri.Collection()) 231 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 232 return 233 } 234 u := uri.String() 235 replyTo = &u 236 } 237 238 comment := db.IssueComment{ 239 Did: user.Did, 240 Rkey: tid.TID(), 241 IssueAt: issue.AtUri().String(), 242 ReplyTo: replyTo, 243 Body: body, 244 Created: time.Now(), 245 } 246 if err = rp.validator.ValidateIssueComment(&comment); err != nil { 247 l.Error("failed to validate comment", "err", err) 248 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 249 return 250 } 251 record := comment.AsRecord() 252 253 client, err := rp.oauth.AuthorizedClient(r) 254 if err != nil { 255 l.Error("failed to get authorized client", "err", err) 256 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 257 return 258 } 259 260 // create a record first 261 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 262 Collection: tangled.RepoIssueCommentNSID, 263 Repo: comment.Did, 264 Rkey: comment.Rkey, 265 Record: &lexutil.LexiconTypeDecoder{ 266 Val: &record, 267 }, 268 }) 269 if err != nil { 270 l.Error("failed to create comment", "err", err) 271 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 272 return 273 } 274 atUri := resp.Uri 275 defer func() { 276 if err := rollbackRecord(context.Background(), atUri, client); err != nil { 277 l.Error("rollback failed", "err", err) 278 } 279 }() 280 281 commentId, err := db.AddIssueComment(rp.db, comment) 282 if err != nil { 283 l.Error("failed to create comment", "err", err) 284 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 285 return 286 } 287 288 // reset atUri to make rollback a no-op 289 atUri = "" 290 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 291} 292 293func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 294 l := rp.logger.With("handler", "IssueComment") 295 user := rp.oauth.GetUser(r) 296 f, err := rp.repoResolver.Resolve(r) 297 if err != nil { 298 l.Error("failed to get repo and knot", "err", err) 299 return 300 } 301 302 issue, ok := r.Context().Value("issue").(*db.Issue) 303 if !ok { 304 l.Error("failed to get issue") 305 rp.pages.Error404(w) 306 return 307 } 308 309 commentId := chi.URLParam(r, "commentId") 310 comments, err := db.GetIssueComments( 311 rp.db, 312 db.FilterEq("id", commentId), 313 ) 314 if err != nil { 315 l.Error("failed to fetch comment", "id", commentId) 316 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 317 return 318 } 319 if len(comments) != 1 { 320 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 321 http.Error(w, "invalid comment id", http.StatusBadRequest) 322 return 323 } 324 comment := comments[0] 325 326 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 327 LoggedInUser: user, 328 RepoInfo: f.RepoInfo(user), 329 Issue: issue, 330 Comment: &comment, 331 }) 332} 333 334func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 335 l := rp.logger.With("handler", "EditIssueComment") 336 user := rp.oauth.GetUser(r) 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").(*db.Issue) 344 if !ok { 345 l.Error("failed to get issue") 346 rp.pages.Error404(w) 347 return 348 } 349 350 commentId := chi.URLParam(r, "commentId") 351 comments, err := db.GetIssueComments( 352 rp.db, 353 db.FilterEq("id", commentId), 354 ) 355 if err != nil { 356 l.Error("failed to fetch comment", "id", commentId) 357 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 358 return 359 } 360 if len(comments) != 1 { 361 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 362 http.Error(w, "invalid comment id", http.StatusBadRequest) 363 return 364 } 365 comment := comments[0] 366 367 if comment.Did != user.Did { 368 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 369 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 370 return 371 } 372 373 switch r.Method { 374 case http.MethodGet: 375 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 376 LoggedInUser: user, 377 RepoInfo: f.RepoInfo(user), 378 Issue: issue, 379 Comment: &comment, 380 }) 381 case http.MethodPost: 382 // extract form value 383 newBody := r.FormValue("body") 384 client, err := rp.oauth.AuthorizedClient(r) 385 if err != nil { 386 log.Println("failed to get authorized client", err) 387 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 388 return 389 } 390 391 now := time.Now() 392 newComment := comment 393 newComment.Body = newBody 394 newComment.Edited = &now 395 record := newComment.AsRecord() 396 397 _, err = db.AddIssueComment(rp.db, newComment) 398 if err != nil { 399 log.Println("failed to perferom update-description query", err) 400 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 401 return 402 } 403 404 // rkey is optional, it was introduced later 405 if comment.Rkey != "" { 406 // update the record on pds 407 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 408 if err != nil { 409 // failed to get record 410 log.Println(err, rkey) 411 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 412 return 413 } 414 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 415 record, _ := data.UnmarshalJSON(value) 416 417 repoAt := record["repo"].(string) 418 issueAt := record["issue"].(string) 419 createdAt := record["createdAt"].(string) 420 421 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 422 Collection: tangled.RepoIssueCommentNSID, 423 Repo: user.Did, 424 Rkey: rkey, 425 SwapRecord: ex.Cid, 426 Record: &lexutil.LexiconTypeDecoder{ 427 Val: &tangled.RepoIssueComment{ 428 Repo: &repoAt, 429 Issue: issueAt, 430 Owner: &comment.OwnerDid, 431 Body: newBody, 432 CreatedAt: createdAt, 433 }, 434 }, 435 }) 436 if err != nil { 437 log.Println(err) 438 } 439 } 440 441 // optimistic update for htmx 442 comment.Body = newBody 443 comment.Edited = &edited 444 445 // return new comment body with htmx 446 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 447 LoggedInUser: user, 448 RepoInfo: f.RepoInfo(user), 449 Issue: issue, 450 Comment: comment, 451 }) 452 return 453 454 } 455 456func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 457 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 458 user := rp.oauth.GetUser(r) 459 f, err := rp.repoResolver.Resolve(r) 460 if err != nil { 461 l.Error("failed to get repo and knot", "err", err) 462 return 463 } 464 465 issue, ok := r.Context().Value("issue").(*db.Issue) 466 if !ok { 467 l.Error("failed to get issue") 468 rp.pages.Error404(w) 469 return 470 } 471 472 commentId := chi.URLParam(r, "commentId") 473 comments, err := db.GetIssueComments( 474 rp.db, 475 db.FilterEq("id", commentId), 476 ) 477 if err != nil { 478 l.Error("failed to fetch comment", "id", commentId) 479 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 480 return 481 } 482 if len(comments) != 1 { 483 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 484 http.Error(w, "invalid comment id", http.StatusBadRequest) 485 return 486 } 487 comment := comments[0] 488 489 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 490 LoggedInUser: user, 491 RepoInfo: f.RepoInfo(user), 492 Issue: issue, 493 Comment: &comment, 494 }) 495} 496 497func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 498 l := rp.logger.With("handler", "ReplyIssueComment") 499 user := rp.oauth.GetUser(r) 500 f, err := rp.repoResolver.Resolve(r) 501 if err != nil { 502 l.Error("failed to get repo and knot", "err", err) 503 return 504 } 505 506 issue, ok := r.Context().Value("issue").(*db.Issue) 507 if !ok { 508 l.Error("failed to get issue") 509 rp.pages.Error404(w) 510 return 511 } 512 513 commentId := chi.URLParam(r, "commentId") 514 comments, err := db.GetIssueComments( 515 rp.db, 516 db.FilterEq("id", commentId), 517 ) 518 if err != nil { 519 l.Error("failed to fetch comment", "id", commentId) 520 http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 521 return 522 } 523 if len(comments) != 1 { 524 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 525 http.Error(w, "invalid comment id", http.StatusBadRequest) 526 return 527 } 528 comment := comments[0] 529 530 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 531 LoggedInUser: user, 532 RepoInfo: f.RepoInfo(user), 533 Issue: issue, 534 Comment: &comment, 535 }) 536} 537 538func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 539 l := rp.logger.With("handler", "DeleteIssueComment") 540 user := rp.oauth.GetUser(r) 541 f, err := rp.repoResolver.Resolve(r) 542 if err != nil { 543 return 544 } 545 546 issueId := chi.URLParam(r, "issue") 547 issueIdInt, err := strconv.Atoi(issueId) 548 if err != nil { 549 http.Error(w, "bad issue id", http.StatusBadRequest) 550 log.Println("failed to parse issue id", err) 551 return 552 } 553 554 issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 555 if err != nil { 556 log.Println("failed to get issue", err) 557 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 558 return 559 } 560 561 commentId := chi.URLParam(r, "comment_id") 562 commentIdInt, err := strconv.Atoi(commentId) 563 if err != nil { 564 http.Error(w, "bad comment id", http.StatusBadRequest) 565 log.Println("failed to parse issue id", err) 566 return 567 } 568 569 comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 570 if err != nil { 571 http.Error(w, "bad comment id", http.StatusBadRequest) 572 return 573 } 574 575 if comment.OwnerDid != user.Did { 576 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 577 return 578 } 579 580 if comment.Deleted != nil { 581 http.Error(w, "comment already deleted", http.StatusBadRequest) 582 return 583 } 584 585 // optimistic deletion 586 deleted := time.Now() 587 err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 588 if err != nil { 589 log.Println("failed to delete comment") 590 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 591 return 592 } 593 594 // delete from pds 595 if comment.Rkey != "" { 596 client, err := rp.oauth.AuthorizedClient(r) 597 if err != nil { 598 log.Println("failed to get authorized client", err) 599 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 600 return 601 } 602 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 603 Collection: tangled.GraphFollowNSID, 604 Repo: user.Did, 605 Rkey: comment.Rkey, 606 }) 607 if err != nil { 608 log.Println(err) 609 } 610 } 611 612 // optimistic update for htmx 613 comment.Body = "" 614 comment.Deleted = &deleted 615 616 // htmx fragment of comment after deletion 617 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 618 LoggedInUser: user, 619 RepoInfo: f.RepoInfo(user), 620 Issue: issue, 621 Comment: comment, 622 }) 623} 624 625func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 626 params := r.URL.Query() 627 state := params.Get("state") 628 isOpen := true 629 switch state { 630 case "open": 631 isOpen = true 632 case "closed": 633 isOpen = false 634 default: 635 isOpen = true 636 } 637 638 page, ok := r.Context().Value("page").(pagination.Page) 639 if !ok { 640 log.Println("failed to get page") 641 page = pagination.FirstPage() 642 } 643 644 user := rp.oauth.GetUser(r) 645 f, err := rp.repoResolver.Resolve(r) 646 if err != nil { 647 log.Println("failed to get repo and knot", err) 648 return 649 } 650 651 issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 652 if err != nil { 653 log.Println("failed to get issues", err) 654 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 655 return 656 } 657 658 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 659 LoggedInUser: rp.oauth.GetUser(r), 660 RepoInfo: f.RepoInfo(user), 661 Issues: issues, 662 FilteringByOpen: isOpen, 663 Page: page, 664 }) 665} 666 667func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 668 user := rp.oauth.GetUser(r) 669 670 f, err := rp.repoResolver.Resolve(r) 671 if err != nil { 672 log.Println("failed to get repo and knot", err) 673 return 674 } 675 676 switch r.Method { 677 case http.MethodGet: 678 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 679 LoggedInUser: user, 680 RepoInfo: f.RepoInfo(user), 681 }) 682 case http.MethodPost: 683 title := r.FormValue("title") 684 body := r.FormValue("body") 685 686 if title == "" || body == "" { 687 rp.pages.Notice(w, "issues", "Title and body are required") 688 return 689 } 690 691 sanitizer := markup.NewSanitizer() 692 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 693 rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") 694 return 695 } 696 if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 697 rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 698 return 699 } 700 701 tx, err := rp.db.BeginTx(r.Context(), nil) 702 if err != nil { 703 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 704 return 705 } 706 707 issue := &db.Issue{ 708 RepoAt: f.RepoAt(), 709 Rkey: tid.TID(), 710 Title: title, 711 Body: body, 712 OwnerDid: user.Did, 713 } 714 err = db.NewIssue(tx, issue) 715 if err != nil { 716 log.Println("failed to create issue", err) 717 rp.pages.Notice(w, "issues", "Failed to create issue.") 718 return 719 } 720 721 client, err := rp.oauth.AuthorizedClient(r) 722 if err != nil { 723 log.Println("failed to get authorized client", err) 724 rp.pages.Notice(w, "issues", "Failed to create issue.") 725 return 726 } 727 atUri := f.RepoAt().String() 728 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 729 Collection: tangled.RepoIssueNSID, 730 Repo: user.Did, 731 Rkey: issue.Rkey, 732 Record: &lexutil.LexiconTypeDecoder{ 733 Val: &tangled.RepoIssue{ 734 Repo: atUri, 735 Title: title, 736 Body: &body, 737 }, 738 }, 739 }) 740 if err != nil { 741 log.Println("failed to create issue", err) 742 rp.pages.Notice(w, "issues", "Failed to create issue.") 743 return 744 } 745 746 rp.notifier.NewIssue(r.Context(), issue) 747 748 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 749 return 750 } 751}