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