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