A vibe coded tangled fork which supports pijul.
1package issues
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "time"
11
12 comatproto "github.com/bluesky-social/indigo/api/atproto"
13 atpclient "github.com/bluesky-social/indigo/atproto/client"
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.org/core/api/tangled"
19 "tangled.org/core/appview/config"
20 "tangled.org/core/appview/db"
21 issues_indexer "tangled.org/core/appview/indexer/issues"
22 "tangled.org/core/appview/mentions"
23 "tangled.org/core/appview/models"
24 "tangled.org/core/appview/notify"
25 "tangled.org/core/appview/oauth"
26 "tangled.org/core/appview/pages"
27 "tangled.org/core/appview/pages/repoinfo"
28 "tangled.org/core/appview/pagination"
29 "tangled.org/core/appview/reporesolver"
30 "tangled.org/core/idresolver"
31 "tangled.org/core/orm"
32 "tangled.org/core/rbac"
33 "tangled.org/core/tid"
34)
35
36type Issues struct {
37 oauth *oauth.OAuth
38 repoResolver *reporesolver.RepoResolver
39 enforcer *rbac.Enforcer
40 pages *pages.Pages
41 idResolver *idresolver.Resolver
42 mentionsResolver *mentions.Resolver
43 db *db.DB
44 config *config.Config
45 notifier notify.Notifier
46 logger *slog.Logger
47 indexer *issues_indexer.Indexer
48}
49
50func New(
51 oauth *oauth.OAuth,
52 repoResolver *reporesolver.RepoResolver,
53 enforcer *rbac.Enforcer,
54 pages *pages.Pages,
55 idResolver *idresolver.Resolver,
56 mentionsResolver *mentions.Resolver,
57 db *db.DB,
58 config *config.Config,
59 notifier notify.Notifier,
60 indexer *issues_indexer.Indexer,
61 logger *slog.Logger,
62) *Issues {
63 return &Issues{
64 oauth: oauth,
65 repoResolver: repoResolver,
66 enforcer: enforcer,
67 pages: pages,
68 idResolver: idResolver,
69 mentionsResolver: mentionsResolver,
70 db: db,
71 config: config,
72 notifier: notifier,
73 logger: logger,
74 indexer: indexer,
75 }
76}
77
78func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
79 l := rp.logger.With("handler", "RepoSingleIssue")
80 user := rp.oauth.GetMultiAccountUser(r)
81 f, err := rp.repoResolver.Resolve(r)
82 if err != nil {
83 l.Error("failed to get repo and knot", "err", err)
84 return
85 }
86
87 issue, ok := r.Context().Value("issue").(*models.Issue)
88 if !ok {
89 l.Error("failed to get issue")
90 rp.pages.Error404(w)
91 return
92 }
93
94 reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri())
95 if err != nil {
96 l.Error("failed to get issue reactions", "err", err)
97 }
98
99 userReactions := map[models.ReactionKind]bool{}
100 if user != nil {
101 userReactions, err = db.GetReactionStatusMap(rp.db, user.Active.Did, issue.AtUri())
102 if err != nil {
103 l.Error("failed to get issue reaction status", "err", err)
104 }
105 }
106
107 backlinks, err := db.GetBacklinks(rp.db, issue.AtUri())
108 if err != nil {
109 l.Error("failed to fetch backlinks", "err", err)
110 rp.pages.Error503(w)
111 return
112 }
113
114 labelDefs, err := db.GetLabelDefinitions(
115 rp.db,
116 orm.FilterIn("at_uri", f.Labels),
117 orm.FilterContains("scope", tangled.RepoIssueNSID),
118 )
119 if err != nil {
120 l.Error("failed to fetch labels", "err", err)
121 rp.pages.Error503(w)
122 return
123 }
124
125 defs := make(map[string]*models.LabelDefinition)
126 for _, l := range labelDefs {
127 defs[l.AtUri().String()] = &l
128 }
129
130 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
131 LoggedInUser: user,
132 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
133 Issue: issue,
134 CommentList: issue.CommentList(),
135 Backlinks: backlinks,
136 Reactions: reactionMap,
137 UserReacted: userReactions,
138 LabelDefs: defs,
139 })
140}
141
142func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
143 l := rp.logger.With("handler", "EditIssue")
144 user := rp.oauth.GetMultiAccountUser(r)
145
146 issue, ok := r.Context().Value("issue").(*models.Issue)
147 if !ok {
148 l.Error("failed to get issue")
149 rp.pages.Error404(w)
150 return
151 }
152
153 switch r.Method {
154 case http.MethodGet:
155 rp.pages.EditIssueFragment(w, pages.EditIssueParams{
156 LoggedInUser: user,
157 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
158 Issue: issue,
159 })
160 case http.MethodPost:
161 noticeId := "issues"
162 newIssue := issue
163 newIssue.Title = r.FormValue("title")
164 newIssue.Body = r.FormValue("body")
165 newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body)
166
167 if err := newIssue.Validate(); err != nil {
168 l.Error("validation error", "err", err)
169 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err))
170 return
171 }
172
173 newRecord := newIssue.AsRecord()
174
175 // edit an atproto record
176 client, err := rp.oauth.AuthorizedClient(r)
177 if err != nil {
178 l.Error("failed to get authorized client", "err", err)
179 rp.pages.Notice(w, noticeId, "Failed to edit issue.")
180 return
181 }
182
183 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Active.Did, newIssue.Rkey)
184 if err != nil {
185 l.Error("failed to get record", "err", err)
186 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
187 return
188 }
189
190 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
191 Collection: tangled.RepoIssueNSID,
192 Repo: user.Active.Did,
193 Rkey: newIssue.Rkey,
194 SwapRecord: ex.Cid,
195 Record: &lexutil.LexiconTypeDecoder{
196 Val: &newRecord,
197 },
198 })
199 if err != nil {
200 l.Error("failed to edit record on PDS", "err", err)
201 rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.")
202 return
203 }
204
205 // modify on DB -- TODO: transact this cleverly
206 tx, err := rp.db.Begin()
207 if err != nil {
208 l.Error("failed to edit issue on DB", "err", err)
209 rp.pages.Notice(w, noticeId, "Failed to edit issue.")
210 return
211 }
212 defer tx.Rollback()
213
214 err = db.PutIssue(tx, newIssue)
215 if err != nil {
216 l.Error("failed to edit issue", "err", err)
217 rp.pages.Notice(w, "issues", "Failed to edit issue.")
218 return
219 }
220
221 if err = tx.Commit(); err != nil {
222 l.Error("failed to edit issue", "err", err)
223 rp.pages.Notice(w, "issues", "Failed to cedit issue.")
224 return
225 }
226
227 rp.pages.HxRefresh(w)
228 }
229}
230
231func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) {
232 l := rp.logger.With("handler", "DeleteIssue")
233 noticeId := "issue-actions-error"
234
235 f, err := rp.repoResolver.Resolve(r)
236 if err != nil {
237 l.Error("failed to get repo and knot", "err", err)
238 return
239 }
240
241 issue, ok := r.Context().Value("issue").(*models.Issue)
242 if !ok {
243 l.Error("failed to get issue")
244 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
245 return
246 }
247 l = l.With("did", issue.Did, "rkey", issue.Rkey)
248
249 tx, err := rp.db.Begin()
250 if err != nil {
251 l.Error("failed to start transaction", "err", err)
252 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
253 return
254 }
255 defer tx.Rollback()
256
257 // delete from PDS
258 client, err := rp.oauth.AuthorizedClient(r)
259 if err != nil {
260 l.Error("failed to get authorized client", "err", err)
261 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
262 return
263 }
264 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
265 Collection: tangled.RepoIssueNSID,
266 Repo: issue.Did,
267 Rkey: issue.Rkey,
268 })
269 if err != nil {
270 // TODO: transact this better
271 l.Error("failed to delete issue from PDS", "err", err)
272 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
273 return
274 }
275
276 // delete from db
277 if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil {
278 l.Error("failed to delete issue", "err", err)
279 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
280 return
281 }
282 tx.Commit()
283
284 rp.notifier.DeleteIssue(r.Context(), issue)
285
286 // return to all issues page
287 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
288 rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues")
289}
290
291func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
292 l := rp.logger.With("handler", "CloseIssue")
293 user := rp.oauth.GetMultiAccountUser(r)
294 f, err := rp.repoResolver.Resolve(r)
295 if err != nil {
296 l.Error("failed to get repo and knot", "err", err)
297 return
298 }
299
300 issue, ok := r.Context().Value("issue").(*models.Issue)
301 if !ok {
302 l.Error("failed to get issue")
303 rp.pages.Error404(w)
304 return
305 }
306
307 roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())}
308 isRepoOwner := roles.IsOwner()
309 isCollaborator := roles.IsCollaborator()
310 isIssueOwner := user.Active.Did == issue.Did
311
312 // TODO: make this more granular
313 if isIssueOwner || isRepoOwner || isCollaborator {
314 err = db.CloseIssues(
315 rp.db,
316 orm.FilterEq("id", issue.Id),
317 )
318 if err != nil {
319 l.Error("failed to close issue", "err", err)
320 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
321 return
322 }
323 // change the issue state (this will pass down to the notifiers)
324 issue.Open = false
325
326 // notify about the issue closure
327 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Active.Did), issue)
328
329 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
330 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
331 return
332 } else {
333 l.Error("user is not permitted to close issue")
334 http.Error(w, "for biden", http.StatusUnauthorized)
335 return
336 }
337}
338
339func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
340 l := rp.logger.With("handler", "ReopenIssue")
341 user := rp.oauth.GetMultiAccountUser(r)
342 f, err := rp.repoResolver.Resolve(r)
343 if err != nil {
344 l.Error("failed to get repo and knot", "err", err)
345 return
346 }
347
348 issue, ok := r.Context().Value("issue").(*models.Issue)
349 if !ok {
350 l.Error("failed to get issue")
351 rp.pages.Error404(w)
352 return
353 }
354
355 roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())}
356 isRepoOwner := roles.IsOwner()
357 isCollaborator := roles.IsCollaborator()
358 isIssueOwner := user.Active.Did == issue.Did
359
360 if isCollaborator || isRepoOwner || isIssueOwner {
361 err := db.ReopenIssues(
362 rp.db,
363 orm.FilterEq("id", issue.Id),
364 )
365 if err != nil {
366 l.Error("failed to reopen issue", "err", err)
367 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
368 return
369 }
370 // change the issue state (this will pass down to the notifiers)
371 issue.Open = true
372
373 // notify about the issue reopen
374 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Active.Did), issue)
375
376 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
377 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
378 return
379 } else {
380 l.Error("user is not the owner of the repo")
381 http.Error(w, "forbidden", http.StatusUnauthorized)
382 return
383 }
384}
385
386func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) {
387 l := rp.logger.With("handler", "NewIssueComment")
388 user := rp.oauth.GetMultiAccountUser(r)
389 f, err := rp.repoResolver.Resolve(r)
390 if err != nil {
391 l.Error("failed to get repo and knot", "err", err)
392 return
393 }
394
395 issue, ok := r.Context().Value("issue").(*models.Issue)
396 if !ok {
397 l.Error("failed to get issue")
398 rp.pages.Error404(w)
399 return
400 }
401
402 body := r.FormValue("body")
403 if body == "" {
404 rp.pages.Notice(w, "issue", "Body is required")
405 return
406 }
407
408 replyToUri := r.FormValue("reply-to")
409 var replyTo *string
410 if replyToUri != "" {
411 replyTo = &replyToUri
412 }
413
414 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
415
416 comment := models.IssueComment{
417 Did: user.Active.Did,
418 Rkey: tid.TID(),
419 IssueAt: issue.AtUri().String(),
420 ReplyTo: replyTo,
421 Body: body,
422 Created: time.Now(),
423 Mentions: mentions,
424 References: references,
425 }
426 if err = comment.Validate(); err != nil {
427 l.Error("failed to validate comment", "err", err)
428 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
429 return
430 }
431 record := comment.AsRecord()
432
433 client, err := rp.oauth.AuthorizedClient(r)
434 if err != nil {
435 l.Error("failed to get authorized client", "err", err)
436 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
437 return
438 }
439
440 // create a record first
441 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
442 Collection: tangled.RepoIssueCommentNSID,
443 Repo: comment.Did,
444 Rkey: comment.Rkey,
445 Record: &lexutil.LexiconTypeDecoder{
446 Val: &record,
447 },
448 })
449 if err != nil {
450 l.Error("failed to create comment", "err", err)
451 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
452 return
453 }
454 atUri := resp.Uri
455 defer func() {
456 if err := rollbackRecord(context.Background(), atUri, client); err != nil {
457 l.Error("rollback failed", "err", err)
458 }
459 }()
460
461 tx, err := rp.db.Begin()
462 if err != nil {
463 l.Error("failed to start transaction", "err", err)
464 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
465 return
466 }
467 defer tx.Rollback()
468
469 commentId, err := db.AddIssueComment(tx, comment)
470 if err != nil {
471 l.Error("failed to create comment", "err", err)
472 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
473 return
474 }
475 err = tx.Commit()
476 if err != nil {
477 l.Error("failed to commit transaction", "err", err)
478 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
479 return
480 }
481
482 // reset atUri to make rollback a no-op
483 atUri = ""
484
485 // notify about the new comment
486 comment.Id = commentId
487
488 rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
489
490 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
491 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId))
492}
493
494func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
495 l := rp.logger.With("handler", "IssueComment")
496 user := rp.oauth.GetMultiAccountUser(r)
497
498 issue, ok := r.Context().Value("issue").(*models.Issue)
499 if !ok {
500 l.Error("failed to get issue")
501 rp.pages.Error404(w)
502 return
503 }
504
505 commentId := chi.URLParam(r, "commentId")
506 comments, err := db.GetIssueComments(
507 rp.db,
508 orm.FilterEq("id", commentId),
509 )
510 if err != nil {
511 l.Error("failed to fetch comment", "id", commentId)
512 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
513 return
514 }
515 if len(comments) != 1 {
516 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
517 http.Error(w, "invalid comment id", http.StatusBadRequest)
518 return
519 }
520 comment := comments[0]
521
522 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
523 LoggedInUser: user,
524 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
525 Issue: issue,
526 Comment: &comment,
527 })
528}
529
530func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
531 l := rp.logger.With("handler", "EditIssueComment")
532 user := rp.oauth.GetMultiAccountUser(r)
533
534 issue, ok := r.Context().Value("issue").(*models.Issue)
535 if !ok {
536 l.Error("failed to get issue")
537 rp.pages.Error404(w)
538 return
539 }
540
541 commentId := chi.URLParam(r, "commentId")
542 comments, err := db.GetIssueComments(
543 rp.db,
544 orm.FilterEq("id", commentId),
545 )
546 if err != nil {
547 l.Error("failed to fetch comment", "id", commentId)
548 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
549 return
550 }
551 if len(comments) != 1 {
552 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
553 http.Error(w, "invalid comment id", http.StatusBadRequest)
554 return
555 }
556 comment := comments[0]
557
558 if comment.Did != user.Active.Did {
559 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did)
560 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
561 return
562 }
563
564 switch r.Method {
565 case http.MethodGet:
566 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
567 LoggedInUser: user,
568 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
569 Issue: issue,
570 Comment: &comment,
571 })
572 case http.MethodPost:
573 // extract form value
574 newBody := r.FormValue("body")
575 client, err := rp.oauth.AuthorizedClient(r)
576 if err != nil {
577 l.Error("failed to get authorized client", "err", err)
578 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
579 return
580 }
581
582 now := time.Now()
583 newComment := comment
584 newComment.Body = newBody
585 newComment.Edited = &now
586 newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody)
587
588 record := newComment.AsRecord()
589
590 tx, err := rp.db.Begin()
591 if err != nil {
592 l.Error("failed to start transaction", "err", err)
593 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
594 return
595 }
596 defer tx.Rollback()
597
598 _, err = db.AddIssueComment(tx, newComment)
599 if err != nil {
600 l.Error("failed to perferom update-description query", "err", err)
601 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
602 return
603 }
604 tx.Commit()
605
606 // rkey is optional, it was introduced later
607 if newComment.Rkey != "" {
608 // update the record on pds
609 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Active.Did, comment.Rkey)
610 if err != nil {
611 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
612 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
613 return
614 }
615
616 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
617 Collection: tangled.RepoIssueCommentNSID,
618 Repo: user.Active.Did,
619 Rkey: newComment.Rkey,
620 SwapRecord: ex.Cid,
621 Record: &lexutil.LexiconTypeDecoder{
622 Val: &record,
623 },
624 })
625 if err != nil {
626 l.Error("failed to update record on PDS", "err", err)
627 }
628 }
629
630 // return new comment body with htmx
631 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
632 LoggedInUser: user,
633 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
634 Issue: issue,
635 Comment: &newComment,
636 })
637 }
638}
639
640func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
641 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
642 user := rp.oauth.GetMultiAccountUser(r)
643
644 issue, ok := r.Context().Value("issue").(*models.Issue)
645 if !ok {
646 l.Error("failed to get issue")
647 rp.pages.Error404(w)
648 return
649 }
650
651 commentId := chi.URLParam(r, "commentId")
652 comments, err := db.GetIssueComments(
653 rp.db,
654 orm.FilterEq("id", commentId),
655 )
656 if err != nil {
657 l.Error("failed to fetch comment", "id", commentId)
658 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
659 return
660 }
661 if len(comments) != 1 {
662 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
663 http.Error(w, "invalid comment id", http.StatusBadRequest)
664 return
665 }
666 comment := comments[0]
667
668 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
669 LoggedInUser: user,
670 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
671 Issue: issue,
672 Comment: &comment,
673 })
674}
675
676func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
677 l := rp.logger.With("handler", "ReplyIssueComment")
678 user := rp.oauth.GetMultiAccountUser(r)
679
680 issue, ok := r.Context().Value("issue").(*models.Issue)
681 if !ok {
682 l.Error("failed to get issue")
683 rp.pages.Error404(w)
684 return
685 }
686
687 commentId := chi.URLParam(r, "commentId")
688 comments, err := db.GetIssueComments(
689 rp.db,
690 orm.FilterEq("id", commentId),
691 )
692 if err != nil {
693 l.Error("failed to fetch comment", "id", commentId)
694 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
695 return
696 }
697 if len(comments) != 1 {
698 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
699 http.Error(w, "invalid comment id", http.StatusBadRequest)
700 return
701 }
702 comment := comments[0]
703
704 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
705 LoggedInUser: user,
706 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
707 Issue: issue,
708 Comment: &comment,
709 })
710}
711
712func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
713 l := rp.logger.With("handler", "DeleteIssueComment")
714 user := rp.oauth.GetMultiAccountUser(r)
715
716 issue, ok := r.Context().Value("issue").(*models.Issue)
717 if !ok {
718 l.Error("failed to get issue")
719 rp.pages.Error404(w)
720 return
721 }
722
723 commentId := chi.URLParam(r, "commentId")
724 comments, err := db.GetIssueComments(
725 rp.db,
726 orm.FilterEq("id", commentId),
727 )
728 if err != nil {
729 l.Error("failed to fetch comment", "id", commentId)
730 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
731 return
732 }
733 if len(comments) != 1 {
734 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
735 http.Error(w, "invalid comment id", http.StatusBadRequest)
736 return
737 }
738 comment := comments[0]
739
740 if comment.Did != user.Active.Did {
741 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did)
742 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
743 return
744 }
745
746 if comment.Deleted != nil {
747 http.Error(w, "comment already deleted", http.StatusBadRequest)
748 return
749 }
750
751 // optimistic deletion
752 deleted := time.Now()
753 err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id))
754 if err != nil {
755 l.Error("failed to delete comment", "err", err)
756 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
757 return
758 }
759
760 // delete from pds
761 if comment.Rkey != "" {
762 client, err := rp.oauth.AuthorizedClient(r)
763 if err != nil {
764 l.Error("failed to get authorized client", "err", err)
765 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
766 return
767 }
768 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
769 Collection: tangled.RepoIssueCommentNSID,
770 Repo: user.Active.Did,
771 Rkey: comment.Rkey,
772 })
773 if err != nil {
774 l.Error("failed to delete from PDS", "err", err)
775 }
776 }
777
778 // optimistic update for htmx
779 comment.Body = ""
780 comment.Deleted = &deleted
781
782 // htmx fragment of comment after deletion
783 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
784 LoggedInUser: user,
785 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
786 Issue: issue,
787 Comment: &comment,
788 })
789}
790
791func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
792 l := rp.logger.With("handler", "RepoIssues")
793
794 params := r.URL.Query()
795 state := params.Get("state")
796 isOpen := true
797 switch state {
798 case "open":
799 isOpen = true
800 case "closed":
801 isOpen = false
802 default:
803 isOpen = true
804 }
805
806 page := pagination.FromContext(r.Context())
807
808 user := rp.oauth.GetMultiAccountUser(r)
809 f, err := rp.repoResolver.Resolve(r)
810 if err != nil {
811 l.Error("failed to get repo and knot", "err", err)
812 return
813 }
814
815 totalIssues := 0
816 if isOpen {
817 totalIssues = f.RepoStats.IssueCount.Open
818 } else {
819 totalIssues = f.RepoStats.IssueCount.Closed
820 }
821
822 keyword := params.Get("q")
823
824 var issues []models.Issue
825 searchOpts := models.IssueSearchOptions{
826 Keyword: keyword,
827 RepoAt: f.RepoAt().String(),
828 IsOpen: isOpen,
829 Page: page,
830 }
831 if keyword != "" {
832 res, err := rp.indexer.Search(r.Context(), searchOpts)
833 if err != nil {
834 l.Error("failed to search for issues", "err", err)
835 return
836 }
837 l.Debug("searched issues with indexer", "count", len(res.Hits))
838 totalIssues = int(res.Total)
839
840 issues, err = db.GetIssues(
841 rp.db,
842 orm.FilterIn("id", res.Hits),
843 )
844 if err != nil {
845 l.Error("failed to get issues", "err", err)
846 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
847 return
848 }
849
850 } else {
851 openInt := 0
852 if isOpen {
853 openInt = 1
854 }
855 issues, err = db.GetIssuesPaginated(
856 rp.db,
857 page,
858 orm.FilterEq("repo_at", f.RepoAt()),
859 orm.FilterEq("open", openInt),
860 )
861 if err != nil {
862 l.Error("failed to get issues", "err", err)
863 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
864 return
865 }
866 }
867
868 labelDefs, err := db.GetLabelDefinitions(
869 rp.db,
870 orm.FilterIn("at_uri", f.Labels),
871 orm.FilterContains("scope", tangled.RepoIssueNSID),
872 )
873 if err != nil {
874 l.Error("failed to fetch labels", "err", err)
875 rp.pages.Error503(w)
876 return
877 }
878
879 defs := make(map[string]*models.LabelDefinition)
880 for _, l := range labelDefs {
881 defs[l.AtUri().String()] = &l
882 }
883
884 rp.pages.RepoIssues(w, pages.RepoIssuesParams{
885 LoggedInUser: rp.oauth.GetMultiAccountUser(r),
886 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
887 Issues: issues,
888 IssueCount: totalIssues,
889 LabelDefs: defs,
890 FilteringByOpen: isOpen,
891 FilterQuery: keyword,
892 Page: page,
893 })
894}
895
896func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
897 l := rp.logger.With("handler", "NewIssue")
898 user := rp.oauth.GetMultiAccountUser(r)
899
900 f, err := rp.repoResolver.Resolve(r)
901 if err != nil {
902 l.Error("failed to get repo and knot", "err", err)
903 return
904 }
905
906 switch r.Method {
907 case http.MethodGet:
908 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
909 LoggedInUser: user,
910 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
911 })
912 case http.MethodPost:
913 body := r.FormValue("body")
914 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
915
916 issue := &models.Issue{
917 RepoAt: f.RepoAt(),
918 Rkey: tid.TID(),
919 Title: r.FormValue("title"),
920 Body: body,
921 Open: true,
922 Did: user.Active.Did,
923 Created: time.Now(),
924 Mentions: mentions,
925 References: references,
926 Repo: f,
927 }
928
929 if err := issue.Validate(); err != nil {
930 l.Error("validation error", "err", err)
931 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
932 return
933 }
934
935 record := issue.AsRecord()
936
937 // create an atproto record
938 client, err := rp.oauth.AuthorizedClient(r)
939 if err != nil {
940 l.Error("failed to get authorized client", "err", err)
941 rp.pages.Notice(w, "issues", "Failed to create issue.")
942 return
943 }
944 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
945 Collection: tangled.RepoIssueNSID,
946 Repo: user.Active.Did,
947 Rkey: issue.Rkey,
948 Record: &lexutil.LexiconTypeDecoder{
949 Val: &record,
950 },
951 })
952 if err != nil {
953 l.Error("failed to create issue", "err", err)
954 rp.pages.Notice(w, "issues", "Failed to create issue.")
955 return
956 }
957 atUri := resp.Uri
958
959 tx, err := rp.db.BeginTx(r.Context(), nil)
960 if err != nil {
961 rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
962 return
963 }
964 rollback := func() {
965 err1 := tx.Rollback()
966 err2 := rollbackRecord(context.Background(), atUri, client)
967
968 if errors.Is(err1, sql.ErrTxDone) {
969 err1 = nil
970 }
971
972 if err := errors.Join(err1, err2); err != nil {
973 l.Error("failed to rollback txn", "err", err)
974 }
975 }
976 defer rollback()
977
978 err = db.PutIssue(tx, issue)
979 if err != nil {
980 l.Error("failed to create issue", "err", err)
981 rp.pages.Notice(w, "issues", "Failed to create issue.")
982 return
983 }
984
985 if err = tx.Commit(); err != nil {
986 l.Error("failed to create issue", "err", err)
987 rp.pages.Notice(w, "issues", "Failed to create issue.")
988 return
989 }
990
991 // everything is successful, do not rollback the atproto record
992 atUri = ""
993
994 rp.notifier.NewIssue(r.Context(), issue, mentions)
995
996 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
997 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
998 return
999 }
1000}
1001
1002// this is used to rollback changes made to the PDS
1003//
1004// it is a no-op if the provided ATURI is empty
1005func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
1006 if aturi == "" {
1007 return nil
1008 }
1009
1010 parsed := syntax.ATURI(aturi)
1011
1012 collection := parsed.Collection().String()
1013 repo := parsed.Authority().String()
1014 rkey := parsed.RecordKey().String()
1015
1016 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
1017 Collection: collection,
1018 Repo: repo,
1019 Rkey: rkey,
1020 })
1021 return err
1022}