diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 778a82c4a0744d..f1d2336b29497c 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ replays: 0007_organizationmember_replay_access seer: 0005_delete_seerorganizationsettings -sentry: 1057_drop_legacy_alert_rule_tables +sentry: 1058_change_code_mapping_unique_constraint social_auth: 0003_social_auth_json_field diff --git a/src/sentry/integrations/api/endpoints/organization_code_mappings.py b/src/sentry/integrations/api/endpoints/organization_code_mappings.py index 3eea2dada5e284..9d747c5bb58029 100644 --- a/src/sentry/integrations/api/endpoints/organization_code_mappings.py +++ b/src/sentry/integrations/api/endpoints/organization_code_mappings.py @@ -70,13 +70,15 @@ def organization(self): def validate(self, attrs): query = RepositoryProjectPathConfig.objects.filter( - project_id=attrs.get("project_id"), stack_root=attrs.get("stack_root") + project_id=attrs.get("project_id"), + stack_root=attrs.get("stack_root"), + source_root=attrs.get("source_root"), ) if self.instance: query = query.exclude(id=self.instance.id) if query.exists(): raise serializers.ValidationError( - "Code path config already exists with this project and stack trace root" + "Code path config already exists with this project, stack trace root, and source root" ) return attrs diff --git a/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py b/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py index 6a56cbf750e2a9..25662a251d9666 100644 --- a/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py +++ b/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py @@ -256,11 +256,9 @@ def post(self, request: Request, organization: Organization) -> Response: config = RepositoryProjectPathConfig.objects.select_for_update().get( project=project, stack_root=mapping["stack_root"], + source_root=mapping["source_root"], ) - for key, value in { - **defaults, - "source_root": mapping["source_root"], - }.items(): + for key, value in defaults.items(): setattr(config, key, value) created = False except RepositoryProjectPathConfig.DoesNotExist: diff --git a/src/sentry/integrations/models/repository_project_path_config.py b/src/sentry/integrations/models/repository_project_path_config.py index 3dd1e22b12c75b..96c41074ac55ea 100644 --- a/src/sentry/integrations/models/repository_project_path_config.py +++ b/src/sentry/integrations/models/repository_project_path_config.py @@ -37,7 +37,7 @@ class RepositoryProjectPathConfig(DefaultFieldsModelExisting): class Meta: app_label = "sentry" db_table = "sentry_repositoryprojectpathconfig" - unique_together = (("project", "stack_root"),) + unique_together = (("project", "stack_root", "source_root"),) def __repr__(self) -> str: return ( diff --git a/src/sentry/issues/auto_source_code_config/code_mapping.py b/src/sentry/issues/auto_source_code_config/code_mapping.py index 5e5cbda07490e5..4918b88728bd5a 100644 --- a/src/sentry/issues/auto_source_code_config/code_mapping.py +++ b/src/sentry/issues/auto_source_code_config/code_mapping.py @@ -348,12 +348,12 @@ def create_code_mapping( new_code_mapping, _ = RepositoryProjectPathConfig.objects.update_or_create( project=project, stack_root=code_mapping.stacktrace_root, + source_root=code_mapping.source_path, defaults={ "repository": repository, "organization_id": organization.id, "integration_id": installation.model.id, "organization_integration_id": installation.org_integration.id, - "source_root": code_mapping.source_path, "default_branch": code_mapping.repo.branch, # This function is called from the UI, thus, we know that the code mapping is user generated "automatically_generated": False, diff --git a/src/sentry/issues/auto_source_code_config/task.py b/src/sentry/issues/auto_source_code_config/task.py index 27101962c21931..b8aaec44eabc89 100644 --- a/src/sentry/issues/auto_source_code_config/task.py +++ b/src/sentry/issues/auto_source_code_config/task.py @@ -238,12 +238,12 @@ def create_code_mapping( _, created = RepositoryProjectPathConfig.objects.get_or_create( project=project, stack_root=code_mapping.stacktrace_root, + source_root=code_mapping.source_path, defaults={ "repository": repository, "organization_integration_id": org_integration.id, "integration_id": org_integration.integration_id, "organization_id": org_integration.organization_id, - "source_root": code_mapping.source_path, "default_branch": code_mapping.repo.branch, "automatically_generated": True, }, diff --git a/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py b/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py new file mode 100644 index 00000000000000..09bcd9161edda6 --- /dev/null +++ b/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.12 on 2026-03-27 11:40 + +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("sentry", "1057_drop_legacy_alert_rule_tables"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.AddConstraint( + model_name="repositoryprojectpathconfig", + constraint=models.UniqueConstraint( + fields=["project", "stack_root", "source_root"], + name="sentry_repositoryproject_project_id_stack_root_so_c371dfa7_uniq", + ), + ), + migrations.AlterUniqueTogether( + name="repositoryprojectpathconfig", + unique_together=set(), + ), + ], + state_operations=[ + migrations.AlterUniqueTogether( + name="repositoryprojectpathconfig", + unique_together={("project", "stack_root", "source_root")}, + ), + ], + ), + ] diff --git a/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py b/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py index cbd48aebe29d32..84ba5ddfc3323d 100644 --- a/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py +++ b/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py @@ -321,7 +321,12 @@ def test_post_existing_code_mapping(self) -> None: response = self.client.post(self.url, data=config_data, format="json") assert response.status_code == 201, response.content - new_code_mapping = RepositoryProjectPathConfig.objects.get( + # Both mappings should coexist: the original and the newly derived one + mappings = RepositoryProjectPathConfig.objects.filter( project=self.project, stack_root="/stack/root" ) - assert new_code_mapping.source_root == "/source/root" + assert mappings.count() == 2 + assert set(mappings.values_list("source_root", flat=True)) == { + "/source/root/wrong", + "/source/root", + } diff --git a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py index de62631476a848..6666b61887a7eb 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py @@ -329,7 +329,7 @@ def test_validate_path_conflict(self) -> None: assert response.status_code == 400 assert response.data == { "nonFieldErrors": [ - "Code path config already exists with this project and stack trace root" + "Code path config already exists with this project, stack trace root, and source root" ] } diff --git a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py index ab668f5e84b74e..e69831f50091a3 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py @@ -96,7 +96,8 @@ def test_update_existing_mapping(self) -> None: project=self.project1, repo=self.repo1, stack_root="com/example/maps", - source_root="old/source/root", + source_root="modules/maps/src/main/java/com/example/maps", + default_branch="old-branch", ) response = self.make_post( @@ -114,16 +115,18 @@ def test_update_existing_mapping(self) -> None: assert response.data["updated"] == 1 config = RepositoryProjectPathConfig.objects.get( - project=self.project1, stack_root="com/example/maps" + project=self.project1, + stack_root="com/example/maps", + source_root="modules/maps/src/main/java/com/example/maps", ) - assert config.source_root == "modules/maps/src/main/java/com/example/maps" + assert config.default_branch == "main" def test_mixed_create_and_update(self) -> None: self.create_code_mapping( project=self.project1, repo=self.repo1, stack_root="com/example/existing", - source_root="old/path", + source_root="existing/path", ) response = self.make_post( @@ -131,7 +134,7 @@ def test_mixed_create_and_update(self) -> None: "mappings": [ { "stackRoot": "com/example/existing", - "sourceRoot": "new/path", + "sourceRoot": "existing/path", }, { "stackRoot": "com/example/new", @@ -440,7 +443,7 @@ def test_repo_from_other_org_returns_404(self) -> None: response = self.make_post({"repository": "other-org/other-repo"}) assert response.status_code == 404 - def test_duplicate_stack_roots_in_request_last_wins(self) -> None: + def test_same_stack_root_different_source_roots_creates_both(self) -> None: response = self.make_post( { "mappings": [ @@ -456,13 +459,17 @@ def test_duplicate_stack_roots_in_request_last_wins(self) -> None: } ) assert response.status_code == 200, response.content - assert response.data["created"] == 1 - assert response.data["updated"] == 1 + assert response.data["created"] == 2 + assert response.data["updated"] == 0 - config = RepositoryProjectPathConfig.objects.get( + configs = RepositoryProjectPathConfig.objects.filter( project=self.project1, stack_root="com/example/maps" ) - assert config.source_root == "second/source/root" + assert configs.count() == 2 + assert set(configs.values_list("source_root", flat=True)) == { + "first/source/root", + "second/source/root", + } def test_multiple_repos_same_name_returns_409(self) -> None: # Intentionally use Repository.objects.create since create_repo uses diff --git a/tests/sentry/issues/auto_source_code_config/test_process_event.py b/tests/sentry/issues/auto_source_code_config/test_process_event.py index 049a06c4f760d3..f6d2531b440484 100644 --- a/tests/sentry/issues/auto_source_code_config/test_process_event.py +++ b/tests/sentry/issues/auto_source_code_config/test_process_event.py @@ -177,10 +177,11 @@ def _process_and_assert_configuration_changes( ) for expected_cm in expected_new_code_mappings: code_mapping = current_code_mappings.get( - project_id=self.project.id, stack_root=expected_cm["stack_root"] + project_id=self.project.id, + stack_root=expected_cm["stack_root"], + source_root=expected_cm["source_root"], ) assert code_mapping is not None - assert code_mapping.source_root == expected_cm["source_root"] assert code_mapping.repository.name == expected_cm["repo_name"] else: assert current_code_mappings.count() == starting_code_mappings_count @@ -939,13 +940,26 @@ def test_prevent_creating_duplicate_rules(self) -> None: self.project.update_option("sentry:grouping_enhancements", "stack.module:foo.bar.** +app") # Manually created code mapping self.create_repo_and_code_mapping(REPO1, "foo/bar/", "src/foo/") - # We do not expect code mappings or in-app rules to be created since - # the developer already created the code mapping and in-app rule + # We do not expect in-app rules to be created since the developer + # already created the in-app rule. A new code mapping is created + # because the source_root differs (src/foo/ vs src/foo/bar/). self._process_and_assert_configuration_changes( repo_trees={REPO1: ["src/foo/bar/Baz.java"]}, frames=[self.frame_from_module("foo.bar.Baz", "Baz.java")], platform=self.platform, + expected_new_code_mappings=[ + self.code_mapping(stack_root="foo/bar/", source_root="src/foo/bar/"), + ], ) + # Both mappings should coexist: the manual one and the auto-created one + mappings = RepositoryProjectPathConfig.objects.filter( + project=self.project, stack_root="foo/bar/" + ) + assert mappings.count() == 2 + assert set(mappings.values_list("source_root", flat=True)) == { + "src/foo/", + "src/foo/bar/", + } def test_basic_case(self) -> None: repo_trees = {REPO1: ["src/com/example/foo/Bar.kt"]}