1package issues
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "slices"
11 "time"
12
13 comatproto "github.com/bluesky-social/indigo/api/atproto"
14 atpclient "github.com/bluesky-social/indigo/atproto/client"
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.org/core/api/tangled"
20 "tangled.org/core/appview/config"
21 "tangled.org/core/appview/db"
22 issues_indexer "tangled.org/core/appview/indexer/issues"
23 "tangled.org/core/appview/models"
24 "tangled.org/core/appview/notify"
25 "tangled.org/core/appview/oauth"
26 "tangled.org/core/appview/pages"
27 "tangled.org/core/appview/pages/markup"
28 "tangled.org/core/appview/pagination"
29 "tangled.org/core/appview/reporesolver"
30 "tangled.org/core/appview/validator"
31 "tangled.org/core/idresolver"
32 "tangled.org/core/tid"
33)
34
35type Issues struct {
36 oauth *oauth.OAuth
37 repoResolver *reporesolver.RepoResolver
38 pages *pages.Pages
39 idResolver *idresolver.Resolver
40 db *db.DB
41 config *config.Config
42 notifier notify.Notifier
43 logger *slog.Logger
44 validator *validator.Validator
45 indexer *issues_indexer.Indexer
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 validator *validator.Validator,
57 indexer *issues_indexer.Indexer,
58 logger *slog.Logger,
59) *Issues {
60 return &Issues{
61 oauth: oauth,
62 repoResolver: repoResolver,
63 pages: pages,
64 idResolver: idResolver,
65 db: db,
66 config: config,
67 notifier: notifier,
68 logger: logger,
69 validator: validator,
70 indexer: indexer,
71 }
72}
73
74func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
75 l := rp.logger.With("handler", "RepoSingleIssue")
76 user := rp.oauth.GetUser(r)
77 f, err := rp.repoResolver.Resolve(r)
78 if err != nil {
79 l.Error("failed to get repo and knot", "err", err)
80 return
81 }
82
83 issue, ok := r.Context().Value("issue").(*models.Issue)
84 if !ok {
85 l.Error("failed to get issue")
86 rp.pages.Error404(w)
87 return
88 }
89
90 reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri())
91 if err != nil {
92 l.Error("failed to get issue reactions", "err", err)
93 }
94
95 userReactions := map[models.ReactionKind]bool{}
96 if user != nil {
97 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
98 }
99
100 labelDefs, err := db.GetLabelDefinitions(
101 rp.db,
102 db.FilterIn("at_uri", f.Repo.Labels),
103 db.FilterContains("scope", tangled.RepoIssueNSID),
104 )
105 if err != nil {
106 l.Error("failed to fetch labels", "err", err)
107 rp.pages.Error503(w)
108 return
109 }
110
111 defs := make(map[string]*models.LabelDefinition)
112 for _, l := range labelDefs {
113 defs[l.AtUri().String()] = &l
114 }
115
116 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
117 LoggedInUser: user,
118 RepoInfo: f.RepoInfo(user),
119 Issue: issue,
120 CommentList: issue.CommentList(),
121 OrderedReactionKinds: models.OrderedReactionKinds,
122 Reactions: reactionMap,
123 UserReacted: userReactions,
124 LabelDefs: defs,
125 })
126}
127
128func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
129 l := rp.logger.With("handler", "EditIssue")
130 user := rp.oauth.GetUser(r)
131 f, err := rp.repoResolver.Resolve(r)
132 if err != nil {
133 l.Error("failed to get repo and knot", "err", err)
134 return
135 }
136
137 issue, ok := r.Context().Value("issue").(*models.Issue)
138 if !ok {
139 l.Error("failed to get issue")
140 rp.pages.Error404(w)
141 return
142 }
143
144 switch r.Method {
145 case http.MethodGet:
146 rp.pages.EditIssueFragment(w, pages.EditIssueParams{
147 LoggedInUser: user,
148 RepoInfo: f.RepoInfo(user),
149 Issue: issue,
150 })
151 case http.MethodPost:
152 noticeId := "issues"
153 newIssue := issue
154 newIssue.Title = r.FormValue("title")
155 newIssue.Body = r.FormValue("body")
156
157 if err := rp.validator.ValidateIssue(newIssue); err != nil {
158 l.Error("validation error", "err", err)
159 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err))
160 return
161 }
162
163 newRecord := newIssue.AsRecord()
164
165 // edit an atproto record
166 client, err := rp.oauth.AuthorizedClient(r)
167 if err != nil {
168 l.Error("failed to get authorized client", "err", err)
169 rp.pages.Notice(w, noticeId, "Failed to edit issue.")
170 return
171 }
172
173 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
174 if err != nil {
175 l.Error("failed to get record", "err", err)
176 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
177 return
178 }
179
180 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
181 Collection: tangled.RepoIssueNSID,
182 Repo: user.Did,
183 Rkey: newIssue.Rkey,
184 SwapRecord: ex.Cid,
185 Record: &lexutil.LexiconTypeDecoder{
186 Val: &newRecord,
187 },
188 })
189 if err != nil {
190 l.Error("failed to edit record on PDS", "err", err)
191 rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.")
192 return
193 }
194
195 // modify on DB -- TODO: transact this cleverly
196 tx, err := rp.db.Begin()
197 if err != nil {
198 l.Error("failed to edit issue on DB", "err", err)
199 rp.pages.Notice(w, noticeId, "Failed to edit issue.")
200 return
201 }
202 defer tx.Rollback()
203
204 err = db.PutIssue(tx, newIssue)
205 if err != nil {
206 l.Error("failed to edit issue", "err", err)
207 rp.pages.Notice(w, "issues", "Failed to edit issue.")
208 return
209 }
210
211 if err = tx.Commit(); err != nil {
212 l.Error("failed to edit issue", "err", err)
213 rp.pages.Notice(w, "issues", "Failed to cedit issue.")
214 return
215 }
216
217 rp.pages.HxRefresh(w)
218 }
219}
220
221func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) {
222 l := rp.logger.With("handler", "DeleteIssue")
223 noticeId := "issue-actions-error"
224
225 user := rp.oauth.GetUser(r)
226
227 f, err := rp.repoResolver.Resolve(r)
228 if err != nil {
229 l.Error("failed to get repo and knot", "err", err)
230 return
231 }
232
233 issue, ok := r.Context().Value("issue").(*models.Issue)
234 if !ok {
235 l.Error("failed to get issue")
236 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
237 return
238 }
239 l = l.With("did", issue.Did, "rkey", issue.Rkey)
240
241 // delete from PDS
242 client, err := rp.oauth.AuthorizedClient(r)
243 if err != nil {
244 l.Error("failed to get authorized client", "err", err)
245 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
246 return
247 }
248 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
249 Collection: tangled.RepoIssueNSID,
250 Repo: issue.Did,
251 Rkey: issue.Rkey,
252 })
253 if err != nil {
254 // TODO: transact this better
255 l.Error("failed to delete issue from PDS", "err", err)
256 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
257 return
258 }
259
260 // delete from db
261 if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil {
262 l.Error("failed to delete issue", "err", err)
263 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
264 return
265 }
266
267 rp.notifier.DeleteIssue(r.Context(), issue)
268
269 // return to all issues page
270 rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
271}
272
273func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
274 l := rp.logger.With("handler", "CloseIssue")
275 user := rp.oauth.GetUser(r)
276 f, err := rp.repoResolver.Resolve(r)
277 if err != nil {
278 l.Error("failed to get repo and knot", "err", err)
279 return
280 }
281
282 issue, ok := r.Context().Value("issue").(*models.Issue)
283 if !ok {
284 l.Error("failed to get issue")
285 rp.pages.Error404(w)
286 return
287 }
288
289 collaborators, err := f.Collaborators(r.Context())
290 if err != nil {
291 l.Error("failed to fetch repo collaborators", "err", err)
292 }
293 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
294 return user.Did == collab.Did
295 })
296 isIssueOwner := user.Did == issue.Did
297
298 // TODO: make this more granular
299 if isIssueOwner || isCollaborator {
300 err = db.CloseIssues(
301 rp.db,
302 db.FilterEq("id", issue.Id),
303 )
304 if err != nil {
305 l.Error("failed to close issue", "err", err)
306 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
307 return
308 }
309 // change the issue state (this will pass down to the notifiers)
310 issue.Open = false
311
312 // notify about the issue closure
313 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
314
315 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
316 return
317 } else {
318 l.Error("user is not permitted to close issue")
319 http.Error(w, "for biden", http.StatusUnauthorized)
320 return
321 }
322}
323
324func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
325 l := rp.logger.With("handler", "ReopenIssue")
326 user := rp.oauth.GetUser(r)
327 f, err := rp.repoResolver.Resolve(r)
328 if err != nil {
329 l.Error("failed to get repo and knot", "err", err)
330 return
331 }
332
333 issue, ok := r.Context().Value("issue").(*models.Issue)
334 if !ok {
335 l.Error("failed to get issue")
336 rp.pages.Error404(w)
337 return
338 }
339
340 collaborators, err := f.Collaborators(r.Context())
341 if err != nil {
342 l.Error("failed to fetch repo collaborators", "err", err)
343 }
344 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
345 return user.Did == collab.Did
346 })
347 isIssueOwner := user.Did == issue.Did
348
349 if isCollaborator || isIssueOwner {
350 err := db.ReopenIssues(
351 rp.db,
352 db.FilterEq("id", issue.Id),
353 )
354 if err != nil {
355 l.Error("failed to reopen issue", "err", err)
356 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
357 return
358 }
359 // change the issue state (this will pass down to the notifiers)
360 issue.Open = true
361
362 // notify about the issue reopen
363 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
364
365 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
366 return
367 } else {
368 l.Error("user is not the owner of the repo")
369 http.Error(w, "forbidden", http.StatusUnauthorized)
370 return
371 }
372}
373
374func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) {
375 l := rp.logger.With("handler", "NewIssueComment")
376 user := rp.oauth.GetUser(r)
377 f, err := rp.repoResolver.Resolve(r)
378 if err != nil {
379 l.Error("failed to get repo and knot", "err", err)
380 return
381 }
382
383 issue, ok := r.Context().Value("issue").(*models.Issue)
384 if !ok {
385 l.Error("failed to get issue")
386 rp.pages.Error404(w)
387 return
388 }
389
390 body := r.FormValue("body")
391 if body == "" {
392 rp.pages.Notice(w, "issue", "Body is required")
393 return
394 }
395
396 replyToUri := r.FormValue("reply-to")
397 var replyTo *string
398 if replyToUri != "" {
399 replyTo = &replyToUri
400 }
401
402 comment := models.IssueComment{
403 Did: user.Did,
404 Rkey: tid.TID(),
405 IssueAt: issue.AtUri().String(),
406 ReplyTo: replyTo,
407 Body: body,
408 Created: time.Now(),
409 }
410 if err = rp.validator.ValidateIssueComment(&comment); err != nil {
411 l.Error("failed to validate comment", "err", err)
412 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
413 return
414 }
415 record := comment.AsRecord()
416
417 client, err := rp.oauth.AuthorizedClient(r)
418 if err != nil {
419 l.Error("failed to get authorized client", "err", err)
420 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
421 return
422 }
423
424 // create a record first
425 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
426 Collection: tangled.RepoIssueCommentNSID,
427 Repo: comment.Did,
428 Rkey: comment.Rkey,
429 Record: &lexutil.LexiconTypeDecoder{
430 Val: &record,
431 },
432 })
433 if err != nil {
434 l.Error("failed to create comment", "err", err)
435 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
436 return
437 }
438 atUri := resp.Uri
439 defer func() {
440 if err := rollbackRecord(context.Background(), atUri, client); err != nil {
441 l.Error("rollback failed", "err", err)
442 }
443 }()
444
445 commentId, err := db.AddIssueComment(rp.db, comment)
446 if err != nil {
447 l.Error("failed to create comment", "err", err)
448 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
449 return
450 }
451
452 // reset atUri to make rollback a no-op
453 atUri = ""
454
455 // notify about the new comment
456 comment.Id = commentId
457
458 rawMentions := markup.FindUserMentions(comment.Body)
459 idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
460 l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
461 var mentions []syntax.DID
462 for _, ident := range idents {
463 if ident != nil && !ident.Handle.IsInvalidHandle() {
464 mentions = append(mentions, ident.DID)
465 }
466 }
467 rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
468
469 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
470}
471
472func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
473 l := rp.logger.With("handler", "IssueComment")
474 user := rp.oauth.GetUser(r)
475 f, err := rp.repoResolver.Resolve(r)
476 if err != nil {
477 l.Error("failed to get repo and knot", "err", err)
478 return
479 }
480
481 issue, ok := r.Context().Value("issue").(*models.Issue)
482 if !ok {
483 l.Error("failed to get issue")
484 rp.pages.Error404(w)
485 return
486 }
487
488 commentId := chi.URLParam(r, "commentId")
489 comments, err := db.GetIssueComments(
490 rp.db,
491 db.FilterEq("id", commentId),
492 )
493 if err != nil {
494 l.Error("failed to fetch comment", "id", commentId)
495 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
496 return
497 }
498 if len(comments) != 1 {
499 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
500 http.Error(w, "invalid comment id", http.StatusBadRequest)
501 return
502 }
503 comment := comments[0]
504
505 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
506 LoggedInUser: user,
507 RepoInfo: f.RepoInfo(user),
508 Issue: issue,
509 Comment: &comment,
510 })
511}
512
513func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
514 l := rp.logger.With("handler", "EditIssueComment")
515 user := rp.oauth.GetUser(r)
516 f, err := rp.repoResolver.Resolve(r)
517 if err != nil {
518 l.Error("failed to get repo and knot", "err", err)
519 return
520 }
521
522 issue, ok := r.Context().Value("issue").(*models.Issue)
523 if !ok {
524 l.Error("failed to get issue")
525 rp.pages.Error404(w)
526 return
527 }
528
529 commentId := chi.URLParam(r, "commentId")
530 comments, err := db.GetIssueComments(
531 rp.db,
532 db.FilterEq("id", commentId),
533 )
534 if err != nil {
535 l.Error("failed to fetch comment", "id", commentId)
536 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
537 return
538 }
539 if len(comments) != 1 {
540 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
541 http.Error(w, "invalid comment id", http.StatusBadRequest)
542 return
543 }
544 comment := comments[0]
545
546 if comment.Did != user.Did {
547 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did)
548 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
549 return
550 }
551
552 switch r.Method {
553 case http.MethodGet:
554 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
555 LoggedInUser: user,
556 RepoInfo: f.RepoInfo(user),
557 Issue: issue,
558 Comment: &comment,
559 })
560 case http.MethodPost:
561 // extract form value
562 newBody := r.FormValue("body")
563 client, err := rp.oauth.AuthorizedClient(r)
564 if err != nil {
565 l.Error("failed to get authorized client", "err", err)
566 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
567 return
568 }
569
570 now := time.Now()
571 newComment := comment
572 newComment.Body = newBody
573 newComment.Edited = &now
574 record := newComment.AsRecord()
575
576 _, err = db.AddIssueComment(rp.db, newComment)
577 if err != nil {
578 l.Error("failed to perferom update-description query", "err", err)
579 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
580 return
581 }
582
583 // rkey is optional, it was introduced later
584 if newComment.Rkey != "" {
585 // update the record on pds
586 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
587 if err != nil {
588 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
589 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
590 return
591 }
592
593 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
594 Collection: tangled.RepoIssueCommentNSID,
595 Repo: user.Did,
596 Rkey: newComment.Rkey,
597 SwapRecord: ex.Cid,
598 Record: &lexutil.LexiconTypeDecoder{
599 Val: &record,
600 },
601 })
602 if err != nil {
603 l.Error("failed to update record on PDS", "err", err)
604 }
605 }
606
607 // return new comment body with htmx
608 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
609 LoggedInUser: user,
610 RepoInfo: f.RepoInfo(user),
611 Issue: issue,
612 Comment: &newComment,
613 })
614 }
615}
616
617func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
618 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
619 user := rp.oauth.GetUser(r)
620 f, err := rp.repoResolver.Resolve(r)
621 if err != nil {
622 l.Error("failed to get repo and knot", "err", err)
623 return
624 }
625
626 issue, ok := r.Context().Value("issue").(*models.Issue)
627 if !ok {
628 l.Error("failed to get issue")
629 rp.pages.Error404(w)
630 return
631 }
632
633 commentId := chi.URLParam(r, "commentId")
634 comments, err := db.GetIssueComments(
635 rp.db,
636 db.FilterEq("id", commentId),
637 )
638 if err != nil {
639 l.Error("failed to fetch comment", "id", commentId)
640 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
641 return
642 }
643 if len(comments) != 1 {
644 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
645 http.Error(w, "invalid comment id", http.StatusBadRequest)
646 return
647 }
648 comment := comments[0]
649
650 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
651 LoggedInUser: user,
652 RepoInfo: f.RepoInfo(user),
653 Issue: issue,
654 Comment: &comment,
655 })
656}
657
658func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
659 l := rp.logger.With("handler", "ReplyIssueComment")
660 user := rp.oauth.GetUser(r)
661 f, err := rp.repoResolver.Resolve(r)
662 if err != nil {
663 l.Error("failed to get repo and knot", "err", err)
664 return
665 }
666
667 issue, ok := r.Context().Value("issue").(*models.Issue)
668 if !ok {
669 l.Error("failed to get issue")
670 rp.pages.Error404(w)
671 return
672 }
673
674 commentId := chi.URLParam(r, "commentId")
675 comments, err := db.GetIssueComments(
676 rp.db,
677 db.FilterEq("id", commentId),
678 )
679 if err != nil {
680 l.Error("failed to fetch comment", "id", commentId)
681 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
682 return
683 }
684 if len(comments) != 1 {
685 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
686 http.Error(w, "invalid comment id", http.StatusBadRequest)
687 return
688 }
689 comment := comments[0]
690
691 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
692 LoggedInUser: user,
693 RepoInfo: f.RepoInfo(user),
694 Issue: issue,
695 Comment: &comment,
696 })
697}
698
699func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
700 l := rp.logger.With("handler", "DeleteIssueComment")
701 user := rp.oauth.GetUser(r)
702 f, err := rp.repoResolver.Resolve(r)
703 if err != nil {
704 l.Error("failed to get repo and knot", "err", err)
705 return
706 }
707
708 issue, ok := r.Context().Value("issue").(*models.Issue)
709 if !ok {
710 l.Error("failed to get issue")
711 rp.pages.Error404(w)
712 return
713 }
714
715 commentId := chi.URLParam(r, "commentId")
716 comments, err := db.GetIssueComments(
717 rp.db,
718 db.FilterEq("id", commentId),
719 )
720 if err != nil {
721 l.Error("failed to fetch comment", "id", commentId)
722 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
723 return
724 }
725 if len(comments) != 1 {
726 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
727 http.Error(w, "invalid comment id", http.StatusBadRequest)
728 return
729 }
730 comment := comments[0]
731
732 if comment.Did != user.Did {
733 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
734 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
735 return
736 }
737
738 if comment.Deleted != nil {
739 http.Error(w, "comment already deleted", http.StatusBadRequest)
740 return
741 }
742
743 // optimistic deletion
744 deleted := time.Now()
745 err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id))
746 if err != nil {
747 l.Error("failed to delete comment", "err", err)
748 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
749 return
750 }
751
752 // delete from pds
753 if comment.Rkey != "" {
754 client, err := rp.oauth.AuthorizedClient(r)
755 if err != nil {
756 l.Error("failed to get authorized client", "err", err)
757 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
758 return
759 }
760 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
761 Collection: tangled.RepoIssueCommentNSID,
762 Repo: user.Did,
763 Rkey: comment.Rkey,
764 })
765 if err != nil {
766 l.Error("failed to delete from PDS", "err", err)
767 }
768 }
769
770 // optimistic update for htmx
771 comment.Body = ""
772 comment.Deleted = &deleted
773
774 // htmx fragment of comment after deletion
775 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
776 LoggedInUser: user,
777 RepoInfo: f.RepoInfo(user),
778 Issue: issue,
779 Comment: &comment,
780 })
781}
782
783func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
784 l := rp.logger.With("handler", "RepoIssues")
785
786 params := r.URL.Query()
787 state := params.Get("state")
788 isOpen := true
789 switch state {
790 case "open":
791 isOpen = true
792 case "closed":
793 isOpen = false
794 default:
795 isOpen = true
796 }
797
798 page := pagination.FromContext(r.Context())
799
800 user := rp.oauth.GetUser(r)
801 f, err := rp.repoResolver.Resolve(r)
802 if err != nil {
803 l.Error("failed to get repo and knot", "err", err)
804 return
805 }
806
807 keyword := params.Get("q")
808
809 var issues []models.Issue
810 searchOpts := models.IssueSearchOptions{
811 Keyword: keyword,
812 RepoAt: f.RepoAt().String(),
813 IsOpen: isOpen,
814 Page: page,
815 }
816 if keyword != "" {
817 res, err := rp.indexer.Search(r.Context(), searchOpts)
818 if err != nil {
819 l.Error("failed to search for issues", "err", err)
820 return
821 }
822 l.Debug("searched issues with indexer", "count", len(res.Hits))
823
824 issues, err = db.GetIssues(
825 rp.db,
826 db.FilterIn("id", res.Hits),
827 )
828 if err != nil {
829 l.Error("failed to get issues", "err", err)
830 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
831 return
832 }
833
834 } else {
835 openInt := 0
836 if isOpen {
837 openInt = 1
838 }
839 issues, err = db.GetIssuesPaginated(
840 rp.db,
841 page,
842 db.FilterEq("repo_at", f.RepoAt()),
843 db.FilterEq("open", openInt),
844 )
845 if err != nil {
846 l.Error("failed to get issues", "err", err)
847 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
848 return
849 }
850 }
851
852 labelDefs, err := db.GetLabelDefinitions(
853 rp.db,
854 db.FilterIn("at_uri", f.Repo.Labels),
855 db.FilterContains("scope", tangled.RepoIssueNSID),
856 )
857 if err != nil {
858 l.Error("failed to fetch labels", "err", err)
859 rp.pages.Error503(w)
860 return
861 }
862
863 defs := make(map[string]*models.LabelDefinition)
864 for _, l := range labelDefs {
865 defs[l.AtUri().String()] = &l
866 }
867
868 rp.pages.RepoIssues(w, pages.RepoIssuesParams{
869 LoggedInUser: rp.oauth.GetUser(r),
870 RepoInfo: f.RepoInfo(user),
871 Issues: issues,
872 LabelDefs: defs,
873 FilteringByOpen: isOpen,
874 FilterQuery: keyword,
875 Page: page,
876 })
877}
878
879func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
880 l := rp.logger.With("handler", "NewIssue")
881 user := rp.oauth.GetUser(r)
882
883 f, err := rp.repoResolver.Resolve(r)
884 if err != nil {
885 l.Error("failed to get repo and knot", "err", err)
886 return
887 }
888
889 switch r.Method {
890 case http.MethodGet:
891 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
892 LoggedInUser: user,
893 RepoInfo: f.RepoInfo(user),
894 })
895 case http.MethodPost:
896 issue := &models.Issue{
897 RepoAt: f.RepoAt(),
898 Rkey: tid.TID(),
899 Title: r.FormValue("title"),
900 Body: r.FormValue("body"),
901 Open: true,
902 Did: user.Did,
903 Created: time.Now(),
904 Repo: &f.Repo,
905 }
906
907 if err := rp.validator.ValidateIssue(issue); err != nil {
908 l.Error("validation error", "err", err)
909 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
910 return
911 }
912
913 record := issue.AsRecord()
914
915 // create an atproto record
916 client, err := rp.oauth.AuthorizedClient(r)
917 if err != nil {
918 l.Error("failed to get authorized client", "err", err)
919 rp.pages.Notice(w, "issues", "Failed to create issue.")
920 return
921 }
922 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
923 Collection: tangled.RepoIssueNSID,
924 Repo: user.Did,
925 Rkey: issue.Rkey,
926 Record: &lexutil.LexiconTypeDecoder{
927 Val: &record,
928 },
929 })
930 if err != nil {
931 l.Error("failed to create issue", "err", err)
932 rp.pages.Notice(w, "issues", "Failed to create issue.")
933 return
934 }
935 atUri := resp.Uri
936
937 tx, err := rp.db.BeginTx(r.Context(), nil)
938 if err != nil {
939 rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
940 return
941 }
942 rollback := func() {
943 err1 := tx.Rollback()
944 err2 := rollbackRecord(context.Background(), atUri, client)
945
946 if errors.Is(err1, sql.ErrTxDone) {
947 err1 = nil
948 }
949
950 if err := errors.Join(err1, err2); err != nil {
951 l.Error("failed to rollback txn", "err", err)
952 }
953 }
954 defer rollback()
955
956 err = db.PutIssue(tx, issue)
957 if err != nil {
958 l.Error("failed to create issue", "err", err)
959 rp.pages.Notice(w, "issues", "Failed to create issue.")
960 return
961 }
962
963 if err = tx.Commit(); err != nil {
964 l.Error("failed to create issue", "err", err)
965 rp.pages.Notice(w, "issues", "Failed to create issue.")
966 return
967 }
968
969 // everything is successful, do not rollback the atproto record
970 atUri = ""
971
972 rawMentions := markup.FindUserMentions(issue.Body)
973 idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
974 l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
975 var mentions []syntax.DID
976 for _, ident := range idents {
977 if ident != nil && !ident.Handle.IsInvalidHandle() {
978 mentions = append(mentions, ident.DID)
979 }
980 }
981 rp.notifier.NewIssue(r.Context(), issue, mentions)
982 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
983 return
984 }
985}
986
987// this is used to rollback changes made to the PDS
988//
989// it is a no-op if the provided ATURI is empty
990func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
991 if aturi == "" {
992 return nil
993 }
994
995 parsed := syntax.ATURI(aturi)
996
997 collection := parsed.Collection().String()
998 repo := parsed.Authority().String()
999 rkey := parsed.RecordKey().String()
1000
1001 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
1002 Collection: collection,
1003 Repo: repo,
1004 Rkey: rkey,
1005 })
1006 return err
1007}