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