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