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