From ebc8938b1f2956a9cfa2f730827d05b07127d146 Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Thu, 2 Apr 2026 14:42:17 -0400 Subject: [PATCH 01/14] feat: Add repo indexing job --- src/sentry/features/temporary.py | 2 + src/sentry/seer/autofix/utils.py | 1 + src/sentry/seer/signed_seer_api.py | 29 +++++++++ src/sentry/tasks/seer/context_engine_index.py | 65 +++++++++++++++++++ 4 files changed, 97 insertions(+) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 0bf28bcf3fc8de..081af1b9617dda 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -291,6 +291,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-explorer-streaming", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable context engine for Seer Explorer manager.add("organizations:seer-explorer-context-engine", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable context engine experimental contexts + manager.add("organizations:context-engine-experiments", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable frontend override for context engine (only for AI/ML/Reasoning platform team) manager.add("organizations:seer-explorer-context-engine-allow-fe-override", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable frontend override UI component for context engine (only for AI/ML/Reasoning platform team) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index fba791d6413418..01bfc119f7ec71 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -768,6 +768,7 @@ def get_autofix_repos_from_project_code_mappings( "owner": repo_name_sections[0], "name": "/".join(repo_name_sections[1:]), "external_id": repo.external_id, + "languages": repo.languages or [], } repo_key = (repo_dict["provider"], repo_dict["owner"], repo_dict["name"]) diff --git a/src/sentry/seer/signed_seer_api.py b/src/sentry/seer/signed_seer_api.py index 10e6034f4392c9..ae85a3bc0cc98f 100644 --- a/src/sentry/seer/signed_seer_api.py +++ b/src/sentry/seer/signed_seer_api.py @@ -152,6 +152,35 @@ class LlmGenerateRequest(TypedDict): response_schema: NotRequired[dict[str, Any]] +class RepoDetails(TypedDict): + project_ids: list[int] + provider: str + owner: str + name: str + external_id: str + languages: list[str] + integration_id: str | None = None + + +class ExplorerIndexOrgRepoRequest(TypedDict): + org_id: int + repos: list[RepoDetails] + + +def make_org_repo_knowledge_index_request( + body: ExplorerIndexOrgRepoRequest, + timeout: int | float | None = None, + viewer_context: SeerViewerContext | None = None, +): + return make_signed_seer_api_request( + seer_autofix_default_connection_pool, + "/v1/automation/explorer/index/org-repo-knowledge", + body=orjson.dumps(body), + timeout=timeout, + viewer_context=viewer_context, + ) + + def make_org_project_knowledge_index_request( body: OrgProjectKnowledgeIndexRequest, timeout: int | float | None = None, diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index 7d3e879713918b..b23c6154757373 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -30,18 +30,22 @@ ) from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( + ExplorerIndexOrgRepoRequest, ExplorerIndexSentryKnowledgeRequest, OrgProjectKnowledgeIndexRequest, OrgProjectKnowledgeProjectData, + RepoDetails, SeerViewerContext, make_index_sentry_knowledge_request, make_org_project_knowledge_index_request, + make_org_repo_knowledge_index_request, ) from sentry.tasks.base import instrumented_task from sentry.taskworker.namespaces import seer_tasks from sentry.utils.hashlib import md5_text from sentry.utils.query import RangeQuerySetWrapper from sentry.utils.snuba_rpc import SnubaRPCRateLimitExceeded +from src.sentry.seer.autofix.utils import get_autofix_repos_from_project_code_mappings logger = logging.getLogger(__name__) @@ -213,6 +217,62 @@ def build_service_map(organization_id: int, *args, **kwargs) -> None: raise +@instrumented_task( + name="sentry.tasks.seer.context_engine_index.index_repos", + namespace=seer_tasks, + processing_deadline_duration=10 * 60, # 10 minutes + retry=Retry(times=3, on=(SnubaRPCRateLimitExceeded,), delay=60), +) +def index_repos(organization_id: int, *args, **kwargs) -> None: + if not options.get("explorer.context_engine_indexing.enable"): + logger.info("explorer.context_engine_indexing.enable flag is disabled") + return + + organization = Organization.objects.get(id=organization_id) + if not features.has("organizations:context-engine-experiments", organization): + logger.info("organizations:context-engine-experiments flag is disabled") + return + + logger.info( + "Starting repo index task", + extra={"org_id": organization_id}, + ) + + projects = list( + Project.objects.filter(organization_id=organization_id, status=ObjectStatus.ACTIVE) + ) + + if not projects: + logger.info("No projects found for organization", extra={"org_id": organization_id}) + return + + org_repo_definitions: dict[tuple[str, str, str], RepoDetails] = {} + + for project in projects: + repos = get_autofix_repos_from_project_code_mappings(project) + for repo in repos: + key = (repo["provider"], repo["owner"], repo["name"]) + if key in org_repo_definitions: + repo_definition = org_repo_definitions[key] + repo_definition["project_ids"].append(project.id) + else: + org_repo_definitions[key] = { + "project_ids": [project.id], + "provider": repo["provider"], + "owner": repo["owner"], + "name": repo["name"], + "external_id": repo["external_id"], + "languages": repo["languages"], + "integration_id": repo["integration_id"], + } + + make_org_repo_knowledge_index_request( + ExplorerIndexOrgRepoRequest( + org_id=organization.id, repos=list(org_repo_definitions.values()) + ) + ) + + def get_allowed_org_ids_context_engine_indexing() -> list[int]: """ Get the list of allowed organizations for context engine indexing. @@ -283,12 +343,17 @@ def schedule_context_engine_indexing_tasks() -> None: return allowed_org_ids = get_allowed_org_ids_context_engine_indexing() + now = datetime.now(UTC) dispatched = 0 for org_id in allowed_org_ids: try: index_org_project_knowledge.apply_async(args=[org_id]) build_service_map.apply_async(args=[org_id]) + + if now.weekday() == 6: # Sunday + index_repos.apply_async(args=[org_id]) + dispatched += 1 except Exception: logger.exception( From c6b7523acf2312e3a6edf8b92d47bcff3456f716 Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Thu, 2 Apr 2026 14:59:34 -0400 Subject: [PATCH 02/14] test(seer): Add tests for index_repos task Add test coverage for the new index_repos task including early return conditions, correct payload construction, and repo deduplication across projects. Also fix broken import path and add missing response status check. Co-Authored-By: Claude Opus 4.6 --- src/sentry/tasks/seer/context_engine_index.py | 8 +- .../tasks/seer/test_context_engine_index.py | 121 ++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index b23c6154757373..1cf6af8ec2218a 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -14,6 +14,7 @@ from sentry.models.organization import Organization from sentry.models.project import Project from sentry.search.events.types import SnubaParams +from sentry.seer.autofix.utils import get_autofix_repos_from_project_code_mappings from sentry.seer.explorer.context_engine_utils import ( EVENT_COUNT_LOOKBACK_DAYS, ProjectEventCounts, @@ -45,7 +46,6 @@ from sentry.utils.hashlib import md5_text from sentry.utils.query import RangeQuerySetWrapper from sentry.utils.snuba_rpc import SnubaRPCRateLimitExceeded -from src.sentry.seer.autofix.utils import get_autofix_repos_from_project_code_mappings logger = logging.getLogger(__name__) @@ -221,7 +221,6 @@ def build_service_map(organization_id: int, *args, **kwargs) -> None: name="sentry.tasks.seer.context_engine_index.index_repos", namespace=seer_tasks, processing_deadline_duration=10 * 60, # 10 minutes - retry=Retry(times=3, on=(SnubaRPCRateLimitExceeded,), delay=60), ) def index_repos(organization_id: int, *args, **kwargs) -> None: if not options.get("explorer.context_engine_indexing.enable"): @@ -266,12 +265,15 @@ def index_repos(organization_id: int, *args, **kwargs) -> None: "integration_id": repo["integration_id"], } - make_org_repo_knowledge_index_request( + response = make_org_repo_knowledge_index_request( ExplorerIndexOrgRepoRequest( org_id=organization.id, repos=list(org_repo_definitions.values()) ) ) + if response.status >= 400: + raise SeerApiError("Seer request failed", response.status) + def get_allowed_org_ids_context_engine_indexing() -> list[int]: """ diff --git a/tests/sentry/tasks/seer/test_context_engine_index.py b/tests/sentry/tasks/seer/test_context_engine_index.py index 7baacda3d5d15d..7464d8be5130a6 100644 --- a/tests/sentry/tasks/seer/test_context_engine_index.py +++ b/tests/sentry/tasks/seer/test_context_engine_index.py @@ -6,6 +6,7 @@ from sentry.tasks.seer.context_engine_index import ( get_allowed_org_ids_context_engine_indexing, index_org_project_knowledge, + index_repos, schedule_context_engine_indexing_tasks, ) from sentry.testutils.cases import TestCase @@ -207,6 +208,126 @@ def feature_enabled_for_all(_flag_name: str, org, *args, **kwargs) -> bool: assert org_without_github.id not in eligible +@django_db_all +class TestIndexRepos(TestCase): + def setUp(self) -> None: + super().setUp() + self.org = self.create_organization() + self.integration, self.org_integration = self.create_provider_integration_for( + organization=self.org, + user=None, + provider="github", + external_id=f"github:{self.org.id}", + ) + self.project1 = self.create_project(organization=self.org) + self.project2 = self.create_project(organization=self.org) + + self.repo1 = self.create_repo( + project=self.project1, + name="getsentry/sentry", + provider="integrations:github", + external_id="123", + integration_id=self.integration.id, + ) + self.repo1.languages = ["python", "javascript"] + self.repo1.save() + + self.repo2 = self.create_repo( + project=self.project2, + name="getsentry/relay", + provider="integrations:github", + external_id="456", + integration_id=self.integration.id, + ) + self.repo2.languages = ["rust"] + self.repo2.save() + + self.create_code_mapping( + project=self.project1, + repo=self.repo1, + organization_integration=self.org_integration, + ) + self.create_code_mapping( + project=self.project2, + repo=self.repo2, + organization_integration=self.org_integration, + ) + + @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") + def test_returns_early_when_option_disabled(self, mock_request) -> None: + with override_options({"explorer.context_engine_indexing.enable": False}): + index_repos(self.org.id) + mock_request.assert_not_called() + + @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") + def test_returns_early_when_feature_flag_disabled(self, mock_request) -> None: + with override_options({"explorer.context_engine_indexing.enable": True}): + index_repos(self.org.id) + mock_request.assert_not_called() + + @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") + def test_returns_early_when_no_projects(self, mock_request) -> None: + org_without_projects = self.create_organization() + with override_options({"explorer.context_engine_indexing.enable": True}): + with self.feature({"organizations:context-engine-experiments": True}): + index_repos(org_without_projects.id) + mock_request.assert_not_called() + + @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") + def test_calls_seer_with_correct_org_and_repos(self, mock_request) -> None: + mock_request.return_value.status = 200 + with override_options({"explorer.context_engine_indexing.enable": True}): + with self.feature({"organizations:context-engine-experiments": True}): + index_repos(self.org.id) + + mock_request.assert_called_once() + body = mock_request.call_args[0][0] + assert body["org_id"] == self.org.id + repos = body["repos"] + assert len(repos) == 2 + + repos_by_name = {r["name"]: r for r in repos} + sentry_repo = repos_by_name["sentry"] + assert sentry_repo["provider"] == "integrations:github" + assert sentry_repo["owner"] == "getsentry" + assert sentry_repo["external_id"] == "123" + assert sentry_repo["languages"] == ["python", "javascript"] + assert sentry_repo["project_ids"] == [self.project1.id] + assert sentry_repo["integration_id"] == str(self.integration.id) + + relay_repo = repos_by_name["relay"] + assert relay_repo["provider"] == "integrations:github" + assert relay_repo["owner"] == "getsentry" + assert relay_repo["external_id"] == "456" + assert relay_repo["languages"] == ["rust"] + assert relay_repo["project_ids"] == [self.project2.id] + assert relay_repo["integration_id"] == str(self.integration.id) + + @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") + def test_deduplicates_repos_across_projects(self, mock_request) -> None: + mock_request.return_value.status = 200 + # Map project2 to the same repo as project1 + self.create_code_mapping( + project=self.project2, + repo=self.repo1, + organization_integration=self.org_integration, + stack_root="src/", + source_root="src/", + ) + + with override_options({"explorer.context_engine_indexing.enable": True}): + with self.feature({"organizations:context-engine-experiments": True}): + index_repos(self.org.id) + + mock_request.assert_called_once() + body = mock_request.call_args[0][0] + repos = body["repos"] + repos_by_name = {r["name"]: r for r in repos} + + sentry_repo = repos_by_name["sentry"] + assert sorted(sentry_repo["project_ids"]) == sorted([self.project1.id, self.project2.id]) + + @django_db_all class TestScheduleContextEngineIndexingTasks(TestCase): @mock.patch("sentry.tasks.seer.context_engine_index.build_service_map.apply_async") From 8ece1bf1f756bb0313c5924ea0ca9a270f58aabf Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Thu, 2 Apr 2026 15:03:19 -0400 Subject: [PATCH 03/14] log --- src/sentry/tasks/seer/context_engine_index.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index 1cf6af8ec2218a..9ca54b8e0161ba 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -274,6 +274,8 @@ def index_repos(organization_id: int, *args, **kwargs) -> None: if response.status >= 400: raise SeerApiError("Seer request failed", response.status) + logger.info("Successfully indexted repos for org", extra={"org_id": organization_id}) + def get_allowed_org_ids_context_engine_indexing() -> list[int]: """ From b7850b546f1bacc1d4ce73b81d1d927725166480 Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Thu, 2 Apr 2026 15:05:07 -0400 Subject: [PATCH 04/14] typing --- src/sentry/seer/signed_seer_api.py | 2 +- src/sentry/tasks/seer/context_engine_index.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/signed_seer_api.py b/src/sentry/seer/signed_seer_api.py index ae85a3bc0cc98f..4b11825ee01bab 100644 --- a/src/sentry/seer/signed_seer_api.py +++ b/src/sentry/seer/signed_seer_api.py @@ -159,7 +159,7 @@ class RepoDetails(TypedDict): name: str external_id: str languages: list[str] - integration_id: str | None = None + integration_id: NotRequired[str | None] class ExplorerIndexOrgRepoRequest(TypedDict): diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index 9ca54b8e0161ba..412c61d85612c0 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -274,7 +274,7 @@ def index_repos(organization_id: int, *args, **kwargs) -> None: if response.status >= 400: raise SeerApiError("Seer request failed", response.status) - logger.info("Successfully indexted repos for org", extra={"org_id": organization_id}) + logger.info("Successfully indexed repos for org", extra={"org_id": organization_id}) def get_allowed_org_ids_context_engine_indexing() -> list[int]: From a5ed0a47e114c7db220958026ddeecd41b6b8bf8 Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Thu, 2 Apr 2026 15:11:14 -0400 Subject: [PATCH 05/14] catch org does not exist --- src/sentry/tasks/seer/context_engine_index.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index 412c61d85612c0..9d685af8b7e307 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -227,7 +227,12 @@ def index_repos(organization_id: int, *args, **kwargs) -> None: logger.info("explorer.context_engine_indexing.enable flag is disabled") return - organization = Organization.objects.get(id=organization_id) + try: + organization = Organization.objects.get(id=organization_id) + except Organization.DoesNotExist: + logger.error("Organization not found", extra={"org_id": organization_id}) + return + if not features.has("organizations:context-engine-experiments", organization): logger.info("organizations:context-engine-experiments flag is disabled") return From 1d536492ae28ce6dab548b95c2cf0903effe3f85 Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Thu, 2 Apr 2026 15:28:13 -0400 Subject: [PATCH 06/14] fix failing test --- tests/sentry/autofix/test_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/sentry/autofix/test_utils.py b/tests/sentry/autofix/test_utils.py index 62f4ce7fb8d7a5..8ad5f539cdbec1 100644 --- a/tests/sentry/autofix/test_utils.py +++ b/tests/sentry/autofix/test_utils.py @@ -44,6 +44,7 @@ def test_get_repos_from_project_code_mappings_with_data(self) -> None: "owner": "getsentry", "name": "sentry", "external_id": "123", + "languages": [], } ] assert repos == expected_repos From 3243fdbe19acdf84cfbeb2a71130eb3fc58e2d32 Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Tue, 7 Apr 2026 11:00:39 -0400 Subject: [PATCH 07/14] use project_pref_repos and autofix repos as fallback --- src/sentry/tasks/seer/context_engine_index.py | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index 9d685af8b7e307..8a5a2c4552459e 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -14,7 +14,10 @@ from sentry.models.organization import Organization from sentry.models.project import Project from sentry.search.events.types import SnubaParams -from sentry.seer.autofix.utils import get_autofix_repos_from_project_code_mappings +from sentry.seer.autofix.utils import ( + bulk_get_project_preferences, + get_autofix_repos_from_project_code_mappings, +) from sentry.seer.explorer.context_engine_utils import ( EVENT_COUNT_LOOKBACK_DAYS, ProjectEventCounts, @@ -245,28 +248,40 @@ def index_repos(organization_id: int, *args, **kwargs) -> None: projects = list( Project.objects.filter(organization_id=organization_id, status=ObjectStatus.ACTIVE) ) + project_map = {p.id: p for p in projects} - if not projects: + if not project_map: logger.info("No projects found for organization", extra={"org_id": organization_id}) return org_repo_definitions: dict[tuple[str, str, str], RepoDetails] = {} - for project in projects: - repos = get_autofix_repos_from_project_code_mappings(project) + preferences_by_id = bulk_get_project_preferences(organization_id, list(project_map.keys())) + + for project_id, project in project_map.items(): + existing_pref = preferences_by_id.get(str(project_id)) + project_pref_repos = existing_pref.get("repositories") or [] + autofix_repos = get_autofix_repos_from_project_code_mappings(project_map[project_id]) + + language_map: dict[tuple[str, str, str], list[str]] = {} + for autofix_repo in autofix_repos: + key = (autofix_repo["provider"], autofix_repo["owner"], autofix_repo["name"]) + language_map[key] = autofix_repo["languages"] + + repos = project_pref_repos if project_pref_repos else autofix_repos for repo in repos: key = (repo["provider"], repo["owner"], repo["name"]) if key in org_repo_definitions: repo_definition = org_repo_definitions[key] - repo_definition["project_ids"].append(project.id) + repo_definition["project_ids"].append(project_id) else: org_repo_definitions[key] = { - "project_ids": [project.id], + "project_ids": [project_id], "provider": repo["provider"], "owner": repo["owner"], "name": repo["name"], "external_id": repo["external_id"], - "languages": repo["languages"], + "languages": language_map.get(key, []), "integration_id": repo["integration_id"], } From 4abf5aa1f8c56320e84cbaf079f31b315d61a895 Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Tue, 7 Apr 2026 11:02:11 -0400 Subject: [PATCH 08/14] comment --- src/sentry/tasks/seer/context_engine_index.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index 8a5a2c4552459e..bf2fb0260d6346 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -263,11 +263,13 @@ def index_repos(organization_id: int, *args, **kwargs) -> None: project_pref_repos = existing_pref.get("repositories") or [] autofix_repos = get_autofix_repos_from_project_code_mappings(project_map[project_id]) + # Use autofix repos to get repo languages language_map: dict[tuple[str, str, str], list[str]] = {} for autofix_repo in autofix_repos: key = (autofix_repo["provider"], autofix_repo["owner"], autofix_repo["name"]) language_map[key] = autofix_repo["languages"] + # Use seer project rpeferences if available, else fallback to autofix repos repos = project_pref_repos if project_pref_repos else autofix_repos for repo in repos: key = (repo["provider"], repo["owner"], repo["name"]) From f8a59de775569376b38d9b6013c692aae2b0aba9 Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Tue, 7 Apr 2026 11:25:09 -0400 Subject: [PATCH 09/14] tests --- src/sentry/tasks/seer/context_engine_index.py | 2 +- .../tasks/seer/test_context_engine_index.py | 89 ++++++++++++++++--- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index bf2fb0260d6346..a036870a5d864b 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -259,7 +259,7 @@ def index_repos(organization_id: int, *args, **kwargs) -> None: preferences_by_id = bulk_get_project_preferences(organization_id, list(project_map.keys())) for project_id, project in project_map.items(): - existing_pref = preferences_by_id.get(str(project_id)) + existing_pref = preferences_by_id.get(str(project_id), {}) project_pref_repos = existing_pref.get("repositories") or [] autofix_repos = get_autofix_repos_from_project_code_mappings(project_map[project_id]) diff --git a/tests/sentry/tasks/seer/test_context_engine_index.py b/tests/sentry/tasks/seer/test_context_engine_index.py index 7464d8be5130a6..995274c2baffea 100644 --- a/tests/sentry/tasks/seer/test_context_engine_index.py +++ b/tests/sentry/tasks/seer/test_context_engine_index.py @@ -253,35 +253,48 @@ def setUp(self) -> None: organization_integration=self.org_integration, ) + @mock.patch("sentry.tasks.seer.context_engine_index.bulk_get_project_preferences") @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") - def test_returns_early_when_option_disabled(self, mock_request) -> None: + def test_returns_early_when_option_disabled( + self, mock_make_org_repo_knowledge_index_request, mock_bulk_get_project_preferences + ) -> None: with override_options({"explorer.context_engine_indexing.enable": False}): index_repos(self.org.id) - mock_request.assert_not_called() + mock_make_org_repo_knowledge_index_request.assert_not_called() + @mock.patch("sentry.tasks.seer.context_engine_index.bulk_get_project_preferences") @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") - def test_returns_early_when_feature_flag_disabled(self, mock_request) -> None: + def test_returns_early_when_feature_flag_disabled( + self, mock_mock_make_org_repo_knowledge_index_request, mock_bulk_get_project_preferences + ) -> None: with override_options({"explorer.context_engine_indexing.enable": True}): index_repos(self.org.id) - mock_request.assert_not_called() + mock_mock_make_org_repo_knowledge_index_request.assert_not_called() + @mock.patch("sentry.tasks.seer.context_engine_index.bulk_get_project_preferences") @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") - def test_returns_early_when_no_projects(self, mock_request) -> None: + def test_returns_early_when_no_projects( + self, mock_mock_make_org_repo_knowledge_index_request, mock_bulk_get_project_preferences + ) -> None: org_without_projects = self.create_organization() with override_options({"explorer.context_engine_indexing.enable": True}): with self.feature({"organizations:context-engine-experiments": True}): index_repos(org_without_projects.id) - mock_request.assert_not_called() + mock_mock_make_org_repo_knowledge_index_request.assert_not_called() + @mock.patch("sentry.tasks.seer.context_engine_index.bulk_get_project_preferences") @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") - def test_calls_seer_with_correct_org_and_repos(self, mock_request) -> None: - mock_request.return_value.status = 200 + def test_calls_seer_with_correct_org_and_repos( + self, mock_mock_make_org_repo_knowledge_index_request, mock_bulk_get_project_preferences + ) -> None: + mock_bulk_get_project_preferences.return_value = {} + mock_mock_make_org_repo_knowledge_index_request.return_value.status = 200 with override_options({"explorer.context_engine_indexing.enable": True}): with self.feature({"organizations:context-engine-experiments": True}): index_repos(self.org.id) - mock_request.assert_called_once() - body = mock_request.call_args[0][0] + mock_mock_make_org_repo_knowledge_index_request.assert_called_once() + body = mock_mock_make_org_repo_knowledge_index_request.call_args[0][0] assert body["org_id"] == self.org.id repos = body["repos"] assert len(repos) == 2 @@ -303,9 +316,13 @@ def test_calls_seer_with_correct_org_and_repos(self, mock_request) -> None: assert relay_repo["project_ids"] == [self.project2.id] assert relay_repo["integration_id"] == str(self.integration.id) + @mock.patch("sentry.tasks.seer.context_engine_index.bulk_get_project_preferences") @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") - def test_deduplicates_repos_across_projects(self, mock_request) -> None: - mock_request.return_value.status = 200 + def test_deduplicates_repos_across_projects( + self, mock_mock_make_org_repo_knowledge_index_request, mock_bulk_get_project_preferences + ) -> None: + mock_bulk_get_project_preferences.return_value = {} + mock_mock_make_org_repo_knowledge_index_request.return_value.status = 200 # Map project2 to the same repo as project1 self.create_code_mapping( project=self.project2, @@ -319,14 +336,58 @@ def test_deduplicates_repos_across_projects(self, mock_request) -> None: with self.feature({"organizations:context-engine-experiments": True}): index_repos(self.org.id) - mock_request.assert_called_once() - body = mock_request.call_args[0][0] + mock_mock_make_org_repo_knowledge_index_request.assert_called_once() + body = mock_mock_make_org_repo_knowledge_index_request.call_args[0][0] repos = body["repos"] repos_by_name = {r["name"]: r for r in repos} sentry_repo = repos_by_name["sentry"] assert sorted(sentry_repo["project_ids"]) == sorted([self.project1.id, self.project2.id]) + @mock.patch("sentry.tasks.seer.context_engine_index.bulk_get_project_preferences") + @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") + def test_uses_seer_project_preferences_if_available( + self, mock_mock_make_org_repo_knowledge_index_request, mock_bulk_get_project_preferences + ) -> None: + mock_mock_make_org_repo_knowledge_index_request.return_value.status = 200 + # Map project2 to the same repo as project1 + self.create_code_mapping( + project=self.project2, + repo=self.repo1, + organization_integration=self.org_integration, + stack_root="src/", + source_root="src/", + ) + + mock_bulk_get_project_preferences.return_value = { + str(self.project1.id): { + "repositories": [ + { + "name": "sentry-seer", + "owner": "getsentry", + "provider": "integrations:github", + "external_id": "999", + "integration_id": "000", + } + ], + }, + str(self.project2.id): { + "repositories": None, + }, + } + + with override_options({"explorer.context_engine_indexing.enable": True}): + with self.feature({"organizations:context-engine-experiments": True}): + index_repos(self.org.id) + + mock_mock_make_org_repo_knowledge_index_request.assert_called_once() + body = mock_mock_make_org_repo_knowledge_index_request.call_args[0][0] + repos = body["repos"] + repos_by_name = {r["name"]: r for r in repos} + + sentry_repo = repos_by_name["sentry-seer"] + assert sorted(sentry_repo["project_ids"]) == sorted([self.project1.id]) + @django_db_all class TestScheduleContextEngineIndexingTasks(TestCase): From 58b0a7072bf1a7d5707b5675d5a3eb4ca4493319 Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Tue, 7 Apr 2026 11:32:36 -0400 Subject: [PATCH 10/14] key error, viewer context --- src/sentry/tasks/seer/context_engine_index.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index a036870a5d864b..462a4c0f6bd9a3 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -261,7 +261,7 @@ def index_repos(organization_id: int, *args, **kwargs) -> None: for project_id, project in project_map.items(): existing_pref = preferences_by_id.get(str(project_id), {}) project_pref_repos = existing_pref.get("repositories") or [] - autofix_repos = get_autofix_repos_from_project_code_mappings(project_map[project_id]) + autofix_repos = get_autofix_repos_from_project_code_mappings(project) # Use autofix repos to get repo languages language_map: dict[tuple[str, str, str], list[str]] = {} @@ -284,13 +284,16 @@ def index_repos(organization_id: int, *args, **kwargs) -> None: "name": repo["name"], "external_id": repo["external_id"], "languages": language_map.get(key, []), - "integration_id": repo["integration_id"], + "integration_id": repo.get("integration_id"), } + viewer_context = SeerViewerContext(organization_id=organization_id) response = make_org_repo_knowledge_index_request( ExplorerIndexOrgRepoRequest( org_id=organization.id, repos=list(org_repo_definitions.values()) - ) + ), + timeout=30, + viewer_context=viewer_context, ) if response.status >= 400: From e1ab127ff13fb5cdc831cf89b6fe62f349b47efe Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Tue, 7 Apr 2026 11:34:28 -0400 Subject: [PATCH 11/14] typing anotations --- src/sentry/seer/signed_seer_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/signed_seer_api.py b/src/sentry/seer/signed_seer_api.py index 4b11825ee01bab..9c06dd0139b0a3 100644 --- a/src/sentry/seer/signed_seer_api.py +++ b/src/sentry/seer/signed_seer_api.py @@ -171,7 +171,7 @@ def make_org_repo_knowledge_index_request( body: ExplorerIndexOrgRepoRequest, timeout: int | float | None = None, viewer_context: SeerViewerContext | None = None, -): +) -> BaseHTTPResponse: return make_signed_seer_api_request( seer_autofix_default_connection_pool, "/v1/automation/explorer/index/org-repo-knowledge", From f63942d7e3026a42ef1e46643dc471ed5778c5f3 Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Tue, 7 Apr 2026 11:45:44 -0400 Subject: [PATCH 12/14] test --- .../tasks/seer/test_context_engine_index.py | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/tests/sentry/tasks/seer/test_context_engine_index.py b/tests/sentry/tasks/seer/test_context_engine_index.py index 995274c2baffea..ae5746819534bd 100644 --- a/tests/sentry/tasks/seer/test_context_engine_index.py +++ b/tests/sentry/tasks/seer/test_context_engine_index.py @@ -391,33 +391,66 @@ def test_uses_seer_project_preferences_if_available( @django_db_all class TestScheduleContextEngineIndexingTasks(TestCase): + @mock.patch("sentry.tasks.seer.context_engine_index.index_repos.apply_async") @mock.patch("sentry.tasks.seer.context_engine_index.build_service_map.apply_async") @mock.patch("sentry.tasks.seer.context_engine_index.index_org_project_knowledge.apply_async") @mock.patch( "sentry.tasks.seer.context_engine_index.get_allowed_org_ids_context_engine_indexing" ) - def test_dispatches_for_allowed_orgs(self, mock_get_orgs, mock_index, mock_build): + def test_dispatches_for_allowed_orgs( + self, mock_get_orgs, mock_index, mock_build, mock_index_repos + ): org1 = self.create_organization() org2 = self.create_organization() mock_get_orgs.return_value = [org1.id, org2.id] - with override_options( - { - "explorer.context_engine_indexing.enable": True, - } - ): - schedule_context_engine_indexing_tasks() + # Freeze to a Wednesday so index_repos is not called + with freeze_time("2024-01-10 12:00:00"): + with override_options( + { + "explorer.context_engine_indexing.enable": True, + } + ): + schedule_context_engine_indexing_tasks() assert mock_index.call_count == 2 assert mock_build.call_count == 2 + mock_index_repos.assert_not_called() dispatched_index_ids = [c[1]["args"][0] for c in mock_index.call_args_list] assert dispatched_index_ids == [org1.id, org2.id] + @mock.patch("sentry.tasks.seer.context_engine_index.index_repos.apply_async") + @mock.patch("sentry.tasks.seer.context_engine_index.build_service_map.apply_async") + @mock.patch("sentry.tasks.seer.context_engine_index.index_org_project_knowledge.apply_async") + @mock.patch( + "sentry.tasks.seer.context_engine_index.get_allowed_org_ids_context_engine_indexing" + ) + def test_dispatches_index_repos_on_sunday( + self, mock_get_orgs, mock_index, mock_build, mock_index_repos + ): + org1 = self.create_organization() + mock_get_orgs.return_value = [org1.id] + + # Freeze to a Sunday so index_repos is called + with freeze_time("2024-01-14 12:00:00"): + with override_options( + { + "explorer.context_engine_indexing.enable": True, + } + ): + schedule_context_engine_indexing_tasks() + + assert mock_index.call_count == 1 + assert mock_build.call_count == 1 + mock_index_repos.assert_called_once_with(args=[org1.id]) + + @mock.patch("sentry.tasks.seer.context_engine_index.index_repos.apply_async") @mock.patch("sentry.tasks.seer.context_engine_index.build_service_map.apply_async") @mock.patch("sentry.tasks.seer.context_engine_index.index_org_project_knowledge.apply_async") - def test_noop_when_no_allowed_orgs(self, mock_index, mock_build): + def test_noop_when_no_allowed_orgs(self, mock_index, mock_build, mock_index_repos): with override_options({"explorer.context_engine_indexing.enable": True}): schedule_context_engine_indexing_tasks() mock_index.assert_not_called() mock_build.assert_not_called() + mock_index_repos.assert_not_called() From f3b33d2cca8f1aa20e5b80de49e2246bb0de1b5a Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Tue, 7 Apr 2026 11:58:29 -0400 Subject: [PATCH 13/14] add retry for transient errors --- src/sentry/tasks/seer/context_engine_index.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index 462a4c0f6bd9a3..c33161b6315781 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -224,6 +224,7 @@ def build_service_map(organization_id: int, *args, **kwargs) -> None: name="sentry.tasks.seer.context_engine_index.index_repos", namespace=seer_tasks, processing_deadline_duration=10 * 60, # 10 minutes + retry=Retry(times=3, on=(SeerApiError,), delay=60), ) def index_repos(organization_id: int, *args, **kwargs) -> None: if not options.get("explorer.context_engine_indexing.enable"): From cf5c5a43064a0e9d3b55ea47e78bb395b8445496 Mon Sep 17 00:00:00 2001 From: Shruthilaya Jaganathan Date: Tue, 7 Apr 2026 13:54:43 -0400 Subject: [PATCH 14/14] skip projects that don't have seer pref --- src/sentry/tasks/seer/context_engine_index.py | 11 +-- .../tasks/seer/test_context_engine_index.py | 85 ++++++++++++++++++- 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index c33161b6315781..15a9da37e49fe8 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -260,19 +260,20 @@ def index_repos(organization_id: int, *args, **kwargs) -> None: preferences_by_id = bulk_get_project_preferences(organization_id, list(project_map.keys())) for project_id, project in project_map.items(): - existing_pref = preferences_by_id.get(str(project_id), {}) + existing_pref = preferences_by_id.get(str(project_id)) + if not existing_pref: + continue + project_pref_repos = existing_pref.get("repositories") or [] - autofix_repos = get_autofix_repos_from_project_code_mappings(project) + autofix_repos = get_autofix_repos_from_project_code_mappings(project) # Use autofix repos to get repo languages language_map: dict[tuple[str, str, str], list[str]] = {} for autofix_repo in autofix_repos: key = (autofix_repo["provider"], autofix_repo["owner"], autofix_repo["name"]) language_map[key] = autofix_repo["languages"] - # Use seer project rpeferences if available, else fallback to autofix repos - repos = project_pref_repos if project_pref_repos else autofix_repos - for repo in repos: + for repo in project_pref_repos: key = (repo["provider"], repo["owner"], repo["name"]) if key in org_repo_definitions: repo_definition = org_repo_definitions[key] diff --git a/tests/sentry/tasks/seer/test_context_engine_index.py b/tests/sentry/tasks/seer/test_context_engine_index.py index ae5746819534bd..f4ed9795f56676 100644 --- a/tests/sentry/tasks/seer/test_context_engine_index.py +++ b/tests/sentry/tasks/seer/test_context_engine_index.py @@ -287,7 +287,30 @@ def test_returns_early_when_no_projects( def test_calls_seer_with_correct_org_and_repos( self, mock_mock_make_org_repo_knowledge_index_request, mock_bulk_get_project_preferences ) -> None: - mock_bulk_get_project_preferences.return_value = {} + mock_bulk_get_project_preferences.return_value = { + str(self.project1.id): { + "repositories": [ + { + "name": "sentry", + "owner": "getsentry", + "provider": "integrations:github", + "external_id": "123", + "integration_id": str(self.integration.id), + } + ], + }, + str(self.project2.id): { + "repositories": [ + { + "name": "relay", + "owner": "getsentry", + "provider": "integrations:github", + "external_id": "456", + "integration_id": str(self.integration.id), + } + ], + }, + } mock_mock_make_org_repo_knowledge_index_request.return_value.status = 200 with override_options({"explorer.context_engine_indexing.enable": True}): with self.feature({"organizations:context-engine-experiments": True}): @@ -321,7 +344,30 @@ def test_calls_seer_with_correct_org_and_repos( def test_deduplicates_repos_across_projects( self, mock_mock_make_org_repo_knowledge_index_request, mock_bulk_get_project_preferences ) -> None: - mock_bulk_get_project_preferences.return_value = {} + mock_bulk_get_project_preferences.return_value = { + str(self.project1.id): { + "repositories": [ + { + "name": "sentry", + "owner": "getsentry", + "provider": "integrations:github", + "external_id": "123", + "integration_id": str(self.integration.id), + } + ], + }, + str(self.project2.id): { + "repositories": [ + { + "name": "sentry", + "owner": "getsentry", + "provider": "integrations:github", + "external_id": "123", + "integration_id": str(self.integration.id), + } + ], + }, + } mock_mock_make_org_repo_knowledge_index_request.return_value.status = 200 # Map project2 to the same repo as project1 self.create_code_mapping( @@ -385,9 +431,44 @@ def test_uses_seer_project_preferences_if_available( repos = body["repos"] repos_by_name = {r["name"]: r for r in repos} + assert len(repos) == 1 sentry_repo = repos_by_name["sentry-seer"] assert sorted(sentry_repo["project_ids"]) == sorted([self.project1.id]) + @mock.patch("sentry.tasks.seer.context_engine_index.bulk_get_project_preferences") + @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") + def test_skips_projects_without_seer_preferences( + self, mock_mock_make_org_repo_knowledge_index_request, mock_bulk_get_project_preferences + ) -> None: + mock_mock_make_org_repo_knowledge_index_request.return_value.status = 200 + + # Only project1 has preferences; project2 is absent from the map + mock_bulk_get_project_preferences.return_value = { + str(self.project1.id): { + "repositories": [ + { + "name": "sentry", + "owner": "getsentry", + "provider": "integrations:github", + "external_id": "123", + "integration_id": str(self.integration.id), + } + ], + }, + } + + with override_options({"explorer.context_engine_indexing.enable": True}): + with self.feature({"organizations:context-engine-experiments": True}): + index_repos(self.org.id) + + mock_mock_make_org_repo_knowledge_index_request.assert_called_once() + body = mock_mock_make_org_repo_knowledge_index_request.call_args[0][0] + repos = body["repos"] + + assert len(repos) == 1 + assert repos[0]["name"] == "sentry" + assert repos[0]["project_ids"] == [self.project1.id] + @django_db_all class TestScheduleContextEngineIndexingTasks(TestCase):