Skip to content

Commit d43aa7f

Browse files
committed
Merge branch 'multi-whiteboard' of github.com:Dexterp37/jira-bugzilla-integration into Dexterp37-multi-whiteboard
2 parents ba7e471 + 87d1d58 commit d43aa7f

File tree

4 files changed

+175
-24
lines changed

4 files changed

+175
-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/unit/test_runner.py

Lines changed: 118 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,87 @@ 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+
):
724+
actions = factories.ActionsFactory(
725+
root=[
726+
factories.ActionFactory(
727+
whiteboard_tag="devtest",
728+
bugzilla_user_id="tbd",
729+
description="test config",
730+
parameters__jira_project_key="JBI",
731+
),
732+
factories.ActionFactory(
733+
whiteboard_tag="other",
734+
bugzilla_user_id="tbd",
735+
description="test config",
736+
parameters__jira_project_key="DE",
737+
),
738+
]
739+
)
740+
741+
webhook = webhook_request_factory(
742+
bug__whiteboard="[devtest][other]",
743+
bug__see_also=[
744+
"https://mozilla.atlassian.net/browse/JBI-234",
745+
"https://mozilla.atlassian.net/browse/DE-567",
746+
],
747+
)
748+
mocked_bugzilla.get_bug.return_value = webhook.bug
749+
750+
def side_effect_for_get_issue(issue_key):
751+
if issue_key.startswith("JBI-"):
752+
return {"fields": {"project": {"key": "JBI"}}}
753+
elif issue_key.startswith("DE-"):
754+
return {"fields": {"project": {"key": "DE"}}}
755+
756+
return None
757+
758+
mocked_jira.get_issue.side_effect = side_effect_for_get_issue
759+
760+
details = execute_action(request=webhook, actions=actions)
761+
762+
# Details has the following shape:
763+
# {'devtest': {'responses': [..]}, 'other': {'responses': [...]}}
764+
assert len(actions) == len(details)
765+
assert "devtest" in details
766+
assert "other" in details
767+
print(details)

0 commit comments

Comments
 (0)