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