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