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