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