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