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