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