diff --git a/backend/analytics_server/mhq/api/incidents.py b/backend/analytics_server/mhq/api/incidents.py index 2821dc484..bfb05cc7f 100644 --- a/backend/analytics_server/mhq/api/incidents.py +++ b/backend/analytics_server/mhq/api/incidents.py @@ -1,5 +1,5 @@ import json -from typing import Dict, List +from typing import Dict, List, Optional as typeOptional from datetime import datetime @@ -23,10 +23,10 @@ adapt_mean_time_to_recovery_metrics, ) from mhq.store.models.incidents import Incident - from mhq.api.request_utils import coerce_workflow_filter, queryschema from mhq.service.query_validator import get_query_validator + app = Blueprint("incidents", __name__) @@ -36,19 +36,29 @@ { Required("from_time"): All(str, Coerce(datetime.fromisoformat)), Required("to_time"): All(str, Coerce(datetime.fromisoformat)), + Optional("pr_filter"): All(str, Coerce(json.loads)), } ), ) -def get_resolved_incidents(team_id: str, from_time: datetime, to_time: datetime): +def get_resolved_incidents( + team_id: str, + from_time: datetime, + to_time: datetime, + pr_filter: typeOptional[Dict] = None, +): query_validator = get_query_validator() interval = query_validator.interval_validator(from_time, to_time) query_validator.team_validator(team_id) + pr_filter: PRFilter = apply_pr_filter( + pr_filter, EntityType.TEAM, team_id, [SettingType.EXCLUDED_PRS_SETTING] + ) + incident_service = get_incident_service() resolved_incidents: List[Incident] = incident_service.get_resolved_team_incidents( - team_id, interval + team_id, interval, pr_filter ) # ToDo: Generate a user map @@ -71,7 +81,7 @@ def get_deployments_with_related_incidents( team_id: str, from_time: datetime, to_time: datetime, - pr_filter: dict = None, + pr_filter: typeOptional[Dict] = None, workflow_filter: WorkflowFilter = None, ): query_validator = get_query_validator() @@ -90,7 +100,9 @@ def get_deployments_with_related_incidents( incident_service = get_incident_service() - incidents: List[Incident] = incident_service.get_team_incidents(team_id, interval) + incidents: List[Incident] = incident_service.get_team_incidents( + team_id, interval, pr_filter + ) deployment_incidents_map: Dict[Deployment, List[Incident]] = ( incident_service.get_deployment_incidents_map(deployments, incidents) @@ -112,18 +124,28 @@ def get_deployments_with_related_incidents( { Required("from_time"): All(str, Coerce(datetime.fromisoformat)), Required("to_time"): All(str, Coerce(datetime.fromisoformat)), + Optional("pr_filter"): All(str, Coerce(json.loads)), } ), ) -def get_team_mttr(team_id: str, from_time: datetime, to_time: datetime): +def get_team_mttr( + team_id: str, + from_time: datetime, + to_time: datetime, + pr_filter: typeOptional[Dict] = None, +): query_validator = get_query_validator() interval = query_validator.interval_validator(from_time, to_time) query_validator.team_validator(team_id) + pr_filter: PRFilter = apply_pr_filter( + pr_filter, EntityType.TEAM, team_id, [SettingType.EXCLUDED_PRS_SETTING] + ) + incident_service = get_incident_service() team_mean_time_to_recovery_metrics = ( - incident_service.get_team_mean_time_to_recovery(team_id, interval) + incident_service.get_team_mean_time_to_recovery(team_id, interval, pr_filter) ) return adapt_mean_time_to_recovery_metrics(team_mean_time_to_recovery_metrics) @@ -135,18 +157,30 @@ def get_team_mttr(team_id: str, from_time: datetime, to_time: datetime): { Required("from_time"): All(str, Coerce(datetime.fromisoformat)), Required("to_time"): All(str, Coerce(datetime.fromisoformat)), + Optional("pr_filter"): All(str, Coerce(json.loads)), } ), ) -def get_team_mttr_trends(team_id: str, from_time: datetime, to_time: datetime): +def get_team_mttr_trends( + team_id: str, + from_time: datetime, + to_time: datetime, + pr_filter: typeOptional[Dict] = None, +): query_validator = get_query_validator() interval = query_validator.interval_validator(from_time, to_time) query_validator.team_validator(team_id) + pr_filter: PRFilter = apply_pr_filter( + pr_filter, EntityType.TEAM, team_id, [SettingType.EXCLUDED_PRS_SETTING] + ) + incident_service = get_incident_service() weekly_mean_time_to_recovery_metrics = ( - incident_service.get_team_mean_time_to_recovery_trends(team_id, interval) + incident_service.get_team_mean_time_to_recovery_trends( + team_id, interval, pr_filter + ) ) return { @@ -172,7 +206,7 @@ def get_team_cfr( team_id: str, from_time: datetime, to_time: datetime, - pr_filter: dict = None, + pr_filter: typeOptional[Dict] = None, workflow_filter: WorkflowFilter = None, ): @@ -192,7 +226,9 @@ def get_team_cfr( incident_service = get_incident_service() - incidents: List[Incident] = incident_service.get_team_incidents(team_id, interval) + incidents: List[Incident] = incident_service.get_team_incidents( + team_id, interval, pr_filter + ) team_change_failure_rate: ChangeFailureRateMetrics = ( incident_service.get_change_failure_rate_metrics(deployments, incidents) @@ -216,7 +252,7 @@ def get_team_cfr_trends( team_id: str, from_time: datetime, to_time: datetime, - pr_filter: dict = None, + pr_filter: typeOptional[Dict] = None, workflow_filter: WorkflowFilter = None, ): @@ -236,7 +272,9 @@ def get_team_cfr_trends( incident_service = get_incident_service() - incidents: List[Incident] = incident_service.get_team_incidents(team_id, interval) + incidents: List[Incident] = incident_service.get_team_incidents( + team_id, interval, pr_filter + ) team_weekly_change_failure_rate: Dict[datetime, ChangeFailureRateMetrics] = ( incident_service.get_weekly_change_failure_rate( diff --git a/backend/analytics_server/mhq/api/resources/settings_resource.py b/backend/analytics_server/mhq/api/resources/settings_resource.py index 192ea2ed2..9e95e693d 100644 --- a/backend/analytics_server/mhq/api/resources/settings_resource.py +++ b/backend/analytics_server/mhq/api/resources/settings_resource.py @@ -5,6 +5,7 @@ ExcludedPRsSetting, IncidentTypesSetting, IncidentSourcesSetting, + IncidentPRsSetting, ) from mhq.store.models import EntityType @@ -55,6 +56,12 @@ def _add_setting_data(config_settings: ConfigurationSettings, response): "default_sync_days": config_settings.specific_settings.default_sync_days } + if isinstance(config_settings.specific_settings, IncidentPRsSetting): + response["setting"] = { + "include_revert_prs": config_settings.specific_settings.include_revert_prs, + "filters": config_settings.specific_settings.filters, + } + # ADD NEW API ADAPTER HERE return response diff --git a/backend/analytics_server/mhq/service/code/pr_filter.py b/backend/analytics_server/mhq/service/code/pr_filter.py index d0aeffec7..d3a9d4f7c 100644 --- a/backend/analytics_server/mhq/service/code/pr_filter.py +++ b/backend/analytics_server/mhq/service/code/pr_filter.py @@ -1,7 +1,7 @@ from typing import List, Dict, Any from mhq.service.settings.configuration_settings import get_settings_service -from mhq.service.settings.models import ExcludedPRsSetting +from mhq.service.settings.models import ExcludedPRsSetting, IncidentPRsSetting from mhq.store.models.code import PRFilter from mhq.store.models.settings.configuration_settings import SettingType from mhq.store.models.settings.enums import EntityType @@ -103,6 +103,8 @@ def apply(self) -> PRFilter: ) if setting_type == SettingType.EXCLUDED_PRS_SETTING: self._apply_excluded_pr_ids_setting(setting=setting) + if setting_type == SettingType.INCIDENT_PRS_SETTING: + self._apply_incident_prs_setting(setting=setting) return self.pr_filter @@ -111,3 +113,8 @@ def _apply_excluded_pr_ids_setting(self, setting: ExcludedPRsSetting): self.pr_filter.excluded_pr_ids = ( self.pr_filter.excluded_pr_ids or [] ) + setting.excluded_pr_ids + + def _apply_incident_prs_setting(self, setting: IncidentPRsSetting): + self.pr_filter.incident_pr_filters = ( + self.pr_filter.incident_pr_filters or [] + ) + setting.filters diff --git a/backend/analytics_server/mhq/service/incidents/incident_filter.py b/backend/analytics_server/mhq/service/incidents/incident_filter.py index f2bc8c7a2..6e7da8502 100644 --- a/backend/analytics_server/mhq/service/incidents/incident_filter.py +++ b/backend/analytics_server/mhq/service/incidents/incident_filter.py @@ -4,8 +4,10 @@ get_settings_service, IncidentSettings, IncidentTypesSetting, + IncidentPRsSetting, ) from mhq.store.models.incidents import IncidentFilter +from mhq.store.models.incidents.enums import IncidentType from mhq.store.models.settings import EntityType @@ -106,4 +108,19 @@ def __incident_type_setting(self) -> List[str]: incident_types = [] if setting and isinstance(setting, IncidentTypesSetting): incident_types = setting.incident_types + + if SettingType.INCIDENT_PRS_SETTING in self.setting_types: + incident_prs_setting: Optional[IncidentPRsSetting] = ( + self.setting_type_to_settings_map.get(SettingType.INCIDENT_PRS_SETTING) + ) + if ( + isinstance(incident_prs_setting, IncidentPRsSetting) + and not incident_prs_setting.include_revert_prs + ): + incident_types = [ + incident_type + for incident_type in incident_types + if incident_type != IncidentType.REVERT_PR + ] + return incident_types diff --git a/backend/analytics_server/mhq/service/incidents/incidents.py b/backend/analytics_server/mhq/service/incidents/incidents.py index 20b89e394..78fe9d11d 100644 --- a/backend/analytics_server/mhq/service/incidents/incidents.py +++ b/backend/analytics_server/mhq/service/incidents/incidents.py @@ -1,6 +1,11 @@ from collections import defaultdict from datetime import datetime -from typing import List, Dict, Tuple +from typing import List, Dict, Tuple, Optional +import re +from mhq.service.settings.models import IncidentPRsSetting +from mhq.store.models.code.filter import PRFilter +from mhq.store.models.code.pull_requests import PullRequest +from mhq.store.repos.code import CodeRepoService from mhq.service.incidents.models.mean_time_to_recovery import ( ChangeFailureRateMetrics, MeanTimeToRecoveryMetrics, @@ -14,14 +19,18 @@ fill_missing_week_buckets, generate_expanded_buckets, get_given_weeks_monday, + time_now, ) - +from mhq.utils.regex import check_regex from mhq.store.models.incidents import Incident from mhq.service.settings.configuration_settings import ( SettingsService, get_settings_service, ) from mhq.store.repos.incidents import IncidentsRepoService +from mhq.service.incidents.models.adapter import adaptIncidentPR +from mhq.service.code.pr_filter import apply_pr_filter +from dataclasses import asdict class IncidentService: @@ -29,12 +38,14 @@ def __init__( self, incidents_repo_service: IncidentsRepoService, settings_service: SettingsService, + code_repo_service: CodeRepoService, ): self._incidents_repo_service = incidents_repo_service self._settings_service = settings_service + self._code_repo_service = code_repo_service def get_resolved_team_incidents( - self, team_id: str, interval: Interval + self, team_id: str, interval: Interval, pr_filter: PRFilter ) -> List[Incident]: incident_filter: IncidentFilter = apply_incident_filter( entity_type=EntityType.TEAM, @@ -42,24 +53,130 @@ def get_resolved_team_incidents( setting_types=[ SettingType.INCIDENT_SETTING, SettingType.INCIDENT_TYPES_SETTING, + SettingType.INCIDENT_PRS_SETTING, ], ) - return self._incidents_repo_service.get_resolved_team_incidents( + resolved_incidents = self._incidents_repo_service.get_resolved_team_incidents( team_id, interval, incident_filter ) + resolved_pr_incidents = self.get_team_pr_incidents(team_id, interval, pr_filter) + + total_incidents = resolved_incidents + resolved_pr_incidents + total_incidents = sorted(total_incidents, key=lambda x: x.creation_date) + + return list({incident.key: incident for incident in total_incidents}.values()) - def get_team_incidents(self, team_id: str, interval: Interval) -> List[Incident]: + def get_team_incidents( + self, team_id: str, interval: Interval, pr_filter: PRFilter + ) -> List[Incident]: incident_filter: IncidentFilter = apply_incident_filter( entity_type=EntityType.TEAM, entity_id=team_id, setting_types=[ SettingType.INCIDENT_SETTING, SettingType.INCIDENT_TYPES_SETTING, + SettingType.INCIDENT_PRS_SETTING, ], ) - return self._incidents_repo_service.get_team_incidents( + incidents = self._incidents_repo_service.get_team_incidents( team_id, interval, incident_filter ) + pr_incidents: List[Incident] = self.get_team_pr_incidents( + team_id, interval, pr_filter + ) + + total_incidents = incidents + pr_incidents + total_incidents = sorted(total_incidents, key=lambda x: x.creation_date) + + return list({incident.key: incident for incident in total_incidents}.values()) + + def get_team_pr_incidents( + self, team_id: str, interval: Interval, pr_filter: PRFilter + ) -> List[Incident]: + + settings = self._settings_service.get_settings( + setting_type=SettingType.INCIDENT_PRS_SETTING, + entity_type=EntityType.TEAM, + entity_id=team_id, + ) + + if not settings: + return [] + + incident_prs_setting: IncidentPRsSetting = settings.specific_settings + + if not incident_prs_setting.filters: + return [] + + team_repo_ids = list( + tr.org_repo_id + for tr in self._code_repo_service.get_active_team_repos_by_team_id(team_id) + ) + resolution_prs_interval = Interval( + from_time=interval.from_time, to_time=time_now() + ) + resolution_prs_filter: PRFilter = apply_pr_filter( + pr_filter=asdict(pr_filter), + entity_type=EntityType.TEAM, + entity_id=team_id, + setting_types=[ + SettingType.EXCLUDED_PRS_SETTING, + SettingType.INCIDENT_PRS_SETTING, + ], + ) + + resolution_prs = self._code_repo_service.get_prs_merged_in_interval( + repo_ids=team_repo_ids, + interval=resolution_prs_interval, + pr_filter=resolution_prs_filter, + ) + + pr_numbers: List[str] = [] + pr_incidents: List[Incident] = [] + repo_id_to_pr_number_to_pr_map: Dict[str, Dict[str, PullRequest]] = defaultdict( + dict + ) + + for pr in resolution_prs: + for filter in incident_prs_setting.filters: + incident_pr_number = self._extract_pr_number_from_regex( + getattr(pr, filter["field"]), filter["value"] + ) + + if incident_pr_number: + pr_numbers.append(incident_pr_number) + repo_id_to_pr_number_to_pr_map[str(pr.repo_id)][ + incident_pr_number + ] = pr + break + + prs = self._code_repo_service.get_prs_merged_in_interval_by_numbers( + repo_ids=list(repo_id_to_pr_number_to_pr_map.keys()), + interval=interval, + numbers=pr_numbers, + pr_filter=pr_filter, + ) + + for pr in prs: + if pr.number not in repo_id_to_pr_number_to_pr_map[str(pr.repo_id)]: + continue + + resolution_pr = repo_id_to_pr_number_to_pr_map[str(pr.repo_id)][pr.number] + adapted_pr_incident = adaptIncidentPR(pr, resolution_pr) + pr_incidents.append(adapted_pr_incident) + + return pr_incidents + + def _extract_pr_number_from_regex( + self, text: str, regex_pattern: str + ) -> Optional[str]: + if not text or not regex_pattern: + return None + if check_regex(regex_pattern): + match = re.search(regex_pattern, text) + if match and len(match.groups()) >= 1: + return match.group(1) + return None def get_deployment_incidents_map( self, deployments: List[Deployment], incidents: List[Incident] @@ -100,18 +217,22 @@ def get_deployment_incidents_map( return deployment_incidents_map def get_team_mean_time_to_recovery( - self, team_id: str, interval: Interval + self, team_id: str, interval: Interval, pr_filter: PRFilter ) -> MeanTimeToRecoveryMetrics: - resolved_team_incidents = self.get_resolved_team_incidents(team_id, interval) + resolved_team_incidents = self.get_resolved_team_incidents( + team_id, interval, pr_filter + ) return self._get_incidents_mean_time_to_recovery(resolved_team_incidents) def get_team_mean_time_to_recovery_trends( - self, team_id: str, interval: Interval + self, team_id: str, interval: Interval, pr_filter: PRFilter ) -> MeanTimeToRecoveryMetrics: - resolved_team_incidents = self.get_resolved_team_incidents(team_id, interval) + resolved_team_incidents = self.get_resolved_team_incidents( + team_id, interval, pr_filter + ) return self._get_incidents_mean_time_to_recovery_trends( resolved_team_incidents, interval @@ -218,4 +339,6 @@ def _get_incidents_mean_time_to_recovery_trends( def get_incident_service(): - return IncidentService(IncidentsRepoService(), get_settings_service()) + return IncidentService( + IncidentsRepoService(), get_settings_service(), CodeRepoService() + ) diff --git a/backend/analytics_server/mhq/service/incidents/models/adapter.py b/backend/analytics_server/mhq/service/incidents/models/adapter.py new file mode 100644 index 000000000..db2ac6372 --- /dev/null +++ b/backend/analytics_server/mhq/service/incidents/models/adapter.py @@ -0,0 +1,22 @@ +from mhq.store.models.incidents import Incident +from mhq.store.models.code.pull_requests import PullRequest +from mhq.store.models.incidents.enums import IncidentStatus, IncidentType + + +def adaptIncidentPR(incident_pr: PullRequest, resolution_pr: PullRequest) -> Incident: + return Incident( + id=incident_pr.id, + provider=incident_pr.provider, + key=str(incident_pr.id), + title=incident_pr.title, + incident_number=int(incident_pr.number), + status=IncidentStatus.RESOLVED.value, + creation_date=incident_pr.state_changed_at, + acknowledged_date=resolution_pr.created_at, + resolved_date=resolution_pr.state_changed_at, + assigned_to=resolution_pr.author, + assignees=[resolution_pr.author], + url=incident_pr.url, + meta={}, + incident_type=IncidentType.REVERT_PR, + ) diff --git a/backend/analytics_server/mhq/service/settings/configuration_settings.py b/backend/analytics_server/mhq/service/settings/configuration_settings.py index 41975312d..14d046619 100644 --- a/backend/analytics_server/mhq/service/settings/configuration_settings.py +++ b/backend/analytics_server/mhq/service/settings/configuration_settings.py @@ -9,6 +9,7 @@ IncidentSettings, IncidentSourcesSetting, IncidentTypesSetting, + IncidentPRsSetting, ) from mhq.store.models.core.users import Users from mhq.store.models.incidents import IncidentSource, IncidentType @@ -66,6 +67,12 @@ def _adapt_default_sync_days_setting_from_setting_data(self, data: Dict[str, any default_sync_days=data.get("default_sync_days", None) ) + def _adapt_incident_prs_setting_from_setting_data(self, data: Dict[str, any]): + return IncidentPRsSetting( + include_revert_prs=data.get("include_revert_prs", True), + filters=data.get("filters", []), + ) + # ADD NEW DICT TO DATACLASS ADAPTERS HERE def _handle_config_setting_from_db_setting( @@ -88,6 +95,9 @@ def _handle_config_setting_from_db_setting( if setting_type == SettingType.DEFAULT_SYNC_DAYS_SETTING: return self._adapt_default_sync_days_setting_from_setting_data(setting_data) + if setting_type == SettingType.INCIDENT_PRS_SETTING: + return self._adapt_incident_prs_setting_from_setting_data(setting_data) + # ADD NEW HANDLE FROM DB SETTINGS HERE raise Exception(f"Invalid Setting Type: {setting_type}") @@ -182,6 +192,12 @@ def _adapt_default_sync_days_setting_from_json(self, data: Dict[str, any]): default_sync_days=data.get("default_sync_days", None) ) + def _adapt_incident_prs_setting_from_json(self, data: Dict[str, any]): + return IncidentPRsSetting( + include_revert_prs=data.get("include_revert_prs", True), + filters=data.get("filters", []), + ) + # ADD NEW DICT TO API ADAPTERS HERE def _handle_config_setting_from_json_data( @@ -204,6 +220,9 @@ def _handle_config_setting_from_json_data( if setting_type == SettingType.DEFAULT_SYNC_DAYS_SETTING: return self._adapt_default_sync_days_setting_from_json(setting_data) + if setting_type == SettingType.INCIDENT_PRS_SETTING: + return self._adapt_incident_prs_setting_from_json(setting_data) + # ADD NEW HANDLE FROM JSON DATA HERE raise Exception(f"Invalid Setting Type: {setting_type}") @@ -242,6 +261,14 @@ def _adapt_default_sync_days_setting_json_data( ): return {"default_sync_days": specific_setting.default_sync_days} + def _adapt_incident_prs_setting_json_data( + self, specific_setting: IncidentPRsSetting + ) -> Dict: + return { + "include_revert_prs": specific_setting.include_revert_prs, + "filters": specific_setting.filters, + } + # ADD NEW DATACLASS TO JSON DATA ADAPTERS HERE def _handle_config_setting_to_db_setting( @@ -273,6 +300,11 @@ def _handle_config_setting_to_db_setting( ): return self._adapt_default_sync_days_setting_json_data(specific_setting) + if setting_type == SettingType.INCIDENT_PRS_SETTING and isinstance( + specific_setting, IncidentPRsSetting + ): + return self._adapt_incident_prs_setting_json_data(specific_setting) + # ADD NEW HANDLE TO DB SETTINGS HERE raise Exception(f"Invalid Setting Type: {setting_type}") diff --git a/backend/analytics_server/mhq/service/settings/default_settings_data.py b/backend/analytics_server/mhq/service/settings/default_settings_data.py index 8d133b1c5..0e151a707 100644 --- a/backend/analytics_server/mhq/service/settings/default_settings_data.py +++ b/backend/analytics_server/mhq/service/settings/default_settings_data.py @@ -29,6 +29,12 @@ def get_default_setting_data(setting_type: SettingType): if setting_type == SettingType.DEFAULT_SYNC_DAYS_SETTING: return {"default_sync_days": 31} + if setting_type == SettingType.INCIDENT_PRS_SETTING: + return { + "include_revert_prs": True, + "filters": [], + } + # ADD NEW DEFAULT SETTING HERE raise Exception(f"Invalid Setting Type: {setting_type}") diff --git a/backend/analytics_server/mhq/service/settings/models.py b/backend/analytics_server/mhq/service/settings/models.py index 8522d79b8..d568cbea9 100644 --- a/backend/analytics_server/mhq/service/settings/models.py +++ b/backend/analytics_server/mhq/service/settings/models.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from datetime import datetime -from typing import List +from typing import List, TypedDict, Literal from mhq.store.models import EntityType from mhq.store.models.incidents.enums import IncidentSource, IncidentType @@ -46,6 +46,17 @@ class DefaultSyncDaysSetting(BaseSetting): default_sync_days: int +class IncidentPRFilter(TypedDict): + field: Literal["title", "head_branch"] + value: str + + +@dataclass +class IncidentPRsSetting(BaseSetting): + include_revert_prs: bool + filters: List[IncidentPRFilter] + + # ADD NEW SETTING CLASS HERE # Sample Future Settings diff --git a/backend/analytics_server/mhq/service/settings/setting_type_validator.py b/backend/analytics_server/mhq/service/settings/setting_type_validator.py index 008e4a9fa..7d71ba7ee 100644 --- a/backend/analytics_server/mhq/service/settings/setting_type_validator.py +++ b/backend/analytics_server/mhq/service/settings/setting_type_validator.py @@ -19,6 +19,9 @@ def settings_type_validator(setting_type: str): if setting_type == SettingType.DEFAULT_SYNC_DAYS_SETTING.value: return SettingType.DEFAULT_SYNC_DAYS_SETTING + if setting_type == SettingType.INCIDENT_PRS_SETTING.value: + return SettingType.INCIDENT_PRS_SETTING + # ADD NEW VALIDATOR HERE raise BadRequest(f"Invalid Setting Type: {setting_type}") diff --git a/backend/analytics_server/mhq/store/models/code/filter.py b/backend/analytics_server/mhq/store/models/code/filter.py index 33c040afa..c39357ec1 100644 --- a/backend/analytics_server/mhq/store/models/code/filter.py +++ b/backend/analytics_server/mhq/store/models/code/filter.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, Dict +from typing import List, Dict, Optional from sqlalchemy import and_, or_ @@ -13,6 +13,7 @@ class PRFilter: repo_filters: Dict[str, Dict] = None excluded_pr_ids: List[str] = None max_cycle_time: int = None + incident_pr_filters: Optional[List[Dict]] = None class RepoFilter: def __init__(self, repo_id: str, repo_filters=None): @@ -85,11 +86,20 @@ def _include_prs_below_max_cycle_time(): PullRequest.cycle_time < self.max_cycle_time, ) + def _incident_pr_filters_query(): + if not self.incident_pr_filters: + return None + return or_( + getattr(PullRequest, filter["field"]).op("~")(filter["value"]) + for filter in self.incident_pr_filters + ) + conditions = { "base_branches": _base_branch_query(), "repo_filters": _repo_filters_query(), "excluded_pr_ids": _excluded_pr_ids_query(), "max_cycle_time": _include_prs_below_max_cycle_time(), + "incident_pr_filters": _incident_pr_filters_query(), } return [ conditions[x] diff --git a/backend/analytics_server/mhq/store/models/settings/configuration_settings.py b/backend/analytics_server/mhq/store/models/settings/configuration_settings.py index 4f81eae72..e33c14634 100644 --- a/backend/analytics_server/mhq/store/models/settings/configuration_settings.py +++ b/backend/analytics_server/mhq/store/models/settings/configuration_settings.py @@ -17,6 +17,7 @@ class SettingType(Enum): INCIDENT_SOURCES_SETTING = "INCIDENT_SOURCES_SETTING" EXCLUDED_PRS_SETTING = "EXCLUDED_PRS_SETTING" DEFAULT_SYNC_DAYS_SETTING = "DEFAULT_SYNC_DAYS_SETTING" + INCIDENT_PRS_SETTING = "INCIDENT_PRS_SETTING" # ADD NEW SETTING TYPE ENUM HERE diff --git a/backend/analytics_server/mhq/store/repos/code.py b/backend/analytics_server/mhq/store/repos/code.py index 115ad6755..310be35c3 100644 --- a/backend/analytics_server/mhq/store/repos/code.py +++ b/backend/analytics_server/mhq/store/repos/code.py @@ -307,6 +307,25 @@ def get_prs_merged_in_interval( return query.all() + @rollback_on_exc + def get_prs_merged_in_interval_by_numbers( + self, + repo_ids: List[str], + interval: Interval, + numbers: List[str], + pr_filter: PRFilter = None, + ) -> List[PullRequest]: + query = self._db.session.query(PullRequest).options(defer(PullRequest.data)) + + query = self._filter_prs_by_repo_ids(query, repo_ids) + query = self._filter_prs_merged_in_interval(query, interval) + query = self._filter_prs(query, pr_filter) + query = query.filter(PullRequest.number.in_(numbers)) + + query = query.order_by(PullRequest.state_changed_at.asc()) + + return query.all() + @rollback_on_exc def get_pull_request_by_id(self, pr_id: str) -> PullRequest: return ( diff --git a/backend/analytics_server/tests/service/Incidents/test_incident_types_setting.py b/backend/analytics_server/tests/service/Incidents/test_incident_types_setting.py new file mode 100644 index 000000000..4186e8b7a --- /dev/null +++ b/backend/analytics_server/tests/service/Incidents/test_incident_types_setting.py @@ -0,0 +1,134 @@ +from mhq.service.incidents.incident_filter import ConfigurationsIncidentFilterProcessor +from mhq.store.models.incidents import IncidentFilter +from mhq.store.models.settings.configuration_settings import SettingType +from mhq.service.settings.configuration_settings import ( + IncidentTypesSetting, + IncidentPRsSetting, +) +from mhq.store.models.incidents.enums import IncidentType +from mhq.store.models.settings import EntityType + + +def test_get_incident_types_when_only_types_setting_present(): + setting_types = [SettingType.INCIDENT_TYPES_SETTING] + setting_type_to_settings_map = { + SettingType.INCIDENT_TYPES_SETTING: IncidentTypesSetting( + incident_types=[ + IncidentType.INCIDENT, + IncidentType.ALERT, + IncidentType.REVERT_PR, + ] + ) + } + + incident_filter = ConfigurationsIncidentFilterProcessor( + incident_filter=IncidentFilter(), + entity_type=EntityType.TEAM, + entity_id="team_id", + setting_types=setting_types, + setting_type_to_settings_map=setting_type_to_settings_map, + ).apply() + + expected_incident_types = [ + IncidentType.INCIDENT, + IncidentType.ALERT, + IncidentType.REVERT_PR, + ] + + assert incident_filter.incident_types == expected_incident_types + + +def test_get_incident_types_when_types_setting_is_empty(): + setting_types = [SettingType.INCIDENT_TYPES_SETTING] + setting_type_to_settings_map = { + SettingType.INCIDENT_TYPES_SETTING: IncidentTypesSetting(incident_types=[]) + } + + incident_filter = ConfigurationsIncidentFilterProcessor( + incident_filter=IncidentFilter(), + entity_type=EntityType.TEAM, + entity_id="dummy_id", + setting_types=setting_types, + setting_type_to_settings_map=setting_type_to_settings_map, + ).apply() + + assert incident_filter.incident_types == [] + + +def test_get_incident_types_when_only_prs_setting_present_returns_none(): + setting_types = [SettingType.INCIDENT_PRS_SETTING] + incident_prs_setting = IncidentPRsSetting( + include_revert_prs=True, + filters=[], + ) + setting_type_to_settings_map = { + SettingType.INCIDENT_PRS_SETTING: incident_prs_setting + } + + incident_filter = ConfigurationsIncidentFilterProcessor( + incident_filter=IncidentFilter(), + entity_type=EntityType.TEAM, + entity_id="team_id", + setting_types=setting_types, + setting_type_to_settings_map=setting_type_to_settings_map, + ).apply() + + assert incident_filter.incident_types is None + + +def test_get_incident_types_when_both_types_and_prs_settings_present_and_includes_revert_prs(): + setting_types = [ + SettingType.INCIDENT_TYPES_SETTING, + SettingType.INCIDENT_PRS_SETTING, + ] + incident_prs_setting = IncidentPRsSetting( + include_revert_prs=True, + filters=[], + ) + setting_type_to_settings_map = { + SettingType.INCIDENT_TYPES_SETTING: IncidentTypesSetting( + incident_types=[IncidentType.INCIDENT, IncidentType.REVERT_PR] + ), + SettingType.INCIDENT_PRS_SETTING: incident_prs_setting, + } + + incident_filter = ConfigurationsIncidentFilterProcessor( + incident_filter=IncidentFilter(), + entity_type=EntityType.TEAM, + entity_id="team_id", + setting_types=setting_types, + setting_type_to_settings_map=setting_type_to_settings_map, + ).apply() + + expected_incident_types = [IncidentType.INCIDENT, IncidentType.REVERT_PR] + + assert incident_filter.incident_types == expected_incident_types + + +def test_get_incident_types_when_both_settings_present_and_not_includes_revert_prs(): + setting_types = [ + SettingType.INCIDENT_TYPES_SETTING, + SettingType.INCIDENT_PRS_SETTING, + ] + incident_prs_setting = IncidentPRsSetting( + include_revert_prs=False, + filters=[], + ) + setting_type_to_settings_map = { + SettingType.INCIDENT_TYPES_SETTING: IncidentTypesSetting( + incident_types=[IncidentType.INCIDENT, IncidentType.REVERT_PR] + ), + SettingType.INCIDENT_PRS_SETTING: incident_prs_setting, + } + + incident_filter = ConfigurationsIncidentFilterProcessor( + incident_filter=IncidentFilter(), + entity_type=EntityType.TEAM, + entity_id="team_id", + setting_types=setting_types, + setting_type_to_settings_map=setting_type_to_settings_map, + ).apply() + + expected_incident_types = [IncidentType.INCIDENT] + + assert incident_filter.incident_types == expected_incident_types diff --git a/backend/analytics_server/tests/service/Incidents/test_mean_time_to_recovery.py b/backend/analytics_server/tests/service/Incidents/test_mean_time_to_recovery.py index e09a711ae..22dc0671b 100644 --- a/backend/analytics_server/tests/service/Incidents/test_mean_time_to_recovery.py +++ b/backend/analytics_server/tests/service/Incidents/test_mean_time_to_recovery.py @@ -16,7 +16,7 @@ def test_get_incidents_mean_time_to_recovery_for_no_incidents(): - incident_service = IncidentService(None, None) + incident_service = IncidentService(None, None, None) mean_time_to_recovery = incident_service._get_incidents_mean_time_to_recovery([]) assert get_mean_time_to_recovery_metrics(None, 0) == mean_time_to_recovery @@ -24,7 +24,7 @@ def test_get_incidents_mean_time_to_recovery_for_no_incidents(): def test_get_incidents_mean_time_to_recovery_for_incidents(): - incident_service = IncidentService(None, None) + incident_service = IncidentService(None, None, None) incident_1_resolution_time = timedelta(seconds=100) incident_1 = get_incident( diff --git a/backend/analytics_server/tests/service/Incidents/test_team_pr_incidents.py b/backend/analytics_server/tests/service/Incidents/test_team_pr_incidents.py new file mode 100644 index 000000000..4d5fbb2ab --- /dev/null +++ b/backend/analytics_server/tests/service/Incidents/test_team_pr_incidents.py @@ -0,0 +1,236 @@ +from datetime import datetime +from unittest.mock import patch, Mock + +import pytz +from mhq.service.incidents.incidents import IncidentService, get_incident_service +from mhq.utils.time import Interval +from mhq.service.settings.models import IncidentPRsSetting +from mhq.store.models.settings.configuration_settings import SettingType +from mhq.store.models.code.filter import PRFilter +from mhq.store.models.code import TeamRepos, PullRequest +from mhq.service.settings.models import ConfigurationSettings +from mhq.store.models import EntityType +import pytest + + +def mock_interval(): + start_time = datetime(2025, 1, 1, 0, 0, 0, tzinfo=pytz.UTC) + end_time = datetime(2025, 3, 31, 0, 0, 0, tzinfo=pytz.UTC) + return Interval(start_time, end_time) + + +@pytest.fixture(autouse=True) +def mock_apply_pr_filter(): + with patch("mhq.service.incidents.incidents.apply_pr_filter") as mock: + mock.return_value = PRFilter() + yield mock + + +class FakeSettingsService: + def get_settings(self, *args, **kwargs): + filters = [ + { + "field": "head_branch", + "value": "^revert-(\\d+)$", + }, + { + "field": "title", + "value": "^Revert PR #(\\d+).*", + }, + ] + return ConfigurationSettings( + entity_id="team_1", + entity_type=EntityType.TEAM, + specific_settings=IncidentPRsSetting( + include_revert_prs=True, filters=filters + ), + updated_by="user_1", + created_at=datetime(2025, 1, 1, 0, 0, 0, tzinfo=pytz.UTC), + updated_at=datetime(2025, 1, 1, 0, 0, 0, tzinfo=pytz.UTC), + ) + + def get_settings_map(self, *args, **kwargs): + return { + SettingType.INCIDENT_PRS_SETTING: self.get_settings(*args, **kwargs), + } + + +class FakeCodeRepoService: + def __init__(self, resolution_prs, prs_using_numbers): + self._resolution_prs = resolution_prs + self._prs_using_numbers = prs_using_numbers + + def get_active_team_repos_by_team_id(self, *args, **kwargs): + return [ + TeamRepos( + team_id="team_1", + org_repo_id="repo_1", + ), + TeamRepos( + team_id="team_1", + org_repo_id="repo_2", + ), + ] + + def get_prs_merged_in_interval(self, *args, **kwargs): + return self._resolution_prs + + def get_prs_merged_in_interval_by_numbers(self, *args, **kwargs): + return self._prs_using_numbers + + +class FakeIncidentsRepoService: + pass + + +def test_get_team_pr_incidents_no_filters(): + incident_service = get_incident_service() + + mock_settings_service = Mock() + mock_settings_service.get_settings.return_value.specific_settings = ( + IncidentPRsSetting(include_revert_prs=True, filters=[]) + ) + incident_service._settings_service = mock_settings_service + + result = incident_service.get_team_pr_incidents( + "team_1", + mock_interval(), + PRFilter(), + ) + + assert result == [] + + +def test_get_team_pr_incidents_with_filters(): + + resolution_prs = [ + PullRequest( + id="pr_2_of_repo_1", + repo_id="repo_1", + number="2", + head_branch="revert-1", + ), + PullRequest( + id="pr_4_of_repo_1", + repo_id="repo_1", + number="4", + head_branch="branch_4", + title="Revert PR #3 due to some reason", + ), + ] + + prs_using_numbers = [ + PullRequest( + id="pr_1_of_repo_1", repo_id="repo_1", number="1", head_branch="branch_1" + ), + PullRequest( + id="pr_3_of_repo_1", + repo_id="repo_1", + number="3", + head_branch="branch_3", + ), + ] + + incident_service = IncidentService( + FakeIncidentsRepoService(), + FakeSettingsService(), + FakeCodeRepoService(resolution_prs, prs_using_numbers), + ) + + expected_result_keys = [ + "pr_1_of_repo_1", + "pr_3_of_repo_1", + ] + + result = incident_service.get_team_pr_incidents( + "team_1", + mock_interval(), + PRFilter(), + ) + + assert expected_result_keys == [incident.key for incident in result] + + +def test_get_team_pr_incidents_with_multiple_repos_but_no_incidents(): + + resolution_prs = [ + PullRequest( + id="pr_2_of_repo_1", + repo_id="repo_1", + number="2", + head_branch="revert-1", + ), + PullRequest( + id="pr_2_of_repo_2", + repo_id="repo_2", + number="2", + head_branch="revert-1", + ), + ] + + prs_using_numbers = [] + + incident_service = IncidentService( + FakeIncidentsRepoService(), + FakeSettingsService(), + FakeCodeRepoService(resolution_prs, prs_using_numbers), + ) + + expected_result_keys = [] + + result = incident_service.get_team_pr_incidents( + "team_1", + mock_interval(), + PRFilter(), + ) + + assert expected_result_keys == [incident.key for incident in result] + + +def test_get_team_pr_incidents_with_multiple_repos_and_incidents(): + + resolution_prs = [ + PullRequest( + id="pr_2_of_repo_1", repo_id="repo_1", number="2", head_branch="revert-1" + ), + PullRequest( + id="pr_2_of_repo_2", + repo_id="repo_2", + number="2", + head_branch="revert-1", + ), + ] + + prs_using_numbers = [ + PullRequest( + id="pr_1_of_repo_1", + repo_id="repo_1", + number="1", + head_branch="branch_1", + ), + PullRequest( + id="pr_1_of_repo_2", + repo_id="repo_2", + number="1", + head_branch="branch_1", + ), + ] + + incident_service = IncidentService( + FakeIncidentsRepoService(), + FakeSettingsService(), + FakeCodeRepoService(resolution_prs, prs_using_numbers), + ) + + expected_result_keys = [ + "pr_1_of_repo_1", + "pr_1_of_repo_2", + ] + + result = incident_service.get_team_pr_incidents( + "team_1", + mock_interval(), + PRFilter(), + ) + + assert expected_result_keys == [incident.key for incident in result]