forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
Monorepo for Tangled
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}