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

import datetime import logging from typing import Any, Optional, TypedDict from urllib.parse import ParseResult, urlparse from pydantic import ( AwareDatetime, BaseModel, TypeAdapter, ValidationError, ValidationInfo, ValidatorFunctionWrapHandler, ) from pydantic.functional_validators import WrapValidator from typing_extensions import Annotated logger = logging.getLogger(__name__) JIRA_HOSTNAMES = ("jira", "atlassian") BugId = TypedDict("BugId", {"id": Optional[int]}) def maybe_add_timezone( v: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ): if isinstance(v, str): try: return handler(v) except ValidationError: return handler(v + "+00:00") assert isinstance(v, datetime.datetime), "must be a datetime here" v = v.replace(tzinfo=datetime.timezone.utc) return v SmartAwareDatetime = Annotated[AwareDatetime, WrapValidator(maybe_add_timezone)] class WebhookUser(BaseModel, frozen=True): """Bugzilla User Object""" id: int login: str real_name: str class WebhookEventChange(BaseModel, frozen=True, coerce_numbers_to_str=True): """Bugzilla Change Object""" field: str removed: str added: str class WebhookEvent(BaseModel, frozen=True): """Bugzilla Event Object""" action: str time: SmartAwareDatetime user: Optional[WebhookUser] = None changes: Optional[list[WebhookEventChange]] = None target: Optional[str] = None routing_key: Optional[str] = None def changed_fields(self) -> list[str]: """Returns the names of changed fields in a bug""" return [c.field for c in self.changes] if self.changes else [] class WebhookComment(BaseModel, frozen=True): """Bugzilla Comment Object""" body: Optional[str] = None id: Optional[int] = None number: Optional[int] = None is_private: Optional[bool] = None creation_time: Optional[SmartAwareDatetime] = None class Bug(BaseModel, frozen=True): """Bugzilla Bug Object""" id: int is_private: Optional[bool] = None type: Optional[str] = None product: Optional[str] = None component: Optional[str] = None whiteboard: Optional[str] = None keywords: Optional[list] = None flags: Optional[list] = None groups: Optional[list] = None status: Optional[str] = None resolution: Optional[str] = None see_also: Optional[list] = None summary: Optional[str] = None severity: Optional[str] = None priority: Optional[str] = None creator: Optional[str] = None assigned_to: Optional[str] = None comment: Optional[WebhookComment] = None # Custom field Firefox for story points cf_fx_points: Optional[str] = None @property def product_component(self) -> str: """Return the component prefixed with the product as show in the Bugzilla UI (eg. ``Core::General``). """ result = self.product + "::" if self.product else "" return result + self.component if self.component else result def is_assigned(self) -> bool: """Return `true` if the bug is assigned to a user.""" return self.assigned_to != "nobody@mozilla.org" def extract_from_see_also(self, project_key): """Extract Jira Issue Key from see_also if jira url present""" if not self.see_also or len(self.see_also) == 0: return None candidates = [] for url in self.see_also: try: parsed_url: ParseResult = urlparse(url=url) host_parts = parsed_url.hostname.split(".") except (ValueError, AttributeError): logger.info( "Bug %s `see_also` is not a URL: %s", self.id, url, extra={ "bug": { "id": self.id, } }, ) continue if any(part in JIRA_HOSTNAMES for part in host_parts): parsed_jira_key = parsed_url.path.rstrip("/").split("/")[-1] if parsed_jira_key: # URL ending with / # Issue keys are like `{project_key}-{number}` if parsed_jira_key.startswith(f"{project_key}-"): return parsed_jira_key # If not obvious, then keep this link as candidate. candidates.append(parsed_jira_key) return candidates[0] if candidates else None class WebhookRequest(BaseModel, frozen=True): """Bugzilla Webhook Request Object""" webhook_id: int webhook_name: str event: WebhookEvent bug: Bug class Comment(BaseModel, frozen=True): """Bugzilla Comment""" id: int text: str is_private: bool creator: str BugzillaComments = TypeAdapter(list[Comment]) class ApiResponse(BaseModel, frozen=True): """Bugzilla Response Object""" faults: Optional[list] = None bugs: Optional[list[Bug]] = None class Webhook(BaseModel, frozen=True): """Bugzilla Webhook""" id: int name: str url: str event: str product: str component: str enabled: bool errors: int # Ignored fields: # creator: str @property def slug(self): """Return readable identifier""" name = self.name.replace(" ", "-").lower() product = self.product.replace(" ", "-").lower() return f"{self.id}-{name}-{product}" class WebhooksResponse(BaseModel, frozen=True): """Bugzilla Webhooks List Response Object""" webhooks: Optional[list[Webhook]] = None