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 var authorDid string
827 if authorHandle := query.Get("author"); authorHandle != nil {
828 identity, err := rp.idResolver.ResolveIdent(r.Context(), *authorHandle)
829 if err != nil {
830 l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err)
831 } else {
832 authorDid = identity.DID.String()
833 }
834 }
835
836 var negatedAuthorDid string
837 if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 {
838 identity, err := rp.idResolver.ResolveIdent(r.Context(), negatedAuthors[0])
839 if err != nil {
840 l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err)
841 } else {
842 negatedAuthorDid = identity.DID.String()
843 }
844 }
845
846 labels := query.GetAll("label")
847 negatedLabels := query.GetAllNegated("label")
848
849 var keywords, negatedKeywords []string
850 var phrases, negatedPhrases []string
851 for _, item := range query.Items() {
852 switch item.Kind {
853 case searchquery.KindKeyword:
854 if item.Negated {
855 negatedKeywords = append(negatedKeywords, item.Value)
856 } else {
857 keywords = append(keywords, item.Value)
858 }
859 case searchquery.KindQuoted:
860 if item.Negated {
861 negatedPhrases = append(negatedPhrases, item.Value)
862 } else {
863 phrases = append(phrases, item.Value)
864 }
865 }
866 }
867
868 searchOpts := models.IssueSearchOptions{
869 Keywords: keywords,
870 Phrases: phrases,
871 RepoAt: f.RepoAt().String(),
872 IsOpen: isOpen,
873 AuthorDid: authorDid,
874 Labels: labels,
875 NegatedKeywords: negatedKeywords,
876 NegatedPhrases: negatedPhrases,
877 NegatedLabels: negatedLabels,
878 NegatedAuthorDid: negatedAuthorDid,
879 Page: page,
880 }
881
882 totalIssues := 0
883 if isOpen == nil {
884 totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed
885 } else if *isOpen {
886 totalIssues = f.RepoStats.IssueCount.Open
887 } else {
888 totalIssues = f.RepoStats.IssueCount.Closed
889 }
890
891 repoInfo := rp.repoResolver.GetRepoInfo(r, user)
892
893 var issues []models.Issue
894
895 if searchOpts.HasSearchFilters() {
896 res, err := rp.indexer.Search(r.Context(), searchOpts)
897 if err != nil {
898 l.Error("failed to search for issues", "err", err)
899 return
900 }
901 l.Debug("searched issues with indexer", "count", len(res.Hits))
902 totalIssues = int(res.Total)
903
904 // update tab counts to reflect filtered results
905 countOpts := searchOpts
906 countOpts.Page = pagination.Page{Limit: 1}
907 countOpts.IsOpen = ptrBool(true)
908 if openRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil {
909 repoInfo.Stats.IssueCount.Open = int(openRes.Total)
910 }
911 countOpts.IsOpen = ptrBool(false)
912 if closedRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil {
913 repoInfo.Stats.IssueCount.Closed = int(closedRes.Total)
914 }
915
916 if len(res.Hits) > 0 {
917 issues, err = db.GetIssues(
918 rp.db,
919 orm.FilterIn("id", res.Hits),
920 )
921 if err != nil {
922 l.Error("failed to get issues", "err", err)
923 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
924 return
925 }
926 }
927 } else {
928 filters := []orm.Filter{
929 orm.FilterEq("repo_at", f.RepoAt()),
930 }
931 if isOpen != nil {
932 openInt := 0
933 if *isOpen {
934 openInt = 1
935 }
936 filters = append(filters, orm.FilterEq("open", openInt))
937 }
938 issues, err = db.GetIssuesPaginated(
939 rp.db,
940 page,
941 filters...,
942 )
943 if err != nil {
944 l.Error("failed to get issues", "err", err)
945 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
946 return
947 }
948 }
949
950 labelDefs, err := db.GetLabelDefinitions(
951 rp.db,
952 orm.FilterIn("at_uri", f.Labels),
953 orm.FilterContains("scope", tangled.RepoIssueNSID),
954 )
955 if err != nil {
956 l.Error("failed to fetch labels", "err", err)
957 rp.pages.Error503(w)
958 return
959 }
960
961 defs := make(map[string]*models.LabelDefinition)
962 for _, l := range labelDefs {
963 defs[l.AtUri().String()] = &l
964 }
965
966 filterState := ""
967 if isOpen != nil {
968 if *isOpen {
969 filterState = "open"
970 } else {
971 filterState = "closed"
972 }
973 }
974
975 rp.pages.RepoIssues(w, pages.RepoIssuesParams{
976 LoggedInUser: rp.oauth.GetMultiAccountUser(r),
977 RepoInfo: repoInfo,
978 Issues: issues,
979 IssueCount: totalIssues,
980 LabelDefs: defs,
981 FilterState: filterState,
982 FilterQuery: query.String(),
983 Page: page,
984 })
985}
986
987func ptrBool(b bool) *bool { return &b }
988
989func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
990 l := rp.logger.With("handler", "NewIssue")
991 user := rp.oauth.GetMultiAccountUser(r)
992
993 f, err := rp.repoResolver.Resolve(r)
994 if err != nil {
995 l.Error("failed to get repo and knot", "err", err)
996 return
997 }
998
999 switch r.Method {
1000 case http.MethodGet:
1001 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1002 LoggedInUser: user,
1003 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
1004 })
1005 case http.MethodPost:
1006 body := r.FormValue("body")
1007 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
1008
1009 issue := &models.Issue{
1010 RepoAt: f.RepoAt(),
1011 Rkey: tid.TID(),
1012 Title: r.FormValue("title"),
1013 Body: body,
1014 Open: true,
1015 Did: user.Active.Did,
1016 Created: time.Now(),
1017 Mentions: mentions,
1018 References: references,
1019 Repo: f,
1020 }
1021
1022 if err := issue.Validate(); err != nil {
1023 l.Error("validation error", "err", err)
1024 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
1025 return
1026 }
1027
1028 record := issue.AsRecord()
1029
1030 // create an atproto record
1031 client, err := rp.oauth.AuthorizedClient(r)
1032 if err != nil {
1033 l.Error("failed to get authorized client", "err", err)
1034 rp.pages.Notice(w, "issues", "Failed to create issue.")
1035 return
1036 }
1037 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1038 Collection: tangled.RepoIssueNSID,
1039 Repo: user.Active.Did,
1040 Rkey: issue.Rkey,
1041 Record: &lexutil.LexiconTypeDecoder{
1042 Val: &record,
1043 },
1044 })
1045 if err != nil {
1046 l.Error("failed to create issue", "err", err)
1047 rp.pages.Notice(w, "issues", "Failed to create issue.")
1048 return
1049 }
1050 atUri := resp.Uri
1051
1052 tx, err := rp.db.BeginTx(r.Context(), nil)
1053 if err != nil {
1054 rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
1055 return
1056 }
1057 rollback := func() {
1058 err1 := tx.Rollback()
1059 err2 := rollbackRecord(context.Background(), atUri, client)
1060
1061 if errors.Is(err1, sql.ErrTxDone) {
1062 err1 = nil
1063 }
1064
1065 if err := errors.Join(err1, err2); err != nil {
1066 l.Error("failed to rollback txn", "err", err)
1067 }
1068 }
1069 defer rollback()
1070
1071 err = db.PutIssue(tx, issue)
1072 if err != nil {
1073 l.Error("failed to create issue", "err", err)
1074 rp.pages.Notice(w, "issues", "Failed to create issue.")
1075 return
1076 }
1077
1078 if err = tx.Commit(); err != nil {
1079 l.Error("failed to create issue", "err", err)
1080 rp.pages.Notice(w, "issues", "Failed to create issue.")
1081 return
1082 }
1083
1084 // everything is successful, do not rollback the atproto record
1085 atUri = ""
1086
1087 rp.notifier.NewIssue(r.Context(), issue, mentions)
1088
1089 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
1090 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
1091 return
1092 }
1093}
1094
1095// this is used to rollback changes made to the PDS
1096//
1097// it is a no-op if the provided ATURI is empty
1098func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
1099 if aturi == "" {
1100 return nil
1101 }
1102
1103 parsed := syntax.ATURI(aturi)
1104
1105 collection := parsed.Collection().String()
1106 repo := parsed.Authority().String()
1107 rkey := parsed.RecordKey().String()
1108
1109 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
1110 Collection: collection,
1111 Repo: repo,
1112 Rkey: rkey,
1113 })
1114 return err
1115}