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