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