Skip to content

Commit 18cd9af

Browse files
romtsnclaude
andauthored
feat(code-mappings): Allow multiple source roots per stack root (#111704)
## Summary - Change unique constraint on `RepositoryProjectPathConfig` from `(project, stack_root)` to `(project, stack_root, source_root)` - Update single-mapping serializer validation to include `source_root` in duplicate check - Update bulk endpoint upsert key to match on `(stack_root, source_root)` instead of just `stack_root` This allows the same stack trace root (e.g. `io/sentry/opentelemetry`) to map to multiple source paths in the repo (e.g. `sentry-opentelemetry-core/src/...` and `sentry-opentelemetry-bootstrap/src/...`), which is needed for monorepos with shared package prefixes across modules. Stack trace resolution already handles this correctly — it iterates all mappings sorted by specificity and uses the first match that resolves to a real file. ## Test plan - [x] All 63 existing tests pass (bulk + single endpoints) - [x] Updated tests to reflect new constraint semantics - [ ] Deploy migration and verify with real monorepo upload 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 005905e commit 18cd9af

File tree

11 files changed

+102
-27
lines changed

11 files changed

+102
-27
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 (

src/sentry/issues/auto_source_code_config/code_mapping.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,12 +348,12 @@ def create_code_mapping(
348348
new_code_mapping, _ = RepositoryProjectPathConfig.objects.update_or_create(
349349
project=project,
350350
stack_root=code_mapping.stacktrace_root,
351+
source_root=code_mapping.source_path,
351352
defaults={
352353
"repository": repository,
353354
"organization_id": organization.id,
354355
"integration_id": installation.model.id,
355356
"organization_integration_id": installation.org_integration.id,
356-
"source_root": code_mapping.source_path,
357357
"default_branch": code_mapping.repo.branch,
358358
# This function is called from the UI, thus, we know that the code mapping is user generated
359359
"automatically_generated": False,

src/sentry/issues/auto_source_code_config/task.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,12 @@ def create_code_mapping(
238238
_, created = RepositoryProjectPathConfig.objects.get_or_create(
239239
project=project,
240240
stack_root=code_mapping.stacktrace_root,
241+
source_root=code_mapping.source_path,
241242
defaults={
242243
"repository": repository,
243244
"organization_integration_id": org_integration.id,
244245
"integration_id": org_integration.integration_id,
245246
"organization_id": org_integration.organization_id,
246-
"source_root": code_mapping.source_path,
247247
"default_branch": code_mapping.repo.branch,
248248
"automatically_generated": True,
249249
},
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Generated by Django 5.2.12 on 2026-03-27 11:40
2+
3+
from django.db import migrations, models
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.SeparateDatabaseAndState(
29+
database_operations=[
30+
migrations.AddConstraint(
31+
model_name="repositoryprojectpathconfig",
32+
constraint=models.UniqueConstraint(
33+
fields=["project", "stack_root", "source_root"],
34+
name="sentry_repositoryproject_project_id_stack_root_so_c371dfa7_uniq",
35+
),
36+
),
37+
migrations.AlterUniqueTogether(
38+
name="repositoryprojectpathconfig",
39+
unique_together=set(),
40+
),
41+
],
42+
state_operations=[
43+
migrations.AlterUniqueTogether(
44+
name="repositoryprojectpathconfig",
45+
unique_together={("project", "stack_root", "source_root")},
46+
),
47+
],
48+
),
49+
]

tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,12 @@ def test_post_existing_code_mapping(self) -> None:
321321
response = self.client.post(self.url, data=config_data, format="json")
322322
assert response.status_code == 201, response.content
323323

324-
new_code_mapping = RepositoryProjectPathConfig.objects.get(
324+
# Both mappings should coexist: the original and the newly derived one
325+
mappings = RepositoryProjectPathConfig.objects.filter(
325326
project=self.project, stack_root="/stack/root"
326327
)
327-
assert new_code_mapping.source_root == "/source/root"
328+
assert mappings.count() == 2
329+
assert set(mappings.values_list("source_root", flat=True)) == {
330+
"/source/root/wrong",
331+
"/source/root",
332+
}

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)