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