Skip to content

Commit a5ec7f6

Browse files
authored
Reorganize calls to Bugzilla into a dedicated class in services.py (#220)
* Remove bugzilla_object attribute from model * Use model for Bugzilla comments * Rename getbug() to get_bug() * Fix metric name * Use parse_obj_as * Assert about comment presence only once * Remove unused model method * Remove useless model config
1 parent 012a26b commit a5ec7f6

File tree

12 files changed

+219
-236
lines changed

12 files changed

+219
-236
lines changed

jbi/actions/default.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def __call__( # pylint: disable=inconsistent-return-statements
7474

7575
def comment_create_or_noop(self, payload: BugzillaWebhookRequest) -> ActionResult:
7676
"""Confirm issue is already linked, then apply comments; otherwise noop"""
77-
bug_obj = payload.bugzilla_object
77+
bug_obj = payload.bug
7878
linked_issue_key = bug_obj.extract_from_see_also()
7979

8080
log_context = ActionLogContext(
@@ -94,17 +94,17 @@ def comment_create_or_noop(self, payload: BugzillaWebhookRequest) -> ActionResul
9494
)
9595
return False, {}
9696

97-
comment = payload.map_as_jira_comment()
98-
if comment is None:
97+
if bug_obj.comment is None:
9998
logger.debug(
10099
"No matching comment found in payload",
101100
extra=log_context.dict(),
102101
)
103102
return False, {}
104103

104+
formatted_comment = payload.map_as_jira_comment()
105105
jira_response = self.jira_client.issue_add_comment(
106106
issue_key=linked_issue_key,
107-
comment=payload.map_as_jira_comment(),
107+
comment=formatted_comment,
108108
)
109109
logger.debug(
110110
"Comment added to Jira issue %s",
@@ -144,7 +144,7 @@ def bug_create_or_update(
144144
self, payload: BugzillaWebhookRequest
145145
) -> ActionResult: # pylint: disable=too-many-locals
146146
"""Create and link jira issue with bug, or update; rollback if multiple events fire"""
147-
bug_obj = payload.bugzilla_object
147+
bug_obj = payload.bug
148148
linked_issue_key = bug_obj.extract_from_see_also() # type: ignore
149149
if not linked_issue_key:
150150
return self.create_and_link_issue(payload, bug_obj)
@@ -205,10 +205,8 @@ def create_and_link_issue( # pylint: disable=too-many-locals
205205
bug_obj.id,
206206
extra=log_context.dict(),
207207
)
208-
comment_list = self.bugzilla_client.get_comments(idlist=[bug_obj.id])
209-
description = comment_list["bugs"][str(bug_obj.id)]["comments"][0]["text"][
210-
:JIRA_DESCRIPTION_CHAR_LIMIT
211-
]
208+
comment_list = self.bugzilla_client.get_comments(bug_obj.id)
209+
description = comment_list[0].text[:JIRA_DESCRIPTION_CHAR_LIMIT]
212210

213211
fields = {
214212
**self.jira_fields(bug_obj), # type: ignore
@@ -238,7 +236,8 @@ def create_and_link_issue( # pylint: disable=too-many-locals
238236

239237
# In the time taken to create the Jira issue the bug may have been updated so
240238
# re-retrieve it to ensure we have the latest data.
241-
bug_obj = payload.getbug_as_bugzilla_object()
239+
bug_obj = self.bugzilla_client.get_bug(bug_obj.id)
240+
242241
jira_key_in_bugzilla = bug_obj.extract_from_see_also()
243242
_duplicate_creation_event = (
244243
jira_key_in_bugzilla is not None
@@ -263,8 +262,9 @@ def create_and_link_issue( # pylint: disable=too-many-locals
263262
bug_obj.id,
264263
extra=log_context.update(operation=Operation.LINK).dict(),
265264
)
266-
update = self.bugzilla_client.build_update(see_also_add=jira_url)
267-
bugzilla_response = self.bugzilla_client.update_bugs([bug_obj.id], update)
265+
bugzilla_response = self.bugzilla_client.update_bug(
266+
bug_obj, see_also_add=jira_url
267+
)
268268

269269
bugzilla_url = f"{settings.bugzilla_base_url}/show_bug.cgi?id={bug_obj.id}"
270270
logger.debug(

jbi/models.py

Lines changed: 8 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525

2626
from jbi import Operation
2727
from jbi.errors import ActionNotFoundError
28-
from jbi.services import get_bugzilla
2928

3029
logger = logging.getLogger(__name__)
3130

@@ -316,41 +315,16 @@ def lookup_action(self, actions: Actions) -> Action:
316315
class BugzillaWebhookRequest(BaseModel):
317316
"""Bugzilla Webhook Request Object"""
318317

319-
class Config:
320-
"""pydantic model config"""
321-
322-
keep_untouched = (
323-
functools.cached_property,
324-
) # https://github.com/samuelcolvin/pydantic/issues/1241
325-
326318
webhook_id: int
327319
webhook_name: str
328320
event: BugzillaWebhookEvent
329321
bug: BugzillaBug
330322

331323
def map_as_jira_comment(self):
332324
"""Extract comment from Webhook Event"""
333-
comment: BugzillaWebhookComment = self.bug.comment
334325
commenter: BugzillaWebhookUser = self.event.user
335-
comment_body: str = comment.body
336-
337-
if comment.is_private:
338-
bug_comments = get_bugzilla().get_comments([self.bug.id])
339-
comment_list = bug_comments["bugs"][str(self.bug.id)]["comments"]
340-
matching_comments = [c for c in comment_list if c["id"] == comment.id]
341-
if len(matching_comments) != 1:
342-
return None
343-
comment_body = matching_comments[0]["text"]
344-
345-
body = f"*({commenter.login})* commented: \n{{quote}}{comment_body}{{quote}}"
346-
return body
347-
348-
def map_as_jira_description(self):
349-
"""Extract description as comment from Webhook Event"""
350326
comment: BugzillaWebhookComment = self.bug.comment
351-
comment_body: str = comment.body
352-
body = f"*(description)*: \n{{quote}}{comment_body}{{quote}}"
353-
return body
327+
return f"*({commenter.login})* commented: \n{{quote}}{comment.body}{{quote}}"
354328

355329
def map_as_comments(
356330
self,
@@ -380,17 +354,14 @@ def map_as_comments(
380354

381355
return [json.dumps(comment, indent=4) for comment in comments]
382356

383-
def getbug_as_bugzilla_object(self) -> BugzillaBug:
384-
"""Helper method to get up to date bug data from Request.bug.id in BugzillaBug format"""
385-
current_bug_info = get_bugzilla().getbug(self.bug.id) # type: ignore
386-
return BugzillaBug.parse_obj(current_bug_info.__dict__)
387357

388-
@functools.cached_property
389-
def bugzilla_object(self) -> BugzillaBug:
390-
"""Returns the bugzilla bug object, querying the API as needed for private bugs"""
391-
if not self.bug.is_private:
392-
return self.bug
393-
return self.getbug_as_bugzilla_object()
358+
class BugzillaComment(BaseModel):
359+
"""Bugzilla Comment"""
360+
361+
id: int
362+
text: str
363+
is_private: bool
364+
creator: str
394365

395366

396367
class BugzillaApiResponse(BaseModel):

jbi/runner.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from jbi import Operation
99
from jbi.environment import Settings
1010
from jbi.errors import ActionNotFoundError, IgnoreInvalidRequestError
11-
from jbi.models import Actions, BugzillaBug, BugzillaWebhookRequest, RunnerLogContext
11+
from jbi.models import Actions, BugzillaWebhookRequest, RunnerLogContext
12+
from jbi.services import get_bugzilla
1213

1314
logger = logging.getLogger(__name__)
1415

@@ -38,23 +39,26 @@ def execute_action(
3839
)
3940

4041
try:
41-
bug_obj: BugzillaBug = request.bugzilla_object
42+
if request.bug.is_private:
43+
request = request.copy(
44+
update={"bug": get_bugzilla().get_bug(request.bug.id)}
45+
)
4246
except Exception as err:
4347
logger.exception("Failed to get bug: %s", err, extra=log_context.dict())
4448
raise IgnoreInvalidRequestError(
4549
"bug not accessible or bugzilla down"
4650
) from err
47-
log_context = log_context.update(bug=bug_obj)
4851

52+
log_context = log_context.update(bug=request.bug)
4953
try:
50-
action = bug_obj.lookup_action(actions)
54+
action = request.bug.lookup_action(actions)
5155
except ActionNotFoundError as err:
5256
raise IgnoreInvalidRequestError(
5357
f"no action matching bug whiteboard tags: {err}"
5458
) from err
5559
log_context = log_context.update(action=action)
5660

57-
if bug_obj.is_private and not action.allow_private:
61+
if request.bug.is_private and not action.allow_private:
5862
raise IgnoreInvalidRequestError(
5963
f"private bugs are not valid for action {action.whiteboard_tag!r}"
6064
)
@@ -63,7 +67,7 @@ def execute_action(
6367
"Execute action '%s:%s' for Bug %s",
6468
action.whiteboard_tag,
6569
action.module,
66-
bug_obj.id,
70+
request.bug.id,
6771
extra=log_context.update(operation=Operation.EXECUTE).dict(),
6872
)
6973

@@ -72,7 +76,7 @@ def execute_action(
7276
logger.info(
7377
"Action %r executed successfully for Bug %s",
7478
action.whiteboard_tag,
75-
bug_obj.id,
79+
request.bug.id,
7680
extra=log_context.update(
7781
operation=Operation.SUCCESS if handled else Operation.IGNORE
7882
).dict(),

jbi/services.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
import backoff
99
import bugzilla as rh_bugzilla
1010
from atlassian import Jira, errors
11+
from pydantic import parse_obj_as
1112
from statsd.defaults.env import statsd
1213

1314
from jbi import environment
15+
from jbi.models import BugzillaBug, BugzillaComment
1416

1517
if TYPE_CHECKING:
1618
from jbi.models import Actions
@@ -84,15 +86,54 @@ def jira_visible_projects(jira=None) -> list[dict]:
8486
return projects
8587

8688

89+
class BugzillaClient:
90+
"""
91+
Wrapper around the Bugzilla client to turn responses into our models instances.
92+
"""
93+
94+
def __init__(self, base_url: str, api_key: str):
95+
"""Constructor"""
96+
self._client = rh_bugzilla.Bugzilla(base_url, api_key=api_key)
97+
98+
@property
99+
def logged_in(self):
100+
"""Return `true` if credentials are valid"""
101+
return self._client.logged_in
102+
103+
def get_bug(self, bugid) -> BugzillaBug:
104+
"""Return the Bugzilla object with all attributes"""
105+
response = self._client.getbug(bugid).__dict__
106+
bug = BugzillaBug.parse_obj(response)
107+
# If comment is private, then webhook does not have comment, fetch it from server
108+
if bug.comment and bug.comment.is_private:
109+
comment_list = self.get_comments(bugid)
110+
matching_comments = [c for c in comment_list if c.id == bug.comment.id]
111+
# If no matching entry is found, set `bug.comment` to `None`.
112+
found = matching_comments[0] if matching_comments else None
113+
bug = bug.copy(update={"comment": found})
114+
return bug
115+
116+
def get_comments(self, bugid) -> list[BugzillaComment]:
117+
"""Return the list of comments for the specified bug ID"""
118+
response = self._client.get_comments(idlist=[bugid])
119+
comments = response["bugs"][str(bugid)]["comments"]
120+
return parse_obj_as(list[BugzillaComment], comments)
121+
122+
def update_bug(self, bugid, **attrs):
123+
"""Update the specified bug with the specified attributes"""
124+
update = self._client.build_update(**attrs)
125+
return self._client.update_bugs([bugid], update)
126+
127+
87128
def get_bugzilla():
88129
"""Get bugzilla service"""
89-
bugzilla_client = rh_bugzilla.Bugzilla(
130+
bugzilla_client = BugzillaClient(
90131
settings.bugzilla_base_url, api_key=str(settings.bugzilla_api_key)
91132
)
92133
instrumented_methods = (
93-
"getbug",
134+
"get_bug",
94135
"get_comments",
95-
"update_bugs",
136+
"update_bug",
96137
)
97138
return InstrumentedClient(
98139
wrapped=bugzilla_client,

tests/conftest.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ def settings():
3232

3333

3434
@pytest.fixture(autouse=True)
35-
def mocked_bugzilla():
36-
with mock.patch("jbi.services.rh_bugzilla.Bugzilla") as mocked_bz:
37-
yield mocked_bz()
35+
def mocked_bugzilla(request):
36+
if "no_mocked_bugzilla" in request.keywords:
37+
yield None
38+
else:
39+
with mock.patch("jbi.services.BugzillaClient") as mocked_bz:
40+
yield mocked_bz()
3841

3942

4043
@pytest.fixture(autouse=True)

tests/fixtures/factories.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from jbi.models import (
22
Action,
33
BugzillaBug,
4+
BugzillaComment,
45
BugzillaWebhookEvent,
56
BugzillaWebhookRequest,
67
BugzillaWebhookUser,
@@ -77,12 +78,14 @@ def webhook_factory(**overrides):
7778

7879

7980
def comment_factory(**overrides):
80-
return {
81-
"id": 343,
82-
"text": "comment text",
83-
"bug_id": 654321,
84-
"count": 1,
85-
"is_private": True,
86-
"creator": "[email protected]",
87-
**overrides,
88-
}
81+
return BugzillaComment.parse_obj(
82+
{
83+
"id": 343,
84+
"text": "comment text",
85+
"bug_id": 654321,
86+
"count": 1,
87+
"is_private": True,
88+
"creator": "[email protected]",
89+
**overrides,
90+
}
91+
)

0 commit comments

Comments
 (0)