|
1 | 1 | import logging |
2 | | -from collections.abc import Iterable |
| 2 | +from collections import defaultdict |
| 3 | +from collections.abc import Callable, Iterable, Mapping |
3 | 4 | from datetime import UTC, datetime |
4 | 5 | from enum import StrEnum |
5 | 6 | from typing import Any, NotRequired, TypedDict |
6 | 7 |
|
7 | 8 | import orjson |
8 | 9 | import pydantic |
| 10 | +import sentry_sdk |
9 | 11 | from django.conf import settings |
10 | 12 | from django.db import router, transaction |
11 | 13 | from pydantic import BaseModel |
12 | 14 | from rest_framework import serializers |
13 | 15 | from urllib3 import BaseHTTPResponse, HTTPConnectionPool |
14 | 16 | from urllib3.util.retry import Retry |
15 | 17 |
|
16 | | -from sentry import features, options, ratelimits |
| 18 | +from sentry import features, options, projectoptions, ratelimits |
17 | 19 | from sentry.constants import ( |
18 | 20 | AUTO_OPEN_PRS_DEFAULT, |
19 | 21 | SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, |
|
25 | 27 | get_sorted_code_mapping_configs, |
26 | 28 | ) |
27 | 29 | from sentry.models.group import Group |
| 30 | +from sentry.models.options.project_option import ProjectOption |
28 | 31 | from sentry.models.organization import Organization |
29 | 32 | from sentry.models.project import Project |
30 | 33 | from sentry.models.repository import Repository |
31 | 34 | from sentry.net.http import connection_from_url |
| 35 | +from sentry.projectoptions.defaults import SEER_PROJECT_PREFERENCE_OPTION_KEYS |
32 | 36 | from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus |
33 | 37 | from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS |
34 | 38 | from sentry.seer.models import ( |
| 39 | + AutofixHandoffPoint, |
35 | 40 | BranchOverride, |
36 | 41 | SeerApiError, |
37 | 42 | SeerApiResponseValidationError, |
| 43 | + SeerAutomationHandoffConfiguration, |
38 | 44 | SeerPermissionError, |
39 | 45 | SeerProjectPreference, |
40 | 46 | SeerRawPreferenceResponse, |
|
44 | 50 | SeerProjectRepository, |
45 | 51 | SeerProjectRepositoryBranchOverride, |
46 | 52 | ) |
47 | | -from sentry.seer.models.seer_api_models import ( |
48 | | - AutofixHandoffPoint, |
49 | | - SeerAutomationHandoffConfiguration, |
50 | | -) |
51 | 53 | from sentry.seer.signed_seer_api import SeerViewerContext, make_signed_seer_api_request |
52 | 54 | from sentry.utils.cache import cache |
53 | 55 | from sentry.utils.outcomes import Outcome, track_outcome |
@@ -674,6 +676,145 @@ def bulk_write_preferences_to_sentry_db( |
674 | 676 | _write_preferences_to_sentry_db(project_preferences) |
675 | 677 |
|
676 | 678 |
|
| 679 | +def build_repo_definition_from_project_repo( |
| 680 | + seer_project_repo: SeerProjectRepository, |
| 681 | +) -> SeerRepoDefinition | None: |
| 682 | + """Build a SeerRepoDefinition from a SeerProjectRepository with its joined Repository. |
| 683 | +
|
| 684 | + Returns None if Repository name is invalid.""" |
| 685 | + repo = seer_project_repo.repository |
| 686 | + repo_name_sections = repo.name.split("/") |
| 687 | + if len(repo_name_sections) < 2: |
| 688 | + sentry_sdk.capture_exception(ValueError(f"Invalid repository name format: {repo.name}")) |
| 689 | + return None |
| 690 | + |
| 691 | + return SeerRepoDefinition( |
| 692 | + repository_id=repo.id, |
| 693 | + organization_id=repo.organization_id, |
| 694 | + integration_id=str(repo.integration_id) if repo.integration_id is not None else None, |
| 695 | + provider=repo.provider or "", |
| 696 | + owner=repo_name_sections[0], |
| 697 | + name="/".join(repo_name_sections[1:]), |
| 698 | + external_id=repo.external_id or "", |
| 699 | + branch_name=seer_project_repo.branch_name, |
| 700 | + instructions=seer_project_repo.instructions, |
| 701 | + branch_overrides=[ |
| 702 | + BranchOverride( |
| 703 | + tag_name=bo.tag_name, |
| 704 | + tag_value=bo.tag_value, |
| 705 | + branch_name=bo.branch_name, |
| 706 | + ) |
| 707 | + for bo in seer_project_repo.branch_overrides.all() |
| 708 | + ], |
| 709 | + ) |
| 710 | + |
| 711 | + |
| 712 | +def _build_automation_handoff( |
| 713 | + get_option: Callable[[str], Any], |
| 714 | +) -> SeerAutomationHandoffConfiguration | None: |
| 715 | + """Build a SeerAutomationHandoffConfiguration from option values, or None if incomplete.""" |
| 716 | + handoff_point = get_option("sentry:seer_automation_handoff_point") |
| 717 | + handoff_target = get_option("sentry:seer_automation_handoff_target") |
| 718 | + handoff_integration_id = get_option("sentry:seer_automation_handoff_integration_id") |
| 719 | + |
| 720 | + if handoff_point is None or handoff_target is None or handoff_integration_id is None: |
| 721 | + return None |
| 722 | + |
| 723 | + return SeerAutomationHandoffConfiguration( |
| 724 | + handoff_point=handoff_point, |
| 725 | + target=handoff_target, |
| 726 | + integration_id=handoff_integration_id, |
| 727 | + auto_create_pr=get_option("sentry:seer_automation_handoff_auto_create_pr"), |
| 728 | + ) |
| 729 | + |
| 730 | + |
| 731 | +def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | None: |
| 732 | + """Read a single project's Seer preferences from Sentry DB. |
| 733 | +
|
| 734 | + For now, should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`.""" |
| 735 | + seer_project_repo_qs = ( |
| 736 | + SeerProjectRepository.objects.filter(project=project) |
| 737 | + .select_related("repository") |
| 738 | + .prefetch_related("branch_overrides") |
| 739 | + ) |
| 740 | + repo_definitions = [ |
| 741 | + repo_def |
| 742 | + for project_repo in seer_project_repo_qs |
| 743 | + if (repo_def := build_repo_definition_from_project_repo(project_repo)) is not None |
| 744 | + ] |
| 745 | + |
| 746 | + has_configured_options = any( |
| 747 | + ProjectOption.objects.isset(project, key) for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS |
| 748 | + ) |
| 749 | + if not repo_definitions and not has_configured_options: |
| 750 | + return None |
| 751 | + |
| 752 | + return SeerProjectPreference( |
| 753 | + organization_id=project.organization_id, |
| 754 | + project_id=project.id, |
| 755 | + repositories=repo_definitions, |
| 756 | + automated_run_stopping_point=project.get_option("sentry:seer_automated_run_stopping_point"), |
| 757 | + automation_handoff=_build_automation_handoff(project.get_option), |
| 758 | + ) |
| 759 | + |
| 760 | + |
| 761 | +def bulk_read_preferences_from_sentry_db( |
| 762 | + organization_id: int, project_ids: list[int] |
| 763 | +) -> dict[int, SeerProjectPreference | None]: |
| 764 | + """Bulk read Seer preferences from Sentry DB. |
| 765 | +
|
| 766 | + For now, should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`.""" |
| 767 | + if not project_ids: |
| 768 | + return {} |
| 769 | + |
| 770 | + projects = list(Project.objects.filter(id__in=project_ids, organization_id=organization_id)) |
| 771 | + |
| 772 | + repo_definitions_by_project: defaultdict[int, list[SeerRepoDefinition]] = defaultdict(list) |
| 773 | + for project_repo in ( |
| 774 | + SeerProjectRepository.objects.filter(project_id__in=project_ids) |
| 775 | + .select_related("repository") |
| 776 | + .prefetch_related("branch_overrides") |
| 777 | + ): |
| 778 | + repo_def = build_repo_definition_from_project_repo(project_repo) |
| 779 | + if repo_def is not None: |
| 780 | + repo_definitions_by_project[project_repo.project_id].append(repo_def) |
| 781 | + |
| 782 | + # get_value_bulk_id returns None for missing options, unlike project.get_option |
| 783 | + # which automatically falls back to the registered well-known key default. |
| 784 | + project_options: dict[str, Mapping[int, Any]] = { |
| 785 | + key: ProjectOption.objects.get_value_bulk_id(project_ids, key) |
| 786 | + for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS |
| 787 | + } |
| 788 | + |
| 789 | + result: dict[int, SeerProjectPreference | None] = {} |
| 790 | + for project in projects: |
| 791 | + has_configured_options = any( |
| 792 | + project_options[key][project.id] is not None |
| 793 | + for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS |
| 794 | + ) |
| 795 | + if project.id not in repo_definitions_by_project and not has_configured_options: |
| 796 | + result[project.id] = None |
| 797 | + continue |
| 798 | + |
| 799 | + def _get_project_option(key: str) -> Any: |
| 800 | + value = project_options[key][project.id] |
| 801 | + if value is None: |
| 802 | + return projectoptions.get_well_known_default(key, project=project) |
| 803 | + return value |
| 804 | + |
| 805 | + result[project.id] = SeerProjectPreference( |
| 806 | + organization_id=project.organization_id, |
| 807 | + project_id=project.id, |
| 808 | + repositories=repo_definitions_by_project.get(project.id, []), |
| 809 | + automated_run_stopping_point=_get_project_option( |
| 810 | + "sentry:seer_automated_run_stopping_point" |
| 811 | + ), |
| 812 | + automation_handoff=_build_automation_handoff(_get_project_option), |
| 813 | + ) |
| 814 | + |
| 815 | + return result |
| 816 | + |
| 817 | + |
677 | 818 | def set_project_seer_preference(preference: SeerProjectPreference) -> None: |
678 | 819 | """Set Seer project preference for a single project via Seer API.""" |
679 | 820 | response = make_set_project_preference_request( |
|
0 commit comments