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/searchquery"
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 indexer *issues_indexer.Indexer
49}
50
51func New(
52 oauth *oauth.OAuth,
53 repoResolver *reporesolver.RepoResolver,
54 enforcer *rbac.Enforcer,
55 pages *pages.Pages,
56 idResolver *idresolver.Resolver,
57 mentionsResolver *mentions.Resolver,
58 db *db.DB,
59 config *config.Config,
60 notifier notify.Notifier,
61 indexer *issues_indexer.Indexer,
62 logger *slog.Logger,
63) *Issues {
64 return &Issues{
65 oauth: oauth,
66 repoResolver: repoResolver,
67 enforcer: enforcer,
68 pages: pages,
69 idResolver: idResolver,
70 mentionsResolver: mentionsResolver,
71 db: db,
72 config: config,
73 notifier: notifier,
74 logger: logger,
75 indexer: indexer,
76 }
77}
78
79func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
80 l := rp.logger.With("handler", "RepoSingleIssue")
81 user := rp.oauth.GetMultiAccountUser(r)
82 f, err := rp.repoResolver.Resolve(r)
83 if err != nil {
84 l.Error("failed to get repo and knot", "err", err)
85 return
86 }
87
88 issue, ok := r.Context().Value("issue").(*models.Issue)
89 if !ok {
90 l.Error("failed to get issue")
91 rp.pages.Error404(w)
92 return
93 }
94
95 reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri())
96 if err != nil {
97 l.Error("failed to get issue reactions", "err", err)
98 }
99
100 userReactions := map[models.ReactionKind]bool{}
101 if user != nil {
102 userReactions, err = db.GetReactionStatusMap(rp.db, user.Active.Did, issue.AtUri())
103 if err != nil {
104 l.Error("failed to get issue reaction status", "err", err)
105 }
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 := newIssue.Validate(); 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.Active.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.Active.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.Active.Did, f.Knot, f.DidSlashRepo())}
309 isRepoOwner := roles.IsOwner()
310 isCollaborator := roles.IsCollaborator()
311 isIssueOwner := user.Active.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.Active.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.Active.Did, f.Knot, f.DidSlashRepo())}
357 isRepoOwner := roles.IsOwner()
358 isCollaborator := roles.IsCollaborator()
359 isIssueOwner := user.Active.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.Active.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", "Body is required")
406 return
407 }
408
409 replyToUri := r.FormValue("reply-to")
410 var replyTo *string
411 if replyToUri != "" {
412 replyTo = &replyToUri
413 }
414
415 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
416
417 comment := models.IssueComment{
418 Did: user.Active.Did,
419 Rkey: tid.TID(),
420 IssueAt: issue.AtUri().String(),
421 ReplyTo: replyTo,
422 Body: body,
423 Created: time.Now(),
424 Mentions: mentions,
425 References: references,
426 }
427 if err = comment.Validate(); err != nil {
428 l.Error("failed to validate comment", "err", err)
429 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
430 return
431 }
432 record := comment.AsRecord()
433
434 client, err := rp.oauth.AuthorizedClient(r)
435 if err != nil {
436 l.Error("failed to get authorized client", "err", err)
437 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
438 return
439 }
440
441 // create a record first
442 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
443 Collection: tangled.RepoIssueCommentNSID,
444 Repo: comment.Did,
445 Rkey: comment.Rkey,
446 Record: &lexutil.LexiconTypeDecoder{
447 Val: &record,
448 },
449 })
450 if err != nil {
451 l.Error("failed to create comment", "err", err)
452 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
453 return
454 }
455 atUri := resp.Uri
456 defer func() {
457 if err := rollbackRecord(context.Background(), atUri, client); err != nil {
458 l.Error("rollback failed", "err", err)
459 }
460 }()
461
462 tx, err := rp.db.Begin()
463 if err != nil {
464 l.Error("failed to start transaction", "err", err)
465 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
466 return
467 }
468 defer tx.Rollback()
469
470 commentId, err := db.AddIssueComment(tx, comment)
471 if err != nil {
472 l.Error("failed to create comment", "err", err)
473 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
474 return
475 }
476 err = tx.Commit()
477 if err != nil {
478 l.Error("failed to commit transaction", "err", err)
479 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
480 return
481 }
482
483 // reset atUri to make rollback a no-op
484 atUri = ""
485
486 // notify about the new comment
487 comment.Id = commentId
488
489 rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
490
491 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
492 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId))
493}
494
495func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
496 l := rp.logger.With("handler", "IssueComment")
497 user := rp.oauth.GetMultiAccountUser(r)
498
499 issue, ok := r.Context().Value("issue").(*models.Issue)
500 if !ok {
501 l.Error("failed to get issue")
502 rp.pages.Error404(w)
503 return
504 }
505
506 commentId := chi.URLParam(r, "commentId")
507 comments, err := db.GetIssueComments(
508 rp.db,
509 orm.FilterEq("id", commentId),
510 )
511 if err != nil {
512 l.Error("failed to fetch comment", "id", commentId)
513 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
514 return
515 }
516 if len(comments) != 1 {
517 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
518 http.Error(w, "invalid comment id", http.StatusBadRequest)
519 return
520 }
521 comment := comments[0]
522
523 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
524 LoggedInUser: user,
525 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
526 Issue: issue,
527 Comment: &comment,
528 })
529}
530
531func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
532 l := rp.logger.With("handler", "EditIssueComment")
533 user := rp.oauth.GetMultiAccountUser(r)
534
535 issue, ok := r.Context().Value("issue").(*models.Issue)
536 if !ok {
537 l.Error("failed to get issue")
538 rp.pages.Error404(w)
539 return
540 }
541
542 commentId := chi.URLParam(r, "commentId")
543 comments, err := db.GetIssueComments(
544 rp.db,
545 orm.FilterEq("id", commentId),
546 )
547 if err != nil {
548 l.Error("failed to fetch comment", "id", commentId)
549 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
550 return
551 }
552 if len(comments) != 1 {
553 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
554 http.Error(w, "invalid comment id", http.StatusBadRequest)
555 return
556 }
557 comment := comments[0]
558
559 if comment.Did != user.Active.Did {
560 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did)
561 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
562 return
563 }
564
565 switch r.Method {
566 case http.MethodGet:
567 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
568 LoggedInUser: user,
569 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
570 Issue: issue,
571 Comment: &comment,
572 })
573 case http.MethodPost:
574 // extract form value
575 newBody := r.FormValue("body")
576 client, err := rp.oauth.AuthorizedClient(r)
577 if err != nil {
578 l.Error("failed to get authorized client", "err", err)
579 rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
580 return
581 }
582
583 now := time.Now()
584 newComment := comment
585 newComment.Body = newBody
586 newComment.Edited = &now
587 newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody)
588
589 record := newComment.AsRecord()
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.AddIssueComment(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 // update the record on pds
610 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Active.Did, comment.Rkey)
611 if err != nil {
612 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
613 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
614 return
615 }
616
617 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
618 Collection: tangled.RepoIssueCommentNSID,
619 Repo: user.Active.Did,
620 Rkey: newComment.Rkey,
621 SwapRecord: ex.Cid,
622 Record: &lexutil.LexiconTypeDecoder{
623 Val: &record,
624 },
625 })
626 if err != nil {
627 l.Error("failed to update record on PDS", "err", err)
628 }
629 }
630
631 // return new comment body with htmx
632 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
633 LoggedInUser: user,
634 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
635 Issue: issue,
636 Comment: &newComment,
637 })
638 }
639}
640
641func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
642 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
643 user := rp.oauth.GetMultiAccountUser(r)
644
645 issue, ok := r.Context().Value("issue").(*models.Issue)
646 if !ok {
647 l.Error("failed to get issue")
648 rp.pages.Error404(w)
649 return
650 }
651
652 commentId := chi.URLParam(r, "commentId")
653 comments, err := db.GetIssueComments(
654 rp.db,
655 orm.FilterEq("id", commentId),
656 )
657 if err != nil {
658 l.Error("failed to fetch comment", "id", commentId)
659 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
660 return
661 }
662 if len(comments) != 1 {
663 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
664 http.Error(w, "invalid comment id", http.StatusBadRequest)
665 return
666 }
667 comment := comments[0]
668
669 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
670 LoggedInUser: user,
671 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
672 Issue: issue,
673 Comment: &comment,
674 })
675}
676
677func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
678 l := rp.logger.With("handler", "ReplyIssueComment")
679 user := rp.oauth.GetMultiAccountUser(r)
680
681 issue, ok := r.Context().Value("issue").(*models.Issue)
682 if !ok {
683 l.Error("failed to get issue")
684 rp.pages.Error404(w)
685 return
686 }
687
688 commentId := chi.URLParam(r, "commentId")
689 comments, err := db.GetIssueComments(
690 rp.db,
691 orm.FilterEq("id", commentId),
692 )
693 if err != nil {
694 l.Error("failed to fetch comment", "id", commentId)
695 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
696 return
697 }
698 if len(comments) != 1 {
699 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
700 http.Error(w, "invalid comment id", http.StatusBadRequest)
701 return
702 }
703 comment := comments[0]
704
705 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
706 LoggedInUser: user,
707 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
708 Issue: issue,
709 Comment: &comment,
710 })
711}
712
713func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
714 l := rp.logger.With("handler", "DeleteIssueComment")
715 user := rp.oauth.GetMultiAccountUser(r)
716
717 issue, ok := r.Context().Value("issue").(*models.Issue)
718 if !ok {
719 l.Error("failed to get issue")
720 rp.pages.Error404(w)
721 return
722 }
723
724 commentId := chi.URLParam(r, "commentId")
725 comments, err := db.GetIssueComments(
726 rp.db,
727 orm.FilterEq("id", commentId),
728 )
729 if err != nil {
730 l.Error("failed to fetch comment", "id", commentId)
731 http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
732 return
733 }
734 if len(comments) != 1 {
735 l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
736 http.Error(w, "invalid comment id", http.StatusBadRequest)
737 return
738 }
739 comment := comments[0]
740
741 if comment.Did != user.Active.Did {
742 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did)
743 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
744 return
745 }
746
747 if comment.Deleted != nil {
748 http.Error(w, "comment already deleted", http.StatusBadRequest)
749 return
750 }
751
752 // optimistic deletion
753 deleted := time.Now()
754 err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id))
755 if err != nil {
756 l.Error("failed to delete comment", "err", err)
757 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
758 return
759 }
760
761 // delete from pds
762 if comment.Rkey != "" {
763 client, err := rp.oauth.AuthorizedClient(r)
764 if err != nil {
765 l.Error("failed to get authorized client", "err", err)
766 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
767 return
768 }
769 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
770 Collection: tangled.RepoIssueCommentNSID,
771 Repo: user.Active.Did,
772 Rkey: comment.Rkey,
773 })
774 if err != nil {
775 l.Error("failed to delete from PDS", "err", err)
776 }
777 }
778
779 // optimistic update for htmx
780 comment.Body = ""
781 comment.Deleted = &deleted
782
783 // htmx fragment of comment after deletion
784 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
785 LoggedInUser: user,
786 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
787 Issue: issue,
788 Comment: &comment,
789 })
790}
791
792func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
793 l := rp.logger.With("handler", "RepoIssues")
794
795 params := r.URL.Query()
796 page := pagination.FromContext(r.Context())
797
798 user := rp.oauth.GetMultiAccountUser(r)
799 f, err := rp.repoResolver.Resolve(r)
800 if err != nil {
801 l.Error("failed to get repo and knot", "err", err)
802 return
803 }
804
805 query := searchquery.Parse(params.Get("q"))
806
807 var isOpen *bool
808 if urlState := params.Get("state"); urlState != "" {
809 switch urlState {
810 case "open":
811 isOpen = ptrBool(true)
812 case "closed":
813 isOpen = ptrBool(false)
814 }
815 query.Set("state", urlState)
816 } else if queryState := query.Get("state"); queryState != nil {
817 switch *queryState {
818 case "open":
819 isOpen = ptrBool(true)
820 case "closed":
821 isOpen = ptrBool(false)
822 }
823 } else if _, hasQ := params["q"]; !hasQ {
824 // no q param at all -- default to open
825 isOpen = ptrBool(true)
826 query.Set("state", "open")
827 }
828
829 var authorDid string
830 if authorHandle := query.Get("author"); authorHandle != nil {
831 identity, err := rp.idResolver.ResolveIdent(r.Context(), *authorHandle)
832 if err != nil {
833 l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err)
834 } else {
835 authorDid = identity.DID.String()
836 }
837 }
838
839 var negatedAuthorDid string
840 if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 {
841 identity, err := rp.idResolver.ResolveIdent(r.Context(), negatedAuthors[0])
842 if err != nil {
843 l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err)
844 } else {
845 negatedAuthorDid = identity.DID.String()
846 }
847 }
848
849 labels := query.GetAll("label")
850 negatedLabels := query.GetAllNegated("label")
851
852 var keywords, negatedKeywords []string
853 var phrases, negatedPhrases []string
854 for _, item := range query.Items() {
855 switch item.Kind {
856 case searchquery.KindKeyword:
857 if item.Negated {
858 negatedKeywords = append(negatedKeywords, item.Value)
859 } else {
860 keywords = append(keywords, item.Value)
861 }
862 case searchquery.KindQuoted:
863 if item.Negated {
864 negatedPhrases = append(negatedPhrases, item.Value)
865 } else {
866 phrases = append(phrases, item.Value)
867 }
868 }
869 }
870
871 searchOpts := models.IssueSearchOptions{
872 Keywords: keywords,
873 Phrases: phrases,
874 RepoAt: f.RepoAt().String(),
875 IsOpen: isOpen,
876 AuthorDid: authorDid,
877 Labels: labels,
878 NegatedKeywords: negatedKeywords,
879 NegatedPhrases: negatedPhrases,
880 NegatedLabels: negatedLabels,
881 NegatedAuthorDid: negatedAuthorDid,
882 Page: page,
883 }
884
885 totalIssues := 0
886 if isOpen == nil {
887 totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed
888 } else if *isOpen {
889 totalIssues = f.RepoStats.IssueCount.Open
890 } else {
891 totalIssues = f.RepoStats.IssueCount.Closed
892 }
893
894 repoInfo := rp.repoResolver.GetRepoInfo(r, user)
895
896 var issues []models.Issue
897
898 if searchOpts.HasSearchFilters() {
899 res, err := rp.indexer.Search(r.Context(), searchOpts)
900 if err != nil {
901 l.Error("failed to search for issues", "err", err)
902 return
903 }
904 l.Debug("searched issues with indexer", "count", len(res.Hits))
905 totalIssues = int(res.Total)
906
907 // update tab counts to reflect filtered results
908 countOpts := searchOpts
909 countOpts.Page = pagination.Page{Limit: 1}
910 countOpts.IsOpen = ptrBool(true)
911 if openRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil {
912 repoInfo.Stats.IssueCount.Open = int(openRes.Total)
913 }
914 countOpts.IsOpen = ptrBool(false)
915 if closedRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil {
916 repoInfo.Stats.IssueCount.Closed = int(closedRes.Total)
917 }
918
919 if len(res.Hits) > 0 {
920 issues, err = db.GetIssues(
921 rp.db,
922 orm.FilterIn("id", res.Hits),
923 )
924 if err != nil {
925 l.Error("failed to get issues", "err", err)
926 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
927 return
928 }
929 }
930 } else {
931 filters := []orm.Filter{
932 orm.FilterEq("repo_at", f.RepoAt()),
933 }
934 if isOpen != nil {
935 openInt := 0
936 if *isOpen {
937 openInt = 1
938 }
939 filters = append(filters, orm.FilterEq("open", openInt))
940 }
941 issues, err = db.GetIssuesPaginated(
942 rp.db,
943 page,
944 filters...,
945 )
946 if err != nil {
947 l.Error("failed to get issues", "err", err)
948 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
949 return
950 }
951 }
952
953 labelDefs, err := db.GetLabelDefinitions(
954 rp.db,
955 orm.FilterIn("at_uri", f.Labels),
956 orm.FilterContains("scope", tangled.RepoIssueNSID),
957 )
958 if err != nil {
959 l.Error("failed to fetch labels", "err", err)
960 rp.pages.Error503(w)
961 return
962 }
963
964 defs := make(map[string]*models.LabelDefinition)
965 for _, l := range labelDefs {
966 defs[l.AtUri().String()] = &l
967 }
968
969 filterState := ""
970 if isOpen != nil {
971 if *isOpen {
972 filterState = "open"
973 } else {
974 filterState = "closed"
975 }
976 }
977
978 rp.pages.RepoIssues(w, pages.RepoIssuesParams{
979 LoggedInUser: rp.oauth.GetMultiAccountUser(r),
980 RepoInfo: repoInfo,
981 Issues: issues,
982 IssueCount: totalIssues,
983 LabelDefs: defs,
984 FilterState: filterState,
985 FilterQuery: query.String(),
986 Page: page,
987 })
988}
989
990func ptrBool(b bool) *bool { return &b }
991
992func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
993 l := rp.logger.With("handler", "NewIssue")
994 user := rp.oauth.GetMultiAccountUser(r)
995
996 f, err := rp.repoResolver.Resolve(r)
997 if err != nil {
998 l.Error("failed to get repo and knot", "err", err)
999 return
1000 }
1001
1002 switch r.Method {
1003 case http.MethodGet:
1004 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1005 LoggedInUser: user,
1006 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
1007 })
1008 case http.MethodPost:
1009 body := r.FormValue("body")
1010 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
1011
1012 issue := &models.Issue{
1013 RepoAt: f.RepoAt(),
1014 Rkey: tid.TID(),
1015 Title: r.FormValue("title"),
1016 Body: body,
1017 Open: true,
1018 Did: user.Active.Did,
1019 Created: time.Now(),
1020 Mentions: mentions,
1021 References: references,
1022 Repo: f,
1023 }
1024
1025 if err := issue.Validate(); err != nil {
1026 l.Error("validation error", "err", err)
1027 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
1028 return
1029 }
1030
1031 record := issue.AsRecord()
1032
1033 // create an atproto record
1034 client, err := rp.oauth.AuthorizedClient(r)
1035 if err != nil {
1036 l.Error("failed to get authorized client", "err", err)
1037 rp.pages.Notice(w, "issues", "Failed to create issue.")
1038 return
1039 }
1040 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1041 Collection: tangled.RepoIssueNSID,
1042 Repo: user.Active.Did,
1043 Rkey: issue.Rkey,
1044 Record: &lexutil.LexiconTypeDecoder{
1045 Val: &record,
1046 },
1047 })
1048 if err != nil {
1049 l.Error("failed to create issue", "err", err)
1050 rp.pages.Notice(w, "issues", "Failed to create issue.")
1051 return
1052 }
1053 atUri := resp.Uri
1054
1055 tx, err := rp.db.BeginTx(r.Context(), nil)
1056 if err != nil {
1057 rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
1058 return
1059 }
1060 rollback := func() {
1061 err1 := tx.Rollback()
1062 err2 := rollbackRecord(context.Background(), atUri, client)
1063
1064 if errors.Is(err1, sql.ErrTxDone) {
1065 err1 = nil
1066 }
1067
1068 if err := errors.Join(err1, err2); err != nil {
1069 l.Error("failed to rollback txn", "err", err)
1070 }
1071 }
1072 defer rollback()
1073
1074 err = db.PutIssue(tx, issue)
1075 if err != nil {
1076 l.Error("failed to create issue", "err", err)
1077 rp.pages.Notice(w, "issues", "Failed to create issue.")
1078 return
1079 }
1080
1081 if err = tx.Commit(); err != nil {
1082 l.Error("failed to create issue", "err", err)
1083 rp.pages.Notice(w, "issues", "Failed to create issue.")
1084 return
1085 }
1086
1087 // everything is successful, do not rollback the atproto record
1088 atUri = ""
1089
1090 rp.notifier.NewIssue(r.Context(), issue, mentions)
1091
1092 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
1093 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
1094 return
1095 }
1096}
1097
1098// this is used to rollback changes made to the PDS
1099//
1100// it is a no-op if the provided ATURI is empty
1101func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
1102 if aturi == "" {
1103 return nil
1104 }
1105
1106 parsed := syntax.ATURI(aturi)
1107
1108 collection := parsed.Collection().String()
1109 repo := parsed.Authority().String()
1110 rkey := parsed.RecordKey().String()
1111
1112 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
1113 Collection: collection,
1114 Repo: repo,
1115 Rkey: rkey,
1116 })
1117 return err
1118}