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