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 lexutil "github.com/bluesky-social/indigo/lex/util" 15 "github.com/go-chi/chi/v5" 16 17 "tangled.sh/tangled.sh/core/api/tangled" 18 "tangled.sh/tangled.sh/core/appview/config" 19 "tangled.sh/tangled.sh/core/appview/db" 20 "tangled.sh/tangled.sh/core/appview/notify" 21 "tangled.sh/tangled.sh/core/appview/oauth" 22 "tangled.sh/tangled.sh/core/appview/pages" 23 "tangled.sh/tangled.sh/core/appview/pagination" 24 "tangled.sh/tangled.sh/core/appview/reporesolver" 25 "tangled.sh/tangled.sh/core/idresolver" 26 "tangled.sh/tangled.sh/core/tid" 27) 28 29type Issues struct { 30 oauth *oauth.OAuth 31 repoResolver *reporesolver.RepoResolver 32 pages *pages.Pages 33 idResolver *idresolver.Resolver 34 db *db.DB 35 config *config.Config 36 notifier notify.Notifier 37} 38 39func New( 40 oauth *oauth.OAuth, 41 repoResolver *reporesolver.RepoResolver, 42 pages *pages.Pages, 43 idResolver *idresolver.Resolver, 44 db *db.DB, 45 config *config.Config, 46 notifier notify.Notifier, 47) *Issues { 48 return &Issues{ 49 oauth: oauth, 50 repoResolver: repoResolver, 51 pages: pages, 52 idResolver: idResolver, 53 db: db, 54 config: config, 55 notifier: notifier, 56 } 57} 58 59func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 60 user := rp.oauth.GetUser(r) 61 f, err := rp.repoResolver.Resolve(r) 62 if err != nil { 63 log.Println("failed to get repo and knot", err) 64 return 65 } 66 67 issueId := chi.URLParam(r, "issue") 68 issueIdInt, err := strconv.Atoi(issueId) 69 if err != nil { 70 http.Error(w, "bad issue id", http.StatusBadRequest) 71 log.Println("failed to parse issue id", err) 72 return 73 } 74 75 issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 76 if err != nil { 77 log.Println("failed to get issue and comments", err) 78 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 79 return 80 } 81 82 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 83 if err != nil { 84 log.Println("failed to get issue reactions") 85 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 86 } 87 88 userReactions := map[db.ReactionKind]bool{} 89 if user != nil { 90 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 91 } 92 93 issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 94 if err != nil { 95 log.Println("failed to resolve issue owner", err) 96 } 97 98 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 99 LoggedInUser: user, 100 RepoInfo: f.RepoInfo(user), 101 Issue: issue, 102 Comments: comments, 103 104 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 105 106 OrderedReactionKinds: db.OrderedReactionKinds, 107 Reactions: reactionCountMap, 108 UserReacted: userReactions, 109 }) 110 111} 112 113func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 114 user := rp.oauth.GetUser(r) 115 f, err := rp.repoResolver.Resolve(r) 116 if err != nil { 117 log.Println("failed to get repo and knot", err) 118 return 119 } 120 121 issueId := chi.URLParam(r, "issue") 122 issueIdInt, err := strconv.Atoi(issueId) 123 if err != nil { 124 http.Error(w, "bad issue id", http.StatusBadRequest) 125 log.Println("failed to parse issue id", err) 126 return 127 } 128 129 issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 130 if err != nil { 131 log.Println("failed to get issue", err) 132 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 133 return 134 } 135 136 collaborators, err := f.Collaborators(r.Context()) 137 if err != nil { 138 log.Println("failed to fetch repo collaborators: %w", err) 139 } 140 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 141 return user.Did == collab.Did 142 }) 143 isIssueOwner := user.Did == issue.OwnerDid 144 145 // TODO: make this more granular 146 if isIssueOwner || isCollaborator { 147 148 closed := tangled.RepoIssueStateClosed 149 150 client, err := rp.oauth.AuthorizedClient(r) 151 if err != nil { 152 log.Println("failed to get authorized client", err) 153 return 154 } 155 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 156 Collection: tangled.RepoIssueStateNSID, 157 Repo: user.Did, 158 Rkey: tid.TID(), 159 Record: &lexutil.LexiconTypeDecoder{ 160 Val: &tangled.RepoIssueState{ 161 Issue: issue.AtUri().String(), 162 State: closed, 163 }, 164 }, 165 }) 166 167 if err != nil { 168 log.Println("failed to update issue state", err) 169 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 170 return 171 } 172 173 err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 174 if err != nil { 175 log.Println("failed to close issue", err) 176 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 177 return 178 } 179 180 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 181 return 182 } else { 183 log.Println("user is not permitted to close issue") 184 http.Error(w, "for biden", http.StatusUnauthorized) 185 return 186 } 187} 188 189func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 190 user := rp.oauth.GetUser(r) 191 f, err := rp.repoResolver.Resolve(r) 192 if err != nil { 193 log.Println("failed to get repo and knot", err) 194 return 195 } 196 197 issueId := chi.URLParam(r, "issue") 198 issueIdInt, err := strconv.Atoi(issueId) 199 if err != nil { 200 http.Error(w, "bad issue id", http.StatusBadRequest) 201 log.Println("failed to parse issue id", err) 202 return 203 } 204 205 issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 206 if err != nil { 207 log.Println("failed to get issue", err) 208 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 209 return 210 } 211 212 collaborators, err := f.Collaborators(r.Context()) 213 if err != nil { 214 log.Println("failed to fetch repo collaborators: %w", err) 215 } 216 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 217 return user.Did == collab.Did 218 }) 219 isIssueOwner := user.Did == issue.OwnerDid 220 221 if isCollaborator || isIssueOwner { 222 err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 223 if err != nil { 224 log.Println("failed to reopen issue", err) 225 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 226 return 227 } 228 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 229 return 230 } else { 231 log.Println("user is not the owner of the repo") 232 http.Error(w, "forbidden", http.StatusUnauthorized) 233 return 234 } 235} 236 237func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 238 user := rp.oauth.GetUser(r) 239 f, err := rp.repoResolver.Resolve(r) 240 if err != nil { 241 log.Println("failed to get repo and knot", err) 242 return 243 } 244 245 issueId := chi.URLParam(r, "issue") 246 issueIdInt, err := strconv.Atoi(issueId) 247 if err != nil { 248 http.Error(w, "bad issue id", http.StatusBadRequest) 249 log.Println("failed to parse issue id", err) 250 return 251 } 252 253 switch r.Method { 254 case http.MethodPost: 255 body := r.FormValue("body") 256 if body == "" { 257 rp.pages.Notice(w, "issue", "Body is required") 258 return 259 } 260 261 commentId := mathrand.IntN(1000000) 262 rkey := tid.TID() 263 264 err := db.NewIssueComment(rp.db, &db.Comment{ 265 OwnerDid: user.Did, 266 RepoAt: f.RepoAt, 267 Issue: issueIdInt, 268 CommentId: commentId, 269 Body: body, 270 Rkey: rkey, 271 }) 272 if err != nil { 273 log.Println("failed to create comment", err) 274 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 275 return 276 } 277 278 createdAt := time.Now().Format(time.RFC3339) 279 commentIdInt64 := int64(commentId) 280 ownerDid := user.Did 281 issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 282 if err != nil { 283 log.Println("failed to get issue at", err) 284 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 285 return 286 } 287 288 atUri := f.RepoAt.String() 289 client, err := rp.oauth.AuthorizedClient(r) 290 if err != nil { 291 log.Println("failed to get authorized client", err) 292 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 293 return 294 } 295 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 296 Collection: tangled.RepoIssueCommentNSID, 297 Repo: user.Did, 298 Rkey: rkey, 299 Record: &lexutil.LexiconTypeDecoder{ 300 Val: &tangled.RepoIssueComment{ 301 Repo: &atUri, 302 Issue: issueAt, 303 CommentId: &commentIdInt64, 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 commentIdInt64 := int64(commentIdInt) 453 454 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 455 Collection: tangled.RepoIssueCommentNSID, 456 Repo: user.Did, 457 Rkey: rkey, 458 SwapRecord: ex.Cid, 459 Record: &lexutil.LexiconTypeDecoder{ 460 Val: &tangled.RepoIssueComment{ 461 Repo: &repoAt, 462 Issue: issueAt, 463 CommentId: &commentIdInt64, 464 Owner: &comment.OwnerDid, 465 Body: newBody, 466 CreatedAt: createdAt, 467 }, 468 }, 469 }) 470 if err != nil { 471 log.Println(err) 472 } 473 } 474 475 // optimistic update for htmx 476 comment.Body = newBody 477 comment.Edited = &edited 478 479 // return new comment body with htmx 480 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 481 LoggedInUser: user, 482 RepoInfo: f.RepoInfo(user), 483 Issue: issue, 484 Comment: comment, 485 }) 486 return 487 488 } 489 490} 491 492func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 493 user := rp.oauth.GetUser(r) 494 f, err := rp.repoResolver.Resolve(r) 495 if err != nil { 496 log.Println("failed to get repo and knot", err) 497 return 498 } 499 500 issueId := chi.URLParam(r, "issue") 501 issueIdInt, err := strconv.Atoi(issueId) 502 if err != nil { 503 http.Error(w, "bad issue id", http.StatusBadRequest) 504 log.Println("failed to parse issue id", err) 505 return 506 } 507 508 issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 509 if err != nil { 510 log.Println("failed to get issue", err) 511 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 512 return 513 } 514 515 commentId := chi.URLParam(r, "comment_id") 516 commentIdInt, err := strconv.Atoi(commentId) 517 if err != nil { 518 http.Error(w, "bad comment id", http.StatusBadRequest) 519 log.Println("failed to parse issue id", err) 520 return 521 } 522 523 comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 524 if err != nil { 525 http.Error(w, "bad comment id", http.StatusBadRequest) 526 return 527 } 528 529 if comment.OwnerDid != user.Did { 530 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 531 return 532 } 533 534 if comment.Deleted != nil { 535 http.Error(w, "comment already deleted", http.StatusBadRequest) 536 return 537 } 538 539 // optimistic deletion 540 deleted := time.Now() 541 err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 542 if err != nil { 543 log.Println("failed to delete comment") 544 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 545 return 546 } 547 548 // delete from pds 549 if comment.Rkey != "" { 550 client, err := rp.oauth.AuthorizedClient(r) 551 if err != nil { 552 log.Println("failed to get authorized client", err) 553 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 554 return 555 } 556 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 557 Collection: tangled.GraphFollowNSID, 558 Repo: user.Did, 559 Rkey: comment.Rkey, 560 }) 561 if err != nil { 562 log.Println(err) 563 } 564 } 565 566 // optimistic update for htmx 567 comment.Body = "" 568 comment.Deleted = &deleted 569 570 // htmx fragment of comment after deletion 571 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 572 LoggedInUser: user, 573 RepoInfo: f.RepoInfo(user), 574 Issue: issue, 575 Comment: comment, 576 }) 577} 578 579func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 580 params := r.URL.Query() 581 state := params.Get("state") 582 isOpen := true 583 switch state { 584 case "open": 585 isOpen = true 586 case "closed": 587 isOpen = false 588 default: 589 isOpen = true 590 } 591 592 page, ok := r.Context().Value("page").(pagination.Page) 593 if !ok { 594 log.Println("failed to get page") 595 page = pagination.FirstPage() 596 } 597 598 user := rp.oauth.GetUser(r) 599 f, err := rp.repoResolver.Resolve(r) 600 if err != nil { 601 log.Println("failed to get repo and knot", err) 602 return 603 } 604 605 issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 606 if err != nil { 607 log.Println("failed to get issues", err) 608 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 609 return 610 } 611 612 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 613 LoggedInUser: rp.oauth.GetUser(r), 614 RepoInfo: f.RepoInfo(user), 615 Issues: issues, 616 FilteringByOpen: isOpen, 617 Page: page, 618 }) 619} 620 621func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 622 user := rp.oauth.GetUser(r) 623 624 f, err := rp.repoResolver.Resolve(r) 625 if err != nil { 626 log.Println("failed to get repo and knot", err) 627 return 628 } 629 630 switch r.Method { 631 case http.MethodGet: 632 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 633 LoggedInUser: user, 634 RepoInfo: f.RepoInfo(user), 635 }) 636 case http.MethodPost: 637 title := r.FormValue("title") 638 body := r.FormValue("body") 639 640 if title == "" || body == "" { 641 rp.pages.Notice(w, "issues", "Title and body are required") 642 return 643 } 644 645 tx, err := rp.db.BeginTx(r.Context(), nil) 646 if err != nil { 647 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 648 return 649 } 650 651 issue := &db.Issue{ 652 RepoAt: f.RepoAt, 653 Rkey: tid.TID(), 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 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 673 Collection: tangled.RepoIssueNSID, 674 Repo: user.Did, 675 Rkey: issue.Rkey, 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 rp.notifier.NewIssue(r.Context(), issue) 693 694 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 695 return 696 } 697}