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