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