forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
1package issues 2 3import ( 4 "fmt" 5 "log" 6 mathrand "math/rand/v2" 7 "net/http" 8 "slices" 9 "strconv" 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 "github.com/bluesky-social/indigo/atproto/data" 14 lexutil "github.com/bluesky-social/indigo/lex/util" 15 "github.com/go-chi/chi/v5" 16 "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 29type Issues struct { 30 oauth *oauth.OAuth 31 repoResolver *reporesolver.RepoResolver 32 pages *pages.Pages 33 idResolver *idresolver.Resolver 34 db *db.DB 35 config *config.Config 36 posthog posthog.Client 37} 38 39func New( 40 oauth *oauth.OAuth, 41 repoResolver *reporesolver.RepoResolver, 42 pages *pages.Pages, 43 idResolver *idresolver.Resolver, 44 db *db.DB, 45 config *config.Config, 46 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 59func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 60 user := rp.oauth.GetUser(r) 61 f, err := rp.repoResolver.Resolve(r) 62 if err != nil { 63 log.Println("failed to get repo and knot", err) 64 return 65 } 66 67 issueId := chi.URLParam(r, "issue") 68 issueIdInt, err := strconv.Atoi(issueId) 69 if err != nil { 70 http.Error(w, "bad issue id", http.StatusBadRequest) 71 log.Println("failed to parse issue id", err) 72 return 73 } 74 75 issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 76 if err != nil { 77 log.Println("failed to get issue and comments", err) 78 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 79 return 80 } 81 82 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 113func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 114 user := rp.oauth.GetUser(r) 115 f, err := rp.repoResolver.Resolve(r) 116 if err != nil { 117 log.Println("failed to get repo and knot", err) 118 return 119 } 120 121 issueId := chi.URLParam(r, "issue") 122 issueIdInt, err := strconv.Atoi(issueId) 123 if err != nil { 124 http.Error(w, "bad issue id", http.StatusBadRequest) 125 log.Println("failed to parse issue id", err) 126 return 127 } 128 129 issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 130 if err != nil { 131 log.Println("failed to get issue", err) 132 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 133 return 134 } 135 136 collaborators, err := f.Collaborators(r.Context()) 137 if err != nil { 138 log.Println("failed to fetch repo collaborators: %w", err) 139 } 140 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 141 return user.Did == collab.Did 142 }) 143 isIssueOwner := user.Did == issue.OwnerDid 144 145 // TODO: make this more granular 146 if isIssueOwner || isCollaborator { 147 148 closed := tangled.RepoIssueStateClosed 149 150 client, err := rp.oauth.AuthorizedClient(r) 151 if err != nil { 152 log.Println("failed to get authorized client", err) 153 return 154 } 155 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 156 Collection: tangled.RepoIssueStateNSID, 157 Repo: user.Did, 158 Rkey: 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 189func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 190 user := rp.oauth.GetUser(r) 191 f, err := rp.repoResolver.Resolve(r) 192 if err != nil { 193 log.Println("failed to get repo and knot", err) 194 return 195 } 196 197 issueId := chi.URLParam(r, "issue") 198 issueIdInt, err := strconv.Atoi(issueId) 199 if err != nil { 200 http.Error(w, "bad issue id", http.StatusBadRequest) 201 log.Println("failed to parse issue id", err) 202 return 203 } 204 205 issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 206 if err != nil { 207 log.Println("failed to get issue", err) 208 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 209 return 210 } 211 212 collaborators, err := f.Collaborators(r.Context()) 213 if err != nil { 214 log.Println("failed to fetch repo collaborators: %w", err) 215 } 216 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 217 return user.Did == collab.Did 218 }) 219 isIssueOwner := user.Did == issue.OwnerDid 220 221 if isCollaborator || isIssueOwner { 222 err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 223 if err != nil { 224 log.Println("failed to reopen issue", err) 225 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 226 return 227 } 228 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 229 return 230 } else { 231 log.Println("user is not the owner of the repo") 232 http.Error(w, "forbidden", http.StatusUnauthorized) 233 return 234 } 235} 236 237func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 238 user := rp.oauth.GetUser(r) 239 f, err := rp.repoResolver.Resolve(r) 240 if err != nil { 241 log.Println("failed to get repo and knot", err) 242 return 243 } 244 245 issueId := chi.URLParam(r, "issue") 246 issueIdInt, err := strconv.Atoi(issueId) 247 if err != nil { 248 http.Error(w, "bad issue id", http.StatusBadRequest) 249 log.Println("failed to parse issue id", err) 250 return 251 } 252 253 switch r.Method { 254 case http.MethodPost: 255 body := r.FormValue("body") 256 if body == "" { 257 rp.pages.Notice(w, "issue", "Body is required") 258 return 259 } 260 261 commentId := mathrand.IntN(1000000) 262 rkey := 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 321func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 322 user := rp.oauth.GetUser(r) 323 f, err := rp.repoResolver.Resolve(r) 324 if err != nil { 325 log.Println("failed to get repo and knot", err) 326 return 327 } 328 329 issueId := chi.URLParam(r, "issue") 330 issueIdInt, err := strconv.Atoi(issueId) 331 if err != nil { 332 http.Error(w, "bad issue id", http.StatusBadRequest) 333 log.Println("failed to parse issue id", err) 334 return 335 } 336 337 commentId := chi.URLParam(r, "comment_id") 338 commentIdInt, err := strconv.Atoi(commentId) 339 if err != nil { 340 http.Error(w, "bad comment id", http.StatusBadRequest) 341 log.Println("failed to parse issue id", err) 342 return 343 } 344 345 issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 346 if err != nil { 347 log.Println("failed to get issue", err) 348 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 349 return 350 } 351 352 comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 353 if err != nil { 354 http.Error(w, "bad comment id", http.StatusBadRequest) 355 return 356 } 357 358 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 380func (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 510func (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 602func (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 660func (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}