Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).

appview: issues: move to own package

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

anirudh.fi 5ddc9dfd fdcb952d

verified
+801 -726
+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" ··· 24 25 "tangled.sh/tangled.sh/core/appview/pages" 25 26 "tangled.sh/tangled.sh/core/appview/pages/markup" 26 27 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 27 - "tangled.sh/tangled.sh/core/appview/pagination" 28 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 29 29 "tangled.sh/tangled.sh/core/knotclient" 30 30 "tangled.sh/tangled.sh/core/patchutil" 31 31 "tangled.sh/tangled.sh/core/rbac" 32 32 "tangled.sh/tangled.sh/core/types" 33 33 34 - "github.com/bluesky-social/indigo/atproto/data" 35 34 securejoin "github.com/cyphar/filepath-securejoin" 36 35 "github.com/go-chi/chi/v5" 37 36 "github.com/go-git/go-git/v5/plumbing" ··· 999 1002 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1000 1003 Branches: result.Branches, 1001 1004 }) 1002 - } 1003 - } 1004 - 1005 - func (rp *Repo) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1006 - user := rp.oauth.GetUser(r) 1007 - f, err := rp.repoResolver.Resolve(r) 1008 - if err != nil { 1009 - log.Println("failed to get repo and knot", err) 1010 - return 1011 - } 1012 - 1013 - issueId := chi.URLParam(r, "issue") 1014 - issueIdInt, err := strconv.Atoi(issueId) 1015 - if err != nil { 1016 - http.Error(w, "bad issue id", http.StatusBadRequest) 1017 - log.Println("failed to parse issue id", err) 1018 - return 1019 - } 1020 - 1021 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 1022 - if err != nil { 1023 - log.Println("failed to get issue and comments", err) 1024 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1025 - return 1026 - } 1027 - 1028 - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 1029 - if err != nil { 1030 - log.Println("failed to resolve issue owner", err) 1031 - } 1032 - 1033 - identsToResolve := make([]string, len(comments)) 1034 - for i, comment := range comments { 1035 - identsToResolve[i] = comment.OwnerDid 1036 - } 1037 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 1038 - didHandleMap := make(map[string]string) 1039 - for _, identity := range resolvedIds { 1040 - if !identity.Handle.IsInvalidHandle() { 1041 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1042 - } else { 1043 - didHandleMap[identity.DID.String()] = identity.DID.String() 1044 - } 1045 - } 1046 - 1047 - rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 1048 - LoggedInUser: user, 1049 - RepoInfo: f.RepoInfo(user), 1050 - Issue: *issue, 1051 - Comments: comments, 1052 - 1053 - IssueOwnerHandle: issueOwnerIdent.Handle.String(), 1054 - DidHandleMap: didHandleMap, 1055 - }) 1056 - 1057 - } 1058 - 1059 - func (rp *Repo) CloseIssue(w http.ResponseWriter, r *http.Request) { 1060 - user := rp.oauth.GetUser(r) 1061 - f, err := rp.repoResolver.Resolve(r) 1062 - if err != nil { 1063 - log.Println("failed to get repo and knot", err) 1064 - return 1065 - } 1066 - 1067 - issueId := chi.URLParam(r, "issue") 1068 - issueIdInt, err := strconv.Atoi(issueId) 1069 - if err != nil { 1070 - http.Error(w, "bad issue id", http.StatusBadRequest) 1071 - log.Println("failed to parse issue id", err) 1072 - return 1073 - } 1074 - 1075 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1076 - if err != nil { 1077 - log.Println("failed to get issue", err) 1078 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1079 - return 1080 - } 1081 - 1082 - collaborators, err := f.Collaborators(r.Context()) 1083 - if err != nil { 1084 - log.Println("failed to fetch repo collaborators: %w", err) 1085 - } 1086 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1087 - return user.Did == collab.Did 1088 - }) 1089 - isIssueOwner := user.Did == issue.OwnerDid 1090 - 1091 - // TODO: make this more granular 1092 - if isIssueOwner || isCollaborator { 1093 - 1094 - closed := tangled.RepoIssueStateClosed 1095 - 1096 - client, err := rp.oauth.AuthorizedClient(r) 1097 - if err != nil { 1098 - log.Println("failed to get authorized client", err) 1099 - return 1100 - } 1101 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1102 - Collection: tangled.RepoIssueStateNSID, 1103 - Repo: user.Did, 1104 - Rkey: appview.TID(), 1105 - Record: &lexutil.LexiconTypeDecoder{ 1106 - Val: &tangled.RepoIssueState{ 1107 - Issue: issue.IssueAt, 1108 - State: closed, 1109 - }, 1110 - }, 1111 - }) 1112 - 1113 - if err != nil { 1114 - log.Println("failed to update issue state", err) 1115 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1116 - return 1117 - } 1118 - 1119 - err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 1120 - if err != nil { 1121 - log.Println("failed to close issue", err) 1122 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1123 - return 1124 - } 1125 - 1126 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1127 - return 1128 - } else { 1129 - log.Println("user is not permitted to close issue") 1130 - http.Error(w, "for biden", http.StatusUnauthorized) 1131 - return 1132 - } 1133 - } 1134 - 1135 - func (rp *Repo) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1136 - user := rp.oauth.GetUser(r) 1137 - f, err := rp.repoResolver.Resolve(r) 1138 - if err != nil { 1139 - log.Println("failed to get repo and knot", err) 1140 - return 1141 - } 1142 - 1143 - issueId := chi.URLParam(r, "issue") 1144 - issueIdInt, err := strconv.Atoi(issueId) 1145 - if err != nil { 1146 - http.Error(w, "bad issue id", http.StatusBadRequest) 1147 - log.Println("failed to parse issue id", err) 1148 - return 1149 - } 1150 - 1151 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1152 - if err != nil { 1153 - log.Println("failed to get issue", err) 1154 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1155 - return 1156 - } 1157 - 1158 - collaborators, err := f.Collaborators(r.Context()) 1159 - if err != nil { 1160 - log.Println("failed to fetch repo collaborators: %w", err) 1161 - } 1162 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1163 - return user.Did == collab.Did 1164 - }) 1165 - isIssueOwner := user.Did == issue.OwnerDid 1166 - 1167 - if isCollaborator || isIssueOwner { 1168 - err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 1169 - if err != nil { 1170 - log.Println("failed to reopen issue", err) 1171 - rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1172 - return 1173 - } 1174 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1175 - return 1176 - } else { 1177 - log.Println("user is not the owner of the repo") 1178 - http.Error(w, "forbidden", http.StatusUnauthorized) 1179 - return 1180 - } 1181 - } 1182 - 1183 - func (rp *Repo) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1184 - user := rp.oauth.GetUser(r) 1185 - f, err := rp.repoResolver.Resolve(r) 1186 - if err != nil { 1187 - log.Println("failed to get repo and knot", err) 1188 - return 1189 - } 1190 - 1191 - issueId := chi.URLParam(r, "issue") 1192 - issueIdInt, err := strconv.Atoi(issueId) 1193 - if err != nil { 1194 - http.Error(w, "bad issue id", http.StatusBadRequest) 1195 - log.Println("failed to parse issue id", err) 1196 - return 1197 - } 1198 - 1199 - switch r.Method { 1200 - case http.MethodPost: 1201 - body := r.FormValue("body") 1202 - if body == "" { 1203 - rp.pages.Notice(w, "issue", "Body is required") 1204 - return 1205 - } 1206 - 1207 - commentId := mathrand.IntN(1000000) 1208 - rkey := appview.TID() 1209 - 1210 - err := db.NewIssueComment(rp.db, &db.Comment{ 1211 - OwnerDid: user.Did, 1212 - RepoAt: f.RepoAt, 1213 - Issue: issueIdInt, 1214 - CommentId: commentId, 1215 - Body: body, 1216 - Rkey: rkey, 1217 - }) 1218 - if err != nil { 1219 - log.Println("failed to create comment", err) 1220 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1221 - return 1222 - } 1223 - 1224 - createdAt := time.Now().Format(time.RFC3339) 1225 - commentIdInt64 := int64(commentId) 1226 - ownerDid := user.Did 1227 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 1228 - if err != nil { 1229 - log.Println("failed to get issue at", err) 1230 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1231 - return 1232 - } 1233 - 1234 - atUri := f.RepoAt.String() 1235 - client, err := rp.oauth.AuthorizedClient(r) 1236 - if err != nil { 1237 - log.Println("failed to get authorized client", err) 1238 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1239 - return 1240 - } 1241 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1242 - Collection: tangled.RepoIssueCommentNSID, 1243 - Repo: user.Did, 1244 - Rkey: rkey, 1245 - Record: &lexutil.LexiconTypeDecoder{ 1246 - Val: &tangled.RepoIssueComment{ 1247 - Repo: &atUri, 1248 - Issue: issueAt, 1249 - CommentId: &commentIdInt64, 1250 - Owner: &ownerDid, 1251 - Body: body, 1252 - CreatedAt: createdAt, 1253 - }, 1254 - }, 1255 - }) 1256 - if err != nil { 1257 - log.Println("failed to create comment", err) 1258 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1259 - return 1260 - } 1261 - 1262 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1263 - return 1264 - } 1265 - } 1266 - 1267 - func (rp *Repo) IssueComment(w http.ResponseWriter, r *http.Request) { 1268 - user := rp.oauth.GetUser(r) 1269 - f, err := rp.repoResolver.Resolve(r) 1270 - if err != nil { 1271 - log.Println("failed to get repo and knot", err) 1272 - return 1273 - } 1274 - 1275 - issueId := chi.URLParam(r, "issue") 1276 - issueIdInt, err := strconv.Atoi(issueId) 1277 - if err != nil { 1278 - http.Error(w, "bad issue id", http.StatusBadRequest) 1279 - log.Println("failed to parse issue id", err) 1280 - return 1281 - } 1282 - 1283 - commentId := chi.URLParam(r, "comment_id") 1284 - commentIdInt, err := strconv.Atoi(commentId) 1285 - if err != nil { 1286 - http.Error(w, "bad comment id", http.StatusBadRequest) 1287 - log.Println("failed to parse issue id", err) 1288 - return 1289 - } 1290 - 1291 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1292 - if err != nil { 1293 - log.Println("failed to get issue", err) 1294 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1295 - return 1296 - } 1297 - 1298 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 1299 - if err != nil { 1300 - http.Error(w, "bad comment id", http.StatusBadRequest) 1301 - return 1302 - } 1303 - 1304 - identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) 1305 - if err != nil { 1306 - log.Println("failed to resolve did") 1307 - return 1308 - } 1309 - 1310 - didHandleMap := make(map[string]string) 1311 - if !identity.Handle.IsInvalidHandle() { 1312 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1313 - } else { 1314 - didHandleMap[identity.DID.String()] = identity.DID.String() 1315 - } 1316 - 1317 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1318 - LoggedInUser: user, 1319 - RepoInfo: f.RepoInfo(user), 1320 - DidHandleMap: didHandleMap, 1321 - Issue: issue, 1322 - Comment: comment, 1323 - }) 1324 - } 1325 - 1326 - func (rp *Repo) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1327 - user := rp.oauth.GetUser(r) 1328 - f, err := rp.repoResolver.Resolve(r) 1329 - if err != nil { 1330 - log.Println("failed to get repo and knot", err) 1331 - return 1332 - } 1333 - 1334 - issueId := chi.URLParam(r, "issue") 1335 - issueIdInt, err := strconv.Atoi(issueId) 1336 - if err != nil { 1337 - http.Error(w, "bad issue id", http.StatusBadRequest) 1338 - log.Println("failed to parse issue id", err) 1339 - return 1340 - } 1341 - 1342 - commentId := chi.URLParam(r, "comment_id") 1343 - commentIdInt, err := strconv.Atoi(commentId) 1344 - if err != nil { 1345 - http.Error(w, "bad comment id", http.StatusBadRequest) 1346 - log.Println("failed to parse issue id", err) 1347 - return 1348 - } 1349 - 1350 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1351 - if err != nil { 1352 - log.Println("failed to get issue", err) 1353 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1354 - return 1355 - } 1356 - 1357 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 1358 - if err != nil { 1359 - http.Error(w, "bad comment id", http.StatusBadRequest) 1360 - return 1361 - } 1362 - 1363 - if comment.OwnerDid != user.Did { 1364 - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1365 - return 1366 - } 1367 - 1368 - switch r.Method { 1369 - case http.MethodGet: 1370 - rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1371 - LoggedInUser: user, 1372 - RepoInfo: f.RepoInfo(user), 1373 - Issue: issue, 1374 - Comment: comment, 1375 - }) 1376 - case http.MethodPost: 1377 - // extract form value 1378 - newBody := r.FormValue("body") 1379 - client, err := rp.oauth.AuthorizedClient(r) 1380 - if err != nil { 1381 - log.Println("failed to get authorized client", err) 1382 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1383 - return 1384 - } 1385 - rkey := comment.Rkey 1386 - 1387 - // optimistic update 1388 - edited := time.Now() 1389 - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1390 - if err != nil { 1391 - log.Println("failed to perferom update-description query", err) 1392 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1393 - return 1394 - } 1395 - 1396 - // rkey is optional, it was introduced later 1397 - if comment.Rkey != "" { 1398 - // update the record on pds 1399 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1400 - if err != nil { 1401 - // failed to get record 1402 - log.Println(err, rkey) 1403 - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1404 - return 1405 - } 1406 - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1407 - record, _ := data.UnmarshalJSON(value) 1408 - 1409 - repoAt := record["repo"].(string) 1410 - issueAt := record["issue"].(string) 1411 - createdAt := record["createdAt"].(string) 1412 - commentIdInt64 := int64(commentIdInt) 1413 - 1414 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1415 - Collection: tangled.RepoIssueCommentNSID, 1416 - Repo: user.Did, 1417 - Rkey: rkey, 1418 - SwapRecord: ex.Cid, 1419 - Record: &lexutil.LexiconTypeDecoder{ 1420 - Val: &tangled.RepoIssueComment{ 1421 - Repo: &repoAt, 1422 - Issue: issueAt, 1423 - CommentId: &commentIdInt64, 1424 - Owner: &comment.OwnerDid, 1425 - Body: newBody, 1426 - CreatedAt: createdAt, 1427 - }, 1428 - }, 1429 - }) 1430 - if err != nil { 1431 - log.Println(err) 1432 - } 1433 - } 1434 - 1435 - // optimistic update for htmx 1436 - didHandleMap := map[string]string{ 1437 - user.Did: user.Handle, 1438 - } 1439 - comment.Body = newBody 1440 - comment.Edited = &edited 1441 - 1442 - // return new comment body with htmx 1443 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1444 - LoggedInUser: user, 1445 - RepoInfo: f.RepoInfo(user), 1446 - DidHandleMap: didHandleMap, 1447 - Issue: issue, 1448 - Comment: comment, 1449 - }) 1450 - return 1451 - 1452 - } 1453 - 1454 - } 1455 - 1456 - func (rp *Repo) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1457 - user := rp.oauth.GetUser(r) 1458 - f, err := rp.repoResolver.Resolve(r) 1459 - if err != nil { 1460 - log.Println("failed to get repo and knot", err) 1461 - return 1462 - } 1463 - 1464 - issueId := chi.URLParam(r, "issue") 1465 - issueIdInt, err := strconv.Atoi(issueId) 1466 - if err != nil { 1467 - http.Error(w, "bad issue id", http.StatusBadRequest) 1468 - log.Println("failed to parse issue id", err) 1469 - return 1470 - } 1471 - 1472 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1473 - if err != nil { 1474 - log.Println("failed to get issue", err) 1475 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1476 - return 1477 - } 1478 - 1479 - commentId := chi.URLParam(r, "comment_id") 1480 - commentIdInt, err := strconv.Atoi(commentId) 1481 - if err != nil { 1482 - http.Error(w, "bad comment id", http.StatusBadRequest) 1483 - log.Println("failed to parse issue id", err) 1484 - return 1485 - } 1486 - 1487 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 1488 - if err != nil { 1489 - http.Error(w, "bad comment id", http.StatusBadRequest) 1490 - return 1491 - } 1492 - 1493 - if comment.OwnerDid != user.Did { 1494 - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1495 - return 1496 - } 1497 - 1498 - if comment.Deleted != nil { 1499 - http.Error(w, "comment already deleted", http.StatusBadRequest) 1500 - return 1501 - } 1502 - 1503 - // optimistic deletion 1504 - deleted := time.Now() 1505 - err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 1506 - if err != nil { 1507 - log.Println("failed to delete comment") 1508 - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 1509 - return 1510 - } 1511 - 1512 - // delete from pds 1513 - if comment.Rkey != "" { 1514 - client, err := rp.oauth.AuthorizedClient(r) 1515 - if err != nil { 1516 - log.Println("failed to get authorized client", err) 1517 - rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 1518 - return 1519 - } 1520 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1521 - Collection: tangled.GraphFollowNSID, 1522 - Repo: user.Did, 1523 - Rkey: comment.Rkey, 1524 - }) 1525 - if err != nil { 1526 - log.Println(err) 1527 - } 1528 - } 1529 - 1530 - // optimistic update for htmx 1531 - didHandleMap := map[string]string{ 1532 - user.Did: user.Handle, 1533 - } 1534 - comment.Body = "" 1535 - comment.Deleted = &deleted 1536 - 1537 - // htmx fragment of comment after deletion 1538 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1539 - LoggedInUser: user, 1540 - RepoInfo: f.RepoInfo(user), 1541 - DidHandleMap: didHandleMap, 1542 - Issue: issue, 1543 - Comment: comment, 1544 - }) 1545 - return 1546 - } 1547 - 1548 - func (rp *Repo) RepoIssues(w http.ResponseWriter, r *http.Request) { 1549 - params := r.URL.Query() 1550 - state := params.Get("state") 1551 - isOpen := true 1552 - switch state { 1553 - case "open": 1554 - isOpen = true 1555 - case "closed": 1556 - isOpen = false 1557 - default: 1558 - isOpen = true 1559 - } 1560 - 1561 - page, ok := r.Context().Value("page").(pagination.Page) 1562 - if !ok { 1563 - log.Println("failed to get page") 1564 - page = pagination.FirstPage() 1565 - } 1566 - 1567 - user := rp.oauth.GetUser(r) 1568 - f, err := rp.repoResolver.Resolve(r) 1569 - if err != nil { 1570 - log.Println("failed to get repo and knot", err) 1571 - return 1572 - } 1573 - 1574 - issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 1575 - if err != nil { 1576 - log.Println("failed to get issues", err) 1577 - rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1578 - return 1579 - } 1580 - 1581 - identsToResolve := make([]string, len(issues)) 1582 - for i, issue := range issues { 1583 - identsToResolve[i] = issue.OwnerDid 1584 - } 1585 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 1586 - didHandleMap := make(map[string]string) 1587 - for _, identity := range resolvedIds { 1588 - if !identity.Handle.IsInvalidHandle() { 1589 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1590 - } else { 1591 - didHandleMap[identity.DID.String()] = identity.DID.String() 1592 - } 1593 - } 1594 - 1595 - rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 1596 - LoggedInUser: rp.oauth.GetUser(r), 1597 - RepoInfo: f.RepoInfo(user), 1598 - Issues: issues, 1599 - DidHandleMap: didHandleMap, 1600 - FilteringByOpen: isOpen, 1601 - Page: page, 1602 - }) 1603 - return 1604 - } 1605 - 1606 - func (rp *Repo) NewIssue(w http.ResponseWriter, r *http.Request) { 1607 - user := rp.oauth.GetUser(r) 1608 - 1609 - f, err := rp.repoResolver.Resolve(r) 1610 - if err != nil { 1611 - log.Println("failed to get repo and knot", err) 1612 - return 1613 - } 1614 - 1615 - switch r.Method { 1616 - case http.MethodGet: 1617 - rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1618 - LoggedInUser: user, 1619 - RepoInfo: f.RepoInfo(user), 1620 - }) 1621 - case http.MethodPost: 1622 - title := r.FormValue("title") 1623 - body := r.FormValue("body") 1624 - 1625 - if title == "" || body == "" { 1626 - rp.pages.Notice(w, "issues", "Title and body are required") 1627 - return 1628 - } 1629 - 1630 - tx, err := rp.db.BeginTx(r.Context(), nil) 1631 - if err != nil { 1632 - rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 1633 - return 1634 - } 1635 - 1636 - err = db.NewIssue(tx, &db.Issue{ 1637 - RepoAt: f.RepoAt, 1638 - Title: title, 1639 - Body: body, 1640 - OwnerDid: user.Did, 1641 - }) 1642 - if err != nil { 1643 - log.Println("failed to create issue", err) 1644 - rp.pages.Notice(w, "issues", "Failed to create issue.") 1645 - return 1646 - } 1647 - 1648 - issueId, err := db.GetIssueId(rp.db, f.RepoAt) 1649 - if err != nil { 1650 - log.Println("failed to get issue id", err) 1651 - rp.pages.Notice(w, "issues", "Failed to create issue.") 1652 - return 1653 - } 1654 - 1655 - client, err := rp.oauth.AuthorizedClient(r) 1656 - if err != nil { 1657 - log.Println("failed to get authorized client", err) 1658 - rp.pages.Notice(w, "issues", "Failed to create issue.") 1659 - return 1660 - } 1661 - atUri := f.RepoAt.String() 1662 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1663 - Collection: tangled.RepoIssueNSID, 1664 - Repo: user.Did, 1665 - Rkey: appview.TID(), 1666 - Record: &lexutil.LexiconTypeDecoder{ 1667 - Val: &tangled.RepoIssue{ 1668 - Repo: atUri, 1669 - Title: title, 1670 - Body: &body, 1671 - Owner: user.Did, 1672 - IssueId: int64(issueId), 1673 - }, 1674 - }, 1675 - }) 1676 - if err != nil { 1677 - log.Println("failed to create issue", err) 1678 - rp.pages.Notice(w, "issues", "Failed to create issue.") 1679 - return 1680 - } 1681 - 1682 - err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) 1683 - if err != nil { 1684 - log.Println("failed to set issue at", err) 1685 - rp.pages.Notice(w, "issues", "Failed to create issue.") 1686 - return 1687 - } 1688 - 1689 - if !rp.config.Core.Dev { 1690 - err = rp.posthog.Enqueue(posthog.Capture{ 1691 - DistinctId: user.Did, 1692 - Event: "new_issue", 1693 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 1694 - }) 1695 - if err != nil { 1696 - log.Println("failed to enqueue posthog event:", err) 1697 - } 1698 - } 1699 - 1700 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1701 - return 1702 1005 } 1703 1006 } 1704 1007
-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" ··· 72 71 r.Use(mw.GoImport()) 73 72 74 73 r.Mount("/", s.RepoRouter(mw)) 75 - 74 + r.Mount("/issues", s.IssuesRouter(mw)) 76 75 r.Mount("/pulls", s.PullsRouter(mw)) 77 76 78 77 // These routes get proxied to the knot ··· 156 155 157 156 func (s *State) OAuthRouter() http.Handler { 158 157 store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 159 - oauth := oauth.New(s.config, s.pages, s.idResolver, s.db, store, s.oauth, s.enforcer, s.posthog) 158 + oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, store, s.oauth, s.enforcer, s.posthog) 160 159 return oauth.Router() 161 160 } 162 161 ··· 169 168 } 170 169 171 170 return settings.Router() 171 + } 172 + 173 + func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 174 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog) 175 + return issues.Router(mw) 176 + 172 177 } 173 178 174 179 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {