Skip to content

Commit a621467

Browse files
authored
New structure for Jira and Bugzilla clients + services (#795)
In this commit, we introduce a slightly new structure for the code we use to communicate with Jira and Bugzilla. Before, we had a `services` package with a `jira` and `bugzilla` module. ``` jbi ├── services ├── __init__.py ├── bugzilla.py ├── common.py └── jira.py ``` Each of those modules had a `*Client` and `*Service` class. Now, we do essentially the same thing, except we have: ``` jbi ├── bugzilla │ ├── __init__.py │ ├── client.py │ └── service.py ├── common │ ├── __init__.py │ └── instrument.py └── jira ├── __init__.py ├── client.py └── service.py ``` this is to: - reinforce the separate concerns of a `client` and `service`, especially in tests - clients should only make HTTP requests and receive responses - services should use clients to do higher-level project operations - allow for the continued encapsulation of different domains. For example, instead of one `models` module, it might make sense to move certain models under specific namespaces
1 parent 3a3154b commit a621467

20 files changed

+505
-421
lines changed

jbi/bugzilla/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .service import BugzillaService as BugzillaService
2+
from .service import get_service as get_service
Lines changed: 2 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,17 @@
1-
"""Contains a Bugzilla REST client and functions comprised of common operations
2-
with that REST client
3-
"""
4-
51
import logging
6-
from functools import lru_cache
72

83
import requests
9-
from statsd.defaults.env import statsd
104

11-
from jbi import Operation, environment
5+
from jbi import environment
6+
from jbi.common.instrument import instrument
127
from jbi.models import (
13-
ActionContext,
148
BugzillaApiResponse,
159
BugzillaBug,
1610
BugzillaComment,
1711
BugzillaComments,
1812
BugzillaWebhooksResponse,
1913
)
2014

21-
from .common import ServiceHealth, instrument
22-
2315
settings = environment.get_settings()
2416

2517
logger = logging.getLogger(__name__)
@@ -132,100 +124,3 @@ def list_webhooks(self):
132124
f"Unexpected response content from 'GET {url}' (no 'webhooks' field)"
133125
)
134126
return [wh for wh in parsed.webhooks if "/bugzilla_webhook" in wh.url]
135-
136-
137-
class BugzillaService:
138-
"""Used by action workflows to perform action-specific Bugzilla tasks"""
139-
140-
def __init__(self, client: BugzillaClient) -> None:
141-
self.client = client
142-
143-
def check_health(self) -> ServiceHealth:
144-
"""Check health for Bugzilla Service"""
145-
logged_in = self.client.logged_in()
146-
all_webhooks_enabled = False
147-
if logged_in:
148-
all_webhooks_enabled = self._all_webhooks_enabled()
149-
150-
health: ServiceHealth = {
151-
"up": logged_in,
152-
"all_webhooks_enabled": all_webhooks_enabled,
153-
}
154-
return health
155-
156-
def _all_webhooks_enabled(self):
157-
# Check that all JBI webhooks are enabled in Bugzilla,
158-
# and report disabled ones.
159-
160-
try:
161-
jbi_webhooks = self.client.list_webhooks()
162-
except (BugzillaClientError, requests.HTTPError):
163-
return False
164-
165-
if len(jbi_webhooks) == 0:
166-
logger.info("No webhooks enabled")
167-
return True
168-
169-
for webhook in jbi_webhooks:
170-
# Report errors in each webhook
171-
statsd.gauge(f"jbi.bugzilla.webhooks.{webhook.slug}.errors", webhook.errors)
172-
# Warn developers when there are errors
173-
if webhook.errors > 0:
174-
logger.warning(
175-
"Webhook %s has %s error(s)", webhook.name, webhook.errors
176-
)
177-
if not webhook.enabled:
178-
logger.error(
179-
"Webhook %s is disabled (%s errors)",
180-
webhook.name,
181-
webhook.errors,
182-
)
183-
return False
184-
return True
185-
186-
def add_link_to_jira(self, context: ActionContext):
187-
"""Add link to Jira in Bugzilla ticket"""
188-
bug = context.bug
189-
issue_key = context.jira.issue
190-
jira_url = f"{settings.jira_base_url}browse/{issue_key}"
191-
logger.debug(
192-
"Link %r on Bug %s",
193-
jira_url,
194-
bug.id,
195-
extra=context.update(operation=Operation.LINK).model_dump(),
196-
)
197-
return self.client.update_bug(bug.id, see_also={"add": [jira_url]})
198-
199-
def get_description(self, bug_id: int):
200-
"""Fetch a bug's description
201-
202-
A Bug's description does not appear in the payload of a bug. Instead, it is "comment 0"
203-
"""
204-
205-
comment_list = self.client.get_comments(bug_id)
206-
comment_body = comment_list[0].text if comment_list else ""
207-
return str(comment_body)
208-
209-
def refresh_bug_data(self, bug: BugzillaBug):
210-
"""Re-fetch a bug to ensure we have the most up-to-date data"""
211-
212-
updated_bug = self.client.get_bug(bug.id)
213-
# When bugs come in as webhook payloads, they have a "comment"
214-
# attribute, but this field isn't available when we get a bug by ID.
215-
# So, we make sure to add the comment back if it was present on the bug.
216-
updated_bug.comment = bug.comment
217-
return updated_bug
218-
219-
def list_webhooks(self):
220-
"""List the currently configured webhooks, including their status."""
221-
222-
return self.client.list_webhooks()
223-
224-
225-
@lru_cache(maxsize=1)
226-
def get_service():
227-
"""Get bugzilla service"""
228-
client = BugzillaClient(
229-
settings.bugzilla_base_url, api_key=str(settings.bugzilla_api_key)
230-
)
231-
return BugzillaService(client=client)

jbi/bugzilla/service.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import logging
2+
from functools import lru_cache
3+
4+
import requests
5+
from statsd.defaults.env import statsd
6+
7+
from jbi import Operation, environment
8+
from jbi.common.instrument import ServiceHealth
9+
from jbi.models import (
10+
ActionContext,
11+
BugzillaBug,
12+
)
13+
14+
from .client import BugzillaClient, BugzillaClientError
15+
16+
settings = environment.get_settings()
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class BugzillaService:
22+
"""Used by action workflows to perform action-specific Bugzilla tasks"""
23+
24+
def __init__(self, client: BugzillaClient) -> None:
25+
self.client = client
26+
27+
def check_health(self) -> ServiceHealth:
28+
"""Check health for Bugzilla Service"""
29+
logged_in = self.client.logged_in()
30+
all_webhooks_enabled = False
31+
if logged_in:
32+
all_webhooks_enabled = self._all_webhooks_enabled()
33+
34+
health: ServiceHealth = {
35+
"up": logged_in,
36+
"all_webhooks_enabled": all_webhooks_enabled,
37+
}
38+
return health
39+
40+
def _all_webhooks_enabled(self):
41+
# Check that all JBI webhooks are enabled in Bugzilla,
42+
# and report disabled ones.
43+
44+
try:
45+
jbi_webhooks = self.client.list_webhooks()
46+
except (BugzillaClientError, requests.HTTPError):
47+
return False
48+
49+
if len(jbi_webhooks) == 0:
50+
logger.info("No webhooks enabled")
51+
return True
52+
53+
for webhook in jbi_webhooks:
54+
# Report errors in each webhook
55+
statsd.gauge(f"jbi.bugzilla.webhooks.{webhook.slug}.errors", webhook.errors)
56+
# Warn developers when there are errors
57+
if webhook.errors > 0:
58+
logger.warning(
59+
"Webhook %s has %s error(s)", webhook.name, webhook.errors
60+
)
61+
if not webhook.enabled:
62+
logger.error(
63+
"Webhook %s is disabled (%s errors)",
64+
webhook.name,
65+
webhook.errors,
66+
)
67+
return False
68+
return True
69+
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]})
82+
83+
def get_description(self, bug_id: int):
84+
"""Fetch a bug's description
85+
86+
A Bug's description does not appear in the payload of a bug. Instead, it is "comment 0"
87+
"""
88+
89+
comment_list = self.client.get_comments(bug_id)
90+
comment_body = comment_list[0].text if comment_list else ""
91+
return str(comment_body)
92+
93+
def refresh_bug_data(self, bug: BugzillaBug):
94+
"""Re-fetch a bug to ensure we have the most up-to-date data"""
95+
96+
updated_bug = self.client.get_bug(bug.id)
97+
# When bugs come in as webhook payloads, they have a "comment"
98+
# attribute, but this field isn't available when we get a bug by ID.
99+
# So, we make sure to add the comment back if it was present on the bug.
100+
updated_bug.comment = bug.comment
101+
return updated_bug
102+
103+
def list_webhooks(self):
104+
"""List the currently configured webhooks, including their status."""
105+
106+
return self.client.list_webhooks()
107+
108+
109+
@lru_cache(maxsize=1)
110+
def get_service():
111+
"""Get bugzilla service"""
112+
client = BugzillaClient(
113+
settings.bugzilla_base_url, api_key=str(settings.bugzilla_api_key)
114+
)
115+
return BugzillaService(client=client)
File renamed without changes.

jbi/services/common.py renamed to jbi/common/instrument.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ def instrument(prefix: str, exceptions: Sequence[Type[Exception]], **backoff_par
2727
"""
2828

2929
def decorator(func):
30-
@wraps(func)
3130
@backoff.on_exception(
3231
backoff.expo,
3332
exceptions,
3433
max_tries=settings.max_retries + 1,
3534
**backoff_params,
3635
)
36+
@wraps(func)
3737
def wrapper(*args, **kwargs):
3838
# Increment the call counter.
3939
statsd.incr(f"jbi.{prefix}.methods.{func.__name__}.count")

jbi/jira/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .service import JiraService as JiraService
2+
from .service import get_service as get_service

jbi/jira/client.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import logging
2+
from typing import Collection, Iterable, Optional
3+
4+
import requests
5+
from atlassian import Jira
6+
from atlassian import errors as atlassian_errors
7+
from atlassian.rest_client import log as atlassian_logger
8+
from requests import exceptions as requests_exceptions
9+
10+
from jbi import environment
11+
from jbi.common.instrument import instrument
12+
13+
settings = environment.get_settings()
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
def fatal_code(exc):
19+
"""Do not retry 4XX errors, mark them as fatal."""
20+
try:
21+
return 400 <= exc.response.status_code < 500
22+
except AttributeError:
23+
# `ApiError` or `ConnectionError` won't have response attribute.
24+
return False
25+
26+
27+
instrumented_method = instrument(
28+
prefix="jira",
29+
exceptions=(
30+
atlassian_errors.ApiError,
31+
requests_exceptions.RequestException,
32+
),
33+
giveup=fatal_code,
34+
)
35+
36+
37+
class JiraCreateError(Exception):
38+
"""Error raised on Jira issue creation."""
39+
40+
41+
class JiraClient(Jira):
42+
"""Adapted Atlassian Jira client that logs errors and wraps methods
43+
in our instrumentation decorator.
44+
"""
45+
46+
def raise_for_status(self, *args, **kwargs):
47+
"""Catch and log HTTP errors responses of the Jira self.client.
48+
49+
Without this the actual requests and responses are not exposed when an error
50+
occurs, which makes troubleshooting tedious.
51+
"""
52+
try:
53+
return super().raise_for_status(*args, **kwargs)
54+
except requests.HTTPError as exc:
55+
request = exc.request
56+
response = exc.response
57+
atlassian_logger.error(
58+
"HTTP: %s %s -> %s %s",
59+
request.method,
60+
request.path_url,
61+
response.status_code,
62+
response.reason,
63+
extra={"body": response.text},
64+
)
65+
raise
66+
67+
get_server_info = instrumented_method(Jira.get_server_info)
68+
get_project_components = instrumented_method(Jira.get_project_components)
69+
update_issue = instrumented_method(Jira.update_issue)
70+
update_issue_field = instrumented_method(Jira.update_issue_field)
71+
set_issue_status = instrumented_method(Jira.set_issue_status)
72+
issue_add_comment = instrumented_method(Jira.issue_add_comment)
73+
create_issue = instrumented_method(Jira.create_issue)
74+
get_project = instrumented_method(Jira.get_project)
75+
76+
@instrumented_method
77+
def paginated_projects(
78+
self,
79+
included_archived=None,
80+
expand=None,
81+
url=None,
82+
keys: Optional[Collection[str]] = None,
83+
):
84+
"""Returns a paginated list of projects visible to the user.
85+
86+
https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-projects/#api-rest-api-2-project-search-get
87+
88+
We've patched this method of the Jira client to accept the `keys` param.
89+
"""
90+
91+
if not self.cloud:
92+
raise ValueError(
93+
"``projects_from_cloud`` method is only available for Jira Cloud platform"
94+
)
95+
96+
params = []
97+
98+
if keys is not None:
99+
if len(keys) > 50:
100+
raise ValueError("Up to 50 project keys can be provided.")
101+
params = [("keys", key) for key in keys]
102+
103+
if included_archived:
104+
params.append(("includeArchived", included_archived))
105+
if expand:
106+
params.append(("expand", expand))
107+
page_url = url or self.resource_url("project/search")
108+
is_url_absolute = bool(page_url.lower().startswith("http"))
109+
return self.get(page_url, params=params, absolute=is_url_absolute)
110+
111+
@instrumented_method
112+
def permitted_projects(self, permissions: Optional[Iterable] = None) -> list[dict]:
113+
"""Fetches projects that the user has the required permissions for
114+
115+
https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-permissions/#api-rest-api-2-permissions-project-post
116+
"""
117+
if permissions is None:
118+
permissions = []
119+
120+
response = self.post(
121+
"/rest/api/2/permissions/project",
122+
json={"permissions": list(permissions)},
123+
)
124+
projects: list[dict] = response["projects"]
125+
return projects

0 commit comments

Comments
 (0)