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