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