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