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