From a1dd371be2f1f80a7a164f945bc6ea40006f0795 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 12 May 2026 15:50:49 -0700 Subject: [PATCH 1/6] 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 0970a4a0dc6e..59a6166da915 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 6f90b6f720ba..bbb99e1a5622 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): self.create_seer_project_repository( 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 48097d5dcf86..6df628f25525 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, @@ -67,6 +68,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 1b1aa894bb62760d940ded03d3468a0ad4e070eb Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 12 May 2026 16:16:19 -0700 Subject: [PATCH 2/6] 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 e1894931b4efec34294d61f77d0cd28a1c5d5d52 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 12 May 2026 16:24:11 -0700 Subject: [PATCH 3/6] 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, ): From b2ce7987f14da21b1207b565a91b1be3cfc2d269 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 13 May 2026 14:24:27 -0700 Subject: [PATCH 4/6] chore(repositories): Switch over SeerProjectRepository attribute uses of `Project` and `Repository` to use `ProjectRepository` (#115459) As per the title, follow up to https://github.com/getsentry/sentry/pull/115456 --- src/sentry/seer/autofix/utils.py | 47 +++++++++++++++++------ src/sentry/tasks/seer/night_shift/cron.py | 4 +- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 2bb5a4c57b23..4d55e72ec34c 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -543,9 +543,13 @@ def _write_preferences_to_sentry_db( # Create branch overrides using the created project repos. overrides_to_create: list[SeerProjectRepositoryBranchOverride] = [] for seer_project_repo in created_project_repos: - for override in overrides_by_key.get( - (seer_project_repo.project_id, seer_project_repo.repository_id), [] - ): + pr = seer_project_repo.project_repository + key = ( + (pr.project_id, pr.repository_id) + if pr is not None + else (seer_project_repo.project_id, seer_project_repo.repository_id) + ) + for override in overrides_by_key.get(key, []): overrides_to_create.append( SeerProjectRepositoryBranchOverride( seer_project_repository=seer_project_repo, @@ -604,11 +608,16 @@ def clear_preference_automation_handoff(project: Project) -> None: def build_repo_definition_from_project_repo( seer_project_repo: SeerProjectRepository, + use_project_repository_fk: bool = False, ) -> SeerRepoDefinition | None: """Build a SeerRepoDefinition from a SeerProjectRepository with its joined Repository. Returns None if Repository name is invalid.""" - repo = seer_project_repo.repository + if use_project_repository_fk: + pr = seer_project_repo.project_repository + repo = pr.repository if pr is not None else seer_project_repo.repository + else: + repo = seer_project_repo.repository repo_name_sections = repo.name.split("/") if len(repo_name_sections) < 2: sentry_sdk.capture_exception(ValueError(f"Invalid repository name format: {repo.name}")) @@ -656,7 +665,8 @@ def build_automation_handoff( def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference: """Read a single project's Seer preferences from Sentry DB.""" - if features.has("organizations:project-repository-fk-reads", project.organization): + use_fk = features.has("organizations:project-repository-fk-reads", project.organization) + if use_fk: seer_project_repo_qs = ( SeerProjectRepository.objects.filter( project_repository__project=project, @@ -670,13 +680,18 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference: SeerProjectRepository.objects.filter( project=project, repository__status=ObjectStatus.ACTIVE ) - .select_related("repository") + .select_related("repository", "project_repository", "project_repository__repository") .prefetch_related("branch_overrides") ) repo_definitions = [ repo_def for project_repo in seer_project_repo_qs - if (repo_def := build_repo_definition_from_project_repo(project_repo)) is not None + if ( + repo_def := build_repo_definition_from_project_repo( + project_repo, use_project_repository_fk=use_fk + ) + ) + is not None ] return SeerProjectPreference( @@ -700,7 +715,8 @@ def bulk_read_preferences_from_sentry_db( org = Organization.objects.get(id=organization_id) repo_definitions_by_project: defaultdict[int, list[SeerRepoDefinition]] = defaultdict(list) - if features.has("organizations:project-repository-fk-reads", org): + use_fk = features.has("organizations:project-repository-fk-reads", org) + if use_fk: seer_repo_qs = SeerProjectRepository.objects.filter( project_repository__project_id__in=project_ids, project_repository__repository__status=ObjectStatus.ACTIVE, @@ -708,11 +724,18 @@ def bulk_read_preferences_from_sentry_db( else: seer_repo_qs = SeerProjectRepository.objects.filter( project_id__in=project_ids, repository__status=ObjectStatus.ACTIVE - ).select_related("repository") - for project_repo in seer_repo_qs.prefetch_related("branch_overrides"): - repo_def = build_repo_definition_from_project_repo(project_repo) + ).select_related("repository", "project_repository", "project_repository__repository") + for seer_repo in seer_repo_qs.prefetch_related("branch_overrides"): + repo_def = build_repo_definition_from_project_repo( + seer_repo, use_project_repository_fk=use_fk + ) if repo_def is not None: - repo_definitions_by_project[project_repo.project_id].append(repo_def) + if use_fk: + pr = seer_repo.project_repository + pid = pr.project_id if pr is not None else seer_repo.project_id + else: + pid = seer_repo.project_id + repo_definitions_by_project[pid].append(repo_def) # get_value_bulk_id returns None for missing options, unlike project.get_option # which automatically falls back to the registered well-known key default. diff --git a/src/sentry/tasks/seer/night_shift/cron.py b/src/sentry/tasks/seer/night_shift/cron.py index c52c31715d06..8c62f3dfd062 100644 --- a/src/sentry/tasks/seer/night_shift/cron.py +++ b/src/sentry/tasks/seer/night_shift/cron.py @@ -123,7 +123,9 @@ def schedule_night_shift( ), step=1000, ): - seer_org_ids.add(spr.project.organization_id) + pr = spr.project_repository + project = pr.project if pr is not None else spr.project + seer_org_ids.add(project.organization_id) logger.info( "night_shift.schedule_org_ids_collected", From ad8b0ab411667934ed930c6f69b2223d538ca64f Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 13 May 2026 14:51:44 -0700 Subject: [PATCH 5/6] fix deletions --- src/sentry/deletions/defaults/repository.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/sentry/deletions/defaults/repository.py b/src/sentry/deletions/defaults/repository.py index 2bfbeeba16e8..dd81508e22a0 100644 --- a/src/sentry/deletions/defaults/repository.py +++ b/src/sentry/deletions/defaults/repository.py @@ -12,14 +12,12 @@ 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}), ] @@ -31,11 +29,16 @@ 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). + # Only delete ProjectRepository and SeerProjectRepository when the + # repo is actually deleted, not when it's hidden/disabled + # (repository_cascade_delete_on_hide). Seer preferences and + # project-repo links should survive a hide so they're restored + # if the repo is re-enabled. + ModelRelation(ProjectRepository, {"repository_id": instance.id}), ModelRelation(SeerProjectRepository, {"repository_id": instance.id}), ] From 92cd92d950cd5cff33f02bbfb8a202fb91fd756b Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 14 May 2026 10:34:55 -0700 Subject: [PATCH 6/6] fix tests --- tests/sentry/seer/code_review/test_contributor_seats.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/sentry/seer/code_review/test_contributor_seats.py b/tests/sentry/seer/code_review/test_contributor_seats.py index 6df628f25525..eec5f042d316 100644 --- a/tests/sentry/seer/code_review/test_contributor_seats.py +++ b/tests/sentry/seer/code_review/test_contributor_seats.py @@ -6,7 +6,6 @@ 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, @@ -69,10 +68,7 @@ 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 - ) + self.create_seer_project_repository(project=self.project, repository=self.repo) with self.feature("organizations:project-repository-fk-reads"): assert _is_autofix_enabled_for_repo(self.organization, self.repo.id) is True