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