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