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