Skip to content

Commit 9f70605

Browse files
Fix errors observed after v4.4.0 release (#543)
* Revert "Ref #520: add typing for action parameters (#523)" This reverts commit c178e30. * Include action module name in raised exceptions * action_factory fixture doesn't return an Action * Remove test Bugzilla action We don't include clients in the action object anymore, so this test class isn't useful * Add typing for steps * Rework some tests `test_modules` - Instead of a test class, assert that the default action serializes - Rework tests for asserting valid and invalid values for bugzilla_user_id - use registered `action_factory` instead of import where possible * use `.get()` for param guard clauses * Add tests for _all_projects_components_exist * Add guards for getting params in steps --------- Co-authored-by: Mathieu Leplatre <[email protected]>
1 parent 73abd2c commit 9f70605

File tree

9 files changed

+89
-86
lines changed

9 files changed

+89
-86
lines changed

jbi/actions/default.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
and when a comment is posted, it will be set to `Operation.COMMENT`.
88
"""
99
import logging
10-
from typing import Optional
10+
from typing import Callable, Optional
1111

1212
from jbi import ActionResult, Operation
1313
from jbi.actions import steps as steps_module
@@ -86,7 +86,7 @@ def init(
8686
class Executor:
8787
"""Callable class that encapsulates the default action."""
8888

89-
def __init__(self, steps, **parameters):
89+
def __init__(self, steps: dict[Operation, list[Callable]], **parameters):
9090
"""Initialize Executor Object"""
9191
self.steps = steps
9292
self.parameters = parameters

jbi/actions/steps.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"""
66

77
import logging
8-
from typing import Optional
8+
from typing import Iterable, Optional
99

1010
from requests import exceptions as requests_exceptions
1111

@@ -154,6 +154,8 @@ def maybe_update_issue_resolution(
154154
https://support.atlassian.com/jira-cloud-administration/docs/what-are-issue-statuses-priorities-and-resolutions/
155155
"""
156156
resolution_map: dict[str, str] = parameters.get("resolution_map", {})
157+
if not isinstance(resolution_map, dict):
158+
raise TypeError("The 'resolution_map' parameter must be a dictionary.")
157159
jira_resolution = resolution_map.get(context.bug.resolution or "")
158160
if jira_resolution is None:
159161
logger.debug(
@@ -181,9 +183,12 @@ def maybe_update_issue_status(context: ActionContext, **parameters):
181183
Update the Jira issue resolution
182184
https://support.atlassian.com/jira-cloud-administration/docs/what-are-issue-statuses-priorities-and-resolutions/
183185
"""
184-
resolution_map: dict[str, str] = parameters.get("status_map", {})
186+
status_map = parameters.get("status_map", {})
187+
if not isinstance(status_map, dict):
188+
raise TypeError("The 'status_map' parameter must be a dictionary.")
189+
185190
bz_status = context.bug.resolution or context.bug.status
186-
jira_status = resolution_map.get(bz_status or "")
191+
jira_status = status_map.get(bz_status or "")
187192

188193
if jira_status is None:
189194
logger.debug(
@@ -212,7 +217,9 @@ def maybe_update_components(context: ActionContext, **parameters):
212217
"""
213218
Update the Jira issue components
214219
"""
215-
config_components: list[str] = parameters.get("jira_components", [])
220+
config_components = parameters.get("jira_components", [])
221+
if not isinstance(config_components, Iterable):
222+
raise TypeError("The 'jira_components' parameter must be an iterable.")
216223
candidate_components = set(config_components)
217224
if context.bug.component:
218225
candidate_components.add(context.bug.component)

jbi/models.py

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import warnings
1010
from inspect import signature
1111
from types import ModuleType
12-
from typing import Callable, Literal, Mapping, Optional, TypedDict
12+
from typing import Any, Callable, Literal, Mapping, Optional, TypedDict
1313
from urllib.parse import ParseResult, urlparse
1414

1515
from pydantic import BaseModel, Extra, Field, PrivateAttr, root_validator, validator
@@ -23,19 +23,6 @@
2323
JIRA_HOSTNAMES = ("jira", "atlassian")
2424

2525

26-
class ActionParameters(BaseModel):
27-
"""Action parameters"""
28-
29-
# For runner
30-
jira_project_key: str
31-
steps: Optional[dict[str, list[str]]] = None
32-
# For steps
33-
status_map: Optional[dict[str, str]] = None
34-
resolution_map: Optional[dict[str, str]] = None
35-
jira_components: Optional[list[str]] = None
36-
sync_whiteboard_labels: bool = True
37-
38-
3926
class Action(YamlModel):
4027
"""
4128
Action is the inner model for each action in the configuration file"""
@@ -46,21 +33,21 @@ class Action(YamlModel):
4633
description: str
4734
enabled: bool = True
4835
allow_private: bool = False
49-
parameters: ActionParameters
36+
parameters: dict = {}
5037
_caller: Optional[Callable] = PrivateAttr(default=None)
5138
_required_jira_permissions: set[str] = PrivateAttr(default=None)
5239

5340
@property
5441
def jira_project_key(self):
5542
"""Return the configured project key."""
56-
return self.parameters.jira_project_key
43+
return self.parameters["jira_project_key"]
5744

5845
@property
5946
def caller(self) -> Callable:
6047
"""Return the initialized callable for this action."""
6148
if self._caller is None:
6249
action_module: ModuleType = importlib.import_module(self.module)
63-
initialized: Callable = action_module.init(**self.parameters.dict()) # type: ignore
50+
initialized: Callable = action_module.init(**self.parameters) # type: ignore
6451
self._caller = initialized
6552
return self._caller
6653

@@ -78,17 +65,14 @@ def validate_action_config(cls, values): # pylint: disable=no-self-argument
7865
"""Validate action: exists, has init function, and has expected params"""
7966
try:
8067
module: str = values["module"] # type: ignore
81-
try:
82-
action_parameters = values["parameters"].dict()
83-
except KeyError:
84-
action_parameters = {}
68+
action_parameters: Optional[dict[str, Any]] = values["parameters"]
8569
action_module: ModuleType = importlib.import_module(module)
8670
if not action_module:
87-
raise TypeError("Module is not found.")
71+
raise TypeError(f"Module '{module}' is not found.")
8872
if not hasattr(action_module, "init"):
89-
raise TypeError("Module is missing `init` method.")
73+
raise TypeError(f"Module '{module}' is missing `init` method.")
9074

91-
signature(action_module.init).bind(**action_parameters)
75+
signature(action_module.init).bind(**action_parameters) # type: ignore
9276
except ImportError as exception:
9377
raise ValueError(f"unknown Python module `{module}`.") from exception
9478
except (TypeError, AttributeError) as exception:
@@ -126,7 +110,11 @@ def get(self, tag: Optional[str]) -> Optional[Action]:
126110
@functools.cached_property
127111
def configured_jira_projects_keys(self) -> set[str]:
128112
"""Return the list of Jira project keys from all configured actions"""
129-
return {action.jira_project_key for action in self.__root__}
113+
return {
114+
action.jira_project_key
115+
for action in self.__root__
116+
if action.parameters.get("jira_project_key")
117+
}
130118

131119
@validator("__root__")
132120
def validate_actions( # pylint: disable=no-self-argument

jbi/runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def execute_action(
8181
event=event,
8282
operation=Operation.IGNORE,
8383
jira=JiraContext(project=action.jira_project_key, issue=linked_issue_key),
84-
extra={k: str(v) for k, v in action.parameters.dict().items()},
84+
extra={k: str(v) for k, v in action.parameters.items()},
8585
)
8686

8787
if action_context.jira.issue is None:

jbi/services/jira.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,9 @@ def _all_projects_permissions(actions: Actions):
129129
def _fetch_project_permissions(actions):
130130
"""Fetches permissions for the configured projects"""
131131
required_perms_by_project = {
132-
action.parameters.jira_project_key: action.required_jira_permissions
132+
action.parameters["jira_project_key"]: action.required_jira_permissions
133133
for action in actions
134+
if action.parameters.get("jira_project_key")
134135
}
135136
client = get_client()
136137
all_projects_perms = {}
@@ -183,8 +184,9 @@ def _validate_permissions(all_projects_perms):
183184

184185
def _all_projects_components_exist(actions: Actions):
185186
components_by_project = {
186-
action.parameters.jira_project_key: action.parameters.jira_components
187+
action.parameters["jira_project_key"]: action.parameters["jira_components"]
187188
for action in actions
189+
if action.parameters.get("jira_components")
188190
}
189191
success = True
190192
for project, specified_components in components_by_project.items():

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ def webhook_modify_private_example() -> BugzillaWebhookRequest:
229229

230230

231231
@pytest.fixture
232-
def action_factory() -> Action:
232+
def action_factory():
233233
return factories.action_factory
234234

235235

tests/fixtures/bugzilla_action.py

Lines changed: 0 additions & 7 deletions
This file was deleted.

tests/unit/services/test_jira.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from requests.exceptions import ConnectionError
88

99
from jbi.environment import get_settings
10+
from jbi.models import Actions
1011
from jbi.services import jira
1112

1213

@@ -84,3 +85,38 @@ def test_jira_retries_failing_connections_in_health_check(
8485
jira.check_health(actions_example)
8586

8687
assert len(mocked_responses.calls) == 4
88+
89+
90+
@pytest.mark.parametrize(
91+
"jira_components, project_components, expected_result",
92+
[
93+
(["Foo"], [{"name": "Foo"}], True),
94+
(["Foo"], [{"name": "Foo"}, {"name": "Bar"}], True),
95+
(["Foo", "Bar"], [{"name": "Foo"}, {"name": "Bar"}], True),
96+
(None, [], True),
97+
(["Foo"], [{"name": "Bar"}], False),
98+
(["Foo", "Bar"], [{"name": "Foo"}], False),
99+
(["Foo"], [], False),
100+
],
101+
)
102+
def test_all_projects_components_exist(
103+
mocked_jira, action_factory, jira_components, project_components, expected_result
104+
):
105+
action = action_factory(
106+
parameters={"jira_project_key": "ABC", "jira_components": jira_components}
107+
)
108+
mocked_jira.get_project_components.return_value = project_components
109+
actions = Actions(__root__=[action])
110+
result = jira._all_projects_components_exist(actions)
111+
assert result is expected_result
112+
113+
114+
def test_all_projects_components_exist_no_components_param(action_factory):
115+
action = action_factory(
116+
parameters={
117+
"jira_project_key": "ABC",
118+
}
119+
)
120+
actions = Actions(__root__=[action])
121+
result = jira._all_projects_components_exist(actions)
122+
assert result is True

tests/unit/test_models.py

Lines changed: 21 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,33 @@
1+
import pydantic
12
import pytest
23
from fastapi.encoders import jsonable_encoder
34

4-
from jbi.models import Action, ActionParameters, Actions
5-
from tests.fixtures.factories import action_factory
5+
from jbi import Operation
6+
from jbi.models import Actions
7+
from tests.fixtures.factories import action_context_factory
68

79

8-
def test_model_serializes():
9-
"""Regression test to assert that action with initialized Bugzilla client serializes"""
10-
action = Action.parse_obj(
11-
{
12-
"whiteboard_tag": "devtest",
13-
"bugzilla_user_id": 123456,
14-
"description": "test config",
15-
"module": "tests.fixtures.bugzilla_action",
16-
"parameters": ActionParameters(jira_project_key="fooo"),
17-
}
10+
def test_default_action_serializes(action_factory):
11+
action = action_factory(
12+
module="jbi.actions.default",
13+
parameters={"jira_project_key": "ABC", "steps": {"new": []}},
1814
)
19-
action.caller(bug=None, event=None)
15+
action.caller(action_context_factory(operation=Operation.CREATE))
2016
serialized_action = jsonable_encoder(action)
2117
assert not serialized_action.get("_caller")
2218
assert not serialized_action.get("caller")
2319

2420

25-
def test_model_with_user_list_serializes():
26-
"""Regression test to assert that action with initialized Bugzilla client serializes (with user id list)"""
27-
action = Action.parse_obj(
28-
{
29-
"whiteboard_tag": "devtest",
30-
"bugzilla_user_id": [123456, 654321, 000000, 111111],
31-
"description": "test config",
32-
"module": "tests.fixtures.bugzilla_action",
33-
"parameters": ActionParameters(jira_project_key="fooo"),
34-
}
35-
)
36-
action.caller(bug=None, event=None)
37-
serialized_action = jsonable_encoder(action)
38-
assert not serialized_action.get("_caller")
39-
assert not serialized_action.get("caller")
21+
@pytest.mark.parametrize("value", [123456, [123456], [12345, 67890], "tbd"])
22+
def test_valid_bugzilla_user_ids(action_factory, value):
23+
action = action_factory(bugzilla_user_id=value)
24+
assert action.bugzilla_user_id == value
4025

41-
def test_model_with_user_list_of_one_serializes():
42-
"""Regression test to assert that action with initialized Bugzilla client serializes (with user id in list)"""
43-
action = Action.parse_obj(
44-
{
45-
"whiteboard_tag": "devtest",
46-
"bugzilla_user_id": [123456],
47-
"description": "test config",
48-
"module": "tests.fixtures.bugzilla_action",
49-
"parameters": ActionParameters(jira_project_key="fooo"),
50-
}
51-
)
52-
action.caller(bug=None, event=None)
53-
serialized_action = jsonable_encoder(action)
54-
assert not serialized_action.get("_caller")
55-
assert not serialized_action.get("caller")
26+
27+
@pytest.mark.parametrize("value", [None, "[email protected]"])
28+
def test_invalid_bugzilla_user_ids(action_factory, value):
29+
with pytest.raises(pydantic.ValidationError):
30+
action = action_factory(bugzilla_user_id=value)
5631

5732

5833
def test_no_actions_fails():
@@ -69,11 +44,13 @@ def test_unknown_module_fails():
6944

7045
def test_bad_module_fails():
7146
with pytest.raises(ValueError) as exc_info:
47+
# use a module that exists in the source, but isn't properly set up as
48+
# a valid action module
7249
Actions.parse_obj([{"whiteboard_tag": "x", "module": "jbi.runner"}])
7350
assert "action 'jbi.runner' is not properly setup" in str(exc_info.value)
7451

7552

76-
def test_duplicated_whiteboard_tag_fails():
53+
def test_duplicated_whiteboard_tag_fails(action_factory):
7754
with pytest.raises(ValueError) as exc_info:
7855
Actions.parse_obj(
7956
[

0 commit comments

Comments
 (0)