This repository has no description
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
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/knotacl"
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/pagination"
29 "tangled.org/core/appview/reporesolver"
30 "tangled.org/core/appview/searchquery"
31 "tangled.org/core/idresolver"
32 "tangled.org/core/ogre"
33 "tangled.org/core/orm"
34 "tangled.org/core/tid"
35)
36
37type Issues struct {
38 oauth *oauth.OAuth
39 repoResolver *reporesolver.RepoResolver
40 acl *knotacl.Service
41 pages *pages.Pages
42 idResolver *idresolver.Resolver
43 mentionsResolver *mentions.Resolver
44 db *db.DB
45 config *config.Config
46 notifier notify.Notifier
47 logger *slog.Logger
48 indexer *issues_indexer.Indexer
49 ogreClient *ogre.Client
50}
51
52func New(
53 oauth *oauth.OAuth,
54 repoResolver *reporesolver.RepoResolver,
55 acl *knotacl.Service,
56 pages *pages.Pages,
57 idResolver *idresolver.Resolver,
58 mentionsResolver *mentions.Resolver,
59 db *db.DB,
60 config *config.Config,
61 notifier notify.Notifier,
62 indexer *issues_indexer.Indexer,
63 logger *slog.Logger,
64) *Issues {
65 return &Issues{
66 oauth: oauth,
67 repoResolver: repoResolver,
68 acl: acl,
69 pages: pages,
70 idResolver: idResolver,
71 mentionsResolver: mentionsResolver,
72 db: db,
73 config: config,
74 notifier: notifier,
75 logger: logger,
76 indexer: indexer,
77 ogreClient: ogre.NewClient(config.Ogre.Host),
78 }
79}
80
81func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
82 l := rp.logger.With("handler", "RepoSingleIssue")
83 user := rp.oauth.GetMultiAccountUser(r)
84 f, err := rp.repoResolver.Resolve(r)
85 if err != nil {
86 l.Error("failed to get repo and knot", "err", err)
87 return
88 }
89
90 issue, ok := r.Context().Value("issue").(*models.Issue)
91 if !ok {
92 l.Error("failed to get issue")
93 rp.pages.Error404(w)
94 return
95 }
96
97 if user != nil {
98 userDid := user.Did
99 repoDid := f.RepoDid
100 issueId := issue.IssueId
101 atUri := issue.AtUri().String()
102 focusing := pages.BaseParamsFromContext(r.Context()).FocusParams.Focusing
103 go func() {
104 if !focusing {
105 if err := db.MarkNotificationsReadForIssue(rp.db, userDid, repoDid, issueId); err != nil {
106 l.Error("failed to mark issue notifications as read", "err", err)
107 }
108 }
109 if err := db.UpsertRecentLink(rp.db, userDid, models.RecentLinkTypeIssue, atUri); err != nil {
110 l.Error("failed to upsert recent link", "err", err)
111 }
112 }()
113 }
114
115 entities := []syntax.ATURI{issue.AtUri()}
116 for _, c := range issue.Comments {
117 entities = append(entities, c.FeedCommentAtUri())
118 }
119 reactions, err := db.ListReactionDisplayDataMap(rp.db, entities, 20)
120 if err != nil {
121 l.Error("failed to get reactions", "err", err)
122 }
123
124 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool
125 if user != nil {
126 userReactions, err = db.ListReactionStatusMap(rp.db, entities, syntax.DID(user.Did))
127 if err != nil {
128 l.Error("failed to get user reactions", "err", err)
129 }
130 }
131
132 backlinks, err := db.GetBacklinks(rp.db, issue.AtUri())
133 if err != nil {
134 l.Error("failed to fetch backlinks", "err", err)
135 rp.pages.Error503(w)
136 return
137 }
138
139 labelDefs, err := db.GetLabelDefinitions(
140 rp.db,
141 orm.FilterIn("at_uri", f.Labels),
142 orm.FilterContains("scope", tangled.RepoIssueNSID),
143 )
144 if err != nil {
145 l.Error("failed to fetch labels", "err", err)
146 rp.pages.Error503(w)
147 return
148 }
149
150 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
151 if user != nil {
152 participants := issue.Participants()
153 vouchRelationships, err = db.GetVouchRelationshipsBatch(rp.db, syntax.DID(user.Did), participants)
154 if err != nil {
155 l.Error("failed to fetch vouch relationships", "err", err)
156 }
157 }
158
159 defs := make(map[string]*models.LabelDefinition)
160 for _, l := range labelDefs {
161 defs[l.AtUri().String()] = &l
162 }
163
164 var isSubscribed *bool
165 if user != nil {
166 sub, found, err2 := db.GetIssueSubscription(rp.db, user.Did, issue.Id)
167 if err2 == nil {
168 if found {
169 isSubscribed = &sub
170 } else {
171 // Implicitly subscribed when you're the author or a participant.
172 isAuthorOrParticipant := issue.Did == user.Did
173 if !isAuthorOrParticipant {
174 for _, p := range issue.Participants() {
175 if p.String() == user.Did {
176 isAuthorOrParticipant = true
177 break
178 }
179 }
180 }
181 if isAuthorOrParticipant {
182 t := true
183 isSubscribed = &t
184 }
185 }
186 }
187 }
188
189 err = rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
190 BaseParams: pages.BaseParamsFromContext(r.Context()),
191 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
192 Issue: issue,
193 CommentList: models.NewCommentList(issue.Comments),
194 Backlinks: backlinks,
195 Reactions: reactions,
196 UserReacted: userReactions,
197 LabelDefs: defs,
198 VouchRelationships: vouchRelationships,
199 IsSubscribed: isSubscribed,
200 })
201 if err != nil {
202 l.Error("failed to render issue", "err", err)
203 }
204}
205
206// SubscribeIssue handles subscribe/unsubscribe for a specific issue.
207func (rp *Issues) SubscribeIssue(w http.ResponseWriter, r *http.Request) {
208 l := rp.logger.With("handler", "SubscribeIssue")
209 user := rp.oauth.GetMultiAccountUser(r)
210 if user == nil {
211 w.WriteHeader(http.StatusUnauthorized)
212 return
213 }
214
215 issue, ok := r.Context().Value("issue").(*models.Issue)
216 if !ok {
217 l.Error("failed to get issue from context")
218 w.WriteHeader(http.StatusNotFound)
219 return
220 }
221
222 subscribe := r.FormValue("subscribe") != "false"
223
224 if err := db.UpsertIssueSubscription(rp.db, user.Did, issue.Id, subscribe); err != nil {
225 l.Error("failed to update issue subscription", "err", err)
226 w.WriteHeader(http.StatusInternalServerError)
227 return
228 }
229
230 // Return the updated subscription button fragment.
231 repoInfo := rp.repoResolver.GetRepoInfo(r, user)
232 rp.pages.IssueSubscribeFragment(w, pages.IssueSubscribeParams{
233 RepoInfo: repoInfo,
234 IssueId: issue.IssueId,
235 IsSubscribed: &subscribe,
236 })
237}
238
239func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
240 l := rp.logger.With("handler", "EditIssue")
241 user := rp.oauth.GetMultiAccountUser(r)
242
243 issue, ok := r.Context().Value("issue").(*models.Issue)
244 if !ok {
245 l.Error("failed to get issue")
246 rp.pages.Error404(w)
247 return
248 }
249
250 switch r.Method {
251 case http.MethodGet:
252 rp.pages.EditIssueFragment(w, pages.EditIssueParams{
253 BaseParams: pages.BaseParamsFromContext(r.Context()),
254 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
255 Issue: issue,
256 })
257 case http.MethodPost:
258 noticeId := "issues"
259 newIssue := issue
260 newIssue.Title = r.FormValue("title")
261 newIssue.Body = r.FormValue("body")
262 newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body)
263
264 if err := newIssue.Validate(); err != nil {
265 l.Error("validation error", "err", err)
266 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err))
267 return
268 }
269
270 newRecord := newIssue.AsRecord()
271
272 // edit an atproto record
273 client, err := rp.oauth.AuthorizedClient(r)
274 if err != nil {
275 l.Error("failed to get authorized client", "err", err)
276 rp.pages.Notice(w, noticeId, "Failed to edit issue.")
277 return
278 }
279
280 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
281 if err != nil {
282 l.Error("failed to get record", "err", err)
283 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
284 return
285 }
286
287 // merge existing pins with new uploads, dropping any removed from body
288 var existingBlobs []*lexutil.LexBlob
289 if ex.Value != nil {
290 if prev, ok := ex.Value.Val.(*tangled.RepoIssue); ok {
291 existingBlobs = prev.Blobs
292 }
293 }
294 newRecord.Blobs = models.MergeBlobs(existingBlobs, r.PostForm["blobs"], newIssue.Body)
295
296 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
297 Collection: tangled.RepoIssueNSID,
298 Repo: user.Did,
299 Rkey: newIssue.Rkey,
300 SwapRecord: ex.Cid,
301 Record: &lexutil.LexiconTypeDecoder{
302 Val: &newRecord,
303 },
304 })
305 if err != nil {
306 l.Error("failed to edit record on PDS", "err", err)
307 rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.")
308 return
309 }
310
311 // modify on DB -- TODO: transact this cleverly
312 tx, err := rp.db.Begin()
313 if err != nil {
314 l.Error("failed to edit issue on DB", "err", err)
315 rp.pages.Notice(w, noticeId, "Failed to edit issue.")
316 return
317 }
318 defer tx.Rollback()
319
320 err = db.PutIssue(tx, newIssue)
321 if err != nil {
322 l.Error("failed to edit issue", "err", err)
323 rp.pages.Notice(w, "issues", "Failed to edit issue.")
324 return
325 }
326
327 if err = tx.Commit(); err != nil {
328 l.Error("failed to edit issue", "err", err)
329 rp.pages.Notice(w, "issues", "Failed to cedit issue.")
330 return
331 }
332
333 rp.pages.HxRefresh(w)
334 }
335}
336
337func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) {
338 l := rp.logger.With("handler", "DeleteIssue")
339 noticeId := "issue-actions-error"
340
341 f, err := rp.repoResolver.Resolve(r)
342 if err != nil {
343 l.Error("failed to get repo and knot", "err", err)
344 return
345 }
346
347 issue, ok := r.Context().Value("issue").(*models.Issue)
348 if !ok {
349 l.Error("failed to get issue")
350 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
351 return
352 }
353 l = l.With("did", issue.Did, "rkey", issue.Rkey)
354
355 tx, err := rp.db.Begin()
356 if err != nil {
357 l.Error("failed to start transaction", "err", err)
358 rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
359 return
360 }
361 defer tx.Rollback()
362
363 // delete from PDS
364 client, err := rp.oauth.AuthorizedClient(r)
365 if err != nil {
366 l.Error("failed to get authorized client", "err", err)
367 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
368 return
369 }
370 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
371 Collection: tangled.RepoIssueNSID,
372 Repo: issue.Did,
373 Rkey: issue.Rkey,
374 })
375 if err != nil {
376 // TODO: transact this better
377 l.Error("failed to delete issue from PDS", "err", err)
378 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
379 return
380 }
381
382 // delete from db
383 if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil {
384 l.Error("failed to delete issue", "err", err)
385 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
386 return
387 }
388 tx.Commit()
389
390 rp.notifier.DeleteIssue(r.Context(), issue)
391
392 // return to all issues page
393 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
394 rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues")
395}
396
397func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
398 l := rp.logger.With("handler", "CloseIssue")
399 user := rp.oauth.GetMultiAccountUser(r)
400 if user == nil {
401 l.Error("nil user")
402 rp.pages.Notice(w, "issue-action", "You must be logged in to close this issue.")
403 return
404 }
405 f, err := rp.repoResolver.Resolve(r)
406 if err != nil {
407 l.Error("failed to get repo and knot", "err", err)
408 return
409 }
410
411 issue, ok := r.Context().Value("issue").(*models.Issue)
412 if !ok {
413 l.Error("failed to get issue")
414 rp.pages.Error404(w)
415 return
416 }
417
418 roles := rp.acl.RolesInRepo(r.Context(), f, user.Did)
419 isRepoOwner := roles.IsOwner()
420 isCollaborator := roles.IsCollaborator()
421 isIssueOwner := user.Did == issue.Did
422
423 // TODO: make this more granular
424 if isIssueOwner || isRepoOwner || isCollaborator {
425 if err := rp.writeIssueStateRecord(r, user.Did, issue.AtUri(), models.StateClosed); err != nil {
426 l.Error("failed to write issue state record", "err", err)
427 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
428 return
429 }
430
431 err = db.CloseIssues(
432 rp.db,
433 orm.FilterEq("id", issue.Id),
434 )
435 if err != nil {
436 l.Error("failed to close issue", "err", err)
437 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
438 return
439 }
440 // change the issue state (this will pass down to the notifiers)
441 issue.Open = false
442
443 // notify about the issue closure
444 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
445
446 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
447 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
448 return
449 } else {
450 l.Error("user is not permitted to close issue")
451 http.Error(w, "for biden", http.StatusUnauthorized)
452 return
453 }
454}
455
456func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
457 l := rp.logger.With("handler", "ReopenIssue")
458 user := rp.oauth.GetMultiAccountUser(r)
459 if user == nil {
460 l.Error("nil user")
461 rp.pages.Notice(w, "issue-action", "You must be logged in to reopen this issue.")
462 return
463 }
464 f, err := rp.repoResolver.Resolve(r)
465 if err != nil {
466 l.Error("failed to get repo and knot", "err", err)
467 return
468 }
469
470 issue, ok := r.Context().Value("issue").(*models.Issue)
471 if !ok {
472 l.Error("failed to get issue")
473 rp.pages.Error404(w)
474 return
475 }
476
477 roles := rp.acl.RolesInRepo(r.Context(), f, user.Did)
478 isRepoOwner := roles.IsOwner()
479 isCollaborator := roles.IsCollaborator()
480 isIssueOwner := user.Did == issue.Did
481
482 if isCollaborator || isRepoOwner || isIssueOwner {
483 if err := rp.writeIssueStateRecord(r, user.Did, issue.AtUri(), models.StateOpen); err != nil {
484 l.Error("failed to write issue state record", "err", err)
485 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
486 return
487 }
488
489 err := db.ReopenIssues(
490 rp.db,
491 orm.FilterEq("id", issue.Id),
492 )
493 if err != nil {
494 l.Error("failed to reopen issue", "err", err)
495 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
496 return
497 }
498 // change the issue state (this will pass down to the notifiers)
499 issue.Open = true
500
501 // notify about the issue reopen
502 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
503
504 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
505 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
506 return
507 } else {
508 l.Error("user is not the owner of the repo")
509 http.Error(w, "forbidden", http.StatusUnauthorized)
510 return
511 }
512}
513
514func (rp *Issues) writeIssueStateRecord(r *http.Request, actorDid string, subject syntax.ATURI, value models.StateValue) error {
515 client, err := rp.oauth.AuthorizedClient(r)
516 if err != nil {
517 return err
518 }
519
520 record, err := models.AsIssueStateRecord(subject, value, time.Now())
521 if err != nil {
522 return err
523 }
524
525 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
526 Collection: tangled.RepoIssueStateNSID,
527 Repo: actorDid,
528 Rkey: tid.TID(),
529 Record: &lexutil.LexiconTypeDecoder{
530 Val: &record,
531 },
532 })
533 return err
534}
535
536func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
537 l := rp.logger.With("handler", "RepoIssues")
538
539 params := r.URL.Query()
540 page := pagination.FromContext(r.Context())
541
542 user := rp.oauth.GetMultiAccountUser(r)
543 f, err := rp.repoResolver.Resolve(r)
544 if err != nil {
545 l.Error("failed to get repo and knot", "err", err)
546 return
547 }
548
549 query := searchquery.Parse(params.Get("q"))
550
551 var isOpen *bool
552 if urlState := params.Get("state"); urlState != "" {
553 switch urlState {
554 case "open":
555 isOpen = ptrBool(true)
556 case "closed":
557 isOpen = ptrBool(false)
558 }
559 query.Set("state", urlState)
560 } else if queryState := query.Get("state"); queryState != nil {
561 switch *queryState {
562 case "open":
563 isOpen = ptrBool(true)
564 case "closed":
565 isOpen = ptrBool(false)
566 }
567 } else if _, hasQ := params["q"]; !hasQ {
568 // no q param at all -- default to open
569 isOpen = ptrBool(true)
570 query.Set("state", "open")
571 }
572
573 resolve := func(ctx context.Context, ident string) (string, error) {
574 id, err := rp.idResolver.ResolveIdent(ctx, ident)
575 if err != nil {
576 return "", err
577 }
578 return id.DID.String(), nil
579 }
580
581 authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l)
582
583 labels := query.GetAll("label")
584 negatedLabels := query.GetAllNegated("label")
585 labelValues := query.GetDynamicTags()
586 negatedLabelValues := query.GetNegatedDynamicTags()
587
588 // resolve DID-format label values: if a dynamic tag's label
589 // definition has format "did", resolve the handle to a DID
590 if len(labelValues) > 0 || len(negatedLabelValues) > 0 {
591 labelDefs, err := db.GetLabelDefinitions(
592 rp.db,
593 orm.FilterIn("at_uri", f.Labels),
594 orm.FilterContains("scope", tangled.RepoIssueNSID),
595 )
596 if err == nil {
597 didLabels := make(map[string]bool)
598 for _, def := range labelDefs {
599 if def.ValueType.Format == models.ValueTypeFormatDid {
600 didLabels[def.Name] = true
601 }
602 }
603 labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l)
604 negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l)
605 } else {
606 l.Debug("failed to fetch label definitions for DID resolution", "err", err)
607 }
608 }
609
610 tf := searchquery.ExtractTextFilters(query)
611
612 searchOpts := models.IssueSearchOptions{
613 Keywords: tf.Keywords,
614 Phrases: tf.Phrases,
615 RepoDid: f.RepoDid,
616 IsOpen: isOpen,
617 AuthorDid: authorDid,
618 Labels: labels,
619 LabelValues: labelValues,
620 NegatedKeywords: tf.NegatedKeywords,
621 NegatedPhrases: tf.NegatedPhrases,
622 NegatedLabels: negatedLabels,
623 NegatedLabelValues: negatedLabelValues,
624 NegatedAuthorDids: negatedAuthorDids,
625 Page: page,
626 }
627
628 totalIssues := 0
629 if isOpen == nil {
630 totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed
631 } else if *isOpen {
632 totalIssues = f.RepoStats.IssueCount.Open
633 } else {
634 totalIssues = f.RepoStats.IssueCount.Closed
635 }
636
637 repoInfo := rp.repoResolver.GetRepoInfo(r, user)
638
639 var issues []models.Issue
640
641 if searchOpts.HasSearchFilters() {
642 res, err := rp.indexer.Search(r.Context(), searchOpts)
643 if err != nil {
644 l.Error("failed to search for issues", "err", err)
645 return
646 }
647 l.Debug("searched issues with indexer", "count", len(res.Hits))
648 totalIssues = int(res.Total)
649
650 // update tab counts to reflect filtered results
651 countOpts := searchOpts
652 countOpts.Page = pagination.Page{Limit: 1}
653 countOpts.IsOpen = ptrBool(true)
654 if openRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil {
655 repoInfo.Stats.IssueCount.Open = int(openRes.Total)
656 }
657 countOpts.IsOpen = ptrBool(false)
658 if closedRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil {
659 repoInfo.Stats.IssueCount.Closed = int(closedRes.Total)
660 }
661
662 if len(res.Hits) > 0 {
663 issues, err = db.GetIssues(
664 rp.db,
665 orm.FilterIn("id", res.Hits),
666 )
667 if err != nil {
668 l.Error("failed to get issues", "err", err)
669 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
670 return
671 }
672 }
673 } else {
674 filters := []orm.Filter{
675 orm.FilterEq("repo_did", f.RepoDid),
676 }
677 if isOpen != nil {
678 openInt := 0
679 if *isOpen {
680 openInt = 1
681 }
682 filters = append(filters, orm.FilterEq("open", openInt))
683 }
684 issues, err = db.GetIssuesPaginated(
685 rp.db,
686 page,
687 filters...,
688 )
689 if err != nil {
690 l.Error("failed to get issues", "err", err)
691 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
692 return
693 }
694 }
695
696 labelDefs, err := db.GetLabelDefinitions(
697 rp.db,
698 orm.FilterIn("at_uri", f.Labels),
699 orm.FilterContains("scope", tangled.RepoIssueNSID),
700 )
701 if err != nil {
702 l.Error("failed to fetch labels", "err", err)
703 rp.pages.Error503(w)
704 return
705 }
706
707 defs := make(map[string]*models.LabelDefinition)
708 for _, l := range labelDefs {
709 defs[l.AtUri().String()] = &l
710 }
711
712 filterState := ""
713 if isOpen != nil {
714 if *isOpen {
715 filterState = "open"
716 } else {
717 filterState = "closed"
718 }
719 }
720
721 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
722 if user != nil {
723 dids := make([]syntax.DID, len(issues))
724 for i, u := range issues {
725 dids[i] = syntax.DID(u.Did)
726 }
727 vouchRelationships, err = db.GetVouchRelationshipsBatch(rp.db, syntax.DID(user.Did), dids)
728 if err != nil {
729 l.Error("failed to fetch vouch relationships", "err", err)
730 }
731 }
732 baseFilterParts := make([]string, 0, len(query.Items()))
733 for _, item := range query.Items() {
734 if item.Kind == searchquery.KindTagValue {
735 if item.Key == "label" || !searchquery.KnownTags[item.Key] {
736 continue
737 }
738 }
739 baseFilterParts = append(baseFilterParts, item.Raw)
740 }
741 baseFilterQuery := strings.Join(baseFilterParts, " ")
742 rp.pages.RepoIssues(w, pages.RepoIssuesParams{
743 BaseParams: pages.BaseParamsFromContext(r.Context()),
744 RepoInfo: repoInfo,
745 Issues: issues,
746 IssueCount: totalIssues,
747 LabelDefs: defs,
748 FilterState: filterState,
749 FilterQuery: query.String(),
750 BaseFilterQuery: baseFilterQuery,
751 Page: page,
752 VouchRelationships: vouchRelationships,
753 })
754}
755
756func ptrBool(b bool) *bool { return &b }
757
758func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
759 l := rp.logger.With("handler", "NewIssue")
760 user := rp.oauth.GetMultiAccountUser(r)
761
762 f, err := rp.repoResolver.Resolve(r)
763 if err != nil {
764 l.Error("failed to get repo and knot", "err", err)
765 return
766 }
767
768 switch r.Method {
769 case http.MethodGet:
770 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
771 BaseParams: pages.BaseParamsFromContext(r.Context()),
772 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
773 })
774 case http.MethodPost:
775 body := r.FormValue("body")
776 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
777
778 issue := &models.Issue{
779 RepoDid: syntax.DID(f.RepoDid),
780 Rkey: tid.TID(),
781 Title: r.FormValue("title"),
782 Body: body,
783 Open: true,
784 Did: user.Did,
785 Created: time.Now(),
786 Mentions: mentions,
787 References: references,
788 Repo: f,
789 }
790
791 if err := issue.Validate(); err != nil {
792 l.Error("validation error", "err", err)
793 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
794 return
795 }
796
797 issue.Blobs = models.ParseBlobs(r.PostForm["blobs"], body)
798
799 record := issue.AsRecord()
800
801 // create an atproto record
802 client, err := rp.oauth.AuthorizedClient(r)
803 if err != nil {
804 l.Error("failed to get authorized client", "err", err)
805 rp.pages.Notice(w, "issues", "Failed to create issue.")
806 return
807 }
808 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
809 Collection: tangled.RepoIssueNSID,
810 Repo: user.Did,
811 Rkey: issue.Rkey,
812 Record: &lexutil.LexiconTypeDecoder{
813 Val: &record,
814 },
815 })
816 if err != nil {
817 l.Error("failed to create issue", "err", err)
818 rp.pages.Notice(w, "issues", "Failed to create issue.")
819 return
820 }
821 atUri := resp.Uri
822
823 tx, err := rp.db.BeginTx(r.Context(), nil)
824 if err != nil {
825 rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
826 return
827 }
828 rollback := func() {
829 err1 := tx.Rollback()
830 err2 := rollbackRecord(context.Background(), atUri, client)
831
832 if errors.Is(err1, sql.ErrTxDone) {
833 err1 = nil
834 }
835
836 if err := errors.Join(err1, err2); err != nil {
837 l.Error("failed to rollback txn", "err", err)
838 }
839 }
840 defer rollback()
841
842 err = db.PutIssue(tx, issue)
843 if err != nil {
844 l.Error("failed to create issue", "err", err)
845 rp.pages.Notice(w, "issues", "Failed to create issue.")
846 return
847 }
848
849 if err = tx.Commit(); err != nil {
850 l.Error("failed to create issue", "err", err)
851 rp.pages.Notice(w, "issues", "Failed to create issue.")
852 return
853 }
854
855 // everything is successful, do not rollback the atproto record
856 atUri = ""
857
858 rp.notifier.NewIssue(r.Context(), issue, mentions)
859
860 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
861 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId))
862 return
863 }
864}
865
866// this is used to rollback changes made to the PDS
867//
868// it is a no-op if the provided ATURI is empty
869func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error {
870 if aturi == "" {
871 return nil
872 }
873
874 parsed := syntax.ATURI(aturi)
875
876 collection := parsed.Collection().String()
877 repo := parsed.Authority().String()
878 rkey := parsed.RecordKey().String()
879
880 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
881 Collection: collection,
882 Repo: repo,
883 Rkey: rkey,
884 })
885 return err
886}