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