Monorepo for Tangled
tangled.org
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 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 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 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
359 LoggedInUser: user,
360 RepoInfo: f.RepoInfo(user),
361 Issue: issue,
362 Comment: comment,
363 })
364}
365
366func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
367 user := rp.oauth.GetUser(r)
368 f, err := rp.repoResolver.Resolve(r)
369 if err != nil {
370 log.Println("failed to get repo and knot", err)
371 return
372 }
373
374 issueId := chi.URLParam(r, "issue")
375 issueIdInt, err := strconv.Atoi(issueId)
376 if err != nil {
377 http.Error(w, "bad issue id", http.StatusBadRequest)
378 log.Println("failed to parse issue id", err)
379 return
380 }
381
382 commentId := chi.URLParam(r, "comment_id")
383 commentIdInt, err := strconv.Atoi(commentId)
384 if err != nil {
385 http.Error(w, "bad comment id", http.StatusBadRequest)
386 log.Println("failed to parse issue id", err)
387 return
388 }
389
390 issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
391 if err != nil {
392 log.Println("failed to get issue", err)
393 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
394 return
395 }
396
397 comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
398 if err != nil {
399 http.Error(w, "bad comment id", http.StatusBadRequest)
400 return
401 }
402
403 if comment.OwnerDid != user.Did {
404 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
405 return
406 }
407
408 switch r.Method {
409 case http.MethodGet:
410 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
411 LoggedInUser: user,
412 RepoInfo: f.RepoInfo(user),
413 Issue: issue,
414 Comment: comment,
415 })
416 case http.MethodPost:
417 // extract form value
418 newBody := r.FormValue("body")
419 client, err := rp.oauth.AuthorizedClient(r)
420 if err != nil {
421 log.Println("failed to get authorized client", err)
422 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
423 return
424 }
425 rkey := comment.Rkey
426
427 // optimistic update
428 edited := time.Now()
429 err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
430 if err != nil {
431 log.Println("failed to perferom update-description query", err)
432 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
433 return
434 }
435
436 // rkey is optional, it was introduced later
437 if comment.Rkey != "" {
438 // update the record on pds
439 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
440 if err != nil {
441 // failed to get record
442 log.Println(err, rkey)
443 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
444 return
445 }
446 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
447 record, _ := data.UnmarshalJSON(value)
448
449 repoAt := record["repo"].(string)
450 issueAt := record["issue"].(string)
451 createdAt := record["createdAt"].(string)
452
453 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
454 Collection: tangled.RepoIssueCommentNSID,
455 Repo: user.Did,
456 Rkey: rkey,
457 SwapRecord: ex.Cid,
458 Record: &lexutil.LexiconTypeDecoder{
459 Val: &tangled.RepoIssueComment{
460 Repo: &repoAt,
461 Issue: issueAt,
462 Owner: &comment.OwnerDid,
463 Body: newBody,
464 CreatedAt: createdAt,
465 },
466 },
467 })
468 if err != nil {
469 log.Println(err)
470 }
471 }
472
473 // optimistic update for htmx
474 comment.Body = newBody
475 comment.Edited = &edited
476
477 // return new comment body with htmx
478 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
479 LoggedInUser: user,
480 RepoInfo: f.RepoInfo(user),
481 Issue: issue,
482 Comment: comment,
483 })
484 return
485
486 }
487
488}
489
490func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
491 user := rp.oauth.GetUser(r)
492 f, err := rp.repoResolver.Resolve(r)
493 if err != nil {
494 log.Println("failed to get repo and knot", err)
495 return
496 }
497
498 issueId := chi.URLParam(r, "issue")
499 issueIdInt, err := strconv.Atoi(issueId)
500 if err != nil {
501 http.Error(w, "bad issue id", http.StatusBadRequest)
502 log.Println("failed to parse issue id", err)
503 return
504 }
505
506 issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
507 if err != nil {
508 log.Println("failed to get issue", err)
509 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
510 return
511 }
512
513 commentId := chi.URLParam(r, "comment_id")
514 commentIdInt, err := strconv.Atoi(commentId)
515 if err != nil {
516 http.Error(w, "bad comment id", http.StatusBadRequest)
517 log.Println("failed to parse issue id", err)
518 return
519 }
520
521 comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
522 if err != nil {
523 http.Error(w, "bad comment id", http.StatusBadRequest)
524 return
525 }
526
527 if comment.OwnerDid != user.Did {
528 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
529 return
530 }
531
532 if comment.Deleted != nil {
533 http.Error(w, "comment already deleted", http.StatusBadRequest)
534 return
535 }
536
537 // optimistic deletion
538 deleted := time.Now()
539 err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
540 if err != nil {
541 log.Println("failed to delete comment")
542 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
543 return
544 }
545
546 // delete from pds
547 if comment.Rkey != "" {
548 client, err := rp.oauth.AuthorizedClient(r)
549 if err != nil {
550 log.Println("failed to get authorized client", err)
551 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
552 return
553 }
554 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
555 Collection: tangled.GraphFollowNSID,
556 Repo: user.Did,
557 Rkey: comment.Rkey,
558 })
559 if err != nil {
560 log.Println(err)
561 }
562 }
563
564 // optimistic update for htmx
565 comment.Body = ""
566 comment.Deleted = &deleted
567
568 // htmx fragment of comment after deletion
569 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
570 LoggedInUser: user,
571 RepoInfo: f.RepoInfo(user),
572 Issue: issue,
573 Comment: comment,
574 })
575}
576
577func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
578 params := r.URL.Query()
579 state := params.Get("state")
580 isOpen := true
581 switch state {
582 case "open":
583 isOpen = true
584 case "closed":
585 isOpen = false
586 default:
587 isOpen = true
588 }
589
590 page, ok := r.Context().Value("page").(pagination.Page)
591 if !ok {
592 log.Println("failed to get page")
593 page = pagination.FirstPage()
594 }
595
596 user := rp.oauth.GetUser(r)
597 f, err := rp.repoResolver.Resolve(r)
598 if err != nil {
599 log.Println("failed to get repo and knot", err)
600 return
601 }
602
603 issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
604 if err != nil {
605 log.Println("failed to get issues", err)
606 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
607 return
608 }
609
610 rp.pages.RepoIssues(w, pages.RepoIssuesParams{
611 LoggedInUser: rp.oauth.GetUser(r),
612 RepoInfo: f.RepoInfo(user),
613 Issues: issues,
614 FilteringByOpen: isOpen,
615 Page: page,
616 })
617}
618
619func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
620 user := rp.oauth.GetUser(r)
621
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 switch r.Method {
629 case http.MethodGet:
630 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
631 LoggedInUser: user,
632 RepoInfo: f.RepoInfo(user),
633 })
634 case http.MethodPost:
635 title := r.FormValue("title")
636 body := r.FormValue("body")
637
638 if title == "" || body == "" {
639 rp.pages.Notice(w, "issues", "Title and body are required")
640 return
641 }
642
643 sanitizer := markup.NewSanitizer()
644 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
645 rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization")
646 return
647 }
648 if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
649 rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization")
650 return
651 }
652
653 tx, err := rp.db.BeginTx(r.Context(), nil)
654 if err != nil {
655 rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
656 return
657 }
658
659 issue := &db.Issue{
660 RepoAt: f.RepoAt(),
661 Rkey: tid.TID(),
662 Title: title,
663 Body: body,
664 OwnerDid: user.Did,
665 }
666 err = db.NewIssue(tx, issue)
667 if err != nil {
668 log.Println("failed to create issue", err)
669 rp.pages.Notice(w, "issues", "Failed to create issue.")
670 return
671 }
672
673 client, err := rp.oauth.AuthorizedClient(r)
674 if err != nil {
675 log.Println("failed to get authorized client", err)
676 rp.pages.Notice(w, "issues", "Failed to create issue.")
677 return
678 }
679 atUri := f.RepoAt().String()
680 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
681 Collection: tangled.RepoIssueNSID,
682 Repo: user.Did,
683 Rkey: issue.Rkey,
684 Record: &lexutil.LexiconTypeDecoder{
685 Val: &tangled.RepoIssue{
686 Repo: atUri,
687 Title: title,
688 Body: &body,
689 },
690 },
691 })
692 if err != nil {
693 log.Println("failed to create issue", err)
694 rp.pages.Notice(w, "issues", "Failed to create issue.")
695 return
696 }
697
698 rp.notifier.NewIssue(r.Context(), issue)
699
700 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
701 return
702 }
703}