jbi/models.py (183 lines of code) (raw):

""" Python Module for Pydantic Models and validation """ import functools import logging import warnings from collections import defaultdict from copy import copy from typing import DefaultDict, Literal, Mapping, Optional from pydantic import ( BaseModel, ConfigDict, Field, RootModel, field_validator, ) from jbi import Operation, steps from jbi.bugzilla.models import Bug, BugId, WebhookEvent logger = logging.getLogger(__name__) JIRA_HOSTNAMES = ("jira", "atlassian") class ActionSteps(BaseModel, frozen=True): """Step functions to run for each type of Bugzilla webhook payload""" new: list[str] = [ "create_issue", "maybe_delete_duplicate", "add_link_to_bugzilla", "add_link_to_jira", "sync_whiteboard_labels", ] existing: list[str] = [ "update_issue_summary", "sync_whiteboard_labels", "add_jira_comments_for_changes", ] comment: list[str] = [ "create_comment", ] attachment: list[str] = [ "create_comment", ] @field_validator("*") @classmethod def validate_steps(cls, function_names: list[str]): """Validate that all configure step functions exist in the steps module""" invalid_functions = [ func_name for func_name in function_names if not hasattr(steps, func_name) ] if invalid_functions: raise ValueError( f"The following functions are not available in the `steps` module: {', '.join(invalid_functions)}" ) # Make sure `maybe_update_resolution` comes after `maybe_update_status`. try: idx_resolution = function_names.index("maybe_update_issue_resolution") idx_status = function_names.index("maybe_update_issue_status") assert idx_resolution > idx_status, ( "Step `maybe_update_resolution` should be put after `maybe_update_issue_status`" ) except ValueError: # One of these 2 steps not listed. pass return function_names class JiraComponents(BaseModel, frozen=True): """Controls how Jira components are set on issues in the `maybe_update_components` step.""" use_bug_component: bool = True use_bug_product: bool = False use_bug_component_with_product_prefix: bool = False set_custom_components: list[str] = [] class ActionParams(BaseModel, frozen=True): """Params passed to Action step functions""" jira_project_key: str steps: ActionSteps = ActionSteps() jira_char_limit: int = 32667 jira_components: JiraComponents = JiraComponents() jira_cf_fx_points_field: str = "customfield_10037" jira_severity_field: str = "customfield_10319" jira_priority_field: str = "priority" jira_resolution_field: str = "resolution" labels_brackets: Literal["yes", "no", "both"] = "no" status_map: dict[str, str] = {} priority_map: dict[str, str] = { "": "(none)", "P1": "P1", "P2": "P2", "P3": "P3", "P4": "Low", "P5": "Lowest", } resolution_map: dict[str, str] = {} severity_map: dict[str, str] = { "": "N/A", "S1": "S1", "S2": "S2", "S3": "S3", "S4": "S4", } cf_fx_points_map: dict[str, int] = { "---": 0, "": 0, "?": 0, "1": 1, "2": 2, "3": 3, "5": 5, "7": 7, "8": 8, "12": 12, "13": 13, "15": 15, } issue_type_map: dict[str, str] = {"task": "Task", "defect": "Bug"} class Action(BaseModel, frozen=True): """ Action is the inner model for each action in the configuration file""" whiteboard_tag: str bugzilla_user_id: int | list[int] | Literal["tbd"] description: str enabled: bool = True parameters: ActionParams @property def jira_project_key(self): """Return the configured project key.""" return self.parameters.jira_project_key class Actions(RootModel): """ Actions is the container model for the list of actions in the configuration file """ root: list[Action] = Field(..., min_length=1) @functools.cached_property def by_tag(self) -> Mapping[str, Action]: """Build mapping of actions by lookup tag.""" return {action.whiteboard_tag: action for action in self.root} def __iter__(self): return iter(self.root) def __len__(self): return len(self.root) def __getitem__(self, item): return self.by_tag[item] def get(self, tag: Optional[str]) -> Optional[Action]: """Lookup actions by whiteboard tag""" return self.by_tag.get(tag.lower()) if tag else None @functools.cached_property def configured_jira_projects_keys(self) -> set[str]: """Return the list of Jira project keys from all configured actions""" return {action.jira_project_key for action in self.root} @field_validator("root") @classmethod def validate_actions(cls, actions: list[Action]): """ Inspect the list of actions: - Validate that lookup tags are uniques - Ensure we haven't exceeded our maximum configured project limit (see error below) - If the action's bugzilla_user_id is "tbd", emit a warning. """ tags = [action.whiteboard_tag.lower() for action in actions] duplicated_tags = [t for i, t in enumerate(tags) if t in tags[:i]] if duplicated_tags: raise ValueError(f"actions have duplicated lookup tags: {duplicated_tags}") if len(tags) > 50: raise ValueError( "The Jira client's `paginated_projects` method assumes we have " "up to 50 projects configured. Adjust that implementation before " "removing this validation check." ) for action in actions: if action.bugzilla_user_id == "tbd": warnings.warn( f"Provide bugzilla_user_id data for `{action.whiteboard_tag}` action." ) assert action.parameters.status_map or ( "maybe_update_issue_status" not in action.parameters.steps.new and "maybe_update_issue_status" not in action.parameters.steps.existing ), "`maybe_update_issue_status` was used without `status_map`" assert action.parameters.resolution_map or ( "maybe_update_issue_resolution" not in action.parameters.steps.new and "maybe_update_issue_resolution" not in action.parameters.steps.existing ), "`maybe_update_issue_resolution` was used without `resolution_map`" return actions model_config = ConfigDict(ignored_types=(functools.cached_property,)) class Context(BaseModel, frozen=True): """Generic log context throughout JBI""" def update(self, **kwargs): """Return a copy with updated fields.""" return self.model_copy(update=kwargs, deep=True) class JiraContext(Context): """Logging context about Jira""" project: str issue: Optional[str] = None labels: Optional[list[str]] = None class RunnerContext(Context, extra="forbid"): """Logging context from runner""" operation: Operation event: WebhookEvent actions: Optional[list[Action]] = None bug: BugId | Bug class ActionContext(Context, extra="forbid"): """Logging context from actions""" action: Action operation: Operation current_step: Optional[str] = None event: WebhookEvent jira: JiraContext bug: Bug extra: dict[str, str] = {} responses_by_step: DefaultDict[str, list] = defaultdict(list) def append_responses(self, *responses): """Shortcut function to add responses to the existing list.""" if not self.current_step: raise ValueError("`current_step` unset in context.") copied = copy(self.responses_by_step) copied[self.current_step].extend(responses) return self.update(responses_by_step=copied)