this repo has no description
1package issues
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "time"
11
12 comatproto "github.com/bluesky-social/indigo/api/atproto"
13 atpclient "github.com/bluesky-social/indigo/atproto/client"
14 "github.com/bluesky-social/indigo/atproto/syntax"
15 lexutil "github.com/bluesky-social/indigo/lex/util"
16 "github.com/go-chi/chi/v5"
17
18 "tangled.org/core/api/tangled"
19 "tangled.org/core/appview/config"
20 "tangled.org/core/appview/db"
21 issues_indexer "tangled.org/core/appview/indexer/issues"
22 "tangled.org/core/appview/models"
23 "tangled.org/core/appview/notify"
24 "tangled.org/core/appview/oauth"
25 "tangled.org/core/appview/pages"
26 "tangled.org/core/appview/pages/markup"
27 "tangled.org/core/appview/pages/repoinfo"
28 "tangled.org/core/appview/pagination"
29 "tangled.org/core/appview/reporesolver"
30 "tangled.org/core/appview/validator"
31 "tangled.org/core/idresolver"
32 "tangled.org/core/rbac"
33 "tangled.org/core/tid"
34)
35
36type Issues struct {
37 oauth *oauth.OAuth
38 repoResolver *reporesolver.RepoResolver
39 enforcer *rbac.Enforcer
40 pages *pages.Pages
41 idResolver *idresolver.Resolver
42 db *db.DB
43 config *config.Config
44 notifier notify.Notifier
45 logger *slog.Logger
46 validator *validator.Validator
47 indexer *issues_indexer.Indexer
48}
49
50func New(
51 oauth *oauth.OAuth,
52 repoResolver *reporesolver.RepoResolver,
53 enforcer *rbac.Enforcer,
54 pages *pages.Pages,
55 idResolver *idresolver.Resolver,
56 db *db.DB,
57 config *config.Config,
58 notifier notify.Notifier,
59 validator *validator.Validator,
60 indexer *issues_indexer.Indexer,
61 logger *slog.Logger,
62) *Issues {
63 return &Issues{
64 oauth: oauth,
65 repoResolver: repoResolver,
66 enforcer: enforcer,
67 pages: pages,
68 idResolver: idResolver,
69 db: db,
70 config: config,
71 notifier: notifier,
72 logger: logger,
73 validator: validator,
74 indexer: indexer,
75 }
76}
77
78func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
79 l := rp.logger.With("handler", "RepoSingleIssue")
80 user := rp.oauth.GetUser(r)
81 f, err := rp.repoResolver.Resolve(r)
82 if err != nil {
83 l.Error("failed to get repo and knot", "err", err)
84 return
85 }
86
87 issue, ok := r.Context().Value("issue").(*models.Issue)
88 if !ok {
89 l.Error("failed to get issue")
90 rp.pages.Error404(w)
91 return
92 }
93
94 reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri())
95 if err != nil {
96 l.Error("failed to get issue reactions", "err", err)
97 }
98
99 userReactions := map[models.ReactionKind]bool{}
100 if user != nil {
101 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
102 }
103
104 labelDefs, err := db.GetLabelDefinitions(
105 rp.db,
106 db.FilterIn("at_uri", f.Repo.Labels),
107 db.FilterContains("scope", tangled.RepoIssueNSID),
108 )
109 if err != nil {
110 l.Error("failed to fetch labels", "err", err)
111 rp.pages.Error503(w)
112 return
113 }
114
115 defs := make(map[string]*models.LabelDefinition)
116 for _, l := range labelDefs {
117 defs[l.AtUri().String()] = &l
118 }
119
120 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
121 LoggedInUser: user,
122 RepoInfo: f.RepoInfo(user),
123 Issue: issue,
124 CommentList: issue.CommentList(),
125 OrderedReactionKinds: models.OrderedReactionKinds,
126 Reactions: reactionMap,
127 UserReacted: userReactions,
128 LabelDefs: defs,
129 })
130}
131
132func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
133 l := rp.logger.With("handler", "EditIssue")
134 user := rp.oauth.GetUser(r)
135 f, err := rp.repoResolver.Resolve(r)
136 if err != nil {
137 l.Error("failed to get repo and knot", "err", err)
138 return
139 }
140
141 issue, ok := r.Context().Value("issue").(*models.Issue)
142 if !ok {
143 l.Error("failed to get issue")
144 rp.pages.Error404(w)
145 return
146 }
147
148 switch r.Method {
149 case http.MethodGet:
150 rp.pages.EditIssueFragment(w, pages.EditIssueParams{
151 LoggedInUser: user,
152 RepoInfo: f.RepoInfo(user),
153 Issue: issue,
154 })
155 case http.MethodPost:
156 noticeId := "issues"
157 newIssue := issue
158 newIssue.Title = r.FormValue("title")
159 newIssue.Body = r.FormValue("body")
160
161 if err := rp.validator.ValidateIssue(newIssue); err != nil {
162 l.Error("validation error", "err", err)
163 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err))
164 return
165 }
166
167 newRecord := newIssue.AsRecord()
168
169 // edit an atproto record
170 client, err := rp.oauth.AuthorizedClient(r)
171 if err != nil {
172 l.Error("failed to get authorized client", "err", err)
173 rp.pages.Notice(w, noticeId, "Failed to edit issue.")
174 return
175 }
176
177 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
178 if err != nil {
179 l.Error("failed to get record", "err", err)
180 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
181 return
182 }
183
184 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
185 Collection: tangled.RepoIssueNSID,
186 Repo: user.Did,
187 Rkey: newIssue.Rkey,
188 SwapRecord: ex.Cid,
189 Record: &lexutil.LexiconTypeDecoder{
190 Val: &newRecord,
191 },
192 })
193 if err != nil {
194 l.Error("failed to edit record on PDS", "err", err)
195 rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.")
196 return
197 }
198
199 // modify on DB -- TODO: transact this cleverly
200 tx, err := rp.db.Begin()
201 if err != nil {
202 l.Error("failed to edit issue on DB", "err", err)
203 rp.pages.Notice(w, noticeId, "Failed to edit issue.")
204 return
205 }
206 defer tx.Rollback()
207
208 err = db.PutIssue(tx, newIssue)
209 if err != nil {
210 l.Error("failed to edit issue", "err", err)
211 rp.pages.Notice(w, "issues", "Failed to edit issue.")
212 return
213 }
214
215 if err = tx.Commit(); err != nil {
216 l.Error("failed to edit issue", "err", err)
217 rp.pages.Notice(w, "issues", "Failed to cedit issue.")
218 return
219 }
220
221 rp.pages.HxRefresh(w)
222 }
223}
224
225func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) {
226 l := rp.logger.With("handler", "DeleteIssue")
227 noticeId := "issue-actions-error"
228
229 f, err := rp.repoResolver.Resolve(r)
230 if err != nil {
231 l.Error("failed to get repo and knot", "err", err)
232 return
233 }
234
235 issue, ok := r.Context().Value("issue").(*models.Issue)
236 if !ok {
237 l.Error("failed to get issue")
238 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
239 return
240 }
241 l = l.With("did", issue.Did, "rkey", issue.Rkey)
242
243 // delete from PDS
244 client, err := rp.oauth.AuthorizedClient(r)
245 if err != nil {
246 l.Error("failed to get authorized client", "err", err)
247 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
248 return
249 }
250 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
251 Collection: tangled.RepoIssueNSID,
252 Repo: issue.Did,
253 Rkey: issue.Rkey,
254 })
255 if err != nil {
256 // TODO: transact this better
257 l.Error("failed to delete issue from PDS", "err", err)
258 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
259 return
260 }
261
262 // delete from db
263 if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil {
264 l.Error("failed to delete issue", "err", err)
265 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
266 return
267 }
268
269 rp.notifier.DeleteIssue(r.Context(), issue)
270
271 // return to all issues page
272 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo)
273 rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues")
274}
275
276func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
277 l := rp.logger.With("handler", "CloseIssue")
278 user := rp.oauth.GetUser(r)
279 f, err := rp.repoResolver.Resolve(r)
280 if err != nil {
281 l.Error("failed to get repo and knot", "err", err)
282 return
283 }
284
285 issue, ok := r.Context().Value("issue").(*models.Issue)
286 if !ok {
287 l.Error("failed to get issue")
288 rp.pages.Error404(w)
289 return
290 }
291
292 roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
293 isRepoOwner := roles.IsOwner()
294 isCollaborator := roles.IsCollaborator()
295 isIssueOwner := user.Did == issue.Did
296
297 // TODO: make this more granular
298 if isIssueOwner || isRepoOwner || isCollaborator {
299 err = db.CloseIssues(
300 rp.db,
301 db.FilterEq("id", issue.Id),
302 )
303 if err != nil {
304 l.Error("failed to close issue", "err", err)
305 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
306 return
307 }
308 // change the issue state (this will pass down to the notifiers)
309 issue.Open = false
310
311 // notify about the issue closure
312 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
313
314 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo)
315 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
316 return
317 } else {
318 l.Error("user is not permitted to close issue")
319 http.Error(w, "for biden", http.StatusUnauthorized)
320 return
321 }
322}
323
324func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
325 l := rp.logger.With("handler", "ReopenIssue")
326 user := rp.oauth.GetUser(r)
327 f, err := rp.repoResolver.Resolve(r)
328 if err != nil {
329 l.Error("failed to get repo and knot", "err", err)
330 return
331 }
332
333 issue, ok := r.Context().Value("issue").(*models.Issue)
334 if !ok {
335 l.Error("failed to get issue")
336 rp.pages.Error404(w)
337 return
338 }
339
340 roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
341 isRepoOwner := roles.IsOwner()
342 isCollaborator := roles.IsCollaborator()
343 isIssueOwner := user.Did == issue.Did
344
345 if isCollaborator || isRepoOwner || isIssueOwner {
346 err := db.ReopenIssues(
347 rp.db,
348 db.FilterEq("id", issue.Id),
349 )
350 if err != nil {
351 l.Error("failed to reopen issue", "err", err)
352 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
353 return
354 }
355 // change the issue state (this will pass down to the notifiers)
356 issue.Open = true
357
358 // notify about the issue reopen
359 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
360
361 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo)
362 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
363 return
364 } else {
365 l.Error("user is not the owner of the repo")
366 http.Error(w, "forbidden", http.StatusUnauthorized)
367 return
368 }
369}
370
371func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) {
372 l := rp.logger.With("handler", "NewIssueComment")
373 user := rp.oauth.GetUser(r)
374 f, err := rp.repoResolver.Resolve(r)
375 if err != nil {
376 l.Error("failed to get repo and knot", "err", err)
377 return
378 }
379
380 issue, ok := r.Context().Value("issue").(*models.Issue)
381 if !ok {
382 l.Error("failed to get issue")
383 rp.pages.Error404(w)
384 return
385 }
386
387 body := r.FormValue("body")
388 if body == "" {
389 rp.pages.Notice(w, "issue", "Body is required")
390 return
391 }
392
393 replyToUri := r.FormValue("reply-to")
394 var replyTo *string
395 if replyToUri != "" {
396 replyTo = &replyToUri
397 }
398
399 comment := models.IssueComment{
400 Did: user.Did,
401 Rkey: tid.TID(),
402 IssueAt: issue.AtUri().String(),
403 ReplyTo: replyTo,
404 Body: body,
405 Created: time.Now(),
406 }
407 if err = rp.validator.ValidateIssueComment(&comment); err != nil {
408 l.Error("failed to validate comment", "err", err)
409 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
410 return
411 }
412 record := comment.AsRecord()
413
414 client, err := rp.oauth.AuthorizedClient(r)
415 if err != nil {
416 l.Error("failed to get authorized client", "err", err)
417 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
418 return
419 }
420
421 // create a record first
422 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
423 Collection: tangled.RepoIssueCommentNSID,
424 Repo: comment.Did,
425 Rkey: comment.Rkey,
426 Record: &lexutil.LexiconTypeDecoder{
427 Val: &record,
428 },
429 })
430 if err != nil {
431 l.Error("failed to create comment", "err", err)
432 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
433 return
434 }
435 atUri := resp.Uri
436 defer func() {
437 if err := rollbackRecord(context.Background(), atUri, client); err != nil {
438 l.Error("rollback failed", "err", err)
439 }
440 }()
441
442 commentId, err := db.AddIssueComment(rp.db, comment)
443 if err != nil {
444 l.Error("failed to create comment", "err", err)
445 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
446 return
447 }
448
449 // reset atUri to make rollback a no-op
450 atUri = ""
451
452 // notify about the new comment
453 comment.Id = commentId
454
455 rawMentions := markup.FindUserMentions(comment.Body)
456 idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
457 l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
458 var mentions []syntax.DID
459 for _, ident := range idents {
460 if ident != nil && !ident.Handle.IsInvalidHandle() {
461 mentions = append(mentions, ident.DID)
462 }
463 }
464 rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
465
466 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo)
467 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId))
468}
469
470func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
471 l := rp.logger.With("handler", "IssueComment")
472 user := rp.oauth.GetUser(r)
473 f, err := rp.repoResolver.Resolve(r)
474 if err != nil {
475 l.Error("failed to get repo and knot", "err", err)
476 return
477 }
478
479 issue, ok := r.Context().Value("issue").(*models.Issue)
480 if !ok {
481 l.Error("failed to get issue")
482 rp.pages.Error404(w)
483 return
484 }
485
486 commentId := chi.URLParam(r, "commentId")
487 comments, err := db.GetIssueComments(
488 rp.db,
489 db.FilterEq("id", commentId),
490 )
491 if err != nil {
492 l.Error("failed to fetch comment", "id", commentId)
493 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
494 return
495 }
496 if len(comments) != 1 {
497 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
498 http.Error(w, "invalid comment id", http.StatusBadRequest)
499 return
500 }
501 comment := comments[0]
502
503 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
504 LoggedInUser: user,
505 RepoInfo: f.RepoInfo(user),
506 Issue: issue,
507 Comment: &comment,
508 })
509}
510
511func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
512 l := rp.logger.With("handler", "EditIssueComment")
513 user := rp.oauth.GetUser(r)
514 f, err := rp.repoResolver.Resolve(r)
515 if err != nil {
516 l.Error("failed to get repo and knot", "err", err)
517 return
518 }
519
520 issue, ok := r.Context().Value("issue").(*models.Issue)
521 if !ok {
522 l.Error("failed to get issue")
523 rp.pages.Error404(w)
524 return
525 }
526
527 commentId := chi.URLParam(r, "commentId")
528 comments, err := db.GetIssueComments(
529 rp.db,
530 db.FilterEq("id", commentId),
531 )
532 if err != nil {
533 l.Error("failed to fetch comment", "id", commentId)
534 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
535 return
536 }
537 if len(comments) != 1 {
538 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
539 http.Error(w, "invalid comment id", http.StatusBadRequest)
540 return
541 }
542 comment := comments[0]
543
544 if comment.Did != user.Did {
545 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did)
546 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
547 return
548 }
549
550 switch r.Method {
551 case http.MethodGet:
552 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
553 LoggedInUser: user,
554 RepoInfo: f.RepoInfo(user),
555 Issue: issue,
556 Comment: &comment,
557 })
558 case http.MethodPost:
559 // extract form value
560 newBody := r.FormValue("body")
561 client, err := rp.oauth.AuthorizedClient(r)
562 if err != nil {
563 l.Error("failed to get authorized client", "err", err)
564 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
565 return
566 }
567
568 now := time.Now()
569 newComment := comment
570 newComment.Body = newBody
571 newComment.Edited = &now
572 record := newComment.AsRecord()
573
574 _, err = db.AddIssueComment(rp.db, newComment)
575 if err != nil {
576 l.Error("failed to perferom update-description query", "err", err)
577 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
578 return
579 }
580
581 // rkey is optional, it was introduced later
582 if newComment.Rkey != "" {
583 // update the record on pds
584 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
585 if err != nil {
586 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
587 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
588 return
589 }
590
591 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
592 Collection: tangled.RepoIssueCommentNSID,
593 Repo: user.Did,
594 Rkey: newComment.Rkey,
595 SwapRecord: ex.Cid,
596 Record: &lexutil.LexiconTypeDecoder{
597 Val: &record,
598 },
599 })
600 if err != nil {
601 l.Error("failed to update record on PDS", "err", err)
602 }
603 }
604
605 // return new comment body with htmx
606 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
607 LoggedInUser: user,
608 RepoInfo: f.RepoInfo(user),
609 Issue: issue,
610 Comment: &newComment,
611 })
612 }
613}
614
615func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
616 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
617 user := rp.oauth.GetUser(r)
618 f, err := rp.repoResolver.Resolve(r)
619 if err != nil {
620 l.Error("failed to get repo and knot", "err", err)
621 return
622 }
623
624 issue, ok := r.Context().Value("issue").(*models.Issue)
625 if !ok {
626 l.Error("failed to get issue")
627 rp.pages.Error404(w)
628 return
629 }
630
631 commentId := chi.URLParam(r, "commentId")
632 comments, err := db.GetIssueComments(
633 rp.db,
634 db.FilterEq("id", commentId),
635 )
636 if err != nil {
637 l.Error("failed to fetch comment", "id", commentId)
638 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
639 return
640 }
641 if len(comments) != 1 {
642 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
643 http.Error(w, "invalid comment id", http.StatusBadRequest)
644 return
645 }
646 comment := comments[0]
647
648 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
649 LoggedInUser: user,
650 RepoInfo: f.RepoInfo(user),
651 Issue: issue,
652 Comment: &comment,
653 })
654}
655
656func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
657 l := rp.logger.With("handler", "ReplyIssueComment")
658 user := rp.oauth.GetUser(r)
659 f, err := rp.repoResolver.Resolve(r)
660 if err != nil {
661 l.Error("failed to get repo and knot", "err", err)
662 return
663 }
664
665 issue, ok := r.Context().Value("issue").(*models.Issue)
666 if !ok {
667 l.Error("failed to get issue")
668 rp.pages.Error404(w)
669 return
670 }
671
672 commentId := chi.URLParam(r, "commentId")
673 comments, err := db.GetIssueComments(
674 rp.db,
675 db.FilterEq("id", commentId),
676 )
677 if err != nil {
678 l.Error("failed to fetch comment", "id", commentId)
679 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
680 return
681 }
682 if len(comments) != 1 {
683 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
684 http.Error(w, "invalid comment id", http.StatusBadRequest)
685 return
686 }
687 comment := comments[0]
688
689 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
690 LoggedInUser: user,
691 RepoInfo: f.RepoInfo(user),
692 Issue: issue,
693 Comment: &comment,
694 })
695}
696
697func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
698 l := rp.logger.With("handler", "DeleteIssueComment")
699 user := rp.oauth.GetUser(r)
700 f, err := rp.repoResolver.Resolve(r)
701 if err != nil {
702 l.Error("failed to get repo and knot", "err", err)
703 return
704 }
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: f.RepoInfo(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.Repo.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: f.RepoInfo(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: f.RepoInfo(user),
901 })
902 case http.MethodPost:
903 issue := &models.Issue{
904 RepoAt: f.RepoAt(),
905 Rkey: tid.TID(),
906 Title: r.FormValue("title"),
907 Body: r.FormValue("body"),
908 Open: true,
909 Did: user.Did,
910 Created: time.Now(),
911 Repo: &f.Repo,
912 }
913
914 if err := rp.validator.ValidateIssue(issue); err != nil {
915 l.Error("validation error", "err", err)
916 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
917 return
918 }
919
920 record := issue.AsRecord()
921
922 // create an atproto record
923 client, err := rp.oauth.AuthorizedClient(r)
924 if err != nil {
925 l.Error("failed to get authorized client", "err", err)
926 rp.pages.Notice(w, "issues", "Failed to create issue.")
927 return
928 }
929 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
930 Collection: tangled.RepoIssueNSID,
931 Repo: user.Did,
932 Rkey: issue.Rkey,
933 Record: &lexutil.LexiconTypeDecoder{
934 Val: &record,
935 },
936 })
937 if err != nil {
938 l.Error("failed to create issue", "err", err)
939 rp.pages.Notice(w, "issues", "Failed to create issue.")
940 return
941 }
942 atUri := resp.Uri
943
944 tx, err := rp.db.BeginTx(r.Context(), nil)
945 if err != nil {
946 rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
947 return
948 }
949 rollback := func() {
950 err1 := tx.Rollback()
951 err2 := rollbackRecord(context.Background(), atUri, client)
952
953 if errors.Is(err1, sql.ErrTxDone) {
954 err1 = nil
955 }
956
957 if err := errors.Join(err1, err2); err != nil {
958 l.Error("failed to rollback txn", "err", err)
959 }
960 }
961 defer rollback()
962
963 err = db.PutIssue(tx, issue)
964 if err != nil {
965 l.Error("failed to create issue", "err", err)
966 rp.pages.Notice(w, "issues", "Failed to create issue.")
967 return
968 }
969
970 if err = tx.Commit(); err != nil {
971 l.Error("failed to create issue", "err", err)
972 rp.pages.Notice(w, "issues", "Failed to create issue.")
973 return
974 }
975
976 // everything is successful, do not rollback the atproto record
977 atUri = ""
978
979 rawMentions := markup.FindUserMentions(issue.Body)
980 idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
981 l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
982 var mentions []syntax.DID
983 for _, ident := range idents {
984 if ident != nil && !ident.Handle.IsInvalidHandle() {
985 mentions = append(mentions, ident.DID)
986 }
987 }
988 rp.notifier.NewIssue(r.Context(), issue, mentions)
989
990 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo)
991 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
992 return
993 }
994}
995
996// this is used to rollback changes made to the PDS
997//
998// it is a no-op if the provided ATURI is empty
999func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
1000 if aturi == "" {
1001 return nil
1002 }
1003
1004 parsed := syntax.ATURI(aturi)
1005
1006 collection := parsed.Collection().String()
1007 repo := parsed.Authority().String()
1008 rkey := parsed.RecordKey().String()
1009
1010 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
1011 Collection: collection,
1012 Repo: repo,
1013 Rkey: rkey,
1014 })
1015 return err
1016}