Monorepo for Tangled
tangled.org
1package issue
2
3import (
4 "context"
5 "log/slog"
6 "time"
7
8 "github.com/bluesky-social/indigo/api/atproto"
9 "github.com/bluesky-social/indigo/atproto/syntax"
10 lexutil "github.com/bluesky-social/indigo/lex/util"
11 "tangled.org/core/api/tangled"
12 "tangled.org/core/appview/config"
13 "tangled.org/core/appview/db"
14 issues_indexer "tangled.org/core/appview/indexer/issues"
15 "tangled.org/core/appview/mentions"
16 "tangled.org/core/appview/models"
17 "tangled.org/core/appview/notify"
18 "tangled.org/core/appview/session"
19 "tangled.org/core/appview/validator"
20 "tangled.org/core/idresolver"
21 "tangled.org/core/orm"
22 "tangled.org/core/rbac"
23 "tangled.org/core/tid"
24)
25
26type Service struct {
27 config *config.Config
28 db *db.DB
29 enforcer *rbac.Enforcer
30 indexer *issues_indexer.Indexer
31 logger *slog.Logger
32 notifier notify.Notifier
33 idResolver *idresolver.Resolver
34 refResolver *mentions.Resolver
35 validator *validator.Validator
36}
37
38func NewService(
39 logger *slog.Logger,
40 config *config.Config,
41 db *db.DB,
42 enforcer *rbac.Enforcer,
43 notifier notify.Notifier,
44 idResolver *idresolver.Resolver,
45 refResolver *mentions.Resolver,
46 indexer *issues_indexer.Indexer,
47 validator *validator.Validator,
48) Service {
49 return Service{
50 config,
51 db,
52 enforcer,
53 indexer,
54 logger,
55 notifier,
56 idResolver,
57 refResolver,
58 validator,
59 }
60}
61
62func (s *Service) NewIssue(ctx context.Context, repo *models.Repo, title, body string) (*models.Issue, error) {
63 l := s.logger.With("method", "NewIssue")
64 sess, ok := session.FromContext(ctx)
65 if !ok {
66 l.Error("user session is missing in context")
67 return nil, ErrForbidden
68 }
69 authorDid := syntax.DID(sess.User.Did)
70 atpclient := sess.AtpClient
71 l = l.With("did", authorDid)
72
73 mentions, references := s.refResolver.Resolve(ctx, body)
74
75 issue := models.Issue{
76 Did: authorDid.String(),
77 Rkey: tid.TID(),
78 RepoAt: repo.RepoAt(),
79 Title: title,
80 Body: body,
81 Created: time.Now(),
82 Mentions: mentions,
83 References: references,
84 Open: true,
85 Repo: repo,
86 }
87
88 if err := s.validator.ValidateIssue(&issue); err != nil {
89 l.Error("validation error", "err", err)
90 return nil, ErrValidationFail
91 }
92
93 tx, err := s.db.BeginTx(ctx, nil)
94 if err != nil {
95 l.Error("db.BeginTx failed", "err", err)
96 return nil, ErrDatabaseFail
97 }
98 defer tx.Rollback()
99
100 if err := db.PutIssue(tx, &issue); err != nil {
101 l.Error("db.PutIssue failed", "err", err)
102 return nil, ErrDatabaseFail
103 }
104
105 record := issue.AsRecord()
106 _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{
107 Repo: issue.Did,
108 Collection: tangled.RepoIssueNSID,
109 Rkey: issue.Rkey,
110 Record: &lexutil.LexiconTypeDecoder{
111 Val: &record,
112 },
113 })
114 if err != nil {
115 l.Error("atproto.RepoPutRecord failed", "err", err)
116 return nil, ErrPDSFail
117 }
118 if err = tx.Commit(); err != nil {
119 l.Error("tx.Commit failed", "err", err)
120 return nil, ErrDatabaseFail
121 }
122
123 s.notifier.NewIssue(ctx, &issue, mentions)
124 return &issue, nil
125}
126
127func (s *Service) GetIssues(ctx context.Context, repo *models.Repo, searchOpts models.IssueSearchOptions) ([]models.Issue, error) {
128 l := s.logger.With("method", "GetIssues")
129
130 var issues []models.Issue
131 var err error
132 if searchOpts.Keyword != "" {
133 res, err := s.indexer.Search(ctx, searchOpts)
134 if err != nil {
135 l.Error("failed to search for issues", "err", err)
136 return nil, ErrIndexerFail
137 }
138 l.Debug("searched issues with indexer", "count", len(res.Hits))
139 issues, err = db.GetIssues(s.db, orm.FilterIn("id", res.Hits))
140 if err != nil {
141 l.Error("failed to get issues", "err", err)
142 return nil, ErrDatabaseFail
143 }
144 } else {
145 openInt := 0
146 if searchOpts.IsOpen {
147 openInt = 1
148 }
149 issues, err = db.GetIssuesPaginated(
150 s.db,
151 searchOpts.Page,
152 orm.FilterEq("repo_at", repo.RepoAt()),
153 orm.FilterEq("open", openInt),
154 )
155 if err != nil {
156 l.Error("failed to get issues", "err", err)
157 return nil, ErrDatabaseFail
158 }
159 }
160
161 return issues, nil
162}
163
164func (s *Service) EditIssue(ctx context.Context, issue *models.Issue) error {
165 l := s.logger.With("method", "EditIssue")
166 sess, ok := session.FromContext(ctx)
167 if !ok {
168 l.Error("user session is missing in context")
169 return ErrForbidden
170 }
171 atpclient := sess.AtpClient
172 l = l.With("did", sess.User.Did)
173
174 mentions, references := s.refResolver.Resolve(ctx, issue.Body)
175 issue.Mentions = mentions
176 issue.References = references
177
178 if sess.User.Did != issue.Did {
179 l.Error("only author can edit the issue")
180 return ErrForbidden
181 }
182
183 if err := s.validator.ValidateIssue(issue); err != nil {
184 l.Error("validation error", "err", err)
185 return ErrValidationFail
186 }
187
188 tx, err := s.db.BeginTx(ctx, nil)
189 if err != nil {
190 l.Error("db.BeginTx failed", "err", err)
191 return ErrDatabaseFail
192 }
193 defer tx.Rollback()
194
195 if err := db.PutIssue(tx, issue); err != nil {
196 l.Error("db.PutIssue failed", "err", err)
197 return ErrDatabaseFail
198 }
199
200 record := issue.AsRecord()
201
202 ex, err := atproto.RepoGetRecord(ctx, atpclient, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey)
203 if err != nil {
204 l.Error("atproto.RepoGetRecord failed", "err", err)
205 return ErrPDSFail
206 }
207 _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{
208 Repo: issue.Did,
209 Collection: tangled.RepoIssueNSID,
210 Rkey: issue.Rkey,
211 SwapRecord: ex.Cid,
212 Record: &lexutil.LexiconTypeDecoder{
213 Val: &record,
214 },
215 })
216 if err != nil {
217 l.Error("atproto.RepoPutRecord failed", "err", err)
218 return ErrPDSFail
219 }
220
221 if err = tx.Commit(); err != nil {
222 l.Error("tx.Commit failed", "err", err)
223 return ErrDatabaseFail
224 }
225
226 // TODO: notify EditIssue
227
228 return nil
229}
230
231func (s *Service) DeleteIssue(ctx context.Context, issue *models.Issue) error {
232 l := s.logger.With("method", "DeleteIssue")
233 sess, ok := session.FromContext(ctx)
234 if !ok {
235 l.Error("user session is missing in context")
236 return ErrForbidden
237 }
238 atpclient := sess.AtpClient
239 l = l.With("did", sess.User.Did)
240
241 if sess.User.Did != issue.Did {
242 l.Error("only author can edit the issue")
243 return ErrForbidden
244 }
245
246 tx, err := s.db.BeginTx(ctx, nil)
247 if err != nil {
248 l.Error("db.BeginTx failed", "err", err)
249 return ErrDatabaseFail
250 }
251 defer tx.Rollback()
252
253 if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil {
254 l.Error("db.DeleteIssues failed", "err", err)
255 return ErrDatabaseFail
256 }
257
258 _, err = atproto.RepoDeleteRecord(ctx, atpclient, &atproto.RepoDeleteRecord_Input{
259 Collection: tangled.RepoIssueNSID,
260 Repo: issue.Did,
261 Rkey: issue.Rkey,
262 })
263 if err != nil {
264 l.Error("atproto.RepoDeleteRecord failed", "err", err)
265 return ErrPDSFail
266 }
267
268 if err := tx.Commit(); err != nil {
269 l.Error("tx.Commit failed", "err", err)
270 return ErrDatabaseFail
271 }
272
273 s.notifier.DeleteIssue(ctx, issue)
274 return nil
275}