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