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