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