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