Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/sentry/deletions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/sentry/deletions/defaults/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/deletions/defaults/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,6 +76,7 @@ def get_child_relations(self, instance: Project) -> list[BaseRelation]:
ProjectCodeOwners,
ReplayRecordingSegment,
RepositoryProjectPathConfig,
ProjectRepository,
ServiceHookProject,
ServiceHook,
UserReport,
Expand Down
14 changes: 14 additions & 0 deletions src/sentry/deletions/defaults/projectrepository.py
Original file line number Diff line number Diff line change
@@ -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}),
]
2 changes: 2 additions & 0 deletions src/sentry/deletions/defaults/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hidden repo cascades into unintended SeerProjectRepository deletion

High Severity

ProjectRepository is added to _get_repository_child_relations, which is shared between RepositoryDeletionTask and repository_cascade_delete_on_hide. The comment on line 37-38 explicitly states that SeerProjectRepository records must NOT be deleted when a repo is merely hidden. However, deleting ProjectRepository during a hide operation triggers ProjectRepositoryDeletionTask, whose child relations include SeerProjectRepository (by project_repository_id), effectively cascade-deleting the very records intended to be preserved. ProjectRepository likely belongs in RepositoryDeletionTask.get_child_relations only, alongside SeerProjectRepository.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a49b4db. Configure here.

]


Expand Down
2 changes: 2 additions & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 20 additions & 4 deletions src/sentry/integrations/services/repository/impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -241,10 +244,17 @@
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Organization.objects.get() raises DoesNotExist and aborts integration cleanup transaction

Line 247 introduces an unguarded Organization.objects.get(id=organization_id) solely to evaluate a feature flag. If the organization has been deleted or is mid-deletion when this RPC runs (e.g., during cascading integration cleanup), it raises Organization.DoesNotExist, which rolls back the surrounding transaction.atomic block and skips the remaining cleanup of ProjectCodeOwners, RepositoryProjectPathConfig, ProjectRepository, and ProjectOption. This leaves orphaned integration data behind.

Verification

Read the full disassociate_organization_integration method (lines 229-291). Confirmed (1) the method is wrapped in transaction.atomic, so a DoesNotExist exception bubbles up and rolls back unrelated cleanup work performed later in the same block; (2) organization_id originates from RPC caller input with no prior validation in this function; (3) features.has() accepts an Organization object — using Organization.objects.filter(id=organization_id).first() with a None fallback would still allow flag evaluation (defaulting to False for unknown orgs) without aborting cleanup. (4) The caller in organizationintegration.py:44-46 explicitly documents this scenario can happen ("This can happen when an organization has been deleted already") and catches CellMappingNotFound, but does not catch Organization.DoesNotExist, confirming this exception will abort the deletion task.

Identified by Warden sentry-backend-bugs · P3Z-ATR

if features.has("organizations:project-repository-fk-reads", org):

Check warning on line 248 in src/sentry/integrations/services/repository/impl.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

Unhandled Organization.DoesNotExist breaks integration disassociation

`Organization.objects.get(id=organization_id)` is called without try/except. If the organization has been deleted (e.g., during a concurrent org deletion that triggers integration cleanup), this raises `Organization.DoesNotExist` and aborts the entire disassociation transaction, leaving `Repository`, `ProjectRepository`, `SeerProjectRepository`, `ProjectCodeOwners`, etc. in an inconsistent state. The organization lookup is only used for a feature-flag check, so a missing org should fall back to the legacy code path rather than crash.
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(
Expand All @@ -257,6 +267,12 @@
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(
Expand Down
4 changes: 4 additions & 0 deletions src/sentry/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
60 changes: 45 additions & 15 deletions src/sentry/seer/autofix/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flag-gated delete misses records with null FK

Medium Severity

When the feature flag is on, the delete query filters through the nullable project_repository FK (project_repository__project_id__in=project_ids). Any SeerProjectRepository rows where project_repository is NULL won't match this join and will survive the delete. The subsequent bulk_create then attempts to insert rows with the same (project, repository) pair, violating the unique_together constraint and raising an IntegrityError that rolls back the entire transaction. No backfill migration was found to ensure project_repository is populated before the flag is enabled.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 173ac04. Configure here.

else:
SeerProjectRepository.objects.filter(
project_id__in=project_ids, repository__status=ObjectStatus.ACTIVE
).delete()

all_repo_ids = {
repo_def.repository_id
Expand Down Expand Up @@ -647,13 +656,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")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N+1 queries from missing select_related("repository") on new path

Medium Severity

When the feature flag is enabled, the new query paths use select_related("project_repository", "project_repository__repository") but the downstream build_repo_definition_from_project_repo function accesses seer_project_repo.repository — the direct FK, not the one traversed through project_repository. Since the direct repository FK is not included in select_related, every iteration triggers a lazy-load DB query, causing an N+1 regression. The old code path correctly used select_related("repository") to eagerly load this relation.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a49b4db. Configure here.

.prefetch_related("branch_overrides")
)
Comment on lines +659 to +667
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The new feature-flagged code path in read_preference_from_sentry_db and bulk_read_preferences_from_sentry_db is missing select_related("repository"), causing an N+1 query issue.
Severity: MEDIUM

Suggested Fix

Add "repository" to the select_related(...) call in the querysets for read_preference_from_sentry_db and bulk_read_preferences_from_sentry_db within the new feature-flagged code path. This will ensure the repository data is prefetched, resolving the N+1 query.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/sentry/seer/autofix/utils.py#L659-L667

Potential issue: When the `organizations:project-repository-fk-reads` feature flag is
enabled, the `read_preference_from_sentry_db` and `bulk_read_preferences_from_sentry_db`
functions do not prefetch the `repository` object via `select_related`. However, the
helper function `build_repo_definition_from_project_repo` accesses
`seer_project_repo.repository`, which triggers an additional database query for each
`SeerProjectRepository` being processed. This introduces a classic N+1 query problem in
the new code path, which can lead to significant performance degradation when handling
multiple repositories.

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
Expand All @@ -679,14 +698,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)
Comment thread
sentry-warden[bot] marked this conversation as resolved.
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")
Comment thread
sentry[bot] marked this conversation as resolved.
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)
Expand Down Expand Up @@ -798,6 +821,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,
Expand Down
7 changes: 7 additions & 0 deletions src/sentry/seer/code_review/contributor_seats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/tasks/seer/night_shift/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down
29 changes: 29 additions & 0 deletions tests/sentry/seer/autofix/test_autofix_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions tests/sentry/seer/code_review/test_contributor_seats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
Loading