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