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