forked from tangled.org/core
Monorepo for Tangled

appview: issues: move to own package

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.sh>

anirudh.fi 5ddc9dfd fdcb952d

verified
Changed files
+801 -726
appview
+757
appview/issues/issues.go
··· 1 + package issues 2 + 3 + import ( 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 + "github.com/posthog/posthog-go" 17 + 18 + "tangled.sh/tangled.sh/core/api/tangled" 19 + "tangled.sh/tangled.sh/core/appview" 20 + "tangled.sh/tangled.sh/core/appview/config" 21 + "tangled.sh/tangled.sh/core/appview/db" 22 + "tangled.sh/tangled.sh/core/appview/idresolver" 23 + "tangled.sh/tangled.sh/core/appview/oauth" 24 + "tangled.sh/tangled.sh/core/appview/pages" 25 + "tangled.sh/tangled.sh/core/appview/pagination" 26 + "tangled.sh/tangled.sh/core/appview/reporesolver" 27 + ) 28 + 29 + type 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 + posthog posthog.Client 37 + } 38 + 39 + func 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 + posthog posthog.Client, 47 + ) *Issues { 48 + return &Issues{ 49 + oauth: oauth, 50 + repoResolver: repoResolver, 51 + pages: pages, 52 + idResolver: idResolver, 53 + db: db, 54 + config: config, 55 + posthog: posthog, 56 + } 57 + } 58 + 59 + func (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 + issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 83 + if err != nil { 84 + log.Println("failed to resolve issue owner", err) 85 + } 86 + 87 + identsToResolve := make([]string, len(comments)) 88 + for i, comment := range comments { 89 + identsToResolve[i] = comment.OwnerDid 90 + } 91 + resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 92 + didHandleMap := make(map[string]string) 93 + for _, identity := range resolvedIds { 94 + if !identity.Handle.IsInvalidHandle() { 95 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 96 + } else { 97 + didHandleMap[identity.DID.String()] = identity.DID.String() 98 + } 99 + } 100 + 101 + rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 102 + LoggedInUser: user, 103 + RepoInfo: f.RepoInfo(user), 104 + Issue: *issue, 105 + Comments: comments, 106 + 107 + IssueOwnerHandle: issueOwnerIdent.Handle.String(), 108 + DidHandleMap: didHandleMap, 109 + }) 110 + 111 + } 112 + 113 + func (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: appview.TID(), 159 + Record: &lexutil.LexiconTypeDecoder{ 160 + Val: &tangled.RepoIssueState{ 161 + Issue: issue.IssueAt, 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 + 189 + func (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 + 237 + func (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 := appview.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 + 321 + func (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 + identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) 359 + if err != nil { 360 + log.Println("failed to resolve did") 361 + return 362 + } 363 + 364 + didHandleMap := make(map[string]string) 365 + if !identity.Handle.IsInvalidHandle() { 366 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 367 + } else { 368 + didHandleMap[identity.DID.String()] = identity.DID.String() 369 + } 370 + 371 + rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 372 + LoggedInUser: user, 373 + RepoInfo: f.RepoInfo(user), 374 + DidHandleMap: didHandleMap, 375 + Issue: issue, 376 + Comment: comment, 377 + }) 378 + } 379 + 380 + func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 381 + user := rp.oauth.GetUser(r) 382 + f, err := rp.repoResolver.Resolve(r) 383 + if err != nil { 384 + log.Println("failed to get repo and knot", err) 385 + return 386 + } 387 + 388 + issueId := chi.URLParam(r, "issue") 389 + issueIdInt, err := strconv.Atoi(issueId) 390 + if err != nil { 391 + http.Error(w, "bad issue id", http.StatusBadRequest) 392 + log.Println("failed to parse issue id", err) 393 + return 394 + } 395 + 396 + commentId := chi.URLParam(r, "comment_id") 397 + commentIdInt, err := strconv.Atoi(commentId) 398 + if err != nil { 399 + http.Error(w, "bad comment id", http.StatusBadRequest) 400 + log.Println("failed to parse issue id", err) 401 + return 402 + } 403 + 404 + issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 405 + if err != nil { 406 + log.Println("failed to get issue", err) 407 + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 408 + return 409 + } 410 + 411 + comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 412 + if err != nil { 413 + http.Error(w, "bad comment id", http.StatusBadRequest) 414 + return 415 + } 416 + 417 + if comment.OwnerDid != user.Did { 418 + http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 419 + return 420 + } 421 + 422 + switch r.Method { 423 + case http.MethodGet: 424 + rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 425 + LoggedInUser: user, 426 + RepoInfo: f.RepoInfo(user), 427 + Issue: issue, 428 + Comment: comment, 429 + }) 430 + case http.MethodPost: 431 + // extract form value 432 + newBody := r.FormValue("body") 433 + client, err := rp.oauth.AuthorizedClient(r) 434 + if err != nil { 435 + log.Println("failed to get authorized client", err) 436 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 437 + return 438 + } 439 + rkey := comment.Rkey 440 + 441 + // optimistic update 442 + edited := time.Now() 443 + err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 444 + if err != nil { 445 + log.Println("failed to perferom update-description query", err) 446 + rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 447 + return 448 + } 449 + 450 + // rkey is optional, it was introduced later 451 + if comment.Rkey != "" { 452 + // update the record on pds 453 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 454 + if err != nil { 455 + // failed to get record 456 + log.Println(err, rkey) 457 + rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 458 + return 459 + } 460 + value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 461 + record, _ := data.UnmarshalJSON(value) 462 + 463 + repoAt := record["repo"].(string) 464 + issueAt := record["issue"].(string) 465 + createdAt := record["createdAt"].(string) 466 + commentIdInt64 := int64(commentIdInt) 467 + 468 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 469 + Collection: tangled.RepoIssueCommentNSID, 470 + Repo: user.Did, 471 + Rkey: rkey, 472 + SwapRecord: ex.Cid, 473 + Record: &lexutil.LexiconTypeDecoder{ 474 + Val: &tangled.RepoIssueComment{ 475 + Repo: &repoAt, 476 + Issue: issueAt, 477 + CommentId: &commentIdInt64, 478 + Owner: &comment.OwnerDid, 479 + Body: newBody, 480 + CreatedAt: createdAt, 481 + }, 482 + }, 483 + }) 484 + if err != nil { 485 + log.Println(err) 486 + } 487 + } 488 + 489 + // optimistic update for htmx 490 + didHandleMap := map[string]string{ 491 + user.Did: user.Handle, 492 + } 493 + comment.Body = newBody 494 + comment.Edited = &edited 495 + 496 + // return new comment body with htmx 497 + rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 498 + LoggedInUser: user, 499 + RepoInfo: f.RepoInfo(user), 500 + DidHandleMap: didHandleMap, 501 + Issue: issue, 502 + Comment: comment, 503 + }) 504 + return 505 + 506 + } 507 + 508 + } 509 + 510 + func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 511 + user := rp.oauth.GetUser(r) 512 + f, err := rp.repoResolver.Resolve(r) 513 + if err != nil { 514 + log.Println("failed to get repo and knot", err) 515 + return 516 + } 517 + 518 + issueId := chi.URLParam(r, "issue") 519 + issueIdInt, err := strconv.Atoi(issueId) 520 + if err != nil { 521 + http.Error(w, "bad issue id", http.StatusBadRequest) 522 + log.Println("failed to parse issue id", err) 523 + return 524 + } 525 + 526 + issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 527 + if err != nil { 528 + log.Println("failed to get issue", err) 529 + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 530 + return 531 + } 532 + 533 + commentId := chi.URLParam(r, "comment_id") 534 + commentIdInt, err := strconv.Atoi(commentId) 535 + if err != nil { 536 + http.Error(w, "bad comment id", http.StatusBadRequest) 537 + log.Println("failed to parse issue id", err) 538 + return 539 + } 540 + 541 + comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 542 + if err != nil { 543 + http.Error(w, "bad comment id", http.StatusBadRequest) 544 + return 545 + } 546 + 547 + if comment.OwnerDid != user.Did { 548 + http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 549 + return 550 + } 551 + 552 + if comment.Deleted != nil { 553 + http.Error(w, "comment already deleted", http.StatusBadRequest) 554 + return 555 + } 556 + 557 + // optimistic deletion 558 + deleted := time.Now() 559 + err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 560 + if err != nil { 561 + log.Println("failed to delete comment") 562 + rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 563 + return 564 + } 565 + 566 + // delete from pds 567 + if comment.Rkey != "" { 568 + client, err := rp.oauth.AuthorizedClient(r) 569 + if err != nil { 570 + log.Println("failed to get authorized client", err) 571 + rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 572 + return 573 + } 574 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 575 + Collection: tangled.GraphFollowNSID, 576 + Repo: user.Did, 577 + Rkey: comment.Rkey, 578 + }) 579 + if err != nil { 580 + log.Println(err) 581 + } 582 + } 583 + 584 + // optimistic update for htmx 585 + didHandleMap := map[string]string{ 586 + user.Did: user.Handle, 587 + } 588 + comment.Body = "" 589 + comment.Deleted = &deleted 590 + 591 + // htmx fragment of comment after deletion 592 + rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 593 + LoggedInUser: user, 594 + RepoInfo: f.RepoInfo(user), 595 + DidHandleMap: didHandleMap, 596 + Issue: issue, 597 + Comment: comment, 598 + }) 599 + return 600 + } 601 + 602 + func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 603 + params := r.URL.Query() 604 + state := params.Get("state") 605 + isOpen := true 606 + switch state { 607 + case "open": 608 + isOpen = true 609 + case "closed": 610 + isOpen = false 611 + default: 612 + isOpen = true 613 + } 614 + 615 + page, ok := r.Context().Value("page").(pagination.Page) 616 + if !ok { 617 + log.Println("failed to get page") 618 + page = pagination.FirstPage() 619 + } 620 + 621 + user := rp.oauth.GetUser(r) 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 + issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 629 + if err != nil { 630 + log.Println("failed to get issues", err) 631 + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 632 + return 633 + } 634 + 635 + identsToResolve := make([]string, len(issues)) 636 + for i, issue := range issues { 637 + identsToResolve[i] = issue.OwnerDid 638 + } 639 + resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 640 + didHandleMap := make(map[string]string) 641 + for _, identity := range resolvedIds { 642 + if !identity.Handle.IsInvalidHandle() { 643 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 644 + } else { 645 + didHandleMap[identity.DID.String()] = identity.DID.String() 646 + } 647 + } 648 + 649 + rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 650 + LoggedInUser: rp.oauth.GetUser(r), 651 + RepoInfo: f.RepoInfo(user), 652 + Issues: issues, 653 + DidHandleMap: didHandleMap, 654 + FilteringByOpen: isOpen, 655 + Page: page, 656 + }) 657 + return 658 + } 659 + 660 + func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 661 + user := rp.oauth.GetUser(r) 662 + 663 + f, err := rp.repoResolver.Resolve(r) 664 + if err != nil { 665 + log.Println("failed to get repo and knot", err) 666 + return 667 + } 668 + 669 + switch r.Method { 670 + case http.MethodGet: 671 + rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 672 + LoggedInUser: user, 673 + RepoInfo: f.RepoInfo(user), 674 + }) 675 + case http.MethodPost: 676 + title := r.FormValue("title") 677 + body := r.FormValue("body") 678 + 679 + if title == "" || body == "" { 680 + rp.pages.Notice(w, "issues", "Title and body are required") 681 + return 682 + } 683 + 684 + tx, err := rp.db.BeginTx(r.Context(), nil) 685 + if err != nil { 686 + rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 687 + return 688 + } 689 + 690 + err = db.NewIssue(tx, &db.Issue{ 691 + RepoAt: f.RepoAt, 692 + Title: title, 693 + Body: body, 694 + OwnerDid: user.Did, 695 + }) 696 + if err != nil { 697 + log.Println("failed to create issue", err) 698 + rp.pages.Notice(w, "issues", "Failed to create issue.") 699 + return 700 + } 701 + 702 + issueId, err := db.GetIssueId(rp.db, f.RepoAt) 703 + if err != nil { 704 + log.Println("failed to get issue id", err) 705 + rp.pages.Notice(w, "issues", "Failed to create issue.") 706 + return 707 + } 708 + 709 + client, err := rp.oauth.AuthorizedClient(r) 710 + if err != nil { 711 + log.Println("failed to get authorized client", err) 712 + rp.pages.Notice(w, "issues", "Failed to create issue.") 713 + return 714 + } 715 + atUri := f.RepoAt.String() 716 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 717 + Collection: tangled.RepoIssueNSID, 718 + Repo: user.Did, 719 + Rkey: appview.TID(), 720 + Record: &lexutil.LexiconTypeDecoder{ 721 + Val: &tangled.RepoIssue{ 722 + Repo: atUri, 723 + Title: title, 724 + Body: &body, 725 + Owner: user.Did, 726 + IssueId: int64(issueId), 727 + }, 728 + }, 729 + }) 730 + if err != nil { 731 + log.Println("failed to create issue", err) 732 + rp.pages.Notice(w, "issues", "Failed to create issue.") 733 + return 734 + } 735 + 736 + err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) 737 + if err != nil { 738 + log.Println("failed to set issue at", err) 739 + rp.pages.Notice(w, "issues", "Failed to create issue.") 740 + return 741 + } 742 + 743 + if !rp.config.Core.Dev { 744 + err = rp.posthog.Enqueue(posthog.Capture{ 745 + DistinctId: user.Did, 746 + Event: "new_issue", 747 + Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 748 + }) 749 + if err != nil { 750 + log.Println("failed to enqueue posthog event:", err) 751 + } 752 + } 753 + 754 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 755 + return 756 + } 757 + }
+34
appview/issues/router.go
··· 1 + package issues 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + "tangled.sh/tangled.sh/core/appview/middleware" 8 + ) 9 + 10 + func (i *Issues) Router(mw *middleware.Middleware) http.Handler { 11 + r := chi.NewRouter() 12 + 13 + r.Route("/", func(r chi.Router) { 14 + r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 + r.Get("/{issue}", i.RepoSingleIssue) 16 + 17 + r.Group(func(r chi.Router) { 18 + r.Use(middleware.AuthMiddleware(i.oauth)) 19 + r.Get("/new", i.NewIssue) 20 + r.Post("/new", i.NewIssue) 21 + r.Post("/{issue}/comment", i.NewIssueComment) 22 + r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { 23 + r.Get("/", i.IssueComment) 24 + r.Delete("/", i.DeleteIssueComment) 25 + r.Get("/edit", i.EditIssueComment) 26 + r.Post("/edit", i.EditIssueComment) 27 + }) 28 + r.Post("/{issue}/close", i.CloseIssue) 29 + r.Post("/{issue}/reopen", i.ReopenIssue) 30 + }) 31 + }) 32 + 33 + return r 34 + }
-703
appview/repo/repo.go
··· 7 7 "fmt" 8 8 "io" 9 9 "log" 10 - mathrand "math/rand/v2" 11 10 "net/http" 12 11 "path" 13 12 "slices" ··· 25 24 "tangled.sh/tangled.sh/core/appview/pages" 26 25 "tangled.sh/tangled.sh/core/appview/pages/markup" 27 26 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 28 - "tangled.sh/tangled.sh/core/appview/pagination" 29 27 "tangled.sh/tangled.sh/core/appview/reporesolver" 30 28 "tangled.sh/tangled.sh/core/knotclient" 31 29 "tangled.sh/tangled.sh/core/patchutil" 32 30 "tangled.sh/tangled.sh/core/rbac" 33 31 "tangled.sh/tangled.sh/core/types" 34 32 35 - "github.com/bluesky-social/indigo/atproto/data" 36 33 securejoin "github.com/cyphar/filepath-securejoin" 37 34 "github.com/go-chi/chi/v5" 38 35 "github.com/go-git/go-git/v5/plumbing" ··· 1002 999 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1003 1000 Branches: result.Branches, 1004 1001 }) 1005 - } 1006 - } 1007 - 1008 - func (rp *Repo) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1009 - user := rp.oauth.GetUser(r) 1010 - f, err := rp.repoResolver.Resolve(r) 1011 - if err != nil { 1012 - log.Println("failed to get repo and knot", err) 1013 - return 1014 - } 1015 - 1016 - issueId := chi.URLParam(r, "issue") 1017 - issueIdInt, err := strconv.Atoi(issueId) 1018 - if err != nil { 1019 - http.Error(w, "bad issue id", http.StatusBadRequest) 1020 - log.Println("failed to parse issue id", err) 1021 - return 1022 - } 1023 - 1024 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 1025 - if err != nil { 1026 - log.Println("failed to get issue and comments", err) 1027 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1028 - return 1029 - } 1030 - 1031 - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 1032 - if err != nil { 1033 - log.Println("failed to resolve issue owner", err) 1034 - } 1035 - 1036 - identsToResolve := make([]string, len(comments)) 1037 - for i, comment := range comments { 1038 - identsToResolve[i] = comment.OwnerDid 1039 - } 1040 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 1041 - didHandleMap := make(map[string]string) 1042 - for _, identity := range resolvedIds { 1043 - if !identity.Handle.IsInvalidHandle() { 1044 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1045 - } else { 1046 - didHandleMap[identity.DID.String()] = identity.DID.String() 1047 - } 1048 - } 1049 - 1050 - rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 1051 - LoggedInUser: user, 1052 - RepoInfo: f.RepoInfo(user), 1053 - Issue: *issue, 1054 - Comments: comments, 1055 - 1056 - IssueOwnerHandle: issueOwnerIdent.Handle.String(), 1057 - DidHandleMap: didHandleMap, 1058 - }) 1059 - 1060 - } 1061 - 1062 - func (rp *Repo) CloseIssue(w http.ResponseWriter, r *http.Request) { 1063 - user := rp.oauth.GetUser(r) 1064 - f, err := rp.repoResolver.Resolve(r) 1065 - if err != nil { 1066 - log.Println("failed to get repo and knot", err) 1067 - return 1068 - } 1069 - 1070 - issueId := chi.URLParam(r, "issue") 1071 - issueIdInt, err := strconv.Atoi(issueId) 1072 - if err != nil { 1073 - http.Error(w, "bad issue id", http.StatusBadRequest) 1074 - log.Println("failed to parse issue id", err) 1075 - return 1076 - } 1077 - 1078 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1079 - if err != nil { 1080 - log.Println("failed to get issue", err) 1081 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1082 - return 1083 - } 1084 - 1085 - collaborators, err := f.Collaborators(r.Context()) 1086 - if err != nil { 1087 - log.Println("failed to fetch repo collaborators: %w", err) 1088 - } 1089 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1090 - return user.Did == collab.Did 1091 - }) 1092 - isIssueOwner := user.Did == issue.OwnerDid 1093 - 1094 - // TODO: make this more granular 1095 - if isIssueOwner || isCollaborator { 1096 - 1097 - closed := tangled.RepoIssueStateClosed 1098 - 1099 - client, err := rp.oauth.AuthorizedClient(r) 1100 - if err != nil { 1101 - log.Println("failed to get authorized client", err) 1102 - return 1103 - } 1104 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1105 - Collection: tangled.RepoIssueStateNSID, 1106 - Repo: user.Did, 1107 - Rkey: appview.TID(), 1108 - Record: &lexutil.LexiconTypeDecoder{ 1109 - Val: &tangled.RepoIssueState{ 1110 - Issue: issue.IssueAt, 1111 - State: closed, 1112 - }, 1113 - }, 1114 - }) 1115 - 1116 - if err != nil { 1117 - log.Println("failed to update issue state", err) 1118 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1119 - return 1120 - } 1121 - 1122 - err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 1123 - if err != nil { 1124 - log.Println("failed to close issue", err) 1125 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1126 - return 1127 - } 1128 - 1129 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1130 - return 1131 - } else { 1132 - log.Println("user is not permitted to close issue") 1133 - http.Error(w, "for biden", http.StatusUnauthorized) 1134 - return 1135 - } 1136 - } 1137 - 1138 - func (rp *Repo) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1139 - user := rp.oauth.GetUser(r) 1140 - f, err := rp.repoResolver.Resolve(r) 1141 - if err != nil { 1142 - log.Println("failed to get repo and knot", err) 1143 - return 1144 - } 1145 - 1146 - issueId := chi.URLParam(r, "issue") 1147 - issueIdInt, err := strconv.Atoi(issueId) 1148 - if err != nil { 1149 - http.Error(w, "bad issue id", http.StatusBadRequest) 1150 - log.Println("failed to parse issue id", err) 1151 - return 1152 - } 1153 - 1154 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1155 - if err != nil { 1156 - log.Println("failed to get issue", err) 1157 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1158 - return 1159 - } 1160 - 1161 - collaborators, err := f.Collaborators(r.Context()) 1162 - if err != nil { 1163 - log.Println("failed to fetch repo collaborators: %w", err) 1164 - } 1165 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1166 - return user.Did == collab.Did 1167 - }) 1168 - isIssueOwner := user.Did == issue.OwnerDid 1169 - 1170 - if isCollaborator || isIssueOwner { 1171 - err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 1172 - if err != nil { 1173 - log.Println("failed to reopen issue", err) 1174 - rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1175 - return 1176 - } 1177 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1178 - return 1179 - } else { 1180 - log.Println("user is not the owner of the repo") 1181 - http.Error(w, "forbidden", http.StatusUnauthorized) 1182 - return 1183 - } 1184 - } 1185 - 1186 - func (rp *Repo) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1187 - user := rp.oauth.GetUser(r) 1188 - f, err := rp.repoResolver.Resolve(r) 1189 - if err != nil { 1190 - log.Println("failed to get repo and knot", err) 1191 - return 1192 - } 1193 - 1194 - issueId := chi.URLParam(r, "issue") 1195 - issueIdInt, err := strconv.Atoi(issueId) 1196 - if err != nil { 1197 - http.Error(w, "bad issue id", http.StatusBadRequest) 1198 - log.Println("failed to parse issue id", err) 1199 - return 1200 - } 1201 - 1202 - switch r.Method { 1203 - case http.MethodPost: 1204 - body := r.FormValue("body") 1205 - if body == "" { 1206 - rp.pages.Notice(w, "issue", "Body is required") 1207 - return 1208 - } 1209 - 1210 - commentId := mathrand.IntN(1000000) 1211 - rkey := appview.TID() 1212 - 1213 - err := db.NewIssueComment(rp.db, &db.Comment{ 1214 - OwnerDid: user.Did, 1215 - RepoAt: f.RepoAt, 1216 - Issue: issueIdInt, 1217 - CommentId: commentId, 1218 - Body: body, 1219 - Rkey: rkey, 1220 - }) 1221 - if err != nil { 1222 - log.Println("failed to create comment", err) 1223 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1224 - return 1225 - } 1226 - 1227 - createdAt := time.Now().Format(time.RFC3339) 1228 - commentIdInt64 := int64(commentId) 1229 - ownerDid := user.Did 1230 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 1231 - if err != nil { 1232 - log.Println("failed to get issue at", err) 1233 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1234 - return 1235 - } 1236 - 1237 - atUri := f.RepoAt.String() 1238 - client, err := rp.oauth.AuthorizedClient(r) 1239 - if err != nil { 1240 - log.Println("failed to get authorized client", err) 1241 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1242 - return 1243 - } 1244 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1245 - Collection: tangled.RepoIssueCommentNSID, 1246 - Repo: user.Did, 1247 - Rkey: rkey, 1248 - Record: &lexutil.LexiconTypeDecoder{ 1249 - Val: &tangled.RepoIssueComment{ 1250 - Repo: &atUri, 1251 - Issue: issueAt, 1252 - CommentId: &commentIdInt64, 1253 - Owner: &ownerDid, 1254 - Body: body, 1255 - CreatedAt: createdAt, 1256 - }, 1257 - }, 1258 - }) 1259 - if err != nil { 1260 - log.Println("failed to create comment", err) 1261 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1262 - return 1263 - } 1264 - 1265 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1266 - return 1267 - } 1268 - } 1269 - 1270 - func (rp *Repo) IssueComment(w http.ResponseWriter, r *http.Request) { 1271 - user := rp.oauth.GetUser(r) 1272 - f, err := rp.repoResolver.Resolve(r) 1273 - if err != nil { 1274 - log.Println("failed to get repo and knot", err) 1275 - return 1276 - } 1277 - 1278 - issueId := chi.URLParam(r, "issue") 1279 - issueIdInt, err := strconv.Atoi(issueId) 1280 - if err != nil { 1281 - http.Error(w, "bad issue id", http.StatusBadRequest) 1282 - log.Println("failed to parse issue id", err) 1283 - return 1284 - } 1285 - 1286 - commentId := chi.URLParam(r, "comment_id") 1287 - commentIdInt, err := strconv.Atoi(commentId) 1288 - if err != nil { 1289 - http.Error(w, "bad comment id", http.StatusBadRequest) 1290 - log.Println("failed to parse issue id", err) 1291 - return 1292 - } 1293 - 1294 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1295 - if err != nil { 1296 - log.Println("failed to get issue", err) 1297 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1298 - return 1299 - } 1300 - 1301 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 1302 - if err != nil { 1303 - http.Error(w, "bad comment id", http.StatusBadRequest) 1304 - return 1305 - } 1306 - 1307 - identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) 1308 - if err != nil { 1309 - log.Println("failed to resolve did") 1310 - return 1311 - } 1312 - 1313 - didHandleMap := make(map[string]string) 1314 - if !identity.Handle.IsInvalidHandle() { 1315 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1316 - } else { 1317 - didHandleMap[identity.DID.String()] = identity.DID.String() 1318 - } 1319 - 1320 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1321 - LoggedInUser: user, 1322 - RepoInfo: f.RepoInfo(user), 1323 - DidHandleMap: didHandleMap, 1324 - Issue: issue, 1325 - Comment: comment, 1326 - }) 1327 - } 1328 - 1329 - func (rp *Repo) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1330 - user := rp.oauth.GetUser(r) 1331 - f, err := rp.repoResolver.Resolve(r) 1332 - if err != nil { 1333 - log.Println("failed to get repo and knot", err) 1334 - return 1335 - } 1336 - 1337 - issueId := chi.URLParam(r, "issue") 1338 - issueIdInt, err := strconv.Atoi(issueId) 1339 - if err != nil { 1340 - http.Error(w, "bad issue id", http.StatusBadRequest) 1341 - log.Println("failed to parse issue id", err) 1342 - return 1343 - } 1344 - 1345 - commentId := chi.URLParam(r, "comment_id") 1346 - commentIdInt, err := strconv.Atoi(commentId) 1347 - if err != nil { 1348 - http.Error(w, "bad comment id", http.StatusBadRequest) 1349 - log.Println("failed to parse issue id", err) 1350 - return 1351 - } 1352 - 1353 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1354 - if err != nil { 1355 - log.Println("failed to get issue", err) 1356 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1357 - return 1358 - } 1359 - 1360 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 1361 - if err != nil { 1362 - http.Error(w, "bad comment id", http.StatusBadRequest) 1363 - return 1364 - } 1365 - 1366 - if comment.OwnerDid != user.Did { 1367 - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1368 - return 1369 - } 1370 - 1371 - switch r.Method { 1372 - case http.MethodGet: 1373 - rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1374 - LoggedInUser: user, 1375 - RepoInfo: f.RepoInfo(user), 1376 - Issue: issue, 1377 - Comment: comment, 1378 - }) 1379 - case http.MethodPost: 1380 - // extract form value 1381 - newBody := r.FormValue("body") 1382 - client, err := rp.oauth.AuthorizedClient(r) 1383 - if err != nil { 1384 - log.Println("failed to get authorized client", err) 1385 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1386 - return 1387 - } 1388 - rkey := comment.Rkey 1389 - 1390 - // optimistic update 1391 - edited := time.Now() 1392 - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1393 - if err != nil { 1394 - log.Println("failed to perferom update-description query", err) 1395 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1396 - return 1397 - } 1398 - 1399 - // rkey is optional, it was introduced later 1400 - if comment.Rkey != "" { 1401 - // update the record on pds 1402 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1403 - if err != nil { 1404 - // failed to get record 1405 - log.Println(err, rkey) 1406 - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1407 - return 1408 - } 1409 - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1410 - record, _ := data.UnmarshalJSON(value) 1411 - 1412 - repoAt := record["repo"].(string) 1413 - issueAt := record["issue"].(string) 1414 - createdAt := record["createdAt"].(string) 1415 - commentIdInt64 := int64(commentIdInt) 1416 - 1417 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1418 - Collection: tangled.RepoIssueCommentNSID, 1419 - Repo: user.Did, 1420 - Rkey: rkey, 1421 - SwapRecord: ex.Cid, 1422 - Record: &lexutil.LexiconTypeDecoder{ 1423 - Val: &tangled.RepoIssueComment{ 1424 - Repo: &repoAt, 1425 - Issue: issueAt, 1426 - CommentId: &commentIdInt64, 1427 - Owner: &comment.OwnerDid, 1428 - Body: newBody, 1429 - CreatedAt: createdAt, 1430 - }, 1431 - }, 1432 - }) 1433 - if err != nil { 1434 - log.Println(err) 1435 - } 1436 - } 1437 - 1438 - // optimistic update for htmx 1439 - didHandleMap := map[string]string{ 1440 - user.Did: user.Handle, 1441 - } 1442 - comment.Body = newBody 1443 - comment.Edited = &edited 1444 - 1445 - // return new comment body with htmx 1446 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1447 - LoggedInUser: user, 1448 - RepoInfo: f.RepoInfo(user), 1449 - DidHandleMap: didHandleMap, 1450 - Issue: issue, 1451 - Comment: comment, 1452 - }) 1453 - return 1454 - 1455 - } 1456 - 1457 - } 1458 - 1459 - func (rp *Repo) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1460 - user := rp.oauth.GetUser(r) 1461 - f, err := rp.repoResolver.Resolve(r) 1462 - if err != nil { 1463 - log.Println("failed to get repo and knot", err) 1464 - return 1465 - } 1466 - 1467 - issueId := chi.URLParam(r, "issue") 1468 - issueIdInt, err := strconv.Atoi(issueId) 1469 - if err != nil { 1470 - http.Error(w, "bad issue id", http.StatusBadRequest) 1471 - log.Println("failed to parse issue id", err) 1472 - return 1473 - } 1474 - 1475 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1476 - if err != nil { 1477 - log.Println("failed to get issue", err) 1478 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1479 - return 1480 - } 1481 - 1482 - commentId := chi.URLParam(r, "comment_id") 1483 - commentIdInt, err := strconv.Atoi(commentId) 1484 - if err != nil { 1485 - http.Error(w, "bad comment id", http.StatusBadRequest) 1486 - log.Println("failed to parse issue id", err) 1487 - return 1488 - } 1489 - 1490 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 1491 - if err != nil { 1492 - http.Error(w, "bad comment id", http.StatusBadRequest) 1493 - return 1494 - } 1495 - 1496 - if comment.OwnerDid != user.Did { 1497 - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1498 - return 1499 - } 1500 - 1501 - if comment.Deleted != nil { 1502 - http.Error(w, "comment already deleted", http.StatusBadRequest) 1503 - return 1504 - } 1505 - 1506 - // optimistic deletion 1507 - deleted := time.Now() 1508 - err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 1509 - if err != nil { 1510 - log.Println("failed to delete comment") 1511 - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 1512 - return 1513 - } 1514 - 1515 - // delete from pds 1516 - if comment.Rkey != "" { 1517 - client, err := rp.oauth.AuthorizedClient(r) 1518 - if err != nil { 1519 - log.Println("failed to get authorized client", err) 1520 - rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 1521 - return 1522 - } 1523 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1524 - Collection: tangled.GraphFollowNSID, 1525 - Repo: user.Did, 1526 - Rkey: comment.Rkey, 1527 - }) 1528 - if err != nil { 1529 - log.Println(err) 1530 - } 1531 - } 1532 - 1533 - // optimistic update for htmx 1534 - didHandleMap := map[string]string{ 1535 - user.Did: user.Handle, 1536 - } 1537 - comment.Body = "" 1538 - comment.Deleted = &deleted 1539 - 1540 - // htmx fragment of comment after deletion 1541 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1542 - LoggedInUser: user, 1543 - RepoInfo: f.RepoInfo(user), 1544 - DidHandleMap: didHandleMap, 1545 - Issue: issue, 1546 - Comment: comment, 1547 - }) 1548 - return 1549 - } 1550 - 1551 - func (rp *Repo) RepoIssues(w http.ResponseWriter, r *http.Request) { 1552 - params := r.URL.Query() 1553 - state := params.Get("state") 1554 - isOpen := true 1555 - switch state { 1556 - case "open": 1557 - isOpen = true 1558 - case "closed": 1559 - isOpen = false 1560 - default: 1561 - isOpen = true 1562 - } 1563 - 1564 - page, ok := r.Context().Value("page").(pagination.Page) 1565 - if !ok { 1566 - log.Println("failed to get page") 1567 - page = pagination.FirstPage() 1568 - } 1569 - 1570 - user := rp.oauth.GetUser(r) 1571 - f, err := rp.repoResolver.Resolve(r) 1572 - if err != nil { 1573 - log.Println("failed to get repo and knot", err) 1574 - return 1575 - } 1576 - 1577 - issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 1578 - if err != nil { 1579 - log.Println("failed to get issues", err) 1580 - rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1581 - return 1582 - } 1583 - 1584 - identsToResolve := make([]string, len(issues)) 1585 - for i, issue := range issues { 1586 - identsToResolve[i] = issue.OwnerDid 1587 - } 1588 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 1589 - didHandleMap := make(map[string]string) 1590 - for _, identity := range resolvedIds { 1591 - if !identity.Handle.IsInvalidHandle() { 1592 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1593 - } else { 1594 - didHandleMap[identity.DID.String()] = identity.DID.String() 1595 - } 1596 - } 1597 - 1598 - rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 1599 - LoggedInUser: rp.oauth.GetUser(r), 1600 - RepoInfo: f.RepoInfo(user), 1601 - Issues: issues, 1602 - DidHandleMap: didHandleMap, 1603 - FilteringByOpen: isOpen, 1604 - Page: page, 1605 - }) 1606 - return 1607 - } 1608 - 1609 - func (rp *Repo) NewIssue(w http.ResponseWriter, r *http.Request) { 1610 - user := rp.oauth.GetUser(r) 1611 - 1612 - f, err := rp.repoResolver.Resolve(r) 1613 - if err != nil { 1614 - log.Println("failed to get repo and knot", err) 1615 - return 1616 - } 1617 - 1618 - switch r.Method { 1619 - case http.MethodGet: 1620 - rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1621 - LoggedInUser: user, 1622 - RepoInfo: f.RepoInfo(user), 1623 - }) 1624 - case http.MethodPost: 1625 - title := r.FormValue("title") 1626 - body := r.FormValue("body") 1627 - 1628 - if title == "" || body == "" { 1629 - rp.pages.Notice(w, "issues", "Title and body are required") 1630 - return 1631 - } 1632 - 1633 - tx, err := rp.db.BeginTx(r.Context(), nil) 1634 - if err != nil { 1635 - rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 1636 - return 1637 - } 1638 - 1639 - err = db.NewIssue(tx, &db.Issue{ 1640 - RepoAt: f.RepoAt, 1641 - Title: title, 1642 - Body: body, 1643 - OwnerDid: user.Did, 1644 - }) 1645 - if err != nil { 1646 - log.Println("failed to create issue", err) 1647 - rp.pages.Notice(w, "issues", "Failed to create issue.") 1648 - return 1649 - } 1650 - 1651 - issueId, err := db.GetIssueId(rp.db, f.RepoAt) 1652 - if err != nil { 1653 - log.Println("failed to get issue id", err) 1654 - rp.pages.Notice(w, "issues", "Failed to create issue.") 1655 - return 1656 - } 1657 - 1658 - client, err := rp.oauth.AuthorizedClient(r) 1659 - if err != nil { 1660 - log.Println("failed to get authorized client", err) 1661 - rp.pages.Notice(w, "issues", "Failed to create issue.") 1662 - return 1663 - } 1664 - atUri := f.RepoAt.String() 1665 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1666 - Collection: tangled.RepoIssueNSID, 1667 - Repo: user.Did, 1668 - Rkey: appview.TID(), 1669 - Record: &lexutil.LexiconTypeDecoder{ 1670 - Val: &tangled.RepoIssue{ 1671 - Repo: atUri, 1672 - Title: title, 1673 - Body: &body, 1674 - Owner: user.Did, 1675 - IssueId: int64(issueId), 1676 - }, 1677 - }, 1678 - }) 1679 - if err != nil { 1680 - log.Println("failed to create issue", err) 1681 - rp.pages.Notice(w, "issues", "Failed to create issue.") 1682 - return 1683 - } 1684 - 1685 - err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) 1686 - if err != nil { 1687 - log.Println("failed to set issue at", err) 1688 - rp.pages.Notice(w, "issues", "Failed to create issue.") 1689 - return 1690 - } 1691 - 1692 - if !rp.config.Core.Dev { 1693 - err = rp.posthog.Enqueue(posthog.Capture{ 1694 - DistinctId: user.Did, 1695 - Event: "new_issue", 1696 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 1697 - }) 1698 - if err != nil { 1699 - log.Println("failed to enqueue posthog event:", err) 1700 - } 1701 - } 1702 - 1703 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1704 - return 1705 1002 } 1706 1003 } 1707 1004
-20
appview/repo/router.go
··· 38 38 r.Get("/blob/{ref}/*", rp.RepoBlob) 39 39 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 40 40 41 - r.Route("/issues", func(r chi.Router) { 42 - r.With(middleware.Paginate).Get("/", rp.RepoIssues) 43 - r.Get("/{issue}", rp.RepoSingleIssue) 44 - 45 - r.Group(func(r chi.Router) { 46 - r.Use(middleware.AuthMiddleware(rp.oauth)) 47 - r.Get("/new", rp.NewIssue) 48 - r.Post("/new", rp.NewIssue) 49 - r.Post("/{issue}/comment", rp.NewIssueComment) 50 - r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { 51 - r.Get("/", rp.IssueComment) 52 - r.Delete("/", rp.DeleteIssueComment) 53 - r.Get("/edit", rp.EditIssueComment) 54 - r.Post("/edit", rp.EditIssueComment) 55 - }) 56 - r.Post("/{issue}/close", rp.CloseIssue) 57 - r.Post("/{issue}/reopen", rp.ReopenIssue) 58 - }) 59 - }) 60 - 61 41 r.Route("/fork", func(r chi.Router) { 62 42 r.Use(middleware.AuthMiddleware(rp.oauth)) 63 43 r.Get("/", rp.ForkRepo)
+10 -3
appview/state/router.go
··· 6 6 7 7 "github.com/go-chi/chi/v5" 8 8 "github.com/gorilla/sessions" 9 + "tangled.sh/tangled.sh/core/appview/issues" 9 10 "tangled.sh/tangled.sh/core/appview/middleware" 10 - oauth "tangled.sh/tangled.sh/core/appview/oauth/handler" 11 + oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler" 11 12 "tangled.sh/tangled.sh/core/appview/pulls" 12 13 "tangled.sh/tangled.sh/core/appview/repo" 13 14 "tangled.sh/tangled.sh/core/appview/settings" ··· 71 72 r.Use(mw.GoImport()) 72 73 73 74 r.Mount("/", s.RepoRouter(mw)) 74 - 75 + r.Mount("/issues", s.IssuesRouter(mw)) 75 76 r.Mount("/pulls", s.PullsRouter(mw)) 76 77 77 78 // These routes get proxied to the knot ··· 155 156 156 157 func (s *State) OAuthRouter() http.Handler { 157 158 store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 158 - oauth := oauth.New(s.config, s.pages, s.idResolver, s.db, store, s.oauth, s.enforcer, s.posthog) 159 + oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, store, s.oauth, s.enforcer, s.posthog) 159 160 return oauth.Router() 160 161 } 161 162 ··· 168 169 } 169 170 170 171 return settings.Router() 172 + } 173 + 174 + func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 175 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog) 176 + return issues.Router(mw) 177 + 171 178 } 172 179 173 180 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {