This repository has no description
1package db
2
3import (
4 "context"
5 "slices"
6
7 "github.com/bluesky-social/indigo/atproto/syntax"
8 "tangled.org/core/appview/db"
9 "tangled.org/core/appview/models"
10 "tangled.org/core/appview/notify"
11 "tangled.org/core/idresolver"
12 "tangled.org/core/log"
13 "tangled.org/core/orm"
14 "tangled.org/core/sets"
15)
16
17const (
18 maxMentions = 8
19)
20
21type databaseNotifier struct {
22 db *db.DB
23 res *idresolver.Resolver
24}
25
26func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier {
27 return &databaseNotifier{
28 db: database,
29 res: resolver,
30 }
31}
32
33var _ notify.Notifier = &databaseNotifier{}
34
35func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
36 // no-op for now
37}
38func (n *databaseNotifier) DeleteRepo(ctx context.Context, repo *models.Repo) {
39 // no-op for now
40}
41
42func (n *databaseNotifier) RenameRepo(ctx context.Context, actor syntax.DID, oldRepo, newRepo *models.Repo) {
43}
44
45func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
46 l := log.FromContext(ctx)
47
48 if star.SubjectType != models.StarSubjectRepo {
49 return
50 }
51
52 repo, err := db.GetRepo(n.db, orm.FilterEq("repo_did", star.Subject))
53 if err != nil {
54 l.Error("failed to get repos", "err", err)
55 return
56 }
57
58 actorDid := syntax.DID(star.Did)
59 recipients := sets.Singleton(syntax.DID(repo.Did))
60 eventType := models.NotificationTypeRepoStarred
61 entityType := "repo"
62 entityId := star.Subject
63 repoId := &repo.Id
64 var issueId *int64
65 var pullId *int64
66
67 n.notifyEvent(
68 ctx,
69 actorDid,
70 recipients,
71 eventType,
72 entityType,
73 entityId,
74 repoId,
75 issueId,
76 pullId,
77 )
78}
79
80func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {
81 // no-op
82}
83
84func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
85 l := log.FromContext(ctx)
86
87 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_did", string(issue.RepoDid)))
88 if err != nil {
89 l.Error("failed to fetch collaborators", "err", err)
90 return
91 }
92
93 // build the recipients list
94 // - owner of the repo
95 // - collaborators in the repo
96 // - remove users already mentioned
97 recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
98 for _, c := range collaborators {
99 recipients.Insert(c.SubjectDid)
100 }
101 for _, m := range mentions {
102 recipients.Remove(m)
103 }
104
105 actorDid := syntax.DID(issue.Did)
106 entityType := "issue"
107 entityId := issue.AtUri().String()
108 repoId := &issue.Repo.Id
109 issueId := &issue.Id
110 var pullId *int64
111
112 n.notifyEvent(
113 ctx,
114 actorDid,
115 recipients,
116 models.NotificationTypeIssueCreated,
117 entityType,
118 entityId,
119 repoId,
120 issueId,
121 pullId,
122 )
123 n.notifyEvent(
124 ctx,
125 actorDid,
126 sets.Collect(slices.Values(mentions)),
127 models.NotificationTypeUserMentioned,
128 entityType,
129 entityId,
130 repoId,
131 issueId,
132 pullId,
133 )
134}
135
136func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
137 l := log.FromContext(ctx)
138
139 issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt))
140 if err != nil {
141 l.Error("failed to get issues", "err", err)
142 return
143 }
144 if len(issues) == 0 {
145 l.Error("no issue found for", "err", comment.IssueAt)
146 return
147 }
148 issue := issues[0]
149
150 // built the recipients list:
151 // - the owner of the repo
152 // - | if the comment is a reply -> everybody on that thread
153 // | if the comment is a top level -> just the issue owner
154 // - remove mentioned users from the recipients list
155 recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
156
157 if comment.IsReply() {
158 // if this comment is a reply, then notify everybody in that thread
159 parentAtUri := *comment.ReplyTo
160
161 // find the parent thread, and add all DIDs from here to the recipient list
162 for _, t := range issue.CommentList() {
163 if t.Self.AtUri().String() == parentAtUri {
164 for _, p := range t.Participants() {
165 recipients.Insert(p)
166 }
167 }
168 }
169 } else {
170 // not a reply, notify just the issue author
171 recipients.Insert(syntax.DID(issue.Did))
172 }
173
174 for _, m := range mentions {
175 recipients.Remove(m)
176 }
177
178 actorDid := syntax.DID(comment.Did)
179 entityType := "issue"
180 entityId := issue.AtUri().String()
181 repoId := &issue.Repo.Id
182 issueId := &issue.Id
183 var pullId *int64
184
185 n.notifyEvent(
186 ctx,
187 actorDid,
188 recipients,
189 models.NotificationTypeIssueCommented,
190 entityType,
191 entityId,
192 repoId,
193 issueId,
194 pullId,
195 )
196 n.notifyEvent(
197 ctx,
198 actorDid,
199 sets.Collect(slices.Values(mentions)),
200 models.NotificationTypeUserMentioned,
201 entityType,
202 entityId,
203 repoId,
204 issueId,
205 pullId,
206 )
207}
208
209func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
210 // no-op for now
211}
212
213func (n *databaseNotifier) NewIssueLabelOp(ctx context.Context, issue *models.Issue) {}
214func (n *databaseNotifier) NewPullLabelOp(ctx context.Context, pull *models.Pull) {}
215
216func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
217 actorDid := syntax.DID(follow.UserDid)
218 recipients := sets.Singleton(syntax.DID(follow.SubjectDid))
219 eventType := models.NotificationTypeFollowed
220 entityType := "follow"
221 entityId := follow.UserDid
222 var repoId, issueId, pullId *int64
223
224 n.notifyEvent(
225 ctx,
226 actorDid,
227 recipients,
228 eventType,
229 entityType,
230 entityId,
231 repoId,
232 issueId,
233 pullId,
234 )
235}
236
237func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
238 // no-op
239}
240
241func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
242 l := log.FromContext(ctx)
243
244 repo, err := db.GetRepo(n.db, orm.FilterEq("repo_did", string(pull.RepoDid)))
245 if err != nil {
246 l.Error("failed to get repos", "err", err)
247 return
248 }
249 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_did", string(pull.RepoDid)))
250 if err != nil {
251 l.Error("failed to fetch collaborators", "err", err)
252 return
253 }
254
255 // build the recipients list
256 // - owner of the repo
257 // - collaborators in the repo
258 recipients := sets.Singleton(syntax.DID(repo.Did))
259 for _, c := range collaborators {
260 recipients.Insert(c.SubjectDid)
261 }
262
263 actorDid := syntax.DID(pull.OwnerDid)
264 eventType := models.NotificationTypePullCreated
265 entityType := "pull"
266 entityId := pull.AtUri().String()
267 repoId := &repo.Id
268 var issueId *int64
269 p := int64(pull.ID)
270 pullId := &p
271
272 n.notifyEvent(
273 ctx,
274 actorDid,
275 recipients,
276 eventType,
277 entityType,
278 entityId,
279 repoId,
280 issueId,
281 pullId,
282 )
283}
284
285func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
286 l := log.FromContext(ctx)
287
288 pull, err := db.GetPull(n.db,
289 orm.FilterEq("repo_did", comment.RepoDid),
290 orm.FilterEq("pull_id", comment.PullId),
291 )
292 if err != nil {
293 l.Error("failed to get pulls", "err", err)
294 return
295 }
296
297 repo, err := db.GetRepo(n.db, orm.FilterEq("repo_did", comment.RepoDid))
298 if err != nil {
299 l.Error("failed to get repos", "err", err)
300 return
301 }
302
303 // build up the recipients list:
304 // - repo owner
305 // - all pull participants
306 // - remove those already mentioned
307 recipients := sets.Singleton(syntax.DID(repo.Did))
308 for _, p := range pull.Participants() {
309 recipients.Insert(p)
310 }
311 for _, m := range mentions {
312 recipients.Remove(m)
313 }
314
315 actorDid := syntax.DID(comment.OwnerDid)
316 eventType := models.NotificationTypePullCommented
317 entityType := "pull"
318 entityId := pull.AtUri().String()
319 repoId := &repo.Id
320 var issueId *int64
321 p := int64(pull.ID)
322 pullId := &p
323
324 n.notifyEvent(
325 ctx,
326 actorDid,
327 recipients,
328 eventType,
329 entityType,
330 entityId,
331 repoId,
332 issueId,
333 pullId,
334 )
335 n.notifyEvent(
336 ctx,
337 actorDid,
338 sets.Collect(slices.Values(mentions)),
339 models.NotificationTypeUserMentioned,
340 entityType,
341 entityId,
342 repoId,
343 issueId,
344 pullId,
345 )
346}
347
348func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
349 // no-op
350}
351
352func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) {
353 // no-op
354}
355
356func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) {
357 // no-op
358}
359
360func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) {
361 // no-op
362}
363
364func (n *databaseNotifier) Push(ctx context.Context, repo *models.Repo, ref, oldSha, newSha, committerDid string) {
365 // no-op for now; webhooks are handled by the webhook notifier
366}
367
368func (n *databaseNotifier) Clone(ctx context.Context, repo *models.Repo) {
369 // no-op
370}
371
372func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
373 l := log.FromContext(ctx)
374
375 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_did", string(issue.RepoDid)))
376 if err != nil {
377 l.Error("failed to fetch collaborators", "err", err)
378 return
379 }
380
381 // build up the recipients list:
382 // - repo owner
383 // - repo collaborators
384 // - all issue participants
385 recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
386 for _, c := range collaborators {
387 recipients.Insert(c.SubjectDid)
388 }
389 for _, p := range issue.Participants() {
390 recipients.Insert(syntax.DID(p))
391 }
392
393 entityType := "issue"
394 entityId := issue.AtUri().String()
395 repoId := &issue.Repo.Id
396 issueId := &issue.Id
397 var pullId *int64
398 var eventType models.NotificationType
399
400 if issue.Open {
401 eventType = models.NotificationTypeIssueReopen
402 } else {
403 eventType = models.NotificationTypeIssueClosed
404 }
405
406 n.notifyEvent(
407 ctx,
408 actor,
409 recipients,
410 eventType,
411 entityType,
412 entityId,
413 repoId,
414 issueId,
415 pullId,
416 )
417}
418
419func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
420 l := log.FromContext(ctx)
421
422 // Get repo details
423 repo, err := db.GetRepo(n.db, orm.FilterEq("repo_did", string(pull.RepoDid)))
424 if err != nil {
425 l.Error("failed to get repos", "err", err)
426 return
427 }
428
429 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_did", string(pull.RepoDid)))
430 if err != nil {
431 l.Error("failed to fetch collaborators", "err", err)
432 return
433 }
434
435 // build up the recipients list:
436 // - repo owner
437 // - all pull participants
438 recipients := sets.Singleton(syntax.DID(repo.Did))
439 for _, c := range collaborators {
440 recipients.Insert(c.SubjectDid)
441 }
442 for _, p := range pull.Participants() {
443 recipients.Insert(p)
444 }
445
446 entityType := "pull"
447 entityId := pull.AtUri().String()
448 repoId := &repo.Id
449 var issueId *int64
450 var eventType models.NotificationType
451 switch pull.State {
452 case models.PullClosed:
453 eventType = models.NotificationTypePullClosed
454 case models.PullOpen:
455 eventType = models.NotificationTypePullReopen
456 case models.PullMerged:
457 eventType = models.NotificationTypePullMerged
458 default:
459 l.Error("unexpected new PR state", "state", pull.State)
460 return
461 }
462 p := int64(pull.ID)
463 pullId := &p
464
465 n.notifyEvent(
466 ctx,
467 actor,
468 recipients,
469 eventType,
470 entityType,
471 entityId,
472 repoId,
473 issueId,
474 pullId,
475 )
476}
477
478func (n *databaseNotifier) notifyEvent(
479 ctx context.Context,
480 actorDid syntax.DID,
481 recipients sets.Set[syntax.DID],
482 eventType models.NotificationType,
483 entityType string,
484 entityId string,
485 repoId *int64,
486 issueId *int64,
487 pullId *int64,
488) {
489 l := log.FromContext(ctx)
490
491 // if the user is attempting to mention >maxMentions users, this is probably spam, do not mention anybody
492 if eventType == models.NotificationTypeUserMentioned && recipients.Len() > maxMentions {
493 return
494 }
495
496 recipients.Remove(actorDid)
497
498 prefMap, err := db.GetNotificationPreferences(
499 n.db,
500 orm.FilterIn("user_did", slices.Collect(recipients.All())),
501 )
502 if err != nil {
503 // failed to get prefs for users
504 return
505 }
506
507 // create a transaction for bulk notification storage
508 tx, err := n.db.Begin()
509 if err != nil {
510 // failed to start tx
511 return
512 }
513 defer tx.Rollback()
514
515 // filter based on preferences
516 for recipientDid := range recipients.All() {
517 prefs, ok := prefMap[recipientDid]
518 if !ok {
519 prefs = models.DefaultNotificationPreferences(recipientDid)
520 }
521
522 // skip users who don’t want this type
523 if !prefs.ShouldNotify(eventType) {
524 continue
525 }
526
527 // create notification
528 notif := &models.Notification{
529 RecipientDid: recipientDid.String(),
530 ActorDid: actorDid.String(),
531 Type: eventType,
532 EntityType: entityType,
533 EntityId: entityId,
534 RepoId: repoId,
535 IssueId: issueId,
536 PullId: pullId,
537 }
538
539 if err := db.CreateNotification(tx, notif); err != nil {
540 l.Error("failed to create notification", "recipientDid", recipientDid, "err", err)
541 }
542 }
543
544 if err := tx.Commit(); err != nil {
545 // failed to commit
546 return
547 }
548}