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