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 "time"
11
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
17 "tangled.sh/tangled.sh/core/api/tangled"
18 "tangled.sh/tangled.sh/core/appview/config"
19 "tangled.sh/tangled.sh/core/appview/db"
20 "tangled.sh/tangled.sh/core/appview/notify"
21 "tangled.sh/tangled.sh/core/appview/oauth"
22 "tangled.sh/tangled.sh/core/appview/pages"
23 "tangled.sh/tangled.sh/core/appview/pagination"
24 "tangled.sh/tangled.sh/core/appview/reporesolver"
25 "tangled.sh/tangled.sh/core/idresolver"
26 "tangled.sh/tangled.sh/core/tid"
27)
28
29type Issues struct {
30 oauth *oauth.OAuth
31 repoResolver *reporesolver.RepoResolver
32 pages *pages.Pages
33 idResolver *idresolver.Resolver
34 db *db.DB
35 config *config.Config
36 notifier notify.Notifier
37}
38
39func New(
40 oauth *oauth.OAuth,
41 repoResolver *reporesolver.RepoResolver,
42 pages *pages.Pages,
43 idResolver *idresolver.Resolver,
44 db *db.DB,
45 config *config.Config,
46 notifier notify.Notifier,
47) *Issues {
48 return &Issues{
49 oauth: oauth,
50 repoResolver: repoResolver,
51 pages: pages,
52 idResolver: idResolver,
53 db: db,
54 config: config,
55 notifier: notifier,
56 }
57}
58
59func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
60 user := rp.oauth.GetUser(r)
61 f, err := rp.repoResolver.Resolve(r)
62 if err != nil {
63 log.Println("failed to get repo and knot", err)
64 return
65 }
66
67 issueId := chi.URLParam(r, "issue")
68 issueIdInt, err := strconv.Atoi(issueId)
69 if err != nil {
70 http.Error(w, "bad issue id", http.StatusBadRequest)
71 log.Println("failed to parse issue id", err)
72 return
73 }
74
75 issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt)
76 if err != nil {
77 log.Println("failed to get issue and comments", err)
78 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
79 return
80 }
81
82 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
83 if err != nil {
84 log.Println("failed to get issue reactions")
85 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
86 }
87
88 userReactions := map[db.ReactionKind]bool{}
89 if user != nil {
90 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
91 }
92
93 issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
94 if err != nil {
95 log.Println("failed to resolve issue owner", err)
96 }
97
98 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
99 LoggedInUser: user,
100 RepoInfo: f.RepoInfo(user),
101 Issue: issue,
102 Comments: comments,
103
104 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
105
106 OrderedReactionKinds: db.OrderedReactionKinds,
107 Reactions: reactionCountMap,
108 UserReacted: userReactions,
109 })
110
111}
112
113func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
114 user := rp.oauth.GetUser(r)
115 f, err := rp.repoResolver.Resolve(r)
116 if err != nil {
117 log.Println("failed to get repo and knot", err)
118 return
119 }
120
121 issueId := chi.URLParam(r, "issue")
122 issueIdInt, err := strconv.Atoi(issueId)
123 if err != nil {
124 http.Error(w, "bad issue id", http.StatusBadRequest)
125 log.Println("failed to parse issue id", err)
126 return
127 }
128
129 issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
130 if err != nil {
131 log.Println("failed to get issue", err)
132 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
133 return
134 }
135
136 collaborators, err := f.Collaborators(r.Context())
137 if err != nil {
138 log.Println("failed to fetch repo collaborators: %w", err)
139 }
140 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
141 return user.Did == collab.Did
142 })
143 isIssueOwner := user.Did == issue.OwnerDid
144
145 // TODO: make this more granular
146 if isIssueOwner || isCollaborator {
147
148 closed := tangled.RepoIssueStateClosed
149
150 client, err := rp.oauth.AuthorizedClient(r)
151 if err != nil {
152 log.Println("failed to get authorized client", err)
153 return
154 }
155 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
156 Collection: tangled.RepoIssueStateNSID,
157 Repo: user.Did,
158 Rkey: tid.TID(),
159 Record: &lexutil.LexiconTypeDecoder{
160 Val: &tangled.RepoIssueState{
161 Issue: issue.AtUri().String(),
162 State: closed,
163 },
164 },
165 })
166
167 if err != nil {
168 log.Println("failed to update issue state", err)
169 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
170 return
171 }
172
173 err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt)
174 if err != nil {
175 log.Println("failed to close issue", err)
176 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
177 return
178 }
179
180 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
181 return
182 } else {
183 log.Println("user is not permitted to close issue")
184 http.Error(w, "for biden", http.StatusUnauthorized)
185 return
186 }
187}
188
189func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
190 user := rp.oauth.GetUser(r)
191 f, err := rp.repoResolver.Resolve(r)
192 if err != nil {
193 log.Println("failed to get repo and knot", err)
194 return
195 }
196
197 issueId := chi.URLParam(r, "issue")
198 issueIdInt, err := strconv.Atoi(issueId)
199 if err != nil {
200 http.Error(w, "bad issue id", http.StatusBadRequest)
201 log.Println("failed to parse issue id", err)
202 return
203 }
204
205 issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
206 if err != nil {
207 log.Println("failed to get issue", err)
208 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
209 return
210 }
211
212 collaborators, err := f.Collaborators(r.Context())
213 if err != nil {
214 log.Println("failed to fetch repo collaborators: %w", err)
215 }
216 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
217 return user.Did == collab.Did
218 })
219 isIssueOwner := user.Did == issue.OwnerDid
220
221 if isCollaborator || isIssueOwner {
222 err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt)
223 if err != nil {
224 log.Println("failed to reopen issue", err)
225 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
226 return
227 }
228 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
229 return
230 } else {
231 log.Println("user is not the owner of the repo")
232 http.Error(w, "forbidden", http.StatusUnauthorized)
233 return
234 }
235}
236
237func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) {
238 user := rp.oauth.GetUser(r)
239 f, err := rp.repoResolver.Resolve(r)
240 if err != nil {
241 log.Println("failed to get repo and knot", err)
242 return
243 }
244
245 issueId := chi.URLParam(r, "issue")
246 issueIdInt, err := strconv.Atoi(issueId)
247 if err != nil {
248 http.Error(w, "bad issue id", http.StatusBadRequest)
249 log.Println("failed to parse issue id", err)
250 return
251 }
252
253 switch r.Method {
254 case http.MethodPost:
255 body := r.FormValue("body")
256 if body == "" {
257 rp.pages.Notice(w, "issue", "Body is required")
258 return
259 }
260
261 commentId := mathrand.IntN(1000000)
262 rkey := tid.TID()
263
264 err := db.NewIssueComment(rp.db, &db.Comment{
265 OwnerDid: user.Did,
266 RepoAt: f.RepoAt(),
267 Issue: issueIdInt,
268 CommentId: commentId,
269 Body: body,
270 Rkey: rkey,
271 })
272 if err != nil {
273 log.Println("failed to create comment", err)
274 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
275 return
276 }
277
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)
282 if err != nil {
283 log.Println("failed to get issue at", err)
284 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
285 return
286 }
287
288 atUri := f.RepoAt().String()
289 client, err := rp.oauth.AuthorizedClient(r)
290 if err != nil {
291 log.Println("failed to get authorized client", err)
292 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
293 return
294 }
295 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
296 Collection: tangled.RepoIssueCommentNSID,
297 Repo: user.Did,
298 Rkey: rkey,
299 Record: &lexutil.LexiconTypeDecoder{
300 Val: &tangled.RepoIssueComment{
301 Repo: &atUri,
302 Issue: issueAt,
303 CommentId: &commentIdInt64,
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 commentIdInt64 := int64(commentIdInt)
453
454 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
455 Collection: tangled.RepoIssueCommentNSID,
456 Repo: user.Did,
457 Rkey: rkey,
458 SwapRecord: ex.Cid,
459 Record: &lexutil.LexiconTypeDecoder{
460 Val: &tangled.RepoIssueComment{
461 Repo: &repoAt,
462 Issue: issueAt,
463 CommentId: &commentIdInt64,
464 Owner: &comment.OwnerDid,
465 Body: newBody,
466 CreatedAt: createdAt,
467 },
468 },
469 })
470 if err != nil {
471 log.Println(err)
472 }
473 }
474
475 // optimistic update for htmx
476 comment.Body = newBody
477 comment.Edited = &edited
478
479 // return new comment body with htmx
480 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
481 LoggedInUser: user,
482 RepoInfo: f.RepoInfo(user),
483 Issue: issue,
484 Comment: comment,
485 })
486 return
487
488 }
489
490}
491
492func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
493 user := rp.oauth.GetUser(r)
494 f, err := rp.repoResolver.Resolve(r)
495 if err != nil {
496 log.Println("failed to get repo and knot", err)
497 return
498 }
499
500 issueId := chi.URLParam(r, "issue")
501 issueIdInt, err := strconv.Atoi(issueId)
502 if err != nil {
503 http.Error(w, "bad issue id", http.StatusBadRequest)
504 log.Println("failed to parse issue id", err)
505 return
506 }
507
508 issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
509 if err != nil {
510 log.Println("failed to get issue", err)
511 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
512 return
513 }
514
515 commentId := chi.URLParam(r, "comment_id")
516 commentIdInt, err := strconv.Atoi(commentId)
517 if err != nil {
518 http.Error(w, "bad comment id", http.StatusBadRequest)
519 log.Println("failed to parse issue id", err)
520 return
521 }
522
523 comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
524 if err != nil {
525 http.Error(w, "bad comment id", http.StatusBadRequest)
526 return
527 }
528
529 if comment.OwnerDid != user.Did {
530 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
531 return
532 }
533
534 if comment.Deleted != nil {
535 http.Error(w, "comment already deleted", http.StatusBadRequest)
536 return
537 }
538
539 // optimistic deletion
540 deleted := time.Now()
541 err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
542 if err != nil {
543 log.Println("failed to delete comment")
544 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
545 return
546 }
547
548 // delete from pds
549 if comment.Rkey != "" {
550 client, err := rp.oauth.AuthorizedClient(r)
551 if err != nil {
552 log.Println("failed to get authorized client", err)
553 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
554 return
555 }
556 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
557 Collection: tangled.GraphFollowNSID,
558 Repo: user.Did,
559 Rkey: comment.Rkey,
560 })
561 if err != nil {
562 log.Println(err)
563 }
564 }
565
566 // optimistic update for htmx
567 comment.Body = ""
568 comment.Deleted = &deleted
569
570 // htmx fragment of comment after deletion
571 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
572 LoggedInUser: user,
573 RepoInfo: f.RepoInfo(user),
574 Issue: issue,
575 Comment: comment,
576 })
577}
578
579func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
580 params := r.URL.Query()
581 state := params.Get("state")
582 isOpen := true
583 switch state {
584 case "open":
585 isOpen = true
586 case "closed":
587 isOpen = false
588 default:
589 isOpen = true
590 }
591
592 page, ok := r.Context().Value("page").(pagination.Page)
593 if !ok {
594 log.Println("failed to get page")
595 page = pagination.FirstPage()
596 }
597
598 user := rp.oauth.GetUser(r)
599 f, err := rp.repoResolver.Resolve(r)
600 if err != nil {
601 log.Println("failed to get repo and knot", err)
602 return
603 }
604
605 issues, err := db.GetIssues(rp.db, f.RepoAt(), isOpen, page)
606 if err != nil {
607 log.Println("failed to get issues", err)
608 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
609 return
610 }
611
612 rp.pages.RepoIssues(w, pages.RepoIssuesParams{
613 LoggedInUser: rp.oauth.GetUser(r),
614 RepoInfo: f.RepoInfo(user),
615 Issues: issues,
616 FilteringByOpen: isOpen,
617 Page: page,
618 })
619}
620
621func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
622 user := rp.oauth.GetUser(r)
623
624 f, err := rp.repoResolver.Resolve(r)
625 if err != nil {
626 log.Println("failed to get repo and knot", err)
627 return
628 }
629
630 switch r.Method {
631 case http.MethodGet:
632 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
633 LoggedInUser: user,
634 RepoInfo: f.RepoInfo(user),
635 })
636 case http.MethodPost:
637 title := r.FormValue("title")
638 body := r.FormValue("body")
639
640 if title == "" || body == "" {
641 rp.pages.Notice(w, "issues", "Title and body are required")
642 return
643 }
644
645 tx, err := rp.db.BeginTx(r.Context(), nil)
646 if err != nil {
647 rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
648 return
649 }
650
651 issue := &db.Issue{
652 RepoAt: f.RepoAt(),
653 Rkey: tid.TID(),
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 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
673 Collection: tangled.RepoIssueNSID,
674 Repo: user.Did,
675 Rkey: issue.Rkey,
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 rp.notifier.NewIssue(r.Context(), issue)
693
694 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
695 return
696 }
697}