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) *Issues {
57 return &Issues{
58 oauth: oauth,
59 repoResolver: repoResolver,
60 pages: pages,
61 idResolver: idResolver,
62 db: db,
63 config: config,
64 notifier: notifier,
65 logger: tlog.New("issues"),
66 validator: validator,
67 }
68}
69
70func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
71 l := rp.logger.With("handler", "RepoSingleIssue")
72 user := rp.oauth.GetUser(r)
73 f, err := rp.repoResolver.Resolve(r)
74 if err != nil {
75 log.Println("failed to get repo and knot", err)
76 return
77 }
78
79 issue, ok := r.Context().Value("issue").(*db.Issue)
80 if !ok {
81 l.Error("failed to get issue")
82 rp.pages.Error404(w)
83 return
84 }
85
86 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
87 if err != nil {
88 l.Error("failed to get issue reactions", "err", err)
89 }
90
91 userReactions := map[db.ReactionKind]bool{}
92 if user != nil {
93 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
94 }
95
96 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
97 LoggedInUser: user,
98 RepoInfo: f.RepoInfo(user),
99 Issue: issue,
100 CommentList: issue.CommentList(),
101 OrderedReactionKinds: db.OrderedReactionKinds,
102 Reactions: reactionCountMap,
103 UserReacted: userReactions,
104 })
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 comment.Rkey != "" {
406 // update the record on pds
407 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
408 if err != nil {
409 // failed to get record
410 log.Println(err, rkey)
411 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
412 return
413 }
414 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
415 record, _ := data.UnmarshalJSON(value)
416
417 repoAt := record["repo"].(string)
418 issueAt := record["issue"].(string)
419 createdAt := record["createdAt"].(string)
420
421 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
422 Collection: tangled.RepoIssueCommentNSID,
423 Repo: user.Did,
424 Rkey: rkey,
425 SwapRecord: ex.Cid,
426 Record: &lexutil.LexiconTypeDecoder{
427 Val: &tangled.RepoIssueComment{
428 Repo: &repoAt,
429 Issue: issueAt,
430 Owner: &comment.OwnerDid,
431 Body: newBody,
432 CreatedAt: createdAt,
433 },
434 },
435 })
436 if err != nil {
437 log.Println(err)
438 }
439 }
440
441 // optimistic update for htmx
442 comment.Body = newBody
443 comment.Edited = &edited
444
445 // return new comment body with htmx
446 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
447 LoggedInUser: user,
448 RepoInfo: f.RepoInfo(user),
449 Issue: issue,
450 Comment: comment,
451 })
452 return
453
454 }
455
456func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
457 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
458 user := rp.oauth.GetUser(r)
459 f, err := rp.repoResolver.Resolve(r)
460 if err != nil {
461 l.Error("failed to get repo and knot", "err", err)
462 return
463 }
464
465 issue, ok := r.Context().Value("issue").(*db.Issue)
466 if !ok {
467 l.Error("failed to get issue")
468 rp.pages.Error404(w)
469 return
470 }
471
472 commentId := chi.URLParam(r, "commentId")
473 comments, err := db.GetIssueComments(
474 rp.db,
475 db.FilterEq("id", commentId),
476 )
477 if err != nil {
478 l.Error("failed to fetch comment", "id", commentId)
479 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
480 return
481 }
482 if len(comments) != 1 {
483 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
484 http.Error(w, "invalid comment id", http.StatusBadRequest)
485 return
486 }
487 comment := comments[0]
488
489 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
490 LoggedInUser: user,
491 RepoInfo: f.RepoInfo(user),
492 Issue: issue,
493 Comment: &comment,
494 })
495}
496
497func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
498 l := rp.logger.With("handler", "ReplyIssueComment")
499 user := rp.oauth.GetUser(r)
500 f, err := rp.repoResolver.Resolve(r)
501 if err != nil {
502 l.Error("failed to get repo and knot", "err", err)
503 return
504 }
505
506 issue, ok := r.Context().Value("issue").(*db.Issue)
507 if !ok {
508 l.Error("failed to get issue")
509 rp.pages.Error404(w)
510 return
511 }
512
513 commentId := chi.URLParam(r, "commentId")
514 comments, err := db.GetIssueComments(
515 rp.db,
516 db.FilterEq("id", commentId),
517 )
518 if err != nil {
519 l.Error("failed to fetch comment", "id", commentId)
520 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
521 return
522 }
523 if len(comments) != 1 {
524 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
525 http.Error(w, "invalid comment id", http.StatusBadRequest)
526 return
527 }
528 comment := comments[0]
529
530 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
531 LoggedInUser: user,
532 RepoInfo: f.RepoInfo(user),
533 Issue: issue,
534 Comment: &comment,
535 })
536}
537
538func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
539 l := rp.logger.With("handler", "DeleteIssueComment")
540 user := rp.oauth.GetUser(r)
541 f, err := rp.repoResolver.Resolve(r)
542 if err != nil {
543 return
544 }
545
546 issueId := chi.URLParam(r, "issue")
547 issueIdInt, err := strconv.Atoi(issueId)
548 if err != nil {
549 http.Error(w, "bad issue id", http.StatusBadRequest)
550 log.Println("failed to parse issue id", err)
551 return
552 }
553
554 issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
555 if err != nil {
556 log.Println("failed to get issue", err)
557 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
558 return
559 }
560
561 commentId := chi.URLParam(r, "comment_id")
562 commentIdInt, err := strconv.Atoi(commentId)
563 if err != nil {
564 http.Error(w, "bad comment id", http.StatusBadRequest)
565 log.Println("failed to parse issue id", err)
566 return
567 }
568
569 comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
570 if err != nil {
571 http.Error(w, "bad comment id", http.StatusBadRequest)
572 return
573 }
574
575 if comment.OwnerDid != user.Did {
576 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
577 return
578 }
579
580 if comment.Deleted != nil {
581 http.Error(w, "comment already deleted", http.StatusBadRequest)
582 return
583 }
584
585 // optimistic deletion
586 deleted := time.Now()
587 err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
588 if err != nil {
589 log.Println("failed to delete comment")
590 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
591 return
592 }
593
594 // delete from pds
595 if comment.Rkey != "" {
596 client, err := rp.oauth.AuthorizedClient(r)
597 if err != nil {
598 log.Println("failed to get authorized client", err)
599 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
600 return
601 }
602 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
603 Collection: tangled.GraphFollowNSID,
604 Repo: user.Did,
605 Rkey: comment.Rkey,
606 })
607 if err != nil {
608 log.Println(err)
609 }
610 }
611
612 // optimistic update for htmx
613 comment.Body = ""
614 comment.Deleted = &deleted
615
616 // htmx fragment of comment after deletion
617 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
618 LoggedInUser: user,
619 RepoInfo: f.RepoInfo(user),
620 Issue: issue,
621 Comment: comment,
622 })
623}
624
625func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
626 params := r.URL.Query()
627 state := params.Get("state")
628 isOpen := true
629 switch state {
630 case "open":
631 isOpen = true
632 case "closed":
633 isOpen = false
634 default:
635 isOpen = true
636 }
637
638 page, ok := r.Context().Value("page").(pagination.Page)
639 if !ok {
640 log.Println("failed to get page")
641 page = pagination.FirstPage()
642 }
643
644 user := rp.oauth.GetUser(r)
645 f, err := rp.repoResolver.Resolve(r)
646 if err != nil {
647 log.Println("failed to get repo and knot", err)
648 return
649 }
650
651 issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
652 if err != nil {
653 log.Println("failed to get issues", err)
654 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
655 return
656 }
657
658 rp.pages.RepoIssues(w, pages.RepoIssuesParams{
659 LoggedInUser: rp.oauth.GetUser(r),
660 RepoInfo: f.RepoInfo(user),
661 Issues: issues,
662 FilteringByOpen: isOpen,
663 Page: page,
664 })
665}
666
667func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
668 user := rp.oauth.GetUser(r)
669
670 f, err := rp.repoResolver.Resolve(r)
671 if err != nil {
672 log.Println("failed to get repo and knot", err)
673 return
674 }
675
676 switch r.Method {
677 case http.MethodGet:
678 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
679 LoggedInUser: user,
680 RepoInfo: f.RepoInfo(user),
681 })
682 case http.MethodPost:
683 title := r.FormValue("title")
684 body := r.FormValue("body")
685
686 if title == "" || body == "" {
687 rp.pages.Notice(w, "issues", "Title and body are required")
688 return
689 }
690
691 sanitizer := markup.NewSanitizer()
692 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
693 rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization")
694 return
695 }
696 if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
697 rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization")
698 return
699 }
700
701 tx, err := rp.db.BeginTx(r.Context(), nil)
702 if err != nil {
703 rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
704 return
705 }
706
707 issue := &db.Issue{
708 RepoAt: f.RepoAt(),
709 Rkey: tid.TID(),
710 Title: title,
711 Body: body,
712 OwnerDid: user.Did,
713 }
714 err = db.NewIssue(tx, issue)
715 if err != nil {
716 log.Println("failed to create issue", err)
717 rp.pages.Notice(w, "issues", "Failed to create issue.")
718 return
719 }
720
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, "issues", "Failed to create issue.")
725 return
726 }
727 atUri := f.RepoAt().String()
728 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
729 Collection: tangled.RepoIssueNSID,
730 Repo: user.Did,
731 Rkey: issue.Rkey,
732 Record: &lexutil.LexiconTypeDecoder{
733 Val: &tangled.RepoIssue{
734 Repo: atUri,
735 Title: title,
736 Body: &body,
737 },
738 },
739 })
740 if err != nil {
741 log.Println("failed to create issue", err)
742 rp.pages.Notice(w, "issues", "Failed to create issue.")
743 return
744 }
745
746 rp.notifier.NewIssue(r.Context(), issue)
747
748 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
749 return
750 }
751}