Monorepo for Tangled tangled.org
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 ownerDid := user.Did 282 issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 283 if err != nil { 284 log.Println("failed to get issue at", err) 285 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 286 return 287 } 288 289 atUri := f.RepoAt().String() 290 client, err := rp.oauth.AuthorizedClient(r) 291 if err != nil { 292 log.Println("failed to get authorized client", err) 293 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 294 return 295 } 296 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 297 Collection: tangled.RepoIssueCommentNSID, 298 Repo: user.Did, 299 Rkey: rkey, 300 Record: &lexutil.LexiconTypeDecoder{ 301 Val: &tangled.RepoIssueComment{ 302 Repo: &atUri, 303 Issue: issueAt, 304 Owner: &ownerDid, 305 Body: body, 306 CreatedAt: createdAt, 307 }, 308 }, 309 }) 310 if err != nil { 311 log.Println("failed to create comment", err) 312 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 313 return 314 } 315 316 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 317 return 318 } 319} 320 321func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 322 user := rp.oauth.GetUser(r) 323 f, err := rp.repoResolver.Resolve(r) 324 if err != nil { 325 log.Println("failed to get repo and knot", err) 326 return 327 } 328 329 issueId := chi.URLParam(r, "issue") 330 issueIdInt, err := strconv.Atoi(issueId) 331 if err != nil { 332 http.Error(w, "bad issue id", http.StatusBadRequest) 333 log.Println("failed to parse issue id", err) 334 return 335 } 336 337 commentId := chi.URLParam(r, "comment_id") 338 commentIdInt, err := strconv.Atoi(commentId) 339 if err != nil { 340 http.Error(w, "bad comment id", http.StatusBadRequest) 341 log.Println("failed to parse issue id", err) 342 return 343 } 344 345 issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 346 if err != nil { 347 log.Println("failed to get issue", err) 348 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 349 return 350 } 351 352 comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 353 if err != nil { 354 http.Error(w, "bad comment id", http.StatusBadRequest) 355 return 356 } 357 358 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 359 LoggedInUser: user, 360 RepoInfo: f.RepoInfo(user), 361 Issue: issue, 362 Comment: comment, 363 }) 364} 365 366func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 367 user := rp.oauth.GetUser(r) 368 f, err := rp.repoResolver.Resolve(r) 369 if err != nil { 370 log.Println("failed to get repo and knot", err) 371 return 372 } 373 374 issueId := chi.URLParam(r, "issue") 375 issueIdInt, err := strconv.Atoi(issueId) 376 if err != nil { 377 http.Error(w, "bad issue id", http.StatusBadRequest) 378 log.Println("failed to parse issue id", err) 379 return 380 } 381 382 commentId := chi.URLParam(r, "comment_id") 383 commentIdInt, err := strconv.Atoi(commentId) 384 if err != nil { 385 http.Error(w, "bad comment id", http.StatusBadRequest) 386 log.Println("failed to parse issue id", err) 387 return 388 } 389 390 issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 391 if err != nil { 392 log.Println("failed to get issue", err) 393 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 394 return 395 } 396 397 comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 398 if err != nil { 399 http.Error(w, "bad comment id", http.StatusBadRequest) 400 return 401 } 402 403 if comment.OwnerDid != user.Did { 404 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 405 return 406 } 407 408 switch r.Method { 409 case http.MethodGet: 410 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 411 LoggedInUser: user, 412 RepoInfo: f.RepoInfo(user), 413 Issue: issue, 414 Comment: comment, 415 }) 416 case http.MethodPost: 417 // extract form value 418 newBody := r.FormValue("body") 419 client, err := rp.oauth.AuthorizedClient(r) 420 if err != nil { 421 log.Println("failed to get authorized client", err) 422 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 423 return 424 } 425 rkey := comment.Rkey 426 427 // optimistic update 428 edited := time.Now() 429 err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 430 if err != nil { 431 log.Println("failed to perferom update-description query", err) 432 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 433 return 434 } 435 436 // rkey is optional, it was introduced later 437 if comment.Rkey != "" { 438 // update the record on pds 439 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 440 if err != nil { 441 // failed to get record 442 log.Println(err, rkey) 443 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 444 return 445 } 446 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 447 record, _ := data.UnmarshalJSON(value) 448 449 repoAt := record["repo"].(string) 450 issueAt := record["issue"].(string) 451 createdAt := record["createdAt"].(string) 452 453 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 454 Collection: tangled.RepoIssueCommentNSID, 455 Repo: user.Did, 456 Rkey: rkey, 457 SwapRecord: ex.Cid, 458 Record: &lexutil.LexiconTypeDecoder{ 459 Val: &tangled.RepoIssueComment{ 460 Repo: &repoAt, 461 Issue: issueAt, 462 Owner: &comment.OwnerDid, 463 Body: newBody, 464 CreatedAt: createdAt, 465 }, 466 }, 467 }) 468 if err != nil { 469 log.Println(err) 470 } 471 } 472 473 // optimistic update for htmx 474 comment.Body = newBody 475 comment.Edited = &edited 476 477 // return new comment body with htmx 478 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 479 LoggedInUser: user, 480 RepoInfo: f.RepoInfo(user), 481 Issue: issue, 482 Comment: comment, 483 }) 484 return 485 486 } 487 488} 489 490func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 491 user := rp.oauth.GetUser(r) 492 f, err := rp.repoResolver.Resolve(r) 493 if err != nil { 494 log.Println("failed to get repo and knot", err) 495 return 496 } 497 498 issueId := chi.URLParam(r, "issue") 499 issueIdInt, err := strconv.Atoi(issueId) 500 if err != nil { 501 http.Error(w, "bad issue id", http.StatusBadRequest) 502 log.Println("failed to parse issue id", err) 503 return 504 } 505 506 issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 507 if err != nil { 508 log.Println("failed to get issue", err) 509 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 510 return 511 } 512 513 commentId := chi.URLParam(r, "comment_id") 514 commentIdInt, err := strconv.Atoi(commentId) 515 if err != nil { 516 http.Error(w, "bad comment id", http.StatusBadRequest) 517 log.Println("failed to parse issue id", err) 518 return 519 } 520 521 comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 522 if err != nil { 523 http.Error(w, "bad comment id", http.StatusBadRequest) 524 return 525 } 526 527 if comment.OwnerDid != user.Did { 528 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 529 return 530 } 531 532 if comment.Deleted != nil { 533 http.Error(w, "comment already deleted", http.StatusBadRequest) 534 return 535 } 536 537 // optimistic deletion 538 deleted := time.Now() 539 err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 540 if err != nil { 541 log.Println("failed to delete comment") 542 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 543 return 544 } 545 546 // delete from pds 547 if comment.Rkey != "" { 548 client, err := rp.oauth.AuthorizedClient(r) 549 if err != nil { 550 log.Println("failed to get authorized client", err) 551 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 552 return 553 } 554 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 555 Collection: tangled.GraphFollowNSID, 556 Repo: user.Did, 557 Rkey: comment.Rkey, 558 }) 559 if err != nil { 560 log.Println(err) 561 } 562 } 563 564 // optimistic update for htmx 565 comment.Body = "" 566 comment.Deleted = &deleted 567 568 // htmx fragment of comment after deletion 569 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 570 LoggedInUser: user, 571 RepoInfo: f.RepoInfo(user), 572 Issue: issue, 573 Comment: comment, 574 }) 575} 576 577func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 578 params := r.URL.Query() 579 state := params.Get("state") 580 isOpen := true 581 switch state { 582 case "open": 583 isOpen = true 584 case "closed": 585 isOpen = false 586 default: 587 isOpen = true 588 } 589 590 page, ok := r.Context().Value("page").(pagination.Page) 591 if !ok { 592 log.Println("failed to get page") 593 page = pagination.FirstPage() 594 } 595 596 user := rp.oauth.GetUser(r) 597 f, err := rp.repoResolver.Resolve(r) 598 if err != nil { 599 log.Println("failed to get repo and knot", err) 600 return 601 } 602 603 issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 604 if err != nil { 605 log.Println("failed to get issues", err) 606 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 607 return 608 } 609 610 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 611 LoggedInUser: rp.oauth.GetUser(r), 612 RepoInfo: f.RepoInfo(user), 613 Issues: issues, 614 FilteringByOpen: isOpen, 615 Page: page, 616 }) 617} 618 619func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 620 user := rp.oauth.GetUser(r) 621 622 f, err := rp.repoResolver.Resolve(r) 623 if err != nil { 624 log.Println("failed to get repo and knot", err) 625 return 626 } 627 628 switch r.Method { 629 case http.MethodGet: 630 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 631 LoggedInUser: user, 632 RepoInfo: f.RepoInfo(user), 633 }) 634 case http.MethodPost: 635 title := r.FormValue("title") 636 body := r.FormValue("body") 637 638 if title == "" || body == "" { 639 rp.pages.Notice(w, "issues", "Title and body are required") 640 return 641 } 642 643 sanitizer := markup.NewSanitizer() 644 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 645 rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") 646 return 647 } 648 if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 649 rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 650 return 651 } 652 653 tx, err := rp.db.BeginTx(r.Context(), nil) 654 if err != nil { 655 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 656 return 657 } 658 659 issue := &db.Issue{ 660 RepoAt: f.RepoAt(), 661 Rkey: tid.TID(), 662 Title: title, 663 Body: body, 664 OwnerDid: user.Did, 665 } 666 err = db.NewIssue(tx, issue) 667 if err != nil { 668 log.Println("failed to create issue", err) 669 rp.pages.Notice(w, "issues", "Failed to create issue.") 670 return 671 } 672 673 client, err := rp.oauth.AuthorizedClient(r) 674 if err != nil { 675 log.Println("failed to get authorized client", err) 676 rp.pages.Notice(w, "issues", "Failed to create issue.") 677 return 678 } 679 atUri := f.RepoAt().String() 680 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 681 Collection: tangled.RepoIssueNSID, 682 Repo: user.Did, 683 Rkey: issue.Rkey, 684 Record: &lexutil.LexiconTypeDecoder{ 685 Val: &tangled.RepoIssue{ 686 Repo: atUri, 687 Title: title, 688 Body: &body, 689 }, 690 }, 691 }) 692 if err != nil { 693 log.Println("failed to create issue", err) 694 rp.pages.Notice(w, "issues", "Failed to create issue.") 695 return 696 } 697 698 rp.notifier.NewIssue(r.Context(), issue) 699 700 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 701 return 702 } 703}