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