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