From 9358cfff839138703b6aa74ec47e324e96b7366b Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 12 May 2026 15:50:49 -0700 Subject: [PATCH 1/3] chore(repositories): Switch over queries for `SeerProjectRepository` to use `ProjectRepository` This adds a feature flag that gates switching queries to start using the new `ProjectRepository` table. --- src/sentry/deletions/__init__.py | 1 + src/sentry/deletions/defaults/__init__.py | 1 + src/sentry/deletions/defaults/project.py | 2 + .../deletions/defaults/projectrepository.py | 14 ++++++ src/sentry/deletions/defaults/repository.py | 4 ++ src/sentry/features/temporary.py | 2 + .../integrations/services/repository/impl.py | 24 ++++++++-- src/sentry/seer/autofix/utils.py | 45 ++++++++++++++----- .../seer/code_review/contributor_seats.py | 7 +++ .../sentry/seer/autofix/test_autofix_utils.py | 29 ++++++++++++ .../code_review/test_contributor_seats.py | 10 +++++ 11 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 src/sentry/deletions/defaults/projectrepository.py diff --git a/src/sentry/deletions/__init__.py b/src/sentry/deletions/__init__.py index 6983fefbba65..4c984d31bd1e 100644 --- a/src/sentry/deletions/__init__.py +++ b/src/sentry/deletions/__init__.py @@ -91,6 +91,7 @@ def load_defaults(manager: DeletionTaskManager) -> None: manager.register(models.ReleaseHeadCommit, BulkModelDeletionTask) manager.register(models.ReleaseProject, BulkModelDeletionTask) manager.register(models.ReleaseProjectEnvironment, BulkModelDeletionTask) + manager.register(models.ProjectRepository, defaults.ProjectRepositoryDeletionTask) manager.register(models.Repository, defaults.RepositoryDeletionTask) manager.register(models.Rule, defaults.RuleDeletionTask) manager.register(models.SavedSearch, BulkModelDeletionTask) diff --git a/src/sentry/deletions/defaults/__init__.py b/src/sentry/deletions/defaults/__init__.py index 26b0f122eb42..a5aceea3f744 100644 --- a/src/sentry/deletions/defaults/__init__.py +++ b/src/sentry/deletions/defaults/__init__.py @@ -22,6 +22,7 @@ from .platform_external_issue import * # noqa: F401,F403 from .preprod_artifact import * # noqa: F401,F403 from .project import * # noqa: F401,F403 +from .projectrepository import * # noqa: F401,F403 from .pullrequest import * # noqa: F401,F403 from .querysubscription import * # noqa: F401,F403 from .release import * # noqa: F401,F403 diff --git a/src/sentry/deletions/defaults/project.py b/src/sentry/deletions/defaults/project.py index 47473690a7b4..ad490ef254a5 100644 --- a/src/sentry/deletions/defaults/project.py +++ b/src/sentry/deletions/defaults/project.py @@ -36,6 +36,7 @@ def get_child_relations(self, instance: Project) -> list[BaseRelation]: from sentry.models.projectbookmark import ProjectBookmark from sentry.models.projectcodeowners import ProjectCodeOwners from sentry.models.projectkey import ProjectKey + from sentry.models.projectrepository import ProjectRepository from sentry.models.projectteam import ProjectTeam from sentry.models.promptsactivity import PromptsActivity from sentry.models.release_threshold import ReleaseThreshold @@ -75,6 +76,7 @@ def get_child_relations(self, instance: Project) -> list[BaseRelation]: ProjectCodeOwners, ReplayRecordingSegment, RepositoryProjectPathConfig, + ProjectRepository, ServiceHookProject, ServiceHook, UserReport, diff --git a/src/sentry/deletions/defaults/projectrepository.py b/src/sentry/deletions/defaults/projectrepository.py new file mode 100644 index 000000000000..6d79067f0abb --- /dev/null +++ b/src/sentry/deletions/defaults/projectrepository.py @@ -0,0 +1,14 @@ +from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation +from sentry.integrations.models.repository_project_path_config import ( + RepositoryProjectPathConfig, +) +from sentry.models.projectrepository import ProjectRepository +from sentry.seer.models.project_repository import SeerProjectRepository + + +class ProjectRepositoryDeletionTask(ModelDeletionTask[ProjectRepository]): + def get_child_relations(self, instance: ProjectRepository) -> list[BaseRelation]: + return [ + ModelRelation(RepositoryProjectPathConfig, {"project_repository_id": instance.id}), + ModelRelation(SeerProjectRepository, {"project_repository_id": instance.id}), + ] diff --git a/src/sentry/deletions/defaults/repository.py b/src/sentry/deletions/defaults/repository.py index d7cf6dc78ce6..426215dfcc15 100644 --- a/src/sentry/deletions/defaults/repository.py +++ b/src/sentry/deletions/defaults/repository.py @@ -12,12 +12,14 @@ def _get_repository_child_relations(instance: Repository) -> list[BaseRelation]: RepositoryProjectPathConfig, ) from sentry.models.commit import Commit + from sentry.models.projectrepository import ProjectRepository from sentry.models.pullrequest import PullRequest return [ ModelRelation(Commit, {"repository_id": instance.id}), ModelRelation(PullRequest, {"repository_id": instance.id}), ModelRelation(RepositoryProjectPathConfig, {"repository_id": instance.id}), + ModelRelation(ProjectRepository, {"repository_id": instance.id}), ] @@ -29,12 +31,14 @@ def should_proceed(self, instance: Repository) -> bool: return instance.status in {ObjectStatus.PENDING_DELETION, ObjectStatus.DELETION_IN_PROGRESS} def get_child_relations(self, instance: Repository) -> list[BaseRelation]: + from sentry.models.projectrepository import ProjectRepository from sentry.seer.models.project_repository import SeerProjectRepository return _get_repository_child_relations(instance) + [ # We only delete SeerProjectRepository when the repo is actually deleted, # but not when it's hidden/disabled (repository_cascade_delete_on_hide). ModelRelation(SeerProjectRepository, {"repository_id": instance.id}), + ModelRelation(ProjectRepository, {"repository_id": instance.id}), ] def delete_instance(self, instance: Repository) -> None: diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index eecd50908d96..66118abace0e 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -226,6 +226,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:preprod-snapshots", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enables PR page manager.add("organizations:pr-page", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Use ProjectRepository FK for code mapping and Seer repo queries + manager.add("organizations:project-repository-fk-reads", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enables the playstation ingestion in relay manager.add("organizations:relay-playstation-ingestion", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) # Enables new error processing pipeline in Relay. diff --git a/src/sentry/integrations/services/repository/impl.py b/src/sentry/integrations/services/repository/impl.py index 5043f28a2a10..6a155731f2e6 100644 --- a/src/sentry/integrations/services/repository/impl.py +++ b/src/sentry/integrations/services/repository/impl.py @@ -6,6 +6,7 @@ from django.db import IntegrityError, router, transaction from django.utils import timezone +from sentry import features from sentry.api.serializers import serialize from sentry.constants import ObjectStatus from sentry.db.postgres.transactions import enforce_constraints @@ -17,7 +18,9 @@ from sentry.models.code_review_event import CodeReviewEvent from sentry.models.commit import Commit from sentry.models.options.project_option import ProjectOption +from sentry.models.organization import Organization from sentry.models.projectcodeowners import ProjectCodeOwners +from sentry.models.projectrepository import ProjectRepository from sentry.models.pullrequest import PullRequest from sentry.models.repository import Repository from sentry.seer.models.project_repository import SeerProjectRepository @@ -241,10 +244,17 @@ def disassociate_organization_integration( Repository.objects.filter(id__in=repo_ids).update(integration_id=None) # Delete Seer preferences for this repository - SeerProjectRepository.objects.filter( - repository_id__in=repo_ids, - project__organization_id=organization_id, - ).delete() + org = Organization.objects.get(id=organization_id) + if features.has("organizations:project-repository-fk-reads", org): + SeerProjectRepository.objects.filter( + project_repository__repository_id__in=repo_ids, + project_repository__project__organization_id=organization_id, + ).delete() + else: + SeerProjectRepository.objects.filter( + repository_id__in=repo_ids, + project__organization_id=organization_id, + ).delete() # Delete Code Owners with a Code Mapping using the OrganizationIntegration ProjectCodeOwners.objects.filter( @@ -257,6 +267,12 @@ def disassociate_organization_integration( RepositoryProjectPathConfig.objects.filter( organization_integration_id=organization_integration_id ).delete() + # Delete project-repo links for the disconnected repos + if repo_ids: + ProjectRepository.objects.filter( + repository_id__in=repo_ids, + project__organization_id=organization_id, + ).delete() # Clear automation_handoff project options that reference this integration. affected_project_ids = ProjectOption.objects.filter( diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index bd986916bb2c..87764b13f362 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -647,13 +647,23 @@ def build_automation_handoff( def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference: """Read a single project's Seer preferences from Sentry DB.""" - seer_project_repo_qs = ( - SeerProjectRepository.objects.filter( - project=project, repository__status=ObjectStatus.ACTIVE + if features.has("organizations:project-repository-fk-reads", project.organization): + seer_project_repo_qs = ( + SeerProjectRepository.objects.filter( + project_repository__project=project, + project_repository__repository__status=ObjectStatus.ACTIVE, + ) + .select_related("project_repository", "project_repository__repository") + .prefetch_related("branch_overrides") + ) + else: + seer_project_repo_qs = ( + SeerProjectRepository.objects.filter( + project=project, repository__status=ObjectStatus.ACTIVE + ) + .select_related("repository") + .prefetch_related("branch_overrides") ) - .select_related("repository") - .prefetch_related("branch_overrides") - ) repo_definitions = [ repo_def for project_repo in seer_project_repo_qs @@ -679,14 +689,18 @@ def bulk_read_preferences_from_sentry_db( projects = list(Project.objects.filter(id__in=project_ids, organization_id=organization_id)) + org = Organization.objects.get(id=organization_id) repo_definitions_by_project: defaultdict[int, list[SeerRepoDefinition]] = defaultdict(list) - for project_repo in ( - SeerProjectRepository.objects.filter( + if features.has("organizations:project-repository-fk-reads", org): + seer_repo_qs = SeerProjectRepository.objects.filter( + project_repository__project_id__in=project_ids, + project_repository__repository__status=ObjectStatus.ACTIVE, + ).select_related("project_repository", "project_repository__repository") + else: + seer_repo_qs = SeerProjectRepository.objects.filter( project_id__in=project_ids, repository__status=ObjectStatus.ACTIVE - ) - .select_related("repository") - .prefetch_related("branch_overrides") - ): + ).select_related("repository") + for project_repo in seer_repo_qs.prefetch_related("branch_overrides"): repo_def = build_repo_definition_from_project_repo(project_repo) if repo_def is not None: repo_definitions_by_project[project_repo.project_id].append(repo_def) @@ -798,6 +812,13 @@ def _set_if_not_default(key: str, value: Any, default: Any) -> None: def has_project_connected_repos(organization: Organization, project: Project) -> bool: """Check if a project has connected repositories for Seer automation.""" + if features.has("organizations:project-repository-fk-reads", organization): + return SeerProjectRepository.objects.filter( + project_repository__project=project, + project_repository__project__organization_id=organization.id, + project_repository__project__status=ObjectStatus.ACTIVE, + project_repository__repository__status=ObjectStatus.ACTIVE, + ).exists() return SeerProjectRepository.objects.filter( project=project, project__organization_id=organization.id, diff --git a/src/sentry/seer/code_review/contributor_seats.py b/src/sentry/seer/code_review/contributor_seats.py index e62c8b883ae4..42db8e6abc09 100644 --- a/src/sentry/seer/code_review/contributor_seats.py +++ b/src/sentry/seer/code_review/contributor_seats.py @@ -40,6 +40,13 @@ def _is_autofix_enabled_for_repo(organization: Organization, repository_id: int) this repository, ie, if any project has this repository configured in Seer preferences. """ + if features.has("organizations:project-repository-fk-reads", organization): + return SeerProjectRepository.objects.filter( + project_repository__repository_id=repository_id, + project_repository__project__organization_id=organization.id, + project_repository__project__status=ObjectStatus.ACTIVE, + project_repository__repository__status=ObjectStatus.ACTIVE, + ).exists() return SeerProjectRepository.objects.filter( repository_id=repository_id, project__organization_id=organization.id, diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 6d5b363d6b69..33888b0dc76e 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -518,6 +518,21 @@ def test_returns_true_when_at_least_one_active_repo(self): assert has_project_connected_repos(self.organization, self.project) is True + def test_returns_true_via_project_repository_fk(self): + repo = self.create_repo( + project=self.project, + provider="integrations:github", + external_id="789", + name="owner/fk-repo", + ) + pr = ProjectRepository.objects.create(project=self.project, repository=repo) + SeerProjectRepository.objects.create( + project=self.project, repository=repo, project_repository=pr + ) + + with self.feature("organizations:project-repository-fk-reads"): + assert has_project_connected_repos(self.organization, self.project) is True + class TestDeduplicateRepositories(TestCase): def test_keys_by_provider_and_external_id(self) -> None: @@ -1135,6 +1150,20 @@ def test_project_with_repos_only(self): assert result.automated_run_stopping_point == "code_changes" assert result.automation_handoff is None + def test_reads_via_project_repository_fk(self): + pr = ProjectRepository.objects.create(project=self.project, repository=self.repo) + SeerProjectRepository.objects.create( + project=self.project, + repository=self.repo, + project_repository=pr, + branch_name="main", + ) + + with self.feature("organizations:project-repository-fk-reads"): + result = read_preference_from_sentry_db(self.project) + assert len(result.repositories) == 1 + assert result.repositories[0].branch_name == "main" + def test_autofix_automation_tuning_default(self): SeerProjectRepository.objects.create( project=self.project, repository=self.repo, branch_name="main" diff --git a/tests/sentry/seer/code_review/test_contributor_seats.py b/tests/sentry/seer/code_review/test_contributor_seats.py index d02de43d69cc..4cc005df7698 100644 --- a/tests/sentry/seer/code_review/test_contributor_seats.py +++ b/tests/sentry/seer/code_review/test_contributor_seats.py @@ -6,6 +6,7 @@ OrganizationContributors, ) from sentry.models.project import Project +from sentry.models.projectrepository import ProjectRepository from sentry.seer.code_review.contributor_seats import ( _is_autofix_enabled_for_repo, should_increment_contributor_seat, @@ -68,6 +69,15 @@ def test_repo_is_inactive(self) -> None: assert _is_autofix_enabled_for_repo(self.organization, self.repo.id) is False + def test_returns_true_via_project_repository_fk(self) -> None: + pr = ProjectRepository.objects.create(project=self.project, repository=self.repo) + SeerProjectRepository.objects.create( + project=self.project, repository=self.repo, project_repository=pr + ) + + with self.feature("organizations:project-repository-fk-reads"): + assert _is_autofix_enabled_for_repo(self.organization, self.repo.id) is True + class ShouldIncrementContributorSeatTest(TestCase): def setUp(self) -> None: From a49b4db5a5d8cca6566697f55b0e59b59ef1067f Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 12 May 2026 16:16:19 -0700 Subject: [PATCH 2/3] fix(repositories): Remove duplicate ProjectRepository deletion + add transfer_to cleanup - Remove duplicate ModelRelation(ProjectRepository) in RepositoryDeletionTask.get_child_relations since the shared _get_repository_child_relations helper already includes it. - Add ProjectRepository cleanup to Project.transfer_to so project-repo links are removed when a project moves to a different org. --- src/sentry/deletions/defaults/repository.py | 2 -- src/sentry/models/project.py | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sentry/deletions/defaults/repository.py b/src/sentry/deletions/defaults/repository.py index 426215dfcc15..2bfbeeba16e8 100644 --- a/src/sentry/deletions/defaults/repository.py +++ b/src/sentry/deletions/defaults/repository.py @@ -31,14 +31,12 @@ def should_proceed(self, instance: Repository) -> bool: return instance.status in {ObjectStatus.PENDING_DELETION, ObjectStatus.DELETION_IN_PROGRESS} def get_child_relations(self, instance: Repository) -> list[BaseRelation]: - from sentry.models.projectrepository import ProjectRepository from sentry.seer.models.project_repository import SeerProjectRepository return _get_repository_child_relations(instance) + [ # We only delete SeerProjectRepository when the repo is actually deleted, # but not when it's hidden/disabled (repository_cascade_delete_on_hide). ModelRelation(SeerProjectRepository, {"repository_id": instance.id}), - ModelRelation(ProjectRepository, {"repository_id": instance.id}), ] def delete_instance(self, instance: Repository) -> None: diff --git a/src/sentry/models/project.py b/src/sentry/models/project.py index c48023028345..b5b3704cc413 100644 --- a/src/sentry/models/project.py +++ b/src/sentry/models/project.py @@ -738,6 +738,10 @@ def transfer_to(self, organization: Organization) -> None: ProjectCodeOwners.objects.filter(project_id=self.id).delete() RepositoryProjectPathConfig.objects.filter(project_id=self.id).delete() + from sentry.models.projectrepository import ProjectRepository + + ProjectRepository.objects.filter(project_id=self.id).delete() + for external_issues in chunked( RangeQuerySetWrapper( ExternalIssue.objects.filter(organization_id=old_org_id, id__in=linked_groups), From 173ac0475ed87db327e3b635df50ffc145b59cb3 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 12 May 2026 16:24:11 -0700 Subject: [PATCH 3/3] ref(repositories): Switch remaining SeerProjectRepository query paths - _write_preferences_to_sentry_db delete: filter by project_repository__project_id and project_repository__repository__status - organization_seer_onboarding_check: filter by project_repository__project__organization_id and status fields - night_shift cron: add select_related for project_repository__project (query filter stays on direct FK since cron is cross-org) --- src/sentry/seer/autofix/utils.py | 15 ++++++++++++--- .../organization_seer_onboarding_check.py | 6 ++++++ src/sentry/tasks/seer/night_shift/cron.py | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 87764b13f362..2bb5a4c57b23 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -463,9 +463,18 @@ def _write_preferences_to_sentry_db( list(Project.objects.select_for_update().filter(id__in=project_ids).order_by("id")) # Only delete SeerProjectRepository for active repos. - SeerProjectRepository.objects.filter( - project_id__in=project_ids, repository__status=ObjectStatus.ACTIVE - ).delete() + if features.has( + "organizations:project-repository-fk-reads", + project_preferences[0][0].organization, + ): + SeerProjectRepository.objects.filter( + project_repository__project_id__in=project_ids, + project_repository__repository__status=ObjectStatus.ACTIVE, + ).delete() + else: + SeerProjectRepository.objects.filter( + project_id__in=project_ids, repository__status=ObjectStatus.ACTIVE + ).delete() all_repo_ids = { repo_def.repository_id diff --git a/src/sentry/seer/endpoints/organization_seer_onboarding_check.py b/src/sentry/seer/endpoints/organization_seer_onboarding_check.py index 0f4098489d88..c911dfa26222 100644 --- a/src/sentry/seer/endpoints/organization_seer_onboarding_check.py +++ b/src/sentry/seer/endpoints/organization_seer_onboarding_check.py @@ -56,6 +56,12 @@ def is_autofix_enabled(organization: Organization) -> bool: Check if autofix/RCA is enabled for any active project in the organization, ie, if any project has repositories configured in Seer preferences. """ + if features.has("organizations:project-repository-fk-reads", organization): + return SeerProjectRepository.objects.filter( + project_repository__project__organization_id=organization.id, + project_repository__project__status=ObjectStatus.ACTIVE, + project_repository__repository__status=ObjectStatus.ACTIVE, + ).exists() return SeerProjectRepository.objects.filter( project__organization_id=organization.id, project__status=ObjectStatus.ACTIVE, diff --git a/src/sentry/tasks/seer/night_shift/cron.py b/src/sentry/tasks/seer/night_shift/cron.py index c81717517b24..c52c31715d06 100644 --- a/src/sentry/tasks/seer/night_shift/cron.py +++ b/src/sentry/tasks/seer/night_shift/cron.py @@ -119,7 +119,7 @@ def schedule_night_shift( seer_org_ids: set[int] = set() for spr in RangeQuerySetWrapper[SeerProjectRepository]( SeerProjectRepository.objects.filter(project__status=ObjectStatus.ACTIVE).select_related( - "project" + "project", "project_repository__project" ), step=1000, ):