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