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