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