diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 1f25ea3abe36..b9f5f4ae9eed 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -43,6 +43,7 @@ AutofixHandoffPoint, BranchOverride, SeerAutomationHandoffConfiguration, + SeerCodeMapping, SeerProjectPreference, SeerRepoDefinition, ) @@ -499,7 +500,9 @@ def get_token_based_billing_project_repos( project_id__in=project_ids, ) .select_related("repository", "seerprojectrepository") - .prefetch_related("seerprojectrepository__branch_overrides") + .prefetch_related( + "seerprojectrepository__branch_overrides", "repositoryprojectpathconfig_set" + ) ) @@ -509,11 +512,12 @@ def get_seer_project_repo(project_repo: ProjectRepository) -> SeerProjectReposit def build_seer_repo_definition( - repo: Repository, seer_project_repo: SeerProjectRepository | None + project_repo: ProjectRepository, seer_project_repo: SeerProjectRepository | None ) -> SeerRepoDefinition | None: - """Build a SeerRepoDefinition from a Repository and its optional per-repo Seer config. + """Build a SeerRepoDefinition from a project's repo link and its optional per-repo Seer config. Returns None if Repository name is invalid.""" + repo = project_repo.repository repo_name_sections = get_repo_url_path(repo).split("/") if len(repo_name_sections) < 2: sentry_sdk.capture_exception(ValueError(f"Invalid repository name format: {repo.name}")) @@ -537,6 +541,11 @@ def build_seer_repo_definition( ) for bo in (seer_project_repo.branch_overrides.all() if seer_project_repo else []) ], + project_repository_source=ProjectRepositorySource(project_repo.source).label, + code_mappings=[ + SeerCodeMapping(stack_root=cm.stack_root, source_root=cm.source_root) + for cm in project_repo.repositoryprojectpathconfig_set.all() + ], ) @@ -546,9 +555,7 @@ def _get_repo_definitions_by_project( repo_definitions_by_project: defaultdict[int, list[SeerRepoDefinition]] = defaultdict(list) if is_seer_token_based_billing_onboarding_enabled(organization): for project_repo in get_token_based_billing_project_repos(organization, project_ids): - repo_def = build_seer_repo_definition( - project_repo.repository, get_seer_project_repo(project_repo) - ) + repo_def = build_seer_repo_definition(project_repo, get_seer_project_repo(project_repo)) if repo_def is not None: repo_definitions_by_project[project_repo.project_id].append(repo_def) else: @@ -558,10 +565,12 @@ def _get_repo_definitions_by_project( project_repository__repository__status=ObjectStatus.ACTIVE, ) .select_related("project_repository", "project_repository__repository") - .prefetch_related("branch_overrides") + .prefetch_related( + "branch_overrides", "project_repository__repositoryprojectpathconfig_set" + ) ): repo_def = build_seer_repo_definition( - seer_project_repo.project_repository.repository, seer_project_repo + seer_project_repo.project_repository, seer_project_repo ) if repo_def is not None: repo_definitions_by_project[seer_project_repo.project_repository.project_id].append( diff --git a/src/sentry/seer/models/seer_api_models.py b/src/sentry/seer/models/seer_api_models.py index e41405fb30d3..5e0f0d358dbd 100644 --- a/src/sentry/seer/models/seer_api_models.py +++ b/src/sentry/seer/models/seer_api_models.py @@ -33,6 +33,11 @@ class IssueSummary(SummarizeIssueResponse): event_id: str +class SeerCodeMapping(BaseModel): + stack_root: str + source_root: str + + class SeerRepoDefinition(BaseModel): repository_id: int | None = None organization_id: int | None = None @@ -56,6 +61,14 @@ class SeerRepoDefinition(BaseModel): ) base_commit_sha: str | None = None provider_raw: str | None = None + project_repository_source: str | None = Field( + default=None, + description="How the repo was linked to the project, such as 'manual' or 'auto_name_match'.", + ) + code_mappings: list[SeerCodeMapping] = Field( + default_factory=list, + description="The project's code mappings for this repo.", + ) class SpanInsight(BaseModel): diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 8cde0ec2ba3d..dbc69fc98654 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -12,7 +12,7 @@ from sentry.models.options.project_option import ProjectOption from sentry.models.organization import Organization from sentry.models.project import Project -from sentry.models.projectrepository import ProjectRepository +from sentry.models.projectrepository import ProjectRepository, ProjectRepositorySource from sentry.seer.autofix.constants import ( AutofixAutomationTuningSettings, AutofixStatus, @@ -43,6 +43,7 @@ AutofixHandoffPoint, BranchOverride, SeerAutomationHandoffConfiguration, + SeerCodeMapping, SeerProjectPreference, SeerRepoDefinition, ) @@ -1050,6 +1051,38 @@ def test_reads_via_project_repository_fk(self) -> None: assert len(result.repositories) == 1 assert result.repositories[0].branch_name == "main" + def test_includes_link_source_and_code_mappings(self) -> None: + self.create_seer_project_repository(project=self.project, repository=self.repo) + self.create_seer_project_repository(project=self.project, repository=self.repo2) + self.create_code_mapping( + project=self.project, + repo=self.repo, + stack_root="app/", + source_root="services/api/app/", + ) + + result = read_preference_from_sentry_db(self.project) + repo_by_name = {r.name: r for r in result.repositories} + assert repo_by_name["test-repo"].project_repository_source == "seer_preference" + assert repo_by_name["test-repo"].code_mappings == [ + SeerCodeMapping(stack_root="app/", source_root="services/api/app/") + ] + assert repo_by_name["other-repo"].code_mappings == [] + + def test_excludes_other_projects_code_mappings(self) -> None: + other_project = self.create_project(organization=self.organization) + self.create_seer_project_repository(project=self.project, repository=self.repo) + self.create_code_mapping( + project=other_project, + repo=self.repo, + stack_root="web/", + source_root="services/web/", + ) + + result = read_preference_from_sentry_db(self.project) + assert len(result.repositories) == 1 + assert result.repositories[0].code_mappings == [] + def test_autofix_automation_tuning_default(self) -> None: self.create_seer_project_repository( project=self.project, repository=self.repo, branch_name="main" @@ -1234,6 +1267,17 @@ def test_includes_repos_without_seer_config(self) -> None: assert result.repositories[0].instructions is None assert result.repositories[0].branch_overrides == [] + def test_includes_auto_link_source(self) -> None: + self.create_project_repository( + project=self.project, + repository=self.repo, + source=ProjectRepositorySource.AUTO_NAME_MATCH, + ) + + result = read_preference_from_sentry_db(self.project) + assert len(result.repositories) == 1 + assert result.repositories[0].project_repository_source == "auto_name_match" + class BulkReadPreferencesFromSentryDbBaseTest(TestCase): __test__ = False @@ -1341,6 +1385,23 @@ def test_autofix_automation_tuning_defaults_to_off(self) -> None: assert pref is not None assert pref.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF + def test_code_mappings_are_per_project(self) -> None: + for project, source_root in ((self.project1, "services/api/"), (self.project2, "web/")): + self.create_seer_project_repository(project=project, repository=self.repo) + self.create_code_mapping( + project=project, repo=self.repo, stack_root="", source_root=source_root + ) + + result = bulk_read_preferences_from_sentry_db( + self.organization.id, [self.project1.id, self.project2.id] + ) + assert result[self.project1.id].repositories[0].code_mappings == [ + SeerCodeMapping(stack_root="", source_root="services/api/") + ] + assert result[self.project2.id].repositories[0].code_mappings == [ + SeerCodeMapping(stack_root="", source_root="web/") + ] + def test_wrong_organization_excluded(self) -> None: other_org = self.create_organization() self.create_seer_project_repository(