Skip to content

Commit c201432

Browse files
authored
Relocate Bugzilla models to bugzilla namespace (#803)
* Ignore f401 in __init__.py files * Reexport models from bugzilla.__init__ * Make `lookup_action` a function, rather than a bug method * Refactor add_link_to_jira to avoid TYPE_CHECKING import * Remove BugzillaWebhookRequest typehints from tests * Remove redundant `Bugzilla*` names from Bugzilla models
1 parent f3f57d7 commit c201432

File tree

17 files changed

+365
-358
lines changed

17 files changed

+365
-358
lines changed

jbi/bugzilla/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
1-
from .service import BugzillaService as BugzillaService
2-
from .service import get_service as get_service
1+
from .models import (
2+
Bug,
3+
BugId,
4+
WebhookEvent,
5+
WebhookRequest,
6+
)
7+
from .service import BugzillaService, get_service

jbi/bugzilla/client.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44

55
from jbi import environment
66
from jbi.common.instrument import instrument
7-
from jbi.models import (
8-
BugzillaApiResponse,
9-
BugzillaBug,
10-
BugzillaComment,
7+
8+
from .models import (
9+
ApiResponse,
10+
Bug,
1111
BugzillaComments,
12-
BugzillaWebhooksResponse,
12+
Comment,
13+
WebhooksResponse,
1314
)
1415

1516
settings = environment.get_settings()
@@ -67,12 +68,12 @@ def logged_in(self) -> bool:
6768
return "id" in resp
6869

6970
@instrumented_method
70-
def get_bug(self, bugid) -> BugzillaBug:
71+
def get_bug(self, bugid) -> Bug:
7172
"""Retrieve details about the specified bug id."""
7273
# https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#rest-single-bug
7374
url = f"{self.base_url}/rest/bug/{bugid}"
7475
bug_info = self._call("GET", url)
75-
parsed = BugzillaApiResponse.model_validate(bug_info)
76+
parsed = ApiResponse.model_validate(bug_info)
7677
if not parsed.bugs:
7778
raise BugzillaClientError(
7879
f"Unexpected response content from 'GET {url}' (no 'bugs' field)"
@@ -88,7 +89,7 @@ def get_bug(self, bugid) -> BugzillaBug:
8889
return bug
8990

9091
@instrumented_method
91-
def get_comments(self, bugid) -> list[BugzillaComment]:
92+
def get_comments(self, bugid) -> list[Comment]:
9293
"""Retrieve the list of comments of the specified bug id."""
9394
# https://bugzilla.readthedocs.io/en/latest/api/core/v1/comment.html#rest-comments
9495
url = f"{self.base_url}/rest/bug/{bugid}/comment"
@@ -101,12 +102,12 @@ def get_comments(self, bugid) -> list[BugzillaComment]:
101102
return BugzillaComments.validate_python(comments)
102103

103104
@instrumented_method
104-
def update_bug(self, bugid, **fields) -> BugzillaBug:
105+
def update_bug(self, bugid, **fields) -> Bug:
105106
"""Update the specified fields of the specified bug."""
106107
# https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#rest-update-bug
107108
url = f"{self.base_url}/rest/bug/{bugid}"
108109
updated_info = self._call("PUT", url, json=fields)
109-
parsed = BugzillaApiResponse.model_validate(updated_info)
110+
parsed = ApiResponse.model_validate(updated_info)
110111
if not parsed.bugs:
111112
raise BugzillaClientError(
112113
f"Unexpected response content from 'PUT {url}' (no 'bugs' field)"
@@ -118,7 +119,7 @@ def list_webhooks(self):
118119
"""List the currently configured webhooks, including their status."""
119120
url = f"{self.base_url}/rest/webhooks/list"
120121
webhooks_info = self._call("GET", url)
121-
parsed = BugzillaWebhooksResponse.model_validate(webhooks_info)
122+
parsed = WebhooksResponse.model_validate(webhooks_info)
122123
if parsed.webhooks is None:
123124
raise BugzillaClientError(
124125
f"Unexpected response content from 'GET {url}' (no 'webhooks' field)"

jbi/bugzilla/models.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import datetime
2+
import logging
3+
from typing import Optional, TypedDict
4+
from urllib.parse import ParseResult, urlparse
5+
6+
from pydantic import BaseModel, TypeAdapter
7+
8+
logger = logging.getLogger(__name__)
9+
JIRA_HOSTNAMES = ("jira", "atlassian")
10+
11+
BugId = TypedDict("BugId", {"id": Optional[int]})
12+
13+
14+
class WebhookUser(BaseModel, frozen=True):
15+
"""Bugzilla User Object"""
16+
17+
id: int
18+
login: str
19+
real_name: str
20+
21+
22+
class WebhookEventChange(BaseModel, frozen=True, coerce_numbers_to_str=True):
23+
"""Bugzilla Change Object"""
24+
25+
field: str
26+
removed: str
27+
added: str
28+
29+
30+
class WebhookEvent(BaseModel, frozen=True):
31+
"""Bugzilla Event Object"""
32+
33+
action: str
34+
time: Optional[datetime.datetime] = None
35+
user: Optional[WebhookUser] = None
36+
changes: Optional[list[WebhookEventChange]] = None
37+
target: Optional[str] = None
38+
routing_key: Optional[str] = None
39+
40+
def changed_fields(self) -> list[str]:
41+
"""Returns the names of changed fields in a bug"""
42+
43+
return [c.field for c in self.changes] if self.changes else []
44+
45+
46+
class WebhookComment(BaseModel, frozen=True):
47+
"""Bugzilla Comment Object"""
48+
49+
body: Optional[str] = None
50+
id: Optional[int] = None
51+
number: Optional[int] = None
52+
is_private: Optional[bool] = None
53+
creation_time: Optional[datetime.datetime] = None
54+
55+
56+
class Bug(BaseModel, frozen=True):
57+
"""Bugzilla Bug Object"""
58+
59+
id: int
60+
is_private: Optional[bool] = None
61+
type: Optional[str] = None
62+
product: Optional[str] = None
63+
component: Optional[str] = None
64+
whiteboard: Optional[str] = None
65+
keywords: Optional[list] = None
66+
flags: Optional[list] = None
67+
groups: Optional[list] = None
68+
status: Optional[str] = None
69+
resolution: Optional[str] = None
70+
see_also: Optional[list] = None
71+
summary: Optional[str] = None
72+
severity: Optional[str] = None
73+
priority: Optional[str] = None
74+
creator: Optional[str] = None
75+
assigned_to: Optional[str] = None
76+
comment: Optional[WebhookComment] = None
77+
78+
@property
79+
def product_component(self) -> str:
80+
"""Return the component prefixed with the product
81+
as show in the Bugzilla UI (eg. ``Core::General``).
82+
"""
83+
result = self.product + "::" if self.product else ""
84+
return result + self.component if self.component else result
85+
86+
def is_assigned(self) -> bool:
87+
"""Return `true` if the bug is assigned to a user."""
88+
return self.assigned_to != "[email protected]"
89+
90+
def extract_from_see_also(self, project_key):
91+
"""Extract Jira Issue Key from see_also if jira url present"""
92+
if not self.see_also or len(self.see_also) == 0:
93+
return None
94+
95+
candidates = []
96+
for url in self.see_also:
97+
try:
98+
parsed_url: ParseResult = urlparse(url=url)
99+
host_parts = parsed_url.hostname.split(".")
100+
except (ValueError, AttributeError):
101+
logger.debug(
102+
"Bug %s `see_also` is not a URL: %s",
103+
self.id,
104+
url,
105+
extra={
106+
"bug": {
107+
"id": self.id,
108+
}
109+
},
110+
)
111+
continue
112+
113+
if any(part in JIRA_HOSTNAMES for part in host_parts):
114+
parsed_jira_key = parsed_url.path.rstrip("/").split("/")[-1]
115+
if parsed_jira_key: # URL ending with /
116+
# Issue keys are like `{project_key}-{number}`
117+
if parsed_jira_key.startswith(f"{project_key}-"):
118+
return parsed_jira_key
119+
# If not obvious, then keep this link as candidate.
120+
candidates.append(parsed_jira_key)
121+
122+
return candidates[0] if candidates else None
123+
124+
125+
class WebhookRequest(BaseModel, frozen=True):
126+
"""Bugzilla Webhook Request Object"""
127+
128+
webhook_id: int
129+
webhook_name: str
130+
event: WebhookEvent
131+
bug: Bug
132+
133+
134+
class Comment(BaseModel, frozen=True):
135+
"""Bugzilla Comment"""
136+
137+
id: int
138+
text: str
139+
is_private: bool
140+
creator: str
141+
142+
143+
BugzillaComments = TypeAdapter(list[Comment])
144+
145+
146+
class ApiResponse(BaseModel, frozen=True):
147+
"""Bugzilla Response Object"""
148+
149+
faults: Optional[list] = None
150+
bugs: Optional[list[Bug]] = None
151+
152+
153+
class Webhook(BaseModel, frozen=True):
154+
"""Bugzilla Webhook"""
155+
156+
id: int
157+
name: str
158+
url: str
159+
event: str
160+
product: str
161+
component: str
162+
enabled: bool
163+
errors: int
164+
# Ignored fields:
165+
# creator: str
166+
167+
@property
168+
def slug(self):
169+
"""Return readable identifier"""
170+
name = self.name.replace(" ", "-").lower()
171+
product = self.product.replace(" ", "-").lower()
172+
return f"{self.id}-{name}-{product}"
173+
174+
175+
class WebhooksResponse(BaseModel, frozen=True):
176+
"""Bugzilla Webhooks List Response Object"""
177+
178+
webhooks: Optional[list[Webhook]] = None

jbi/bugzilla/service.py

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,11 @@
44
import requests
55
from statsd.defaults.env import statsd
66

7-
from jbi import Operation, environment
7+
from jbi import environment
88
from jbi.common.instrument import ServiceHealth
9-
from jbi.models import (
10-
ActionContext,
11-
BugzillaBug,
12-
)
139

1410
from .client import BugzillaClient, BugzillaClientError
11+
from .models import Bug
1512

1613
settings = environment.get_settings()
1714

@@ -67,18 +64,10 @@ def _all_webhooks_enabled(self):
6764
return False
6865
return True
6966

70-
def add_link_to_jira(self, context: ActionContext):
71-
"""Add link to Jira in Bugzilla ticket"""
72-
bug = context.bug
73-
issue_key = context.jira.issue
74-
jira_url = f"{settings.jira_base_url}browse/{issue_key}"
75-
logger.debug(
76-
"Link %r on Bug %s",
77-
jira_url,
78-
bug.id,
79-
extra=context.update(operation=Operation.LINK).model_dump(),
80-
)
81-
return self.client.update_bug(bug.id, see_also={"add": [jira_url]})
67+
def add_link_to_see_also(self, bug: Bug, link: str):
68+
"""Add link to Bugzilla ticket"""
69+
70+
return self.client.update_bug(bug.id, see_also={"add": [link]})
8271

8372
def get_description(self, bug_id: int):
8473
"""Fetch a bug's description
@@ -90,7 +79,7 @@ def get_description(self, bug_id: int):
9079
comment_body = comment_list[0].text if comment_list else ""
9180
return str(comment_body)
9281

93-
def refresh_bug_data(self, bug: BugzillaBug):
82+
def refresh_bug_data(self, bug: Bug):
9483
"""Re-fetch a bug to ensure we have the most up-to-date data"""
9584

9685
refreshed_bug_data = self.client.get_bug(bug.id)

jbi/jira/service.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
import requests
1414
from requests import exceptions as requests_exceptions
1515

16-
from jbi import Operation, environment
16+
from jbi import Operation, bugzilla, environment
1717
from jbi.common.instrument import ServiceHealth
18-
from jbi.models import ActionContext, BugzillaBug
18+
from jbi.models import ActionContext
1919

2020
from .client import JiraClient, JiraCreateError
2121

@@ -306,7 +306,7 @@ def add_jira_comments_for_changes(self, context: ActionContext):
306306
return jira_response_comments
307307

308308
def delete_jira_issue_if_duplicate(
309-
self, context: ActionContext, latest_bug: BugzillaBug
309+
self, context: ActionContext, latest_bug: bugzilla.Bug
310310
):
311311
"""Rollback the Jira issue creation if there is already a linked Jira issue
312312
on the Bugzilla ticket"""

0 commit comments

Comments
 (0)