Monorepo for Tangled tangled.org
at sl/shared-stacks 275 lines 6.8 kB view raw
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}