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