From 2fa0c9d16041b5d105afebfe1524f5829ae938eb Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 27 Mar 2026 13:10:37 +0100 Subject: [PATCH 1/4] feat(code-mappings): Allow multiple source roots per stack root Change the unique constraint on RepositoryProjectPathConfig from (project, stack_root) to (project, stack_root, source_root). This allows the same stack trace root to map to multiple source paths, which is needed for monorepos where the same package prefix exists in multiple modules (e.g. io/sentry/opentelemetry mapping to both sentry-opentelemetry-core and sentry-opentelemetry-bootstrap). Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations_lockfile.txt | 2 +- .../endpoints/organization_code_mappings.py | 6 ++-- .../organization_code_mappings_bulk.py | 6 ++-- .../models/repository_project_path_config.py | 2 +- ...8_change_code_mapping_unique_constraint.py | 32 +++++++++++++++++++ .../test_organization_code_mappings.py | 2 +- .../test_organization_code_mappings_bulk.py | 27 ++++++++++------ 7 files changed, 58 insertions(+), 19 deletions(-) create mode 100644 src/sentry/migrations/1058_change_code_mapping_unique_constraint.py 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/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..e3473f8cf85e55 --- /dev/null +++ b/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.12 on 2026-03-27 11:40 + +from django.db import migrations + +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.AlterUniqueTogether( + name="repositoryprojectpathconfig", + unique_together={("project", "stack_root", "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 From f7f201aad0ea840ec77aa4de4517b3fd1ca7cad3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 27 Mar 2026 14:37:47 +0100 Subject: [PATCH 2/4] fix(code-mappings): Add source_root to get_or_create/update_or_create lookups Update auto-source-code-config callers to include source_root in their lookup keys, matching the new unique constraint on (project, stack_root, source_root). Without this, these calls would raise MultipleObjectsReturned once multiple mappings share the same stack_root. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../auto_source_code_config/code_mapping.py | 2 +- .../issues/auto_source_code_config/task.py | 2 +- .../test_process_event.py | 22 +++++++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) 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/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"]} From 4e491883a5c25865da38b240b04a85878327f7e0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 27 Mar 2026 15:20:30 +0100 Subject: [PATCH 3/4] fix(code-mappings): Fix derive code mappings test for new unique constraint Update test_post_existing_code_mapping to assert both mappings coexist when stack_root matches but source_root differs, matching the new (project, stack_root, source_root) unique constraint. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../issues/test_organization_derive_code_mappings.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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", + } From 07c49aa705e47803b1b210886342338f5f76729a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 31 Mar 2026 11:14:58 +0200 Subject: [PATCH 4/4] fix(code-mappings): Add new constraint before dropping old one in migration Use SeparateDatabaseAndState to add the new unique constraint before removing the old one, avoiding a window without any unique constraint on the table. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...8_change_code_mapping_unique_constraint.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py b/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py index e3473f8cf85e55..09bcd9161edda6 100644 --- a/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py +++ b/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py @@ -1,6 +1,6 @@ # Generated by Django 5.2.12 on 2026-03-27 11:40 -from django.db import migrations +from django.db import migrations, models from sentry.new_migrations.migrations import CheckedMigration @@ -25,8 +25,25 @@ class Migration(CheckedMigration): ] operations = [ - migrations.AlterUniqueTogether( - name="repositoryprojectpathconfig", - unique_together={("project", "stack_root", "source_root")}, + 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")}, + ), + ], ), ]