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