diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d55a776caa5241..858deb0a38ecee 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -338,6 +338,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/views/explore/spans/spansTabSeerComboBox.tsx @getsentry/explore @getsentry/machine-learning-ai /static/app/views/traces/ @getsentry/explore /static/app/components/quickTrace/ @getsentry/explore +/static/app/components/dnd/ @getsentry/explore /src/sentry/insights/ @getsentry/data-browsing /static/app/views/performance/ @getsentry/data-browsing /static/app/components/performance/ @getsentry/data-browsing diff --git a/.github/workflows/scripts/compute-sentry-selected-tests.py b/.github/workflows/scripts/compute-sentry-selected-tests.py index 65bbcd1a6ba034..24d1c626f8caad 100644 --- a/.github/workflows/scripts/compute-sentry-selected-tests.py +++ b/.github/workflows/scripts/compute-sentry-selected-tests.py @@ -135,6 +135,11 @@ def main() -> int: required=True, help="Space-separated changed files relative to sentry repo root", ) + parser.add_argument( + "--previous-filenames", + default="", + help="Space-separated previous filenames for renamed files (queried against coverage DB)", + ) parser.add_argument("--output", help="Output file path for selected test files (one per line)") parser.add_argument("--github-output", action="store_true", help="Write to GITHUB_OUTPUT") args = parser.parse_args() @@ -145,6 +150,7 @@ def main() -> int: return 1 changed = [f.strip() for f in args.changed_files.split() if f.strip()] + previous_filenames = [f.strip() for f in args.previous_filenames.split() if f.strip()] selective_applied = False @@ -152,8 +158,9 @@ def main() -> int: print("No changed files provided, running full test suite") affected_test_files: set[str] = set() else: + all_paths = changed + previous_filenames triggered_by = [ - f for f in changed if any(_matches_trigger(f, t) for t in FULL_SUITE_TRIGGERS) + f for f in all_paths if any(_matches_trigger(f, t) for t in FULL_SUITE_TRIGGERS) ] if triggered_by: print(f"Full test suite triggered by: {', '.join(triggered_by)}") @@ -161,8 +168,12 @@ def main() -> int: else: selective_applied = True - # Map repo-relative paths to DB format (add ../sentry/ prefix) + # Map repo-relative paths to DB format (add ../sentry/ prefix). + # Include previous filenames for renames so the coverage DB + # (which still stores the old path) can find the right tests. db_paths = [DB_PREFIX + f for f in changed] + for old_name in previous_filenames: + db_paths.append(DB_PREFIX + old_name) print(f"Computing selected tests for {len(changed)} changed files...") try: diff --git a/.github/workflows/scripts/getsentry-dispatch.js b/.github/workflows/scripts/getsentry-dispatch.js index 285be77daec73e..b0cb8caaba5440 100644 --- a/.github/workflows/scripts/getsentry-dispatch.js +++ b/.github/workflows/scripts/getsentry-dispatch.js @@ -20,6 +20,7 @@ export async function dispatch({ fileChanges, mergeCommitSha, sentryChangedFiles, + sentryPreviousFilenames, targetWorkflow, }) { core.startGroup('Dispatching request to getsentry.'); @@ -42,6 +43,7 @@ export async function dispatch({ // Changed files for selective testing. Empty string means full suite. 'sentry-changed-files': sentryChangedFiles || '', + 'sentry-previous-filenames': sentryPreviousFilenames || '', }; core.info( diff --git a/.github/workflows/scripts/test_compute_sentry_selected_tests.py b/.github/workflows/scripts/test_compute_sentry_selected_tests.py index d2af3dd08d5dae..5020d39dc96b9c 100644 --- a/.github/workflows/scripts/test_compute_sentry_selected_tests.py +++ b/.github/workflows/scripts/test_compute_sentry_selected_tests.py @@ -285,6 +285,72 @@ def test_zero_tests_signals_selective_applied(self, tmp_path): assert "test-count=0" in gh assert output.read_text() == "" + def test_renamed_file_queries_old_path(self, tmp_path): + """When a file is renamed, the old path should be queried against the coverage DB.""" + db_path = tmp_path / "coverage.db" + _create_coverage_db( + str(db_path), + { + # Coverage DB still has the old filename + "../sentry/src/sentry/models/old_name.py": [ + "../sentry/tests/sentry/test_old_name.py::T::test|run", + ], + }, + ) + output = tmp_path / "output.txt" + gh_output = tmp_path / "gh_output" + gh_output.write_text("") + + with mock.patch("compute_sentry_selected_tests.Path.exists", return_value=True): + _run( + [ + "--coverage-db", + str(db_path), + "--changed-files", + "src/sentry/models/new_name.py", + "--previous-filenames", + "src/sentry/models/old_name.py", + "--output", + str(output), + "--github-output", + ], + {"GITHUB_OUTPUT": str(gh_output)}, + ) + + gh = gh_output.read_text() + assert "has-selected-tests=true" in gh + assert "test-count=1" in gh + assert output.read_text().strip() == "tests/sentry/test_old_name.py" + + def test_renamed_file_without_previous_misses_coverage(self, tmp_path): + """Without --previous-filenames, a renamed file gets no coverage hits.""" + db_path = tmp_path / "coverage.db" + _create_coverage_db( + str(db_path), + { + "../sentry/src/sentry/models/old_name.py": [ + "../sentry/tests/sentry/test_old_name.py::T::test|run", + ], + }, + ) + gh_output = tmp_path / "gh_output" + gh_output.write_text("") + + _run( + [ + "--coverage-db", + str(db_path), + "--changed-files", + "src/sentry/models/new_name.py", + "--github-output", + ], + {"GITHUB_OUTPUT": str(gh_output)}, + ) + + gh = gh_output.read_text() + assert "has-selected-tests=true" in gh + assert "test-count=0" in gh + def test_missing_db_returns_error(self): ret = _run(["--coverage-db", "/nonexistent/coverage.db", "--changed-files", "foo.py"]) assert ret == 1 diff --git a/knip.config.ts b/knip.config.ts index 83730053fb429e..b0b8657a864274 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -21,6 +21,8 @@ const productionEntryPoints = [ 'static/app/chartcuterie/**/*.{js,ts,tsx}', // TODO: Remove when used 'static/app/components/pipeline/**/*.{js,ts,tsx}', + // TODO: Remove when used + 'static/app/views/seerExplorer/contexts/**/*.{js,ts,tsx}', ]; const testingEntryPoints = [ diff --git a/src/sentry/analytics/events/ai_autofix_pr_events.py b/src/sentry/analytics/events/ai_autofix_pr_events.py index 71a5e3c9068423..e9330c6764fce5 100644 --- a/src/sentry/analytics/events/ai_autofix_pr_events.py +++ b/src/sentry/analytics/events/ai_autofix_pr_events.py @@ -9,6 +9,7 @@ class AiAutofixPrEvent(analytics.Event): run_id: int integration: str github_app: str + referrer: str | None = None @analytics.eventclass("ai.autofix.pr.closed") diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index b06d6aa591ff27..1f68aa0fc4f336 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -58,6 +58,7 @@ ROLLBACK_ENABLED_DEFAULT, SAMPLING_MODE_DEFAULT, SCRAPE_JAVASCRIPT_DEFAULT, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, SEER_DEFAULT_CODING_AGENT_DEFAULT, TARGET_SAMPLE_RATE_DEFAULT, ObjectStatus, @@ -558,8 +559,9 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp defaultSeerScannerAutomation: bool enableSeerEnhancedAlerts: bool enableSeerCoding: bool - defaultCodingAgent: str | None + defaultCodingAgent: str defaultCodingAgentIntegrationId: int | None + defaultAutomatedRunStoppingPoint: str autoEnableCodeReview: bool autoOpenPrs: bool defaultCodeReviewTriggers: list[str] @@ -734,12 +736,14 @@ def serialize( # type: ignore[override] ) ), "defaultCodingAgent": obj.get_option( - "sentry:seer_default_coding_agent", - SEER_DEFAULT_CODING_AGENT_DEFAULT, + "sentry:seer_default_coding_agent", SEER_DEFAULT_CODING_AGENT_DEFAULT ), "defaultCodingAgentIntegrationId": obj.get_option( - "sentry:seer_default_coding_agent_integration_id", - None, + "sentry:seer_default_coding_agent_integration_id", None + ), + "defaultAutomatedRunStoppingPoint": obj.get_option( + "sentry:default_automated_run_stopping_point", + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, ), "autoOpenPrs": bool( obj.get_option( diff --git a/src/sentry/apidocs/examples/organization_examples.py b/src/sentry/apidocs/examples/organization_examples.py index 67acd600769107..fd238fc93f95ce 100644 --- a/src/sentry/apidocs/examples/organization_examples.py +++ b/src/sentry/apidocs/examples/organization_examples.py @@ -304,8 +304,9 @@ class OrganizationExamples: "enableSeerCoding": True, "enableSeerEnhancedAlerts": True, "autoOpenPrs": False, - "defaultCodingAgent": None, + "defaultCodingAgent": "seer", "defaultCodingAgentIntegrationId": None, + "defaultAutomatedRunStoppingPoint": "code_changes", "issueAlertsThreadFlag": True, "metricAlertsThreadFlag": True, "trustedRelays": [], diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index cbd1bd453e7908..c83313afaa50ff 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -1013,10 +1013,6 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "task": "workflow_engine:sentry.workflow_engine.tasks.workflows.schedule_delayed_workflows", "schedule": timedelta(seconds=15), }, - "prune-old-fire-history": { - "task": "workflow_engine:sentry.workflow_engine.tasks.cleanup.prune_old_fire_history", - "schedule": timedelta(minutes=2), - }, "resolve-stale-sourcemap-detectors": { "task": "workflow_engine:sentry.processing_errors.tasks.resolve_stale_sourcemap_detectors", "schedule": crontab("*/5", "*", "*", "*", "*"), diff --git a/src/sentry/core/endpoints/organization_details.py b/src/sentry/core/endpoints/organization_details.py index 5eecb3461606ec..a3c7fd1f39b75a 100644 --- a/src/sentry/core/endpoints/organization_details.py +++ b/src/sentry/core/endpoints/organization_details.py @@ -68,6 +68,7 @@ ROLLBACK_ENABLED_DEFAULT, SAMPLING_MODE_DEFAULT, SCRAPE_JAVASCRIPT_DEFAULT, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, SEER_DEFAULT_CODING_AGENT_DEFAULT, TARGET_SAMPLE_RATE_DEFAULT, ObjectStatus, @@ -254,12 +255,17 @@ None, ), ( - # Informs UI default for automated_run_stopping_point in project preferences "autoOpenPrs", "sentry:auto_open_prs", bool, AUTO_OPEN_PRS_DEFAULT, ), + ( + "defaultAutomatedRunStoppingPoint", + "sentry:default_automated_run_stopping_point", + str, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, + ), ( "autoEnableCodeReview", "sentry:auto_enable_code_review", @@ -371,8 +377,15 @@ class OrganizationSerializer(BaseOrganizationSerializer): dashboardsAsyncQueueParallelLimit = serializers.IntegerField(required=False, min_value=1) enableSeerEnhancedAlerts = serializers.BooleanField(required=False) enableSeerCoding = serializers.BooleanField(required=False) - defaultCodingAgent = serializers.CharField(required=False, allow_null=True) + defaultCodingAgent = serializers.ChoiceField( + choices=["seer", "cursor", "claude_code", "cursor_background_agent", "claude_code_agent"], + required=False, + allow_null=True, + ) defaultCodingAgentIntegrationId = serializers.IntegerField(required=False, allow_null=True) + defaultAutomatedRunStoppingPoint = serializers.ChoiceField( + choices=["code_changes", "open_pr"], required=False + ) autoOpenPrs = serializers.BooleanField(required=False) autoEnableCodeReview = serializers.BooleanField(required=False) defaultCodeReviewTriggers = serializers.ListField( @@ -401,6 +414,15 @@ def validate_relayPiiConfig(self, value): organization = self.context["organization"] return validate_pii_config_update(organization, value) + def validate_defaultCodingAgent(self, value: str | None) -> str: + if value is None: + return SEER_DEFAULT_CODING_AGENT_DEFAULT + coding_agent_aliases: dict[str, str] = { + "cursor": "cursor_background_agent", + "claude_code": "claude_code_agent", + } + return coding_agent_aliases.get(value, value) + def validate_defaultCodingAgentIntegrationId(self, value: int | None) -> int | None: if value is None: return None diff --git a/src/sentry/core/endpoints/team_projects.py b/src/sentry/core/endpoints/team_projects.py index ae2d9b8b254cc8..b82545bfdbb5d4 100644 --- a/src/sentry/core/endpoints/team_projects.py +++ b/src/sentry/core/endpoints/team_projects.py @@ -31,8 +31,8 @@ from sentry.models.team import Team from sentry.seer.similarity.utils import ( project_is_seer_eligible, - set_default_project_auto_open_prs, set_default_project_autofix_automation_tuning, + set_default_project_seer_preferences, set_default_project_seer_scanner_automation, ) from sentry.signals import project_created @@ -56,7 +56,7 @@ def apply_default_project_settings(organization: Organization, project: Project) set_default_project_autofix_automation_tuning(organization, project) set_default_project_seer_scanner_automation(organization, project) - set_default_project_auto_open_prs(organization, project) + set_default_project_seer_preferences(organization, project) class ProjectPostSerializer(serializers.Serializer): diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 12e4d5feb2092a..fba791d6413418 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -15,6 +15,7 @@ from sentry import features, options, ratelimits from sentry.constants import ( + AUTO_OPEN_PRS_DEFAULT, SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, DataCategory, ObjectStatus, @@ -42,6 +43,10 @@ SeerProjectRepository, SeerProjectRepositoryBranchOverride, ) +from sentry.seer.models.seer_api_models import ( + AutofixHandoffPoint, + SeerAutomationHandoffConfiguration, +) from sentry.seer.signed_seer_api import SeerViewerContext, make_signed_seer_api_request from sentry.utils.cache import cache from sentry.utils.outcomes import Outcome, track_outcome @@ -393,6 +398,38 @@ def default_seer_project_preference(project: Project) -> SeerProjectPreference: ) +def get_org_default_seer_automation_handoff( + organization: Organization, +) -> tuple[str, SeerAutomationHandoffConfiguration | None]: + """Get the default stopping point and automation handoff for an organization.""" + stopping_point = organization.get_option( + "sentry:default_automated_run_stopping_point", SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + + auto_open_prs = organization.get_option("sentry:auto_open_prs", AUTO_OPEN_PRS_DEFAULT) + + automation_handoff: SeerAutomationHandoffConfiguration | None = None + coding_agent = organization.get_option("sentry:seer_default_coding_agent") + coding_agent_integration_id = organization.get_option( + "sentry:seer_default_coding_agent_integration_id" + ) + if coding_agent and coding_agent != "seer" and coding_agent_integration_id is not None: + automation_handoff = SeerAutomationHandoffConfiguration( + handoff_point=AutofixHandoffPoint.ROOT_CAUSE, + target=coding_agent, + integration_id=coding_agent_integration_id, + auto_create_pr=auto_open_prs, + ) + # If Seer agent and auto open PRs, we can run up to open_pr. + elif auto_open_prs: + stopping_point = "open_pr" + # If Seer agent and no auto open PRs, we shouldn't go past code_changes. + elif stopping_point == "open_pr": + stopping_point = "code_changes" + + return stopping_point, automation_handoff + + def get_project_seer_preferences(project_id: int) -> SeerRawPreferenceResponse: """ Fetch Seer project preferences from the Seer API. diff --git a/src/sentry/seer/autofix/webhooks.py b/src/sentry/seer/autofix/webhooks.py index 3f6e4f4ad5a15d..f61384333cf681 100644 --- a/src/sentry/seer/autofix/webhooks.py +++ b/src/sentry/seer/autofix/webhooks.py @@ -11,8 +11,10 @@ AiAutofixPrOpenedEvent, ) from sentry.integrations.types import IntegrationProviderSlug +from sentry.models.group import Group from sentry.models.organization import Organization from sentry.seer.autofix.utils import get_autofix_state_from_pr_id +from sentry.seer.explorer.client_utils import get_explorer_state_from_pr_id from sentry.utils import metrics AnalyticAction = Literal["opened", "closed", "merged"] @@ -43,24 +45,57 @@ def handle_github_pr_webhook_for_autofix( if action not in ["opened", "closed"]: return None + try: + record_pr_action_analytic(org, action, pull_request, github_app) + except Exception as e: + sentry_sdk.capture_exception(e) + + +def record_pr_action_analytic( + org: Organization, action: str, pull_request: dict[str, Any], github_app: str +) -> None: + analytic_action: AnalyticAction = "opened" if action == "opened" else "closed" + if pull_request["merged"]: + analytic_action = "merged" + autofix_state = get_autofix_state_from_pr_id("integrations:github", pull_request["id"]) if autofix_state: - analytic_action: AnalyticAction = "opened" if action == "opened" else "closed" - if pull_request["merged"]: - analytic_action = "merged" - - try: - analytics.record( - ACTION_TO_EVENTS[analytic_action]( - organization_id=org.id, - integration=IntegrationProviderSlug.GITHUB.value, - project_id=autofix_state.request.project_id, - group_id=autofix_state.request.issue["id"], - run_id=autofix_state.run_id, - github_app=github_app, - ) + analytics.record( + ACTION_TO_EVENTS[analytic_action]( + organization_id=org.id, + integration=IntegrationProviderSlug.GITHUB.value, + project_id=autofix_state.request.project_id, + group_id=autofix_state.request.issue["id"], + run_id=autofix_state.run_id, + github_app=github_app, ) - except Exception as e: - sentry_sdk.capture_exception(e) + ) metrics.incr(f"ai.autofix.pr.{analytic_action}") + return + + explorer_state = get_explorer_state_from_pr_id( + org.id, "integrations:github", pull_request["id"] + ) + if explorer_state: + group_id = explorer_state.metadata.get("group_id") if explorer_state.metadata else None + if group_id is None: + raise ValueError(f"Missing group id in explorer run {explorer_state.run_id}") + group = Group.objects.get(id=group_id, project__organization_id=org.id) + + analytics.record( + ACTION_TO_EVENTS[analytic_action]( + organization_id=org.id, + integration=IntegrationProviderSlug.GITHUB.value, + project_id=group.project.id, + group_id=group.id, + run_id=explorer_state.run_id, + github_app=github_app, + referrer=explorer_state.metadata.get("referrer") + if explorer_state.metadata + else None, + ) + ) + + metrics.incr(f"ai.autofix.pr.{analytic_action}", tags={"mode": "explorer"}) + return diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index cf699f2ffb3f05..fd5aa32171957b 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -88,6 +88,12 @@ class ExplorerUpdateRequest(TypedDict): payload: NotRequired[dict[str, Any]] +class ExplorerPrStateRequest(TypedDict): + organization_id: int + provider: str + pr_id: int + + def make_explorer_state_request( body: ExplorerStateRequest, connection_pool: HTTPConnectionPool | None = None, @@ -140,6 +146,39 @@ def make_explorer_update_request( ) +def make_explorer_state_pr_request( + body: ExplorerPrStateRequest, + connection_pool: HTTPConnectionPool | None = None, + viewer_context: SeerViewerContext | None = None, +) -> BaseHTTPResponse: + return make_signed_seer_api_request( + connection_pool or explorer_connection_pool, + "/v1/automation/explorer/state/pr", + body=orjson.dumps(body, option=orjson.OPT_NON_STR_KEYS), + viewer_context=viewer_context, + ) + + +def get_explorer_state_from_pr_id( + organization_id: int, provider: str, pr_id: int +) -> SeerRunState | None: + body = ExplorerPrStateRequest(organization_id=organization_id, provider=provider, pr_id=pr_id) + response = make_explorer_state_pr_request(body) + + if response.status >= 400: + raise SeerApiError("Seer request failed", response.status) + + result = response.json() + if not result: + return None + + session = result.get("session") + if session is None: + return None + + return SeerRunState(**session) + + def has_seer_explorer_access_with_detail( organization: Organization, actor: SentryUser | AnonymousUser | RpcUser | None = None ) -> tuple[bool, str | None]: diff --git a/src/sentry/seer/similarity/utils.py b/src/sentry/seer/similarity/utils.py index 448ab305a0e13f..ff530ed98c12dd 100644 --- a/src/sentry/seer/similarity/utils.py +++ b/src/sentry/seer/similarity/utils.py @@ -9,7 +9,9 @@ from tokenizers import Tokenizer from sentry import features, options -from sentry.constants import DATA_ROOT +from sentry.constants import ( + DATA_ROOT, +) from sentry.grouping.api import get_contributing_variant_and_component from sentry.grouping.grouping_info import get_grouping_info_from_variants_legacy from sentry.grouping.variants import BaseVariant @@ -18,12 +20,14 @@ from sentry.models.project import Project from sentry.seer.autofix.constants import AutofixAutomationTuningSettings from sentry.seer.autofix.utils import ( - AutofixStoppingPoint, + get_org_default_seer_automation_handoff, is_seer_seat_based_tier_enabled, set_project_seer_preference, write_preference_to_sentry_db, ) -from sentry.seer.models import SeerProjectPreference +from sentry.seer.models import ( + SeerProjectPreference, +) from sentry.seer.similarity.types import GroupingVersion from sentry.services.eventstore.models import Event, GroupEvent from sentry.utils import metrics @@ -563,14 +567,14 @@ def set_default_project_seer_scanner_automation( project.update_option("sentry:seer_scanner_automation", org_default) -def set_default_project_auto_open_prs(organization: Organization, project: Project) -> None: - """Called once at project creation time to set the initial auto open PRs.""" +def set_default_project_seer_preferences(organization: Organization, project: Project) -> None: + """Called once at project creation time to set the initial automated run stopping + point and automation handoff. + """ if not is_seer_seat_based_tier_enabled(organization): return - stopping_point = AutofixStoppingPoint.CODE_CHANGES - if organization.get_option("sentry:auto_open_prs"): - stopping_point = AutofixStoppingPoint.OPEN_PR + stopping_point, automation_handoff = get_org_default_seer_automation_handoff(organization) # We need to make an API call to Seer to set this preference preference = SeerProjectPreference( @@ -578,7 +582,9 @@ def set_default_project_auto_open_prs(organization: Organization, project: Proje project_id=project.id, repositories=[], automated_run_stopping_point=stopping_point, + automation_handoff=automation_handoff, ) + try: set_project_seer_preference(preference) except Exception as e: diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 8671d98bee3281..1d4ac97d8abcb2 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -8,7 +8,9 @@ from sentry import analytics, features from sentry.analytics.events.autofix_automation_events import AiAutofixAutomationEvent -from sentry.constants import ObjectStatus +from sentry.constants import ( + ObjectStatus, +) from sentry.models.group import Group from sentry.models.organization import Organization from sentry.models.project import Project @@ -24,6 +26,7 @@ deduplicate_repositories, get_autofix_repos_from_project_code_mappings, get_autofix_state, + get_org_default_seer_automation_handoff, get_seer_seat_based_tier_cache_key, resolve_repository_ids, ) @@ -238,34 +241,49 @@ def configure_seer_for_existing_org(organization_id: int) -> None: "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM ) + default_stopping_point, default_handoff = get_org_default_seer_automation_handoff(organization) + default_handoff_dict = default_handoff.dict() if default_handoff else None + + valid_stopping_points = {"open_pr", "code_changes"} + preferences_by_id = bulk_get_project_preferences(organization_id, project_ids) # Determine which projects need updates preferences_to_set = [] projects_by_id = {p.id: p for p in projects} for project_id in project_ids: + stopping_point = default_stopping_point + handoff = default_handoff_dict + existing_pref = preferences_by_id.get(str(project_id)) if not existing_pref: # No existing preferences, get repositories from code mappings repositories = get_autofix_repos_from_project_code_mappings(projects_by_id[project_id]) else: - # Skip projects that already have an acceptable stopping point configured - if existing_pref.get("automated_run_stopping_point") in ("open_pr", "code_changes"): - continue repositories = existing_pref.get("repositories") or [] - repositories = deduplicate_repositories(repositories) + existing_stopping_point = existing_pref.get("automated_run_stopping_point") + existing_handoff = existing_pref.get("automation_handoff") + + # Skip projects that a) already have an acceptable stopping point configured + # AND b) already have a handoff configured or no org default handoff. + if existing_stopping_point in valid_stopping_points and ( + existing_handoff or default_handoff_dict is None + ): + continue + + if existing_stopping_point in valid_stopping_points: + stopping_point = existing_stopping_point + if existing_handoff: + handoff = existing_handoff - # Preserve existing repositories and automation_handoff, only update the stopping point preferences_to_set.append( { "organization_id": organization_id, "project_id": project_id, - "repositories": repositories or [], - "automated_run_stopping_point": "code_changes", - "automation_handoff": ( - existing_pref.get("automation_handoff") if existing_pref else None - ), + "repositories": deduplicate_repositories(repositories) or [], + "automated_run_stopping_point": stopping_point, + "automation_handoff": handoff, } ) diff --git a/static/app/components/events/autofix/v3/nextStep.spec.tsx b/static/app/components/events/autofix/v3/nextStep.spec.tsx index 5a9f7bf457c79d..5efc1cfecc76f8 100644 --- a/static/app/components/events/autofix/v3/nextStep.spec.tsx +++ b/static/app/components/events/autofix/v3/nextStep.spec.tsx @@ -234,6 +234,33 @@ describe('SeerDrawerNextStep', () => { await screen.findByRole('button', {name: 'More code fix options'}) ).toBeInTheDocument(); }); + + it('shows Add Integration link in dropdown footer', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/coding-agents/', + body: { + integrations: [ + {id: '1', name: 'Copilot', provider: 'github', requires_identity: false}, + ], + }, + }); + const autofix = makeAutofix(); + render( + + ); + await userEvent.click( + await screen.findByRole('button', {name: 'More code fix options'}) + ); + const addIntegrationLink = screen.getByRole('button', {name: 'Add Integration'}); + expect(addIntegrationLink).toHaveAttribute( + 'href', + '/settings/org-slug/integrations/?category=coding%20agent' + ); + }); }); describe('SolutionNextStep', () => { diff --git a/static/app/components/events/autofix/v3/nextStep.tsx b/static/app/components/events/autofix/v3/nextStep.tsx index 2f3df3bcc24323..f5c5579f4f9059 100644 --- a/static/app/components/events/autofix/v3/nextStep.tsx +++ b/static/app/components/events/autofix/v3/nextStep.tsx @@ -1,11 +1,13 @@ import {useCallback, useMemo, useState, type ReactNode} from 'react'; import {Button, ButtonBar} from '@sentry/scraps/button'; +import {MenuComponents} from '@sentry/scraps/compactSelect'; import {Flex} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {TextArea} from '@sentry/scraps/textarea'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; +import {DropdownMenuFooter} from 'sentry/components/dropdownMenu/footer'; import { organizationIntegrationsCodingAgents, type CodingAgentIntegration, @@ -18,6 +20,7 @@ import { type AutofixSection, type useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; +import {IconAdd} from 'sentry/icons/iconAdd'; import {IconChevron} from 'sentry/icons/iconChevron'; import {t} from 'sentry/locale'; import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; @@ -275,6 +278,8 @@ function NextStepTemplate({ codingAgentIntegrations, onCodingAgentHandoff, }: NextStepTemplateProps) { + const organization = useOrganization(); + const codingAgentOptions = useMemo(() => { return (codingAgentIntegrations ?? []).map(integration => { const actionLabel = @@ -349,6 +354,16 @@ function NextStepTemplate({ /> )} position="bottom-end" + menuFooter={ + + } + to={`/settings/${organization.slug}/integrations/?category=coding%20agent`} + > + {t('Add Integration')} + + + } /> ) : null} diff --git a/static/app/views/app/index.tsx b/static/app/views/app/index.tsx index 8c33108514b4d9..38ab3b139210f5 100644 --- a/static/app/views/app/index.tsx +++ b/static/app/views/app/index.tsx @@ -36,6 +36,7 @@ import {AsyncSDKIntegrationContextProvider} from 'sentry/views/app/asyncSDKInteg import {LastKnownRouteContextProvider} from 'sentry/views/lastKnownRouteContextProvider'; import {OrganizationContextProvider} from 'sentry/views/organizationContext'; import {RouteAnalyticsContextProvider} from 'sentry/views/routeAnalyticsContextProvider'; +import {LLMContextProvider} from 'sentry/views/seerExplorer/contexts/llmContext'; import {ExplorerPanel} from 'sentry/views/seerExplorer/explorerPanel'; import {ExplorerPanelProvider} from 'sentry/views/seerExplorer/useExplorerPanel'; @@ -241,12 +242,14 @@ export function App() { - - - - - {renderBody()} - + + + + + + {renderBody()} + + diff --git a/static/app/views/automations/list.tsx b/static/app/views/automations/list.tsx index d2237fa8b42e37..7f9554dfda4e0e 100644 --- a/static/app/views/automations/list.tsx +++ b/static/app/views/automations/list.tsx @@ -23,6 +23,7 @@ import {AutomationSearch} from 'sentry/views/automations/components/automationLi import {AUTOMATION_LIST_PAGE_LIMIT} from 'sentry/views/automations/constants'; import {useAutomationsQuery} from 'sentry/views/automations/hooks'; import {makeAutomationCreatePathname} from 'sentry/views/automations/pathnames'; +import {AlertsRedirectNotice} from 'sentry/views/detectors/list/common/alertsRedirectNotice'; export default function AutomationsList() { const location = useLocation(); @@ -85,6 +86,9 @@ export default function AutomationsList() { )} docsUrl="https://docs.sentry.io/product/new-monitors-and-alerts/alerts/" > + + {t('Alert Rules have been moved to Monitors and Alerts.')} +
- - - {t('Select monitor type')} - - - {tct( - 'Do you want to alert existing issues? Create a [newAlertLink:new alert], or [connectAlertLink:connect an existing one].', - { - newAlertLink: , - connectAlertLink: ( - - ), - } - )} - - + + {tct('Want to just alert on an existing issue? [link:Create an issue alert].', { + link: , + })} + + + {t( + 'If you’re looking for an Error Monitors, those are created by Sentry. To customize an error monitor, click into an existing one.' + )} + ); } @@ -94,7 +85,9 @@ function MonitorTypeField() { { id: 'metric_issue', name: getDetectorTypeLabel('metric_issue'), - description: t('Monitor error counts, transaction duration, and more!'), + description: t( + 'Monitor error counts, logs, custom metrics, span duration, crash rates, and more. ' + ), visualization: , infoBanner: canCreateMetricDetector ? undefined : ( diff --git a/static/app/views/detectors/list/allMonitors.tsx b/static/app/views/detectors/list/allMonitors.tsx index e9b38236c92529..8e61ebc506ceb1 100644 --- a/static/app/views/detectors/list/allMonitors.tsx +++ b/static/app/views/detectors/list/allMonitors.tsx @@ -1,7 +1,6 @@ import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {WorkflowEngineListLayout} from 'sentry/components/workflowEngine/layout/list'; import {t} from 'sentry/locale'; -import {AlertsRedirectNotice} from 'sentry/views/detectors/list/common/alertsRedirectNotice'; import {DetectorListActions} from 'sentry/views/detectors/list/common/detectorListActions'; import {DetectorListContent} from 'sentry/views/detectors/list/common/detectorListContent'; import {DetectorListHeader} from 'sentry/views/detectors/list/common/detectorListHeader'; @@ -24,9 +23,6 @@ export default function AllMonitors() { description={DESCRIPTION} docsUrl={DOCS_URL} > - - {t('Alert Rules have been moved to Monitors and Alerts.')} - diff --git a/static/app/views/detectors/new.tsx b/static/app/views/detectors/new.tsx index d563ddbb2ac723..212b96bed9661f 100644 --- a/static/app/views/detectors/new.tsx +++ b/static/app/views/detectors/new.tsx @@ -2,12 +2,14 @@ import {useTheme} from '@emotion/react'; import {parseAsString, useQueryState} from 'nuqs'; import {Button, LinkButton} from '@sentry/scraps/button'; +import {ExternalLink} from '@sentry/scraps/link'; +import {Text} from '@sentry/scraps/text'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {EditLayout} from 'sentry/components/workflowEngine/layout/edit'; import {useWorkflowEngineFeatureGate} from 'sentry/components/workflowEngine/useWorkflowEngineFeatureGate'; -import {t} from 'sentry/locale'; +import {t, tct} from 'sentry/locale'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; import { @@ -43,8 +45,6 @@ export default function DetectorNew() { const [detectorType] = useDetectorTypeQueryState(); const [projectId] = useQueryState('project', parseAsString); - const newMonitorName = t('New Monitor'); - const formProps = { onSubmit: () => { navigate({ @@ -62,12 +62,21 @@ export default function DetectorNew() { return ( - - + - + + + {tct( + 'Monitors detect problems in your application and send alerts when they occur. [docsLink:Read the Docs].', + { + docsLink: ( + + ), + } + )} +
diff --git a/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx b/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx index 5416d04c20e570..93566027385de7 100644 --- a/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx @@ -382,7 +382,6 @@ function AutofixPreviews({ size="md" icon={} aria-label={t('Open Seer')} - tooltipProps={{title: t('Open Seer')}} priority="primary" onClick={openSeerDrawer} analyticsEventKey="issue_details.seer_opened" diff --git a/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx b/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx index 74abd32fd23cc6..0922df43bc238e 100644 --- a/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx +++ b/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx @@ -4,7 +4,7 @@ import styled from '@emotion/styled'; import {t} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {makeMonitorBasePathname} from 'sentry/views/detectors/pathnames'; +import {makeAutomationBasePathname} from 'sentry/views/automations/pathnames'; import {ISSUE_TAXONOMY_CONFIG} from 'sentry/views/issueList/taxonomies'; import {usePrimaryNavigation} from 'sentry/views/navigation/primaryNavigationContext'; import {SecondaryNavigation} from 'sentry/views/navigation/secondary/components'; @@ -127,7 +127,7 @@ function ConfigureSection({baseUrl}: {baseUrl: string}) { !hasRedirectOptOut && organization.features.includes('workflow-engine-ui'); const alertsLink = shouldRedirectToWorkflowEngineUI - ? `${makeMonitorBasePathname(organization.slug)}?alertsRedirect=true` + ? `${makeAutomationBasePathname(organization.slug)}?alertsRedirect=true` : `${baseUrl}/alerts/rules/`; return ( diff --git a/static/app/views/seerExplorer/contexts/llmContext.spec.tsx b/static/app/views/seerExplorer/contexts/llmContext.spec.tsx new file mode 100644 index 00000000000000..faa19cf9bf38c0 --- /dev/null +++ b/static/app/views/seerExplorer/contexts/llmContext.spec.tsx @@ -0,0 +1,339 @@ +import type {ReactNode} from 'react'; + +import {render, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {LLMContextProvider, useLLMContext} from './llmContext'; +import type {LLMContextSnapshot} from './llmContextTypes'; +import {registerLLMContext} from './registerLLMContext'; + +// --------------------------------------------------------------------------- +// Test helper: ContextCapture +// +// Renders nothing but stores a reference to the getSnapshot function. +// Since the context value is memoized (stable), this component only renders +// once. However, getSnapshot() always reads stateRef.current (fresh), so +// calling capturedRef.current() in waitFor gives live data. +// --------------------------------------------------------------------------- + +function makeContextCapture() { + const ref: {current: ((componentOnly?: boolean) => LLMContextSnapshot) | null} = { + current: null, + }; + + function ContextCapture() { + const {getLLMContext} = useLLMContext(); + ref.current = getLLMContext; + return null; + } + + function getSnapshot(componentOnly?: boolean): LLMContextSnapshot { + if (!ref.current) throw new Error('ContextCapture not mounted'); + return ref.current(componentOnly); + } + + return {ContextCapture, getSnapshot}; +} + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +function DummyChart({label}: {label?: string}) { + useLLMContext({label: label ?? 'chart'}); + return
{label ?? 'chart'}
; +} + +function DummyWidget({title, children}: {children?: ReactNode; title?: string}) { + useLLMContext({title: title ?? 'widget', type: 'timeseries', unit: 'ms'}); + return ( +
+ {title ?? 'widget'} + {children} +
+ ); +} + +function DummyDashboard({name, children}: {children?: ReactNode; name?: string}) { + useLLMContext({name: name ?? 'dashboard'}); + return ( +
+ {name ?? 'dashboard'} + {children} +
+ ); +} + +const ContextChart = registerLLMContext('chart', DummyChart); +const ContextWidget = registerLLMContext('widget', DummyWidget); +const ContextDashboard = registerLLMContext('dashboard', DummyDashboard); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('LLMContextProvider — empty state', () => { + it('returns an empty snapshot when no nodes are registered', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + render( + + + + ); + + await waitFor(() => { + expect(getSnapshot().nodes).toEqual([]); + }); + }); +}); + +describe('registerLLMContext — nesting', () => { + it('nests Chart inside Widget inside Dashboard in the snapshot', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + render( + + + + + + + + + ); + + // Wait for the cascade of registration effects to settle, then assert the + // entire nested shape in one pass so the failure message shows the full tree. + await waitFor(() => { + expect(getSnapshot()).toEqual({ + version: expect.any(Number), + nodes: [ + { + nodeType: 'dashboard', + data: {name: 'Backend Health'}, + children: [ + { + nodeType: 'widget', + data: {title: 'Error Rate', type: 'timeseries', unit: 'ms'}, + children: [ + { + nodeType: 'chart', + data: {label: 'p99'}, + children: [], + }, + ], + }, + ], + }, + ], + }); + }); + }); +}); + +describe('registerLLMContext — unmount cleanup', () => { + it('removes the node from the tree when the component unmounts', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + const {rerender} = render( + + + + + ); + + // Node should be present after mount + await waitFor(() => { + expect(getSnapshot().nodes).toHaveLength(1); + }); + + // Unmount the widget + rerender( + + + + ); + + // Node should be gone + await waitFor(() => { + expect(getSnapshot().nodes).toHaveLength(0); + }); + }); +}); + +describe('useLLMContext — data updates', () => { + it('writes data into the node and updates it on re-render', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + function Gauge({value}: {value: number}) { + useLLMContext({value}); + return
{value}
; + } + const ContextGauge = registerLLMContext('widget', Gauge); + + const {rerender} = render( + + + + + ); + + // Initial data written after HOC registers and inner component re-renders + await waitFor(() => { + expect(getSnapshot().nodes[0]?.data).toEqual({value: 1}); + }); + + // Update the prop + rerender( + + + + + ); + + await waitFor(() => { + expect(getSnapshot().nodes[0]?.data).toEqual({value: 2}); + }); + }); + + it('handles non-object data types', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + function Label({text}: {text: string}) { + useLLMContext(text); + return
{text}
; + } + const ContextLabel = registerLLMContext('widget', Label); + + render( + + + + + ); + + await waitFor(() => { + expect(getSnapshot().nodes[0]?.data).toBe('hello'); + }); + }); + + it('handles array data', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + function Tags({items}: {items: string[]}) { + useLLMContext(items); + return
{items.join(',')}
; + } + const ContextTags = registerLLMContext('widget', Tags); + + render( + + + + + ); + + await waitFor(() => { + expect(getSnapshot().nodes[0]?.data).toEqual(['a', 'b', 'c']); + }); + }); + + it('handles numeric data', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + function Score({value}: {value: number}) { + useLLMContext(value); + return
{value}
; + } + const ContextScore = registerLLMContext('widget', Score); + + render( + + + + + ); + + await waitFor(() => { + expect(getSnapshot().nodes[0]?.data).toBe(42); + }); + }); +}); + +describe('getLLMContext — full tree vs componentOnly', () => { + it('getLLMContext() returns full tree including sibling branches', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + function Widget1() { + useLLMContext({id: 'w1'}); + return
w1
; + } + function Widget2() { + useLLMContext({id: 'w2'}); + return
w2
; + } + const CW1 = registerLLMContext('widget', Widget1); + const CW2 = registerLLMContext('widget', Widget2); + + render( + + + + + + ); + + await waitFor(() => { + const snapshot = getSnapshot(); + expect(snapshot.nodes).toHaveLength(2); + const types = snapshot.nodes.map(n => n.nodeType); + expect(types).toEqual(['widget', 'widget']); + }); + }); + + it('getLLMContext(true) returns only the current component subtree', async () => { + // We need a capture inside the dashboard to test componentOnly + const innerRef: { + current: ((c?: boolean) => LLMContextSnapshot) | null; + } = {current: null}; + + function DashboardWithCapture({name}: {name: string}) { + useLLMContext({name}); + const {getLLMContext} = useLLMContext(); + innerRef.current = getLLMContext; + return ( +
+ +
+ ); + } + const ContextDashboardWithCapture = registerLLMContext( + 'dashboard', + DashboardWithCapture + ); + + function SiblingDashboard() { + useLLMContext({name: 'sibling'}); + return
sibling
; + } + const ContextSiblingDashboard = registerLLMContext('dashboard', SiblingDashboard); + + render( + + + + + ); + + // componentOnly snapshot should contain only the dashboard + its inner widget, + // not the sibling dashboard + await waitFor(() => { + if (!innerRef.current) throw new Error('not mounted'); + const snapshot = innerRef.current(true); // componentOnly + expect(snapshot.nodes).toHaveLength(1); + expect(snapshot.nodes[0]?.nodeType).toBe('dashboard'); + expect(snapshot.nodes[0]?.children).toHaveLength(1); + expect(snapshot.nodes[0]?.children[0]?.nodeType).toBe('widget'); + }); + }); +}); diff --git a/static/app/views/seerExplorer/contexts/llmContext.tsx b/static/app/views/seerExplorer/contexts/llmContext.tsx new file mode 100644 index 00000000000000..9919867ccb132f --- /dev/null +++ b/static/app/views/seerExplorer/contexts/llmContext.tsx @@ -0,0 +1,251 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; + +import {createDefinedContext} from 'sentry/utils/performance/contexts/utils'; + +import type { + LLMContextInternalValue, + LLMContextNode, + LLMContextNodeSnapshot, + LLMContextSnapshot, + LLMContextState, +} from './llmContextTypes'; + +// --------------------------------------------------------------------------- +// Internal context — holds the registry operations (registerNode, etc.) +// --------------------------------------------------------------------------- + +const [_LLMContextProvider, _useLLMContextValue] = + createDefinedContext({ + name: 'LLMContext', + strict: true, + }); + +/** + * Hook for internal use by registerLLMContext and useLLMContext to access + * the registry operations (registerNode, unregisterNode, updateNodeData, getSnapshot). + * Throws if called outside an LLMContextProvider. + */ +export const useLLMContextRegistry = _useLLMContextValue; + +// --------------------------------------------------------------------------- +// LLMNodeContext — carries the current component's nodeId down the tree +// so child registerLLMContext wrappers can declare their parentId immediately +// during render (before any effects have fired). +// Default undefined = no parent (root level). +// --------------------------------------------------------------------------- + +export const LLMNodeContext = createContext(undefined); + +// --------------------------------------------------------------------------- +// Tree assembly helpers — convert the flat node map to a nested snapshot. +// Data is read from nodeData (imperative ref) rather than the reducer state +// so that writes from useLLMContext(data) are visible immediately even +// before the HOC's registerNode effect has fired. +// --------------------------------------------------------------------------- + +function collectDescendantIds( + nodes: Map, + nodeId: string, + result = new Set() +): Set { + result.add(nodeId); + for (const [id, node] of nodes) { + if (node.parentId === nodeId) { + collectDescendantIds(nodes, id, result); + } + } + return result; +} + +function buildTree( + nodes: LLMContextState['nodes'], + nodeData: Map, + parentId: string | undefined +): LLMContextNodeSnapshot[] { + const children: LLMContextNodeSnapshot[] = []; + for (const [id, node] of nodes) { + if (node.parentId === parentId) { + children.push({ + nodeType: node.nodeType, + data: nodeData.has(id) ? nodeData.get(id) : {}, + children: buildTree(nodes, nodeData, id), + }); + } + } + return children; +} + +function serializeState( + state: LLMContextState, + nodeData: Map, + fromNodeId?: string +): LLMContextSnapshot { + if (fromNodeId) { + const node = state.nodes.get(fromNodeId); + if (!node) { + return {version: state.version, nodes: []}; + } + return { + version: state.version, + nodes: [ + { + nodeType: node.nodeType, + data: nodeData.has(fromNodeId) ? nodeData.get(fromNodeId) : {}, + children: buildTree(state.nodes, nodeData, fromNodeId), + }, + ], + }; + } + return { + version: state.version, + nodes: buildTree(state.nodes, nodeData, undefined), + }; +} + +// --------------------------------------------------------------------------- +// LLMContextProvider — root of the entire context tree +// --------------------------------------------------------------------------- + +interface LLMContextProviderProps { + children: ReactNode; +} + +const INITIAL_STATE: LLMContextState = { + nodes: new Map(), + version: 0, +}; + +export function LLMContextProvider({children}: LLMContextProviderProps) { + // All state lives in refs — no re-renders needed. Consumers read + // the latest data imperatively via getSnapshot(). + const stateRef = useRef(INITIAL_STATE); + const nodeDataRef = useRef>(new Map()); + + const getSnapshot = useCallback((fromNodeId?: string): LLMContextSnapshot => { + return serializeState(stateRef.current, nodeDataRef.current, fromNodeId); + }, []); + + const registerNode = useCallback( + (nodeId: string, nodeType: string, parentId?: string): void => { + const prev = stateRef.current; + const newNodes = new Map(prev.nodes); + newNodes.set(nodeId, {nodeType, parentId}); + stateRef.current = {nodes: newNodes, version: prev.version + 1}; + }, + [] + ); + + const unregisterNode = useCallback((nodeId: string) => { + const prev = stateRef.current; + if (!prev.nodes.has(nodeId)) { + return; + } + const toRemove = collectDescendantIds(prev.nodes, nodeId); + const newNodes = new Map(prev.nodes); + for (const id of toRemove) { + newNodes.delete(id); + nodeDataRef.current.delete(id); + } + stateRef.current = {nodes: newNodes, version: prev.version + 1}; + }, []); + + const updateNodeData = useCallback((nodeId: string, data: unknown) => { + nodeDataRef.current.set(nodeId, data); + // Bump version so consumers using it as a change token detect data updates. + stateRef.current = {...stateRef.current, version: stateRef.current.version + 1}; + }, []); + + // Memoize so that the context value reference is stable across re-renders. + const value = useMemo( + () => ({getSnapshot, registerNode, unregisterNode, updateNodeData}), + [getSnapshot, registerNode, unregisterNode, updateNodeData] + ); + + return <_LLMContextProvider value={value}>{children}; +} + +// --------------------------------------------------------------------------- +// useLLMContext — write overload +// +// Call inside a registerLLMContext-wrapped component (or any descendant) +// to push structured data into the nearest registered context node. +// Accepts any value type — objects, arrays, strings, numbers, etc. +// +// useLLMContext({ title: 'Error Rate', threshold: 5 }); +// useLLMContext(someComputedValue); +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- {} here means "any non-undefined value" to distinguish from the no-arg read overload +export function useLLMContext(data: {} | null): void; + +// --------------------------------------------------------------------------- +// useLLMContext — read overload +// +// Call with no arguments to get getLLMContext. +// +// const { getLLMContext } = useLLMContext(); +// getLLMContext() // full tree from root +// getLLMContext(true) // current component's subtree only +// --------------------------------------------------------------------------- + +export function useLLMContext(): { + getLLMContext: (componentOnly?: boolean) => LLMContextSnapshot; +}; + +export function useLLMContext( + data?: unknown +): void | {getLLMContext: (componentOnly?: boolean) => LLMContextSnapshot} { + const ctx = useLLMContextRegistry(); + const nodeId = useContext(LLMNodeContext); + const prevDataRef = useRef(''); + + // Write path: sync data into the nearest node whenever it changes. + // JSON equality guard prevents redundant writes. updateNodeData writes + // imperatively to a ref — no dispatch, no re-render required. + useEffect(() => { + if (!nodeId || data === undefined) { + return; + } + let serialized: string | null; + let safeData: unknown = data; + try { + serialized = JSON.stringify(data); + } catch { + // Non-serializable value (e.g. circular reference) — store a + // placeholder so getSnapshot() remains JSON-serializable. + serialized = null; + safeData = {error: 'non-serializable value'}; + } + if (serialized === null || serialized !== prevDataRef.current) { + if (serialized !== null) { + prevDataRef.current = serialized; + } + ctx.updateNodeData(nodeId, safeData); + } + }); + + // Read path: always created so hooks run unconditionally. + // Only returned when called without data. + const getLLMContext = useCallback( + (componentOnly?: boolean): LLMContextSnapshot => { + if (componentOnly && nodeId) { + return ctx.getSnapshot(nodeId); + } + return ctx.getSnapshot(); + }, + [ctx, nodeId] + ); + + if (data === undefined) { + return {getLLMContext}; + } + return undefined; +} diff --git a/static/app/views/seerExplorer/contexts/llmContextTypes.ts b/static/app/views/seerExplorer/contexts/llmContextTypes.ts new file mode 100644 index 00000000000000..1120072a3a6667 --- /dev/null +++ b/static/app/views/seerExplorer/contexts/llmContextTypes.ts @@ -0,0 +1,71 @@ +/** + * LLM Context System — Types + * + * A flat map of context nodes that captures semantic state from the currently + * rendered page. Each node corresponds to a React component (dashboard, + * widget, etc.) and holds key-value data about it. The LLM context reader + * (e.g. Seer Explorer) reads a snapshot of this tree instead of scraping + * the DOM. + * + * Nodes are stored flat (keyed by ID) with a `parentId` pointer. The nested + * tree structure is assembled lazily at getSnapshot() time. This avoids + * ordering dependencies during registration — a child can declare its + * parentId immediately even before the parent's effect has fired. + */ + +/** + * Known node types for the LLM context tree. + * Add new types here as new context-aware components are registered. + */ +export type LLMContextNodeType = 'chart' | 'dashboard' | 'widget'; + +/** + * A single node in the flat registry. + * + * - `nodeType` — what kind of thing this is ("dashboard", "widget", etc.) + * - `parentId` — ID of the parent node, or undefined for root-level nodes + * + * Note: node data is stored separately in the provider's imperative + * `nodeDataRef` rather than on this struct, so that writes from + * `useLLMContext(data)` don't require a state mutation. + */ +export interface LLMContextNode { + nodeType: string; + parentId?: string; +} + +/** + * The full state held by the provider (stored in a ref, not reactive). + * + * - `nodes` — flat map of all registered nodes keyed by ID + * - `version` — bumped on every mutation so consumers can detect updates cheaply + */ +export interface LLMContextState { + nodes: Map; + version: number; +} + +/** + * The snapshot format returned by `getSnapshot()`. This is what gets sent + * to the LLM API — a plain-JSON-serializable nested tree. + */ +export interface LLMContextSnapshot { + nodes: LLMContextNodeSnapshot[]; + version: number; +} + +export interface LLMContextNodeSnapshot { + children: LLMContextNodeSnapshot[]; + data: unknown; + nodeType: string; +} + +/** + * The value exposed by the internal LLMContext to the HOC and hooks. + */ +export interface LLMContextInternalValue { + getSnapshot: (fromNodeId?: string) => LLMContextSnapshot; + registerNode: (nodeId: string, nodeType: string, parentId?: string) => void; + unregisterNode: (nodeId: string) => void; + updateNodeData: (nodeId: string, data: unknown) => void; +} diff --git a/static/app/views/seerExplorer/contexts/registerLLMContext.tsx b/static/app/views/seerExplorer/contexts/registerLLMContext.tsx new file mode 100644 index 00000000000000..062603624a64d1 --- /dev/null +++ b/static/app/views/seerExplorer/contexts/registerLLMContext.tsx @@ -0,0 +1,61 @@ +import type {ComponentType} from 'react'; +import {useContext, useEffect, useId} from 'react'; + +import {LLMNodeContext, useLLMContextRegistry} from './llmContext'; +import type {LLMContextNodeType} from './llmContextTypes'; + +/** + * HOC that registers a component as a named node in the LLM context tree. + * + * On mount, a new node of the given `nodeType` is created in the tree, + * nested under the nearest parent node (from `LLMNodeContext`). + * On unmount, the node and all its descendants are removed. + * + * The wrapped component receives all its original props unchanged. + * To push structured data into this component's node, call + * `useLLMContext({ key: value })` anywhere inside the wrapped component. + * + * Usage: + * const ContextAwareDashboard = registerLLMContext('dashboard', Dashboard); + * const ContextAwareWidget = registerLLMContext('widget', Widget); + * + * // Widget rendered inside Dashboard will nest correctly: + * // { nodeType: 'dashboard', children: [{ nodeType: 'widget', ... }] } + */ +export function registerLLMContext

>( + nodeType: LLMContextNodeType, + WrappedComponent: ComponentType

+): ComponentType

{ + function LLMContextWrapper(props: P) { + const ctx = useLLMContextRegistry(); + + // Read the nearest parent's nodeId from LLMNodeContext. + // undefined = no parent (this node will be at root level). + const parentNodeId = useContext(LLMNodeContext); + + // React's useId generates a stable, unique ID for this component instance. + const ownNodeId = useId(); + + useEffect(() => { + ctx.registerNode(ownNodeId, nodeType, parentNodeId); + return () => { + ctx.unregisterNode(ownNodeId); + }; + // parentNodeId in deps: if the parent context changes (e.g. parent + // component re-mounts), re-register under the new parent. + }, [ctx, ownNodeId, parentNodeId]); + + return ( + // Provide ownNodeId downward so child registerLLMContext wrappers + // and useLLMContext(data) calls read this as their context anchor. + + {/* TODO(any): HoC prop types not working w/ emotion https://github.com/emotion-js/emotion/issues/3261 */} + + + ); + } + + LLMContextWrapper.displayName = `registerLLMContext(${nodeType}, ${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`; + + return LLMContextWrapper; +} diff --git a/tests/sentry/core/endpoints/test_organization_details.py b/tests/sentry/core/endpoints/test_organization_details.py index 60679a0f2e9085..f01d258575ad5f 100644 --- a/tests/sentry/core/endpoints/test_organization_details.py +++ b/tests/sentry/core/endpoints/test_organization_details.py @@ -21,6 +21,7 @@ from sentry.auth.authenticators.totp import TotpInterface from sentry.constants import ( RESERVED_ORGANIZATION_SLUGS, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, SEER_DEFAULT_CODING_AGENT_DEFAULT, ObjectStatus, ) @@ -1499,19 +1500,50 @@ def test_default_coding_agent_default(self) -> None: response = self.get_success_response(self.organization.slug) assert response.data["defaultCodingAgent"] == SEER_DEFAULT_CODING_AGENT_DEFAULT - def test_default_coding_agent_can_be_set(self) -> None: + def test_default_coding_agent_can_be_set_to_seer(self) -> None: data = {"defaultCodingAgent": "seer"} response = self.get_success_response(self.organization.slug, **data) assert self.organization.get_option("sentry:seer_default_coding_agent") == "seer" assert response.data["defaultCodingAgent"] == "seer" - def test_default_coding_agent_null_on_first_write_create_path(self) -> None: - # Tests the create path (no OrganizationOption row exists yet): sending null - # must store null rather than the string "None" via str(None). + def test_default_coding_agent_can_be_set_to_cursor(self) -> None: + for value in ("cursor", "cursor_background_agent"): + data = {"defaultCodingAgent": value} + response = self.get_success_response(self.organization.slug, **data) + assert ( + self.organization.get_option("sentry:seer_default_coding_agent") + == "cursor_background_agent" + ) + assert response.data["defaultCodingAgent"] == "cursor_background_agent" + + def test_default_coding_agent_can_be_set_to_claude(self) -> None: + for value in ("claude_code", "claude_code_agent"): + data = {"defaultCodingAgent": value} + response = self.get_success_response(self.organization.slug, **data) + assert ( + self.organization.get_option("sentry:seer_default_coding_agent") + == "claude_code_agent" + ) + assert response.data["defaultCodingAgent"] == "claude_code_agent" + + def test_default_coding_agent_none_casts_to_seer(self) -> None: + data = {"defaultCodingAgent": None} + response = self.get_success_response(self.organization.slug, **data) + assert self.organization.get_option("sentry:seer_default_coding_agent") == "seer" + assert response.data["defaultCodingAgent"] == "seer" + + def test_default_coding_agent_none_resets_to_seer(self) -> None: + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) data = {"defaultCodingAgent": None} response = self.get_success_response(self.organization.slug, **data) - assert self.organization.get_option("sentry:seer_default_coding_agent") is None - assert response.data["defaultCodingAgent"] is None + assert self.organization.get_option("sentry:seer_default_coding_agent") == "seer" + assert response.data["defaultCodingAgent"] == "seer" + + def test_default_coding_agent_rejects_invalid_choice(self) -> None: + data = {"defaultCodingAgent": "invalid_agent"} + self.get_error_response(self.organization.slug, status_code=400, **data) def test_default_coding_agent_writing_default_value_stores_but_skips_audit_log( self, @@ -1581,12 +1613,25 @@ def test_default_coding_agent_integration_id_null_on_first_write_create_path(sel ) assert response.data["defaultCodingAgentIntegrationId"] is None - def test_default_coding_agent_can_be_cleared(self) -> None: - self.organization.update_option("sentry:seer_default_coding_agent", "seer") - data = {"defaultCodingAgent": None} - response = self.get_success_response(self.organization.slug, **data) - assert self.organization.get_option("sentry:seer_default_coding_agent") is None - assert response.data["defaultCodingAgent"] is None + def test_default_automated_run_stopping_point_default(self) -> None: + response = self.get_success_response(self.organization.slug) + assert ( + response.data["defaultAutomatedRunStoppingPoint"] + == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + + def test_default_automated_run_stopping_point_can_be_set(self) -> None: + for choice in ("code_changes", "open_pr"): + with self.subTest(choice=choice): + data = {"defaultAutomatedRunStoppingPoint": choice} + response = self.get_success_response(self.organization.slug, **data) + assert response.data["defaultAutomatedRunStoppingPoint"] == choice + + def test_default_automated_run_stopping_point_rejects_invalid(self) -> None: + for invalid in ("root_cause", "solution", "invalid_point"): + with self.subTest(value=invalid): + data = {"defaultAutomatedRunStoppingPoint": invalid} + self.get_error_response(self.organization.slug, status_code=400, **data) def test_default_coding_agent_integration_id_can_be_cleared(self) -> None: self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 123) diff --git a/tests/sentry/grouping/test_parameterization.py b/tests/sentry/grouping/test_parameterization.py index 43bf6ef0bf5152..49f9a6130c8d53 100644 --- a/tests/sentry/grouping/test_parameterization.py +++ b/tests/sentry/grouping/test_parameterization.py @@ -161,7 +161,7 @@ ("hex without prefix - no letters, < 8 digits, negative", "-1234567", ""), ("hex without prefix - no letters, 8+ digits, positive", "12345678", ""), ("git sha", "commit a93c7d2", "commit "), - ("git sha - all letters", "commit deadbeef", "commit deadbeef"), + ("git sha - all letters", "commit cabcafe", "commit cabcafe"), ("git sha - all numbers", "commit 4150908", "commit "), ("float", "0.23", ""), ("int", "23", ""), diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 96260e838075e7..2f5c8c401d6ed3 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -15,6 +15,7 @@ deduplicate_repositories, get_autofix_prompt, get_coding_agent_prompt, + get_org_default_seer_automation_handoff, has_project_connected_repos, is_seer_seat_based_tier_enabled, resolve_repository_ids, @@ -1218,3 +1219,91 @@ def test_bulk_write_replaces_per_project(self) -> None: assert p1_repo.branch_name == "new-branch" p2_repo = SeerProjectRepository.objects.get(project=project2) assert p2_repo.branch_name == "project-2-branch" + + +class TestGetOrgDefaultSeerAutomationHandoff(TestCase): + def test_defaults(self): + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "code_changes" + assert handoff is None + + def test_respects_org_stopping_point_option(self): + self.organization.update_option("sentry:default_automated_run_stopping_point", "open_pr") + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "open_pr" + assert handoff is None + + def test_seer_agent_auto_open_prs_forces_open_pr(self): + self.organization.update_option( + "sentry:default_automated_run_stopping_point", "code_changes" + ) + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "open_pr" + assert handoff is None + + def test_seer_agent_no_auto_open_prs_caps_open_pr_to_code_changes(self): + self.organization.update_option("sentry:default_automated_run_stopping_point", "open_pr") + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "code_changes" + assert handoff is None + + def test_external_agent_returns_handoff_config(self): + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "code_changes" + assert handoff is not None + assert handoff.handoff_point == "root_cause" + assert handoff.target == "cursor_background_agent" + assert handoff.integration_id == 42 + assert handoff.auto_create_pr is False + + def test_external_agent_auto_open_prs_sets_auto_create_pr(self): + self.organization.update_option("sentry:seer_default_coding_agent", "claude_code_agent") + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 99) + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert handoff is not None + assert handoff.auto_create_pr is True + + def test_external_agent_auto_open_prs_does_not_override_stopping_point(self): + self.organization.update_option( + "sentry:default_automated_run_stopping_point", "code_changes" + ) + self.organization.update_option("sentry:auto_open_prs", True) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "code_changes" + assert handoff is not None + + def test_external_agent_without_integration_id_falls_back_to_seer(self): + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "open_pr" + assert handoff is None + + def test_seer_coding_agent_treated_as_no_external_agent(self): + self.organization.update_option("sentry:seer_default_coding_agent", "seer") + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "open_pr" + assert handoff is None diff --git a/tests/sentry/tasks/seer/test_autofix.py b/tests/sentry/tasks/seer/test_autofix.py index 20566413060f75..ed92f065fb2c17 100644 --- a/tests/sentry/tasks/seer/test_autofix.py +++ b/tests/sentry/tasks/seer/test_autofix.py @@ -4,6 +4,7 @@ import pytest from django.test import TestCase +from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT from sentry.models.repository import Repository from sentry.seer.autofix.constants import AutofixStatus, SeerAutomationSource from sentry.seer.autofix.utils import AutofixState, get_seer_seat_based_tier_cache_key @@ -197,23 +198,166 @@ def test_overrides_autofix_off_to_medium( @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") - def test_skips_projects_with_existing_stopping_point( + def test_new_project_gets_stopping_point_and_no_handoff_from_org_defaults( self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock ) -> None: - """Test that projects with open_pr or code_changes stopping point are skipped.""" - project1 = self.create_project(organization=self.organization) - project2 = self.create_project(organization=self.organization) + """Project with no existing prefs gets stopping point and no handoff (seer coding agent) from org defaults.""" + project = self.create_project(organization=self.organization) + + mock_bulk_get.return_value = {} + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_called_once() + prefs = mock_bulk_set.call_args[0][1] + prefs_by_project = {p["project_id"]: p for p in prefs} + assert prefs_by_project[project.id]["automated_run_stopping_point"] == "code_changes" + assert prefs_by_project[project.id]["automation_handoff"] is None + + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_new_project_gets_stopping_point_and_handoff_from_org_defaults( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project with no existing prefs gets stopping point and external agent handoff from org defaults.""" + project = self.create_project(organization=self.organization) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + self.organization.update_option("sentry:auto_open_prs", True) + + mock_bulk_get.return_value = {} + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_called_once() + prefs = mock_bulk_set.call_args[0][1] + prefs_by_project = {p["project_id"]: p for p in prefs} + assert prefs_by_project[project.id]["automation_handoff"] == { + "handoff_point": "root_cause", + "target": "cursor_background_agent", + "integration_id": 42, + "auto_create_pr": True, + } + # auto_open_prs should NOT override stopping point for external agents + assert ( + prefs_by_project[project.id]["automated_run_stopping_point"] + == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_skips_project_with_valid_stopping_point_and_no_default_handoff( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project is skipped when it has a valid stopping point and the org has no default handoff (seer agent).""" + project = self.create_project(organization=self.organization) mock_bulk_get.return_value = { - str(project1.id): {"automated_run_stopping_point": "open_pr"}, - str(project2.id): {"automated_run_stopping_point": "code_changes"}, + str(project.id): {"automated_run_stopping_point": "open_pr"}, } configure_seer_for_existing_org(organization_id=self.organization.id) - # bulk_set should not be called since both projects are skipped mock_bulk_set.assert_not_called() + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_skips_project_with_valid_stopping_point_and_existing_handoff( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project is skipped when it has a valid stopping point and an existing handoff configured.""" + project = self.create_project(organization=self.organization) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + + mock_bulk_get.return_value = { + str(project.id): { + "automated_run_stopping_point": "code_changes", + "automation_handoff": { + "handoff_point": "root_cause", + "target": "claude_code_agent", + "integration_id": 99, + "auto_create_pr": False, + }, + }, + } + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_not_called() + + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_project_with_valid_stopping_point_gets_handoff_from_org_defaults( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project with valid stopping point but no handoff gets org default handoff applied. + Existing stopping point is preserved.""" + project = self.create_project(organization=self.organization) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + self.organization.update_option("sentry:auto_open_prs", True) + + mock_bulk_get.return_value = { + str(project.id): {"automated_run_stopping_point": "open_pr"}, + } + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_called_once() + prefs = mock_bulk_set.call_args[0][1] + prefs_by_project = {p["project_id"]: p for p in prefs} + assert prefs_by_project[project.id]["automated_run_stopping_point"] == "open_pr" + assert prefs_by_project[project.id]["automation_handoff"] == { + "handoff_point": "root_cause", + "target": "cursor_background_agent", + "integration_id": 42, + "auto_create_pr": True, + } + + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_project_with_invalid_stopping_point_gets_org_default_stopping_point( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project with unrecognized stopping point gets org default stopping point applied. + Existing handoff (if any) is preserved.""" + project = self.create_project(organization=self.organization) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + + existing_handoff = { + "handoff_point": "root_cause", + "target": "claude_code_agent", + "integration_id": 99, + "auto_create_pr": False, + } + mock_bulk_get.return_value = { + str(project.id): { + "automated_run_stopping_point": "root_cause", + "automation_handoff": existing_handoff, + }, + } + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_called_once() + prefs = mock_bulk_set.call_args[0][1] + prefs_by_project = {p["project_id"]: p for p in prefs} + assert ( + prefs_by_project[project.id]["automated_run_stopping_point"] + == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + assert prefs_by_project[project.id]["automation_handoff"] == existing_handoff + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") def test_raises_on_bulk_get_api_failure(self, mock_bulk_get: MagicMock) -> None: """Test that task raises on bulk GET API failure to trigger retry."""