···
6
+
mathrand "math/rand/v2"
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"
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"
29
+
type Issues struct {
31
+
repoResolver *reporesolver.RepoResolver
33
+
idResolver *idresolver.Resolver
35
+
config *config.Config
36
+
posthog posthog.Client
41
+
repoResolver *reporesolver.RepoResolver,
43
+
idResolver *idresolver.Resolver,
45
+
config *config.Config,
46
+
posthog posthog.Client,
50
+
repoResolver: repoResolver,
52
+
idResolver: idResolver,
59
+
func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
60
+
user := rp.oauth.GetUser(r)
61
+
f, err := rp.repoResolver.Resolve(r)
63
+
log.Println("failed to get repo and knot", err)
67
+
issueId := chi.URLParam(r, "issue")
68
+
issueIdInt, err := strconv.Atoi(issueId)
70
+
http.Error(w, "bad issue id", http.StatusBadRequest)
71
+
log.Println("failed to parse issue id", err)
75
+
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt)
77
+
log.Println("failed to get issue and comments", err)
78
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
82
+
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
84
+
log.Println("failed to resolve issue owner", err)
87
+
identsToResolve := make([]string, len(comments))
88
+
for i, comment := range comments {
89
+
identsToResolve[i] = comment.OwnerDid
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())
97
+
didHandleMap[identity.DID.String()] = identity.DID.String()
101
+
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
102
+
LoggedInUser: user,
103
+
RepoInfo: f.RepoInfo(user),
105
+
Comments: comments,
107
+
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
108
+
DidHandleMap: didHandleMap,
113
+
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
114
+
user := rp.oauth.GetUser(r)
115
+
f, err := rp.repoResolver.Resolve(r)
117
+
log.Println("failed to get repo and knot", err)
121
+
issueId := chi.URLParam(r, "issue")
122
+
issueIdInt, err := strconv.Atoi(issueId)
124
+
http.Error(w, "bad issue id", http.StatusBadRequest)
125
+
log.Println("failed to parse issue id", err)
129
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
131
+
log.Println("failed to get issue", err)
132
+
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
136
+
collaborators, err := f.Collaborators(r.Context())
138
+
log.Println("failed to fetch repo collaborators: %w", err)
140
+
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
141
+
return user.Did == collab.Did
143
+
isIssueOwner := user.Did == issue.OwnerDid
145
+
// TODO: make this more granular
146
+
if isIssueOwner || isCollaborator {
148
+
closed := tangled.RepoIssueStateClosed
150
+
client, err := rp.oauth.AuthorizedClient(r)
152
+
log.Println("failed to get authorized client", err)
155
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
156
+
Collection: tangled.RepoIssueStateNSID,
158
+
Rkey: appview.TID(),
159
+
Record: &lexutil.LexiconTypeDecoder{
160
+
Val: &tangled.RepoIssueState{
161
+
Issue: issue.IssueAt,
168
+
log.Println("failed to update issue state", err)
169
+
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
173
+
err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt)
175
+
log.Println("failed to close issue", err)
176
+
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
180
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
183
+
log.Println("user is not permitted to close issue")
184
+
http.Error(w, "for biden", http.StatusUnauthorized)
189
+
func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
190
+
user := rp.oauth.GetUser(r)
191
+
f, err := rp.repoResolver.Resolve(r)
193
+
log.Println("failed to get repo and knot", err)
197
+
issueId := chi.URLParam(r, "issue")
198
+
issueIdInt, err := strconv.Atoi(issueId)
200
+
http.Error(w, "bad issue id", http.StatusBadRequest)
201
+
log.Println("failed to parse issue id", err)
205
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
207
+
log.Println("failed to get issue", err)
208
+
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
212
+
collaborators, err := f.Collaborators(r.Context())
214
+
log.Println("failed to fetch repo collaborators: %w", err)
216
+
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
217
+
return user.Did == collab.Did
219
+
isIssueOwner := user.Did == issue.OwnerDid
221
+
if isCollaborator || isIssueOwner {
222
+
err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt)
224
+
log.Println("failed to reopen issue", err)
225
+
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
228
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
231
+
log.Println("user is not the owner of the repo")
232
+
http.Error(w, "forbidden", http.StatusUnauthorized)
237
+
func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) {
238
+
user := rp.oauth.GetUser(r)
239
+
f, err := rp.repoResolver.Resolve(r)
241
+
log.Println("failed to get repo and knot", err)
245
+
issueId := chi.URLParam(r, "issue")
246
+
issueIdInt, err := strconv.Atoi(issueId)
248
+
http.Error(w, "bad issue id", http.StatusBadRequest)
249
+
log.Println("failed to parse issue id", err)
254
+
case http.MethodPost:
255
+
body := r.FormValue("body")
257
+
rp.pages.Notice(w, "issue", "Body is required")
261
+
commentId := mathrand.IntN(1000000)
262
+
rkey := appview.TID()
264
+
err := db.NewIssueComment(rp.db, &db.Comment{
265
+
OwnerDid: user.Did,
268
+
CommentId: commentId,
273
+
log.Println("failed to create comment", err)
274
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
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)
283
+
log.Println("failed to get issue at", err)
284
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
288
+
atUri := f.RepoAt.String()
289
+
client, err := rp.oauth.AuthorizedClient(r)
291
+
log.Println("failed to get authorized client", err)
292
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
295
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
296
+
Collection: tangled.RepoIssueCommentNSID,
299
+
Record: &lexutil.LexiconTypeDecoder{
300
+
Val: &tangled.RepoIssueComment{
303
+
CommentId: &commentIdInt64,
306
+
CreatedAt: createdAt,
311
+
log.Println("failed to create comment", err)
312
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
316
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
321
+
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
322
+
user := rp.oauth.GetUser(r)
323
+
f, err := rp.repoResolver.Resolve(r)
325
+
log.Println("failed to get repo and knot", err)
329
+
issueId := chi.URLParam(r, "issue")
330
+
issueIdInt, err := strconv.Atoi(issueId)
332
+
http.Error(w, "bad issue id", http.StatusBadRequest)
333
+
log.Println("failed to parse issue id", err)
337
+
commentId := chi.URLParam(r, "comment_id")
338
+
commentIdInt, err := strconv.Atoi(commentId)
340
+
http.Error(w, "bad comment id", http.StatusBadRequest)
341
+
log.Println("failed to parse issue id", err)
345
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
347
+
log.Println("failed to get issue", err)
348
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
352
+
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
354
+
http.Error(w, "bad comment id", http.StatusBadRequest)
358
+
identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid)
360
+
log.Println("failed to resolve did")
364
+
didHandleMap := make(map[string]string)
365
+
if !identity.Handle.IsInvalidHandle() {
366
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
368
+
didHandleMap[identity.DID.String()] = identity.DID.String()
371
+
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
372
+
LoggedInUser: user,
373
+
RepoInfo: f.RepoInfo(user),
374
+
DidHandleMap: didHandleMap,
380
+
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
381
+
user := rp.oauth.GetUser(r)
382
+
f, err := rp.repoResolver.Resolve(r)
384
+
log.Println("failed to get repo and knot", err)
388
+
issueId := chi.URLParam(r, "issue")
389
+
issueIdInt, err := strconv.Atoi(issueId)
391
+
http.Error(w, "bad issue id", http.StatusBadRequest)
392
+
log.Println("failed to parse issue id", err)
396
+
commentId := chi.URLParam(r, "comment_id")
397
+
commentIdInt, err := strconv.Atoi(commentId)
399
+
http.Error(w, "bad comment id", http.StatusBadRequest)
400
+
log.Println("failed to parse issue id", err)
404
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
406
+
log.Println("failed to get issue", err)
407
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
411
+
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
413
+
http.Error(w, "bad comment id", http.StatusBadRequest)
417
+
if comment.OwnerDid != user.Did {
418
+
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
423
+
case http.MethodGet:
424
+
rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
425
+
LoggedInUser: user,
426
+
RepoInfo: f.RepoInfo(user),
430
+
case http.MethodPost:
431
+
// extract form value
432
+
newBody := r.FormValue("body")
433
+
client, err := rp.oauth.AuthorizedClient(r)
435
+
log.Println("failed to get authorized client", err)
436
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
439
+
rkey := comment.Rkey
441
+
// optimistic update
442
+
edited := time.Now()
443
+
err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
445
+
log.Println("failed to perferom update-description query", err)
446
+
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
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)
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.")
460
+
value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
461
+
record, _ := data.UnmarshalJSON(value)
463
+
repoAt := record["repo"].(string)
464
+
issueAt := record["issue"].(string)
465
+
createdAt := record["createdAt"].(string)
466
+
commentIdInt64 := int64(commentIdInt)
468
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
469
+
Collection: tangled.RepoIssueCommentNSID,
472
+
SwapRecord: ex.Cid,
473
+
Record: &lexutil.LexiconTypeDecoder{
474
+
Val: &tangled.RepoIssueComment{
477
+
CommentId: &commentIdInt64,
478
+
Owner: &comment.OwnerDid,
480
+
CreatedAt: createdAt,
489
+
// optimistic update for htmx
490
+
didHandleMap := map[string]string{
491
+
user.Did: user.Handle,
493
+
comment.Body = newBody
494
+
comment.Edited = &edited
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,
510
+
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
511
+
user := rp.oauth.GetUser(r)
512
+
f, err := rp.repoResolver.Resolve(r)
514
+
log.Println("failed to get repo and knot", err)
518
+
issueId := chi.URLParam(r, "issue")
519
+
issueIdInt, err := strconv.Atoi(issueId)
521
+
http.Error(w, "bad issue id", http.StatusBadRequest)
522
+
log.Println("failed to parse issue id", err)
526
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
528
+
log.Println("failed to get issue", err)
529
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
533
+
commentId := chi.URLParam(r, "comment_id")
534
+
commentIdInt, err := strconv.Atoi(commentId)
536
+
http.Error(w, "bad comment id", http.StatusBadRequest)
537
+
log.Println("failed to parse issue id", err)
541
+
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
543
+
http.Error(w, "bad comment id", http.StatusBadRequest)
547
+
if comment.OwnerDid != user.Did {
548
+
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
552
+
if comment.Deleted != nil {
553
+
http.Error(w, "comment already deleted", http.StatusBadRequest)
557
+
// optimistic deletion
558
+
deleted := time.Now()
559
+
err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
561
+
log.Println("failed to delete comment")
562
+
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
567
+
if comment.Rkey != "" {
568
+
client, err := rp.oauth.AuthorizedClient(r)
570
+
log.Println("failed to get authorized client", err)
571
+
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
574
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
575
+
Collection: tangled.GraphFollowNSID,
577
+
Rkey: comment.Rkey,
584
+
// optimistic update for htmx
585
+
didHandleMap := map[string]string{
586
+
user.Did: user.Handle,
589
+
comment.Deleted = &deleted
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,
602
+
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
603
+
params := r.URL.Query()
604
+
state := params.Get("state")
615
+
page, ok := r.Context().Value("page").(pagination.Page)
617
+
log.Println("failed to get page")
618
+
page = pagination.FirstPage()
621
+
user := rp.oauth.GetUser(r)
622
+
f, err := rp.repoResolver.Resolve(r)
624
+
log.Println("failed to get repo and knot", err)
628
+
issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page)
630
+
log.Println("failed to get issues", err)
631
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
635
+
identsToResolve := make([]string, len(issues))
636
+
for i, issue := range issues {
637
+
identsToResolve[i] = issue.OwnerDid
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())
645
+
didHandleMap[identity.DID.String()] = identity.DID.String()
649
+
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
650
+
LoggedInUser: rp.oauth.GetUser(r),
651
+
RepoInfo: f.RepoInfo(user),
653
+
DidHandleMap: didHandleMap,
654
+
FilteringByOpen: isOpen,
660
+
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
661
+
user := rp.oauth.GetUser(r)
663
+
f, err := rp.repoResolver.Resolve(r)
665
+
log.Println("failed to get repo and knot", err)
670
+
case http.MethodGet:
671
+
rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
672
+
LoggedInUser: user,
673
+
RepoInfo: f.RepoInfo(user),
675
+
case http.MethodPost:
676
+
title := r.FormValue("title")
677
+
body := r.FormValue("body")
679
+
if title == "" || body == "" {
680
+
rp.pages.Notice(w, "issues", "Title and body are required")
684
+
tx, err := rp.db.BeginTx(r.Context(), nil)
686
+
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
690
+
err = db.NewIssue(tx, &db.Issue{
694
+
OwnerDid: user.Did,
697
+
log.Println("failed to create issue", err)
698
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
702
+
issueId, err := db.GetIssueId(rp.db, f.RepoAt)
704
+
log.Println("failed to get issue id", err)
705
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
709
+
client, err := rp.oauth.AuthorizedClient(r)
711
+
log.Println("failed to get authorized client", err)
712
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
715
+
atUri := f.RepoAt.String()
716
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
717
+
Collection: tangled.RepoIssueNSID,
719
+
Rkey: appview.TID(),
720
+
Record: &lexutil.LexiconTypeDecoder{
721
+
Val: &tangled.RepoIssue{
726
+
IssueId: int64(issueId),
731
+
log.Println("failed to create issue", err)
732
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
736
+
err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri)
738
+
log.Println("failed to set issue at", err)
739
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
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},
750
+
log.Println("failed to enqueue posthog event:", err)
754
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))