Skip to content

Commit 0a9bedf

Browse files
Dexterp37leplatrem
andauthored
Fix #407, #708: Run multiple actions when multiple whiteboard tags (#1109)
* Mention pandoc version requirement * Rename and return a list from lookup_actions * Lookup multiple actions * Execute multiple actions if configured This additionally adds a test that verifies actions are properly detected and executed. * Address review comments This adds an additional test to make sure that actions are dispatched to the appropriate project. * Add assertion about respective issues calls --------- Co-authored-by: Mathieu Leplatre <[email protected]>
1 parent cefbc94 commit 0a9bedf

File tree

6 files changed

+209
-24
lines changed

6 files changed

+209
-24
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ graph TD
7575
7676
# Development
7777
78-
We use [pandoc](https://pandoc.org) to convert markdown to the Jira syntax. Make sure the binary is found in path or [specify your custom location](https://github.com/JessicaTegner/pypandoc#specifying-the-location-of-pandoc-binaries).
78+
We use [pandoc](https://pandoc.org) to convert markdown to the Jira syntax. Make sure the binary is found in path or [specify your custom location](https://github.com/JessicaTegner/pypandoc#specifying-the-location-of-pandoc-binaries) and its version is sufficiently recent to support Jira syntax (e.g. 3.6.3).
7979
8080
- `make start`: run the application locally (http://localhost:8000)
8181
- `make test`: run the unit tests suites

jbi/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ class RunnerContext(Context, extra="forbid"):
238238

239239
operation: Operation
240240
event: WebhookEvent
241-
action: Optional[Action] = None
241+
actions: Optional[list[Action]] = None
242242
bug: BugId | Bug
243243

244244

jbi/runner.py

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,23 @@ def groups2operation(steps: ActionSteps):
5858
return by_operation
5959

6060

61-
def lookup_action(bug: bugzilla_models.Bug, actions: Actions) -> Action:
61+
def lookup_actions(bug: bugzilla_models.Bug, actions: Actions) -> list[Action]:
6262
"""
63-
Find first matching action from bug's whiteboard field.
63+
Find matching actions from bug's whiteboard field.
6464
6565
Tags are strings between brackets and can have prefixes/suffixes
6666
using dashes (eg. ``[project]``, ``[project-moco]``, ``[project-moco-sprint1]``).
6767
"""
6868

6969
if bug.whiteboard:
70+
relevant_actions = []
7071
for tag, action in actions.by_tag.items():
7172
# [tag-word], [tag-], [tag], but not [word-tag] or [tagword]
7273
search_string = r"\[" + tag + r"(-[^\]]*)*\]"
7374
if re.search(search_string, bug.whiteboard, flags=re.IGNORECASE):
74-
return action
75+
relevant_actions.append(action)
76+
if len(relevant_actions):
77+
return relevant_actions
7578

7679
raise ActionNotFoundError(", ".join(actions.by_tag.keys()))
7780

@@ -203,12 +206,17 @@ def execute_action(
203206
request: bugzilla_models.WebhookRequest,
204207
actions: Actions,
205208
):
206-
"""Execute the configured action for the specified `request`.
209+
"""Execute the configured actions for the specified `request`.
210+
211+
If multiple actions are configured for a given request, all of them
212+
are executed.
207213
208214
This will raise an `IgnoreInvalidRequestError` error if the request
209215
does not contain bug data or does not match any action.
210216
211-
The value returned by the action call is returned.
217+
A dictionary containing the values returned by the actions calls
218+
is returned. The action tag is used to index the responses in the
219+
dictionary.
212220
"""
213221
bug, event = request.bug, request.event
214222
runner_context = RunnerContext(
@@ -221,7 +229,7 @@ def execute_action(
221229
raise IgnoreInvalidRequestError("private bugs are not supported")
222230

223231
try:
224-
action = lookup_action(bug, actions)
232+
relevant_actions = lookup_actions(bug, actions)
225233
except ActionNotFoundError as err:
226234
raise IgnoreInvalidRequestError(
227235
f"no bug whiteboard matching action tags: {err}"
@@ -238,10 +246,42 @@ def execute_action(
238246
# is processed (eg. if it spent some time in the DL queue)
239247
raise IgnoreInvalidRequestError(str(err)) from err
240248

241-
runner_context = runner_context.update(bug=bug)
249+
runner_context = runner_context.update(bug=bug, actions=relevant_actions)
250+
251+
return do_execute_actions(runner_context, bug, relevant_actions)
252+
except IgnoreInvalidRequestError as exception:
253+
logger.info(
254+
"Ignore incoming request: %s",
255+
exception,
256+
extra=runner_context.update(operation=Operation.IGNORE).model_dump(),
257+
)
258+
statsd.incr("jbi.bugzilla.ignored.count")
259+
raise
242260

243-
runner_context = runner_context.update(action=action)
244261

262+
@statsd.timer("jbi.action.execution.timer")
263+
def do_execute_actions(
264+
runner_context: RunnerContext,
265+
bug: bugzilla_models.Bug,
266+
actions: Actions,
267+
):
268+
"""Execute the provided actions on the bug, within the provided context.
269+
270+
This will raise an `IgnoreInvalidRequestError` error if the request
271+
does not contain bug data or does not match any action.
272+
273+
A dictionary containing the values returned by the actions calls
274+
is returned. The action tag is used to index the responses in the
275+
dictionary.
276+
"""
277+
runner_context = runner_context.update(bug=bug)
278+
279+
runner_context = runner_context.update(actions=actions)
280+
281+
event = runner_context.event
282+
283+
details = {}
284+
for action in actions:
245285
linked_issue_key: Optional[str] = bug.extract_from_see_also(
246286
project_key=action.jira_project_key
247287
)
@@ -275,6 +315,10 @@ def execute_action(
275315
if (
276316
project_key := jira_issue["fields"]["project"]["key"]
277317
) != action_context.jira.project:
318+
# TODO: We're now executing multiple actions for a given bug, we
319+
# should probably either not fail and instead report which actions
320+
# failed to apply, or execute all the changes as a "transaction" and
321+
# roll them back if one of them fails.
278322
raise IgnoreInvalidRequestError(
279323
f"ignore linked project {project_key!r} (!={action_context.jira.project!r})"
280324
)
@@ -306,7 +350,8 @@ def execute_action(
306350
extra=runner_context.update(operation=Operation.EXECUTE).model_dump(),
307351
)
308352
executor = Executor(parameters=action.parameters)
309-
handled, details = executor(context=action_context)
353+
handled, action_details = executor(context=action_context)
354+
details[action.whiteboard_tag] = action_details
310355
statsd.incr(f"jbi.operation.{action_context.operation.lower()}.count")
311356
logger.info(
312357
"Action %r executed successfully for Bug %s",
@@ -317,12 +362,4 @@ def execute_action(
317362
).model_dump(),
318363
)
319364
statsd.incr("jbi.bugzilla.processed.count")
320-
return details
321-
except IgnoreInvalidRequestError as exception:
322-
logger.info(
323-
"Ignore incoming request: %s",
324-
exception,
325-
extra=runner_context.update(operation=Operation.IGNORE).model_dump(),
326-
)
327-
statsd.incr("jbi.bugzilla.ignored.count")
328-
raise
365+
return details

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def mocked_statsd():
5555
yield _mocked_statsd
5656

5757

58+
register(factories.ActionStepsFactory)
5859
register(factories.ActionContextFactory)
5960
register(factories.ActionFactory)
6061
register(factories.ActionsFactory, "_actions")

tests/fixtures/factories.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,16 @@ def _build(cls, model_class, *args, **kwargs):
2323
return model_class.model_construct(**kwargs)
2424

2525

26+
class ActionStepsFactory(PydanticFactory):
27+
class Meta:
28+
model = models.ActionSteps
29+
30+
2631
class ActionParamsFactory(PydanticFactory):
2732
class Meta:
2833
model = models.ActionParams
2934

35+
steps = factory.SubFactory(ActionStepsFactory)
3036
jira_project_key = "JBI"
3137
jira_components = {}
3238
labels_brackets = "no"

tests/unit/test_runner.py

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import requests
66
import responses
77

8+
import tests.fixtures.factories as factories
89
from jbi import Operation
910
from jbi.bugzilla.client import BugNotAccessibleError
1011
from jbi.environment import get_settings
@@ -15,7 +16,7 @@
1516
Executor,
1617
execute_action,
1718
execute_or_queue,
18-
lookup_action,
19+
lookup_actions,
1920
)
2021

2122

@@ -187,7 +188,7 @@ def test_action_is_logged_as_success_if_returns_true(
187188
("Action 'devtest' executed successfully for Bug 654321", Operation.SUCCESS),
188189
]
189190
assert capturelogs.records[-1].bug["id"] == 654321
190-
assert capturelogs.records[-1].action["whiteboard_tag"] == "devtest"
191+
assert capturelogs.records[-1].actions[0]["whiteboard_tag"] == "devtest"
191192

192193

193194
def test_action_is_logged_as_ignore_if_returns_false(
@@ -619,11 +620,42 @@ def test_counter_is_incremented_for_attachment(
619620
)
620621
def test_lookup_action_found(whiteboard, actions, bug_factory):
621622
bug = bug_factory(id=1234, whiteboard=whiteboard)
622-
action = lookup_action(bug, actions)
623+
action = lookup_actions(bug, actions)[0]
623624
assert action.whiteboard_tag == "devtest"
624625
assert "test config" in action.description
625626

626627

628+
@pytest.mark.parametrize(
629+
"whiteboard,expected_tags",
630+
[
631+
("[example][DevTest]", ["devtest"]),
632+
("[DevTest][example]", ["devtest"]),
633+
("[example][DevTest][other]", ["devtest", "other"]),
634+
],
635+
)
636+
def test_multiple_lookup_actions_found(whiteboard, expected_tags, bug_factory):
637+
actions = factories.ActionsFactory(
638+
root=[
639+
factories.ActionFactory(
640+
whiteboard_tag="devtest",
641+
bugzilla_user_id="tbd",
642+
description="test config",
643+
),
644+
factories.ActionFactory(
645+
whiteboard_tag="other",
646+
bugzilla_user_id="tbd",
647+
description="test config",
648+
),
649+
]
650+
)
651+
bug = bug_factory(id=1234, whiteboard=whiteboard)
652+
acts = lookup_actions(bug, actions)
653+
assert len(acts) == len(expected_tags)
654+
looked_up_tags = [a.whiteboard_tag for a in acts]
655+
assert sorted(looked_up_tags) == sorted(expected_tags)
656+
assert all(["test config" == a.description for a in acts])
657+
658+
627659
@pytest.mark.parametrize(
628660
"whiteboard",
629661
[
@@ -649,5 +681,114 @@ def test_lookup_action_found(whiteboard, actions, bug_factory):
649681
def test_lookup_action_not_found(whiteboard, actions, bug_factory):
650682
bug = bug_factory(id=1234, whiteboard=whiteboard)
651683
with pytest.raises(ActionNotFoundError) as exc_info:
652-
lookup_action(bug, actions)
684+
lookup_actions(bug, actions)[0]
653685
assert str(exc_info.value) == "devtest"
686+
687+
688+
def test_request_triggers_multiple_actions(
689+
webhook_request_factory,
690+
mocked_bugzilla,
691+
):
692+
actions = factories.ActionsFactory(
693+
root=[
694+
factories.ActionFactory(
695+
whiteboard_tag="devtest",
696+
bugzilla_user_id="tbd",
697+
description="test config",
698+
),
699+
factories.ActionFactory(
700+
whiteboard_tag="other",
701+
bugzilla_user_id="tbd",
702+
description="test config",
703+
),
704+
]
705+
)
706+
707+
webhook = webhook_request_factory(bug__whiteboard="[devtest][other]")
708+
mocked_bugzilla.get_bug.return_value = webhook.bug
709+
710+
details = execute_action(request=webhook, actions=actions)
711+
712+
# Details has the following shape:
713+
# {'devtest': {'responses': [..]}, 'other': {'responses': [...]}}
714+
assert len(actions) == len(details)
715+
assert "devtest" in details
716+
assert "other" in details
717+
718+
719+
def test_request_triggers_multiple_update_actions(
720+
webhook_request_factory,
721+
mocked_bugzilla,
722+
mocked_jira,
723+
webhook_event_change_factory,
724+
):
725+
actions = factories.ActionsFactory(
726+
root=[
727+
factories.ActionFactory(
728+
whiteboard_tag="devtest",
729+
bugzilla_user_id="tbd",
730+
description="test config",
731+
parameters__jira_project_key="JBI",
732+
parameters__steps__existing=["maybe_update_issue_resolution"],
733+
parameters__resolution_map={
734+
"FIXED": "Closed",
735+
},
736+
),
737+
factories.ActionFactory(
738+
whiteboard_tag="other",
739+
bugzilla_user_id="tbd",
740+
description="test config",
741+
parameters__jira_project_key="DE",
742+
parameters__steps__existing=["maybe_update_issue_resolution"],
743+
parameters__resolution_map={
744+
"FIXED": "Done",
745+
},
746+
),
747+
]
748+
)
749+
750+
webhook = webhook_request_factory(
751+
bug__whiteboard="[devtest][other]",
752+
bug__see_also=[
753+
"https://mozilla.atlassian.net/browse/JBI-234",
754+
"https://mozilla.atlassian.net/browse/DE-567",
755+
],
756+
bug__resolution="FIXED",
757+
event__changes=[
758+
webhook_event_change_factory(
759+
field="resolution", removed="OPEN", added="FIXED"
760+
)
761+
],
762+
)
763+
mocked_bugzilla.get_bug.return_value = webhook.bug
764+
765+
def side_effect_for_get_issue(issue_key):
766+
if issue_key.startswith("JBI-"):
767+
return {"fields": {"project": {"key": "JBI"}}}
768+
elif issue_key.startswith("DE-"):
769+
return {"fields": {"project": {"key": "DE"}}}
770+
771+
return None
772+
773+
mocked_jira.get_issue.side_effect = side_effect_for_get_issue
774+
775+
details = execute_action(request=webhook, actions=actions)
776+
777+
mocked_jira.update_issue_field.assert_any_call(
778+
key="JBI-234",
779+
fields={
780+
"resolution": {"name": "Closed"},
781+
},
782+
)
783+
mocked_jira.update_issue_field.assert_any_call(
784+
key="DE-567",
785+
fields={
786+
"resolution": {"name": "Done"},
787+
},
788+
)
789+
790+
# Details has the following shape:
791+
# {'devtest': {'responses': [..]}, 'other': {'responses': [...]}}
792+
assert len(actions) == len(details)
793+
assert "devtest" in details
794+
assert "other" in details

0 commit comments

Comments
 (0)