Skip to content

Commit 2fa0c9d

Browse files
romtsnclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 9bcfd0f commit 2fa0c9d

File tree

7 files changed

+58
-19
lines changed

7 files changed

+58
-19
lines changed

migrations_lockfile.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ replays: 0007_organizationmember_replay_access
3131

3232
seer: 0005_delete_seerorganizationsettings
3333

34-
sentry: 1057_drop_legacy_alert_rule_tables
34+
sentry: 1058_change_code_mapping_unique_constraint
3535

3636
social_auth: 0003_social_auth_json_field
3737

src/sentry/integrations/api/endpoints/organization_code_mappings.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,15 @@ def organization(self):
7070

7171
def validate(self, attrs):
7272
query = RepositoryProjectPathConfig.objects.filter(
73-
project_id=attrs.get("project_id"), stack_root=attrs.get("stack_root")
73+
project_id=attrs.get("project_id"),
74+
stack_root=attrs.get("stack_root"),
75+
source_root=attrs.get("source_root"),
7476
)
7577
if self.instance:
7678
query = query.exclude(id=self.instance.id)
7779
if query.exists():
7880
raise serializers.ValidationError(
79-
"Code path config already exists with this project and stack trace root"
81+
"Code path config already exists with this project, stack trace root, and source root"
8082
)
8183
return attrs
8284

src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -256,11 +256,9 @@ def post(self, request: Request, organization: Organization) -> Response:
256256
config = RepositoryProjectPathConfig.objects.select_for_update().get(
257257
project=project,
258258
stack_root=mapping["stack_root"],
259+
source_root=mapping["source_root"],
259260
)
260-
for key, value in {
261-
**defaults,
262-
"source_root": mapping["source_root"],
263-
}.items():
261+
for key, value in defaults.items():
264262
setattr(config, key, value)
265263
created = False
266264
except RepositoryProjectPathConfig.DoesNotExist:

src/sentry/integrations/models/repository_project_path_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class RepositoryProjectPathConfig(DefaultFieldsModelExisting):
3737
class Meta:
3838
app_label = "sentry"
3939
db_table = "sentry_repositoryprojectpathconfig"
40-
unique_together = (("project", "stack_root"),)
40+
unique_together = (("project", "stack_root", "source_root"),)
4141

4242
def __repr__(self) -> str:
4343
return (
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 5.2.12 on 2026-03-27 11:40
2+
3+
from django.db import migrations
4+
5+
from sentry.new_migrations.migrations import CheckedMigration
6+
7+
8+
class Migration(CheckedMigration):
9+
# This flag is used to mark that a migration shouldn't be automatically run in production.
10+
# This should only be used for operations where it's safe to run the migration after your
11+
# code has deployed. So this should not be used for most operations that alter the schema
12+
# of a table.
13+
# Here are some things that make sense to mark as post deployment:
14+
# - Large data migrations. Typically we want these to be run manually so that they can be
15+
# monitored and not block the deploy for a long period of time while they run.
16+
# - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
17+
# run this outside deployments so that we don't block them. Note that while adding an index
18+
# is a schema change, it's completely safe to run the operation after the code has deployed.
19+
# Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
20+
21+
is_post_deployment = False
22+
23+
dependencies = [
24+
("sentry", "1057_drop_legacy_alert_rule_tables"),
25+
]
26+
27+
operations = [
28+
migrations.AlterUniqueTogether(
29+
name="repositoryprojectpathconfig",
30+
unique_together={("project", "stack_root", "source_root")},
31+
),
32+
]

tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ def test_validate_path_conflict(self) -> None:
329329
assert response.status_code == 400
330330
assert response.data == {
331331
"nonFieldErrors": [
332-
"Code path config already exists with this project and stack trace root"
332+
"Code path config already exists with this project, stack trace root, and source root"
333333
]
334334
}
335335

tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ def test_update_existing_mapping(self) -> None:
9696
project=self.project1,
9797
repo=self.repo1,
9898
stack_root="com/example/maps",
99-
source_root="old/source/root",
99+
source_root="modules/maps/src/main/java/com/example/maps",
100+
default_branch="old-branch",
100101
)
101102

102103
response = self.make_post(
@@ -114,24 +115,26 @@ def test_update_existing_mapping(self) -> None:
114115
assert response.data["updated"] == 1
115116

116117
config = RepositoryProjectPathConfig.objects.get(
117-
project=self.project1, stack_root="com/example/maps"
118+
project=self.project1,
119+
stack_root="com/example/maps",
120+
source_root="modules/maps/src/main/java/com/example/maps",
118121
)
119-
assert config.source_root == "modules/maps/src/main/java/com/example/maps"
122+
assert config.default_branch == "main"
120123

121124
def test_mixed_create_and_update(self) -> None:
122125
self.create_code_mapping(
123126
project=self.project1,
124127
repo=self.repo1,
125128
stack_root="com/example/existing",
126-
source_root="old/path",
129+
source_root="existing/path",
127130
)
128131

129132
response = self.make_post(
130133
{
131134
"mappings": [
132135
{
133136
"stackRoot": "com/example/existing",
134-
"sourceRoot": "new/path",
137+
"sourceRoot": "existing/path",
135138
},
136139
{
137140
"stackRoot": "com/example/new",
@@ -440,7 +443,7 @@ def test_repo_from_other_org_returns_404(self) -> None:
440443
response = self.make_post({"repository": "other-org/other-repo"})
441444
assert response.status_code == 404
442445

443-
def test_duplicate_stack_roots_in_request_last_wins(self) -> None:
446+
def test_same_stack_root_different_source_roots_creates_both(self) -> None:
444447
response = self.make_post(
445448
{
446449
"mappings": [
@@ -456,13 +459,17 @@ def test_duplicate_stack_roots_in_request_last_wins(self) -> None:
456459
}
457460
)
458461
assert response.status_code == 200, response.content
459-
assert response.data["created"] == 1
460-
assert response.data["updated"] == 1
462+
assert response.data["created"] == 2
463+
assert response.data["updated"] == 0
461464

462-
config = RepositoryProjectPathConfig.objects.get(
465+
configs = RepositoryProjectPathConfig.objects.filter(
463466
project=self.project1, stack_root="com/example/maps"
464467
)
465-
assert config.source_root == "second/source/root"
468+
assert configs.count() == 2
469+
assert set(configs.values_list("source_root", flat=True)) == {
470+
"first/source/root",
471+
"second/source/root",
472+
}
466473

467474
def test_multiple_repos_same_name_returns_409(self) -> None:
468475
# Intentionally use Repository.objects.create since create_repo uses

0 commit comments

Comments
 (0)