Skip to content

Commit 7c99daf

Browse files
committed
Change methods for fetching visible projects and issue types
In v6.3.6, we bumped atlassian-python-api from 3.41.4. That bump included a [PR](atlassian-api/atlassian-python-api#1270) which changed the way library fetched projects. This somehow affected our project when we fetched visible projects and issue types in the heartbeat. We never fully determined the root cause, but the while loop in the linked PR was suspect. In this commit, we make further improvements to the code that's involved in the heartbeat to make it more efficent. Note that one of the tradeoffs we made for now was hardcoding the maximum number of projects that may be configured at 50. This is due to the way we fetch issue types by project. This is something that we can fix in the future, but is not a problem that we need to solve for now since we only have 26 project configured.
1 parent af548f9 commit 7c99daf

File tree

6 files changed

+102
-80
lines changed

6 files changed

+102
-80
lines changed

jbi/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,21 @@ def validate_actions(cls, actions: list[Action]):
138138
"""
139139
Inspect the list of actions:
140140
- Validate that lookup tags are uniques
141+
- Ensure we haven't exceeded our maximum configured project limit (see error below)
141142
- If the action's bugzilla_user_id is "tbd", emit a warning.
142143
"""
143144
tags = [action.whiteboard_tag.lower() for action in actions]
144145
duplicated_tags = [t for i, t in enumerate(tags) if t in tags[:i]]
145146
if duplicated_tags:
146147
raise ValueError(f"actions have duplicated lookup tags: {duplicated_tags}")
147148

149+
if len(tags) > 50:
150+
raise ValueError(
151+
"The Jira client's `paginated_projects` method assumes we have "
152+
"up to 50 projects configured. Adjust that implementation before "
153+
"removing this validation check."
154+
)
155+
148156
for action in actions:
149157
if action.bugzilla_user_id == "tbd":
150158
warnings.warn(

jbi/router.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,7 @@ def get_bugzilla_webhooks(bugzilla_service: BugzillaServiceDep):
106106
@router.get("/jira_projects/")
107107
def get_jira_projects(jira_service: JiraServiceDep):
108108
"""API for viewing projects that are currently accessible by API"""
109-
visible_projects: list[dict] = jira_service.fetch_visible_projects()
110-
return [project["key"] for project in visible_projects]
109+
return jira_service.fetch_visible_projects()
111110

112111

113112
SRC_DIR = Path(__file__).parent

jbi/services/jira.py

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import json
1212
import logging
1313
from functools import lru_cache
14-
from typing import TYPE_CHECKING, Any, Iterable, Optional
14+
from typing import TYPE_CHECKING, Any, Collection, Iterable, Optional
1515

1616
import requests
1717
from atlassian import Jira
@@ -94,7 +94,6 @@ def raise_for_status(self, *args, **kwargs):
9494

9595
get_server_info = instrumented_method(Jira.get_server_info)
9696
get_project_components = instrumented_method(Jira.get_project_components)
97-
projects = instrumented_method(Jira.projects)
9897
update_issue = instrumented_method(Jira.update_issue)
9998
update_issue_field = instrumented_method(Jira.update_issue_field)
10099
set_issue_status = instrumented_method(Jira.set_issue_status)
@@ -103,17 +102,55 @@ def raise_for_status(self, *args, **kwargs):
103102
get_project = instrumented_method(Jira.get_project)
104103

105104
@instrumented_method
106-
def permitted_projects(self, permissions: Iterable):
105+
def paginated_projects(
106+
self,
107+
included_archived=None,
108+
expand=None,
109+
url=None,
110+
keys: Optional[Collection[str]] = None,
111+
):
112+
"""Returns a paginated list of projects visible to the user.
113+
114+
https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-projects/#api-rest-api-2-project-search-get
115+
116+
We've patched this method of the Jira client to accept the `keys` param.
117+
"""
118+
119+
if not self.cloud:
120+
raise ValueError(
121+
"``projects_from_cloud`` method is only available for Jira Cloud platform"
122+
)
123+
124+
params = {}
125+
126+
if keys is not None:
127+
if len(keys) > 50:
128+
raise ValueError("Up to 50 project keys can be provided.")
129+
params["keys"] = list(keys)
130+
131+
if included_archived:
132+
params["includeArchived"] = included_archived
133+
if expand:
134+
params["expand"] = expand
135+
page_url = url or self.resource_url("project/search")
136+
is_url_absolute = bool(page_url.lower().startswith("http"))
137+
return self.get(page_url, params=params, absolute=is_url_absolute)
138+
139+
@instrumented_method
140+
def permitted_projects(self, permissions: Optional[Iterable] = None) -> list[dict]:
107141
"""Fetches projects that the user has the required permissions for
108142
109143
https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-permissions/#api-rest-api-2-permissions-project-post
110144
"""
145+
if permissions is None:
146+
permissions = []
111147

112148
response = self.post(
113149
"/rest/api/2/permissions/project",
114150
json={"permissions": list(JIRA_REQUIRED_PERMISSIONS)},
115151
)
116-
return response["projects"]
152+
projects: list[dict] = response["projects"]
153+
return projects
117154

118155

119156
class JiraService:
@@ -122,11 +159,11 @@ class JiraService:
122159
def __init__(self, client) -> None:
123160
self.client = client
124161

125-
def fetch_visible_projects(self) -> list[dict]:
162+
def fetch_visible_projects(self) -> list[str]:
126163
"""Return list of projects that are visible with the configured Jira credentials"""
127164

128-
projects: list[dict] = self.client.projects(included_archived=None)
129-
return projects
165+
projects = self.client.permitted_projects()
166+
return [project["key"] for project in projects]
130167

131168
def check_health(self, actions: Actions) -> ServiceHealth:
132169
"""Check health for Jira Service"""
@@ -149,14 +186,12 @@ def check_health(self, actions: Actions) -> ServiceHealth:
149186

150187
def _all_projects_visible(self, actions: Actions) -> bool:
151188
try:
152-
visible_projects = {
153-
project["key"] for project in self.fetch_visible_projects()
154-
}
189+
visible_projects = self.fetch_visible_projects()
155190
except requests.HTTPError:
156191
logger.exception("Error fetching visible Jira projects")
157192
return False
158193

159-
missing_projects = actions.configured_jira_projects_keys - visible_projects
194+
missing_projects = actions.configured_jira_projects_keys - set(visible_projects)
160195
if missing_projects:
161196
logger.error(
162197
"Jira projects %s are not visible with configured credentials",
@@ -232,7 +267,10 @@ def _check_project_components(self, action):
232267
return True
233268

234269
def _all_project_issue_types_exist(self, actions: Actions):
235-
projects = self.client.projects(included_archived=None, expand="issueTypes")
270+
paginated_project_response = self.client.paginated_projects(
271+
expand="issueTypes", keys=actions.configured_jira_projects_keys
272+
)
273+
projects = paginated_project_response["values"]
236274
issue_types_by_project = {
237275
project["key"]: {issue_type["name"] for issue_type in project["issueTypes"]}
238276
for project in projects

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def settings():
8383

8484
@pytest.fixture(autouse=True)
8585
def actions():
86-
return get_actions
86+
return get_actions()
8787

8888

8989
@pytest.fixture(autouse=True)

tests/unit/test_models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,9 @@ def test_payload_changes_list(webhook_event_change_factory, webhook_event_factor
172172
"status",
173173
"assignee",
174174
]
175+
176+
177+
def test_max_configured_projects_raises_error(action_factory):
178+
actions = [action_factory(whiteboard_tag=str(i)) for i in range(51)]
179+
with pytest.raises(pydantic.ValidationError):
180+
Actions(root=actions)

tests/unit/test_router.py

Lines changed: 36 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import datetime
44
from unittest import mock
55

6+
import pytest
67
from fastapi.testclient import TestClient
78

89
from jbi.app import app
@@ -26,7 +27,7 @@ def test_whiteboard_tags(anon_client):
2627

2728

2829
def test_jira_projects(anon_client, mocked_jira):
29-
mocked_jira.projects.return_value = [{"key": "Firefox"}, {"key": "Fenix"}]
30+
mocked_jira.permitted_projects.return_value = [{"key": "Firefox"}, {"key": "Fenix"}]
3031

3132
resp = anon_client.get("/jira_projects/")
3233
infos = resp.json()
@@ -247,52 +248,6 @@ def test_read_heartbeat_bugzilla_services_fails(anon_client, mocked_bugzilla):
247248
}
248249

249250

250-
def test_read_heartbeat_success(
251-
anon_client, mocked_jira, mocked_bugzilla, bugzilla_webhook_factory
252-
):
253-
"""/__heartbeat__ returns 200 when checks succeed."""
254-
mocked_bugzilla.logged_in.return_value = True
255-
mocked_bugzilla.list_webhooks.return_value = [bugzilla_webhook_factory()]
256-
mocked_jira.get_server_info.return_value = {}
257-
mocked_jira.projects.return_value = [
258-
{
259-
"key": "DevTest",
260-
"issueTypes": [
261-
{"name": "Task"},
262-
{"name": "Bug"},
263-
],
264-
}
265-
]
266-
mocked_jira.get_project_components.return_value = [{"name": "Main"}]
267-
mocked_jira.permitted_projects.return_value = [{"id": "12345", "key": "DevTest"}]
268-
269-
mocked_jira.get_project_components.return_value = [{"name": "Main"}]
270-
mocked_jira.permitted_projects.return_value = [{"id": "12345", "key": "DevTest"}]
271-
mocked_jira.get_project.return_value = {
272-
"issueTypes": [
273-
{"name": "Task"},
274-
{"name": "Bug"},
275-
]
276-
}
277-
278-
resp = anon_client.get("/__heartbeat__")
279-
280-
assert resp.status_code == 200
281-
assert resp.json() == {
282-
"jira": {
283-
"up": True,
284-
"all_projects_are_visible": True,
285-
"all_projects_have_permissions": True,
286-
"all_project_custom_components_exist": True,
287-
"all_projects_issue_types_exist": True,
288-
},
289-
"bugzilla": {
290-
"up": True,
291-
"all_webhooks_enabled": True,
292-
},
293-
}
294-
295-
296251
def test_jira_heartbeat_visible_projects(anon_client, mocked_jira):
297252
"""/__heartbeat__ fails if configured projects don't match."""
298253
mocked_jira.get_server_info.return_value = {}
@@ -349,35 +304,51 @@ def test_jira_heartbeat_unknown_issue_types(anon_client, mocked_jira):
349304
assert not resp.json()["jira"]["all_projects_issue_types_exist"]
350305

351306

352-
def test_head_heartbeat_success(
353-
anon_client, mocked_jira, mocked_bugzilla, bugzilla_webhook_factory
307+
@pytest.mark.parametrize("method", ["HEAD", "GET"])
308+
def test_read_heartbeat_success(
309+
anon_client, method, mocked_jira, mocked_bugzilla, bugzilla_webhook_factory
354310
):
355-
"""/__heartbeat__ support head requests"""
311+
"""/__heartbeat__ returns 200 when checks succeed."""
356312
mocked_bugzilla.logged_in.return_value = True
357313
mocked_bugzilla.list_webhooks.return_value = [bugzilla_webhook_factory()]
358314
mocked_jira.get_server_info.return_value = {}
359-
mocked_jira.projects.return_value = [
360-
{
361-
"key": "DevTest",
362-
"issueTypes": [
363-
{"name": "Task"},
364-
{"name": "Bug"},
365-
],
366-
}
367-
]
315+
mocked_jira.paginated_projects.return_value = {
316+
"values": [
317+
{
318+
"key": "DevTest",
319+
"issueTypes": [
320+
{"name": "Task"},
321+
{"name": "Bug"},
322+
],
323+
}
324+
]
325+
}
368326
mocked_jira.get_project_components.return_value = [{"name": "Main"}]
369-
mocked_jira.permitted_projects.return_value = [{"id": "12345", "key": "DevTest"}]
327+
mocked_jira.permitted_projects.return_value = [{"key": "DevTest"}]
370328

371-
resp = anon_client.head("/__heartbeat__")
329+
resp = anon_client.request(method, "__heartbeat__")
372330

373331
assert resp.status_code == 200
332+
if method == "GET":
333+
assert resp.json() == {
334+
"jira": {
335+
"up": True,
336+
"all_projects_are_visible": True,
337+
"all_projects_have_permissions": True,
338+
"all_project_custom_components_exist": True,
339+
"all_projects_issue_types_exist": True,
340+
},
341+
"bugzilla": {
342+
"up": True,
343+
"all_webhooks_enabled": True,
344+
},
345+
}
374346

375347

376-
def test_lbheartbeat(anon_client):
348+
@pytest.mark.parametrize("method", ["HEAD", "GET"])
349+
def test_lbheartbeat(anon_client, method):
377350
"""/__lbheartbeat__ always returns 200"""
378351

379-
resp = anon_client.get("/__lbheartbeat__")
352+
resp = anon_client.request(method, "__lbheartbeat__")
380353
assert resp.status_code == 200
381354

382-
resp = anon_client.head("/__lbheartbeat__")
383-
assert resp.status_code == 200

0 commit comments

Comments
 (0)