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 // return to all issues page
267 rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
268}
269
270func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
271 l := rp.logger.With("handler", "CloseIssue")
272 user := rp.oauth.GetUser(r)
273 f, err := rp.repoResolver.Resolve(r)
274 if err != nil {
275 l.Error("failed to get repo and knot", "err", err)
276 return
277 }
278
279 issue, ok := r.Context().Value("issue").(*models.Issue)
280 if !ok {
281 l.Error("failed to get issue")
282 rp.pages.Error404(w)
283 return
284 }
285
286 collaborators, err := f.Collaborators(r.Context())
287 if err != nil {
288 l.Error("failed to fetch repo collaborators", "err", err)
289 }
290 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
291 return user.Did == collab.Did
292 })
293 isIssueOwner := user.Did == issue.Did
294
295 // TODO: make this more granular
296 if isIssueOwner || isCollaborator {
297 err = db.CloseIssues(
298 rp.db,
299 db.FilterEq("id", issue.Id),
300 )
301 if err != nil {
302 l.Error("failed to close issue", "err", err)
303 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
304 return
305 }
306
307 // notify about the issue closure
308 rp.notifier.NewIssueClosed(r.Context(), issue)
309
310 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
311 return
312 } else {
313 l.Error("user is not permitted to close issue")
314 http.Error(w, "for biden", http.StatusUnauthorized)
315 return
316 }
317}
318
319func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
320 l := rp.logger.With("handler", "ReopenIssue")
321 user := rp.oauth.GetUser(r)
322 f, err := rp.repoResolver.Resolve(r)
323 if err != nil {
324 l.Error("failed to get repo and knot", "err", err)
325 return
326 }
327
328 issue, ok := r.Context().Value("issue").(*models.Issue)
329 if !ok {
330 l.Error("failed to get issue")
331 rp.pages.Error404(w)
332 return
333 }
334
335 collaborators, err := f.Collaborators(r.Context())
336 if err != nil {
337 l.Error("failed to fetch repo collaborators", "err", err)
338 }
339 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
340 return user.Did == collab.Did
341 })
342 isIssueOwner := user.Did == issue.Did
343
344 if isCollaborator || isIssueOwner {
345 err := db.ReopenIssues(
346 rp.db,
347 db.FilterEq("id", issue.Id),
348 )
349 if err != nil {
350 l.Error("failed to reopen issue", "err", err)
351 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
352 return
353 }
354 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
355 return
356 } else {
357 l.Error("user is not the owner of the repo")
358 http.Error(w, "forbidden", http.StatusUnauthorized)
359 return
360 }
361}
362
363func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) {
364 l := rp.logger.With("handler", "NewIssueComment")
365 user := rp.oauth.GetUser(r)
366 f, err := rp.repoResolver.Resolve(r)
367 if err != nil {
368 l.Error("failed to get repo and knot", "err", err)
369 return
370 }
371
372 issue, ok := r.Context().Value("issue").(*models.Issue)
373 if !ok {
374 l.Error("failed to get issue")
375 rp.pages.Error404(w)
376 return
377 }
378
379 body := r.FormValue("body")
380 if body == "" {
381 rp.pages.Notice(w, "issue", "Body is required")
382 return
383 }
384
385 replyToUri := r.FormValue("reply-to")
386 var replyTo *string
387 if replyToUri != "" {
388 replyTo = &replyToUri
389 }
390
391 comment := models.IssueComment{
392 Did: user.Did,
393 Rkey: tid.TID(),
394 IssueAt: issue.AtUri().String(),
395 ReplyTo: replyTo,
396 Body: body,
397 Created: time.Now(),
398 }
399 if err = rp.validator.ValidateIssueComment(&comment); err != nil {
400 l.Error("failed to validate comment", "err", err)
401 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
402 return
403 }
404 record := comment.AsRecord()
405
406 client, err := rp.oauth.AuthorizedClient(r)
407 if err != nil {
408 l.Error("failed to get authorized client", "err", err)
409 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
410 return
411 }
412
413 // create a record first
414 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
415 Collection: tangled.RepoIssueCommentNSID,
416 Repo: comment.Did,
417 Rkey: comment.Rkey,
418 Record: &lexutil.LexiconTypeDecoder{
419 Val: &record,
420 },
421 })
422 if err != nil {
423 l.Error("failed to create comment", "err", err)
424 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
425 return
426 }
427 atUri := resp.Uri
428 defer func() {
429 if err := rollbackRecord(context.Background(), atUri, client); err != nil {
430 l.Error("rollback failed", "err", err)
431 }
432 }()
433
434 commentId, err := db.AddIssueComment(rp.db, comment)
435 if err != nil {
436 l.Error("failed to create comment", "err", err)
437 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
438 return
439 }
440
441 // reset atUri to make rollback a no-op
442 atUri = ""
443
444 // notify about the new comment
445 comment.Id = commentId
446 rp.notifier.NewIssueComment(r.Context(), &comment)
447
448 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
449}
450
451func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
452 l := rp.logger.With("handler", "IssueComment")
453 user := rp.oauth.GetUser(r)
454 f, err := rp.repoResolver.Resolve(r)
455 if err != nil {
456 l.Error("failed to get repo and knot", "err", err)
457 return
458 }
459
460 issue, ok := r.Context().Value("issue").(*models.Issue)
461 if !ok {
462 l.Error("failed to get issue")
463 rp.pages.Error404(w)
464 return
465 }
466
467 commentId := chi.URLParam(r, "commentId")
468 comments, err := db.GetIssueComments(
469 rp.db,
470 db.FilterEq("id", commentId),
471 )
472 if err != nil {
473 l.Error("failed to fetch comment", "id", commentId)
474 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
475 return
476 }
477 if len(comments) != 1 {
478 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
479 http.Error(w, "invalid comment id", http.StatusBadRequest)
480 return
481 }
482 comment := comments[0]
483
484 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
485 LoggedInUser: user,
486 RepoInfo: f.RepoInfo(user),
487 Issue: issue,
488 Comment: &comment,
489 })
490}
491
492func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
493 l := rp.logger.With("handler", "EditIssueComment")
494 user := rp.oauth.GetUser(r)
495 f, err := rp.repoResolver.Resolve(r)
496 if err != nil {
497 l.Error("failed to get repo and knot", "err", err)
498 return
499 }
500
501 issue, ok := r.Context().Value("issue").(*models.Issue)
502 if !ok {
503 l.Error("failed to get issue")
504 rp.pages.Error404(w)
505 return
506 }
507
508 commentId := chi.URLParam(r, "commentId")
509 comments, err := db.GetIssueComments(
510 rp.db,
511 db.FilterEq("id", commentId),
512 )
513 if err != nil {
514 l.Error("failed to fetch comment", "id", commentId)
515 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
516 return
517 }
518 if len(comments) != 1 {
519 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
520 http.Error(w, "invalid comment id", http.StatusBadRequest)
521 return
522 }
523 comment := comments[0]
524
525 if comment.Did != user.Did {
526 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did)
527 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
528 return
529 }
530
531 switch r.Method {
532 case http.MethodGet:
533 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
534 LoggedInUser: user,
535 RepoInfo: f.RepoInfo(user),
536 Issue: issue,
537 Comment: &comment,
538 })
539 case http.MethodPost:
540 // extract form value
541 newBody := r.FormValue("body")
542 client, err := rp.oauth.AuthorizedClient(r)
543 if err != nil {
544 l.Error("failed to get authorized client", "err", err)
545 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
546 return
547 }
548
549 now := time.Now()
550 newComment := comment
551 newComment.Body = newBody
552 newComment.Edited = &now
553 record := newComment.AsRecord()
554
555 _, err = db.AddIssueComment(rp.db, newComment)
556 if err != nil {
557 l.Error("failed to perferom update-description query", "err", err)
558 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
559 return
560 }
561
562 // rkey is optional, it was introduced later
563 if newComment.Rkey != "" {
564 // update the record on pds
565 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
566 if err != nil {
567 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
568 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
569 return
570 }
571
572 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
573 Collection: tangled.RepoIssueCommentNSID,
574 Repo: user.Did,
575 Rkey: newComment.Rkey,
576 SwapRecord: ex.Cid,
577 Record: &lexutil.LexiconTypeDecoder{
578 Val: &record,
579 },
580 })
581 if err != nil {
582 l.Error("failed to update record on PDS", "err", err)
583 }
584 }
585
586 // return new comment body with htmx
587 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
588 LoggedInUser: user,
589 RepoInfo: f.RepoInfo(user),
590 Issue: issue,
591 Comment: &newComment,
592 })
593 }
594}
595
596func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
597 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
598 user := rp.oauth.GetUser(r)
599 f, err := rp.repoResolver.Resolve(r)
600 if err != nil {
601 l.Error("failed to get repo and knot", "err", err)
602 return
603 }
604
605 issue, ok := r.Context().Value("issue").(*models.Issue)
606 if !ok {
607 l.Error("failed to get issue")
608 rp.pages.Error404(w)
609 return
610 }
611
612 commentId := chi.URLParam(r, "commentId")
613 comments, err := db.GetIssueComments(
614 rp.db,
615 db.FilterEq("id", commentId),
616 )
617 if err != nil {
618 l.Error("failed to fetch comment", "id", commentId)
619 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
620 return
621 }
622 if len(comments) != 1 {
623 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
624 http.Error(w, "invalid comment id", http.StatusBadRequest)
625 return
626 }
627 comment := comments[0]
628
629 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
630 LoggedInUser: user,
631 RepoInfo: f.RepoInfo(user),
632 Issue: issue,
633 Comment: &comment,
634 })
635}
636
637func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
638 l := rp.logger.With("handler", "ReplyIssueComment")
639 user := rp.oauth.GetUser(r)
640 f, err := rp.repoResolver.Resolve(r)
641 if err != nil {
642 l.Error("failed to get repo and knot", "err", err)
643 return
644 }
645
646 issue, ok := r.Context().Value("issue").(*models.Issue)
647 if !ok {
648 l.Error("failed to get issue")
649 rp.pages.Error404(w)
650 return
651 }
652
653 commentId := chi.URLParam(r, "commentId")
654 comments, err := db.GetIssueComments(
655 rp.db,
656 db.FilterEq("id", commentId),
657 )
658 if err != nil {
659 l.Error("failed to fetch comment", "id", commentId)
660 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
661 return
662 }
663 if len(comments) != 1 {
664 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
665 http.Error(w, "invalid comment id", http.StatusBadRequest)
666 return
667 }
668 comment := comments[0]
669
670 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
671 LoggedInUser: user,
672 RepoInfo: f.RepoInfo(user),
673 Issue: issue,
674 Comment: &comment,
675 })
676}
677
678func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
679 l := rp.logger.With("handler", "DeleteIssueComment")
680 user := rp.oauth.GetUser(r)
681 f, err := rp.repoResolver.Resolve(r)
682 if err != nil {
683 l.Error("failed to get repo and knot", "err", err)
684 return
685 }
686
687 issue, ok := r.Context().Value("issue").(*models.Issue)
688 if !ok {
689 l.Error("failed to get issue")
690 rp.pages.Error404(w)
691 return
692 }
693
694 commentId := chi.URLParam(r, "commentId")
695 comments, err := db.GetIssueComments(
696 rp.db,
697 db.FilterEq("id", commentId),
698 )
699 if err != nil {
700 l.Error("failed to fetch comment", "id", commentId)
701 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
702 return
703 }
704 if len(comments) != 1 {
705 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
706 http.Error(w, "invalid comment id", http.StatusBadRequest)
707 return
708 }
709 comment := comments[0]
710
711 if comment.Did != user.Did {
712 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
713 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
714 return
715 }
716
717 if comment.Deleted != nil {
718 http.Error(w, "comment already deleted", http.StatusBadRequest)
719 return
720 }
721
722 // optimistic deletion
723 deleted := time.Now()
724 err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id))
725 if err != nil {
726 l.Error("failed to delete comment", "err", err)
727 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
728 return
729 }
730
731 // delete from pds
732 if comment.Rkey != "" {
733 client, err := rp.oauth.AuthorizedClient(r)
734 if err != nil {
735 l.Error("failed to get authorized client", "err", err)
736 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
737 return
738 }
739 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
740 Collection: tangled.RepoIssueCommentNSID,
741 Repo: user.Did,
742 Rkey: comment.Rkey,
743 })
744 if err != nil {
745 l.Error("failed to delete from PDS", "err", err)
746 }
747 }
748
749 // optimistic update for htmx
750 comment.Body = ""
751 comment.Deleted = &deleted
752
753 // htmx fragment of comment after deletion
754 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
755 LoggedInUser: user,
756 RepoInfo: f.RepoInfo(user),
757 Issue: issue,
758 Comment: &comment,
759 })
760}
761
762func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
763 l := rp.logger.With("handler", "RepoIssues")
764
765 params := r.URL.Query()
766 state := params.Get("state")
767 isOpen := true
768 switch state {
769 case "open":
770 isOpen = true
771 case "closed":
772 isOpen = false
773 default:
774 isOpen = true
775 }
776
777 page := pagination.FromContext(r.Context())
778
779 user := rp.oauth.GetUser(r)
780 f, err := rp.repoResolver.Resolve(r)
781 if err != nil {
782 l.Error("failed to get repo and knot", "err", err)
783 return
784 }
785
786 openVal := 0
787 if isOpen {
788 openVal = 1
789 }
790 issues, err := db.GetIssuesPaginated(
791 rp.db,
792 page,
793 db.FilterEq("repo_at", f.RepoAt()),
794 db.FilterEq("open", openVal),
795 )
796 if err != nil {
797 l.Error("failed to get issues", "err", err)
798 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
799 return
800 }
801
802 labelDefs, err := db.GetLabelDefinitions(
803 rp.db,
804 db.FilterIn("at_uri", f.Repo.Labels),
805 db.FilterContains("scope", tangled.RepoIssueNSID),
806 )
807 if err != nil {
808 l.Error("failed to fetch labels", "err", err)
809 rp.pages.Error503(w)
810 return
811 }
812
813 defs := make(map[string]*models.LabelDefinition)
814 for _, l := range labelDefs {
815 defs[l.AtUri().String()] = &l
816 }
817
818 rp.pages.RepoIssues(w, pages.RepoIssuesParams{
819 LoggedInUser: rp.oauth.GetUser(r),
820 RepoInfo: f.RepoInfo(user),
821 Issues: issues,
822 LabelDefs: defs,
823 FilteringByOpen: isOpen,
824 Page: page,
825 })
826}
827
828func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
829 l := rp.logger.With("handler", "NewIssue")
830 user := rp.oauth.GetUser(r)
831
832 f, err := rp.repoResolver.Resolve(r)
833 if err != nil {
834 l.Error("failed to get repo and knot", "err", err)
835 return
836 }
837
838 switch r.Method {
839 case http.MethodGet:
840 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
841 LoggedInUser: user,
842 RepoInfo: f.RepoInfo(user),
843 })
844 case http.MethodPost:
845 issue := &models.Issue{
846 RepoAt: f.RepoAt(),
847 Rkey: tid.TID(),
848 Title: r.FormValue("title"),
849 Body: r.FormValue("body"),
850 Open: true,
851 Did: user.Did,
852 Created: time.Now(),
853 Repo: &f.Repo,
854 }
855
856 if err := rp.validator.ValidateIssue(issue); err != nil {
857 l.Error("validation error", "err", err)
858 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
859 return
860 }
861
862 record := issue.AsRecord()
863
864 // create an atproto record
865 client, err := rp.oauth.AuthorizedClient(r)
866 if err != nil {
867 l.Error("failed to get authorized client", "err", err)
868 rp.pages.Notice(w, "issues", "Failed to create issue.")
869 return
870 }
871 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
872 Collection: tangled.RepoIssueNSID,
873 Repo: user.Did,
874 Rkey: issue.Rkey,
875 Record: &lexutil.LexiconTypeDecoder{
876 Val: &record,
877 },
878 })
879 if err != nil {
880 l.Error("failed to create issue", "err", err)
881 rp.pages.Notice(w, "issues", "Failed to create issue.")
882 return
883 }
884 atUri := resp.Uri
885
886 tx, err := rp.db.BeginTx(r.Context(), nil)
887 if err != nil {
888 rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
889 return
890 }
891 rollback := func() {
892 err1 := tx.Rollback()
893 err2 := rollbackRecord(context.Background(), atUri, client)
894
895 if errors.Is(err1, sql.ErrTxDone) {
896 err1 = nil
897 }
898
899 if err := errors.Join(err1, err2); err != nil {
900 l.Error("failed to rollback txn", "err", err)
901 }
902 }
903 defer rollback()
904
905 err = db.PutIssue(tx, issue)
906 if err != nil {
907 l.Error("failed to create issue", "err", err)
908 rp.pages.Notice(w, "issues", "Failed to create issue.")
909 return
910 }
911
912 if err = tx.Commit(); err != nil {
913 l.Error("failed to create issue", "err", err)
914 rp.pages.Notice(w, "issues", "Failed to create issue.")
915 return
916 }
917
918 // everything is successful, do not rollback the atproto record
919 atUri = ""
920 rp.notifier.NewIssue(r.Context(), issue)
921 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
922 return
923 }
924}
925
926// this is used to rollback changes made to the PDS
927//
928// it is a no-op if the provided ATURI is empty
929func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
930 if aturi == "" {
931 return nil
932 }
933
934 parsed := syntax.ATURI(aturi)
935
936 collection := parsed.Collection().String()
937 repo := parsed.Authority().String()
938 rkey := parsed.RecordKey().String()
939
940 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
941 Collection: collection,
942 Repo: repo,
943 Rkey: rkey,
944 })
945 return err
946}