This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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