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