Skip to content

Commit db5bc43

Browse files
authored
Fix #393: keep existing labels on Jira issue (#539)
* Fix #393: keep existing labels on Jira issue * Extract parts of sync_whiteboard_labels into private functions * Add comment to explain sorted() * Rearrange usage of webhook event factories in test_steps
1 parent fa57bee commit db5bc43

File tree

6 files changed

+99
-61
lines changed

6 files changed

+99
-61
lines changed

jbi/actions/steps.py

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

77
import logging
8+
from typing import Optional
89

910
from requests import exceptions as requests_exceptions
1011

@@ -244,14 +245,47 @@ def maybe_update_components(context: ActionContext, **parameters):
244245
return context, (resp,)
245246

246247

248+
def _whiteboard_as_labels(whiteboard: Optional[str]) -> list[str]:
249+
"""Split the whiteboard string into a list of labels"""
250+
splitted = whiteboard.replace("[", "").split("]") if whiteboard else []
251+
stripped = [x.strip() for x in splitted if x not in ["", " "]]
252+
# Jira labels can't contain a " ", convert to "."
253+
nospace = [wb.replace(" ", ".") for wb in stripped]
254+
with_brackets = [f"[{wb}]" for wb in nospace]
255+
return ["bugzilla"] + nospace + with_brackets
256+
257+
258+
def _build_labels_update(added, removed=None):
259+
after = _whiteboard_as_labels(added)
260+
# We don't bother detecting if label was already there.
261+
updates = [{"add": label} for label in after]
262+
if removed:
263+
before = _whiteboard_as_labels(removed)
264+
deleted = sorted(set(before).difference(set(after))) # sorted for unit testing
265+
updates.extend([{"remove": label} for label in deleted])
266+
return updates
267+
268+
247269
def sync_whiteboard_labels(context: ActionContext, **parameters):
248270
"""
249271
Set whiteboard tags as labels on the Jira issue
250272
"""
273+
274+
# On update of whiteboard field, add/remove corresponding labels
275+
if context.event.changes:
276+
changes_by_field = {change.field: change for change in context.event.changes}
277+
if change := changes_by_field.get("whiteboard"):
278+
updates = _build_labels_update(added=change.added, removed=change.removed)
279+
else:
280+
# Whiteboard field not changed, ignore.
281+
return context, ()
282+
else:
283+
# On creation, just add them all.
284+
updates = _build_labels_update(added=context.bug.whiteboard)
285+
251286
try:
252-
# Note: fetch existing first, see mozilla/jira-bugzilla-integration#393
253-
resp = jira.get_client().update_issue_field(
254-
key=context.jira.issue, fields={"labels": context.bug.get_jira_labels()}
287+
resp = jira.get_client().update_issue(
288+
issue_key=context.jira.issue, update={"update": {"labels": updates}}
255289
)
256290
except requests_exceptions.HTTPError as exc:
257291
if exc.response.status_code != 400:

jbi/models.py

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -246,24 +246,6 @@ def is_assigned(self) -> bool:
246246
"""Return `true` if the bug is assigned to a user."""
247247
return self.assigned_to != "[email protected]"
248248

249-
def get_whiteboard_as_list(self) -> list[str]:
250-
"""Convert string whiteboard into list, splitting on ']' and removing '['."""
251-
split_list = (
252-
self.whiteboard.replace("[", "").split("]") if self.whiteboard else []
253-
)
254-
return [x.strip() for x in split_list if x not in ["", " "]]
255-
256-
def get_jira_labels(self) -> list[str]:
257-
"""
258-
whiteboard labels are added as a convenience for users to search in jira;
259-
bugzilla is an expected label in Jira
260-
since jira-labels can't contain a " ", convert to "."
261-
"""
262-
wb_list = self.get_whiteboard_as_list()
263-
wb_bracket_list = [f"[{wb}]" for wb in wb_list]
264-
without_spaces = [wb.replace(" ", ".") for wb in (wb_list + wb_bracket_list)]
265-
return ["bugzilla"] + without_spaces
266-
267249
def issue_type(self) -> str:
268250
"""Get the Jira issue type for this bug"""
269251
type_map: dict = {"enhancement": "Task", "task": "Task", "defect": "Bug"}
@@ -368,6 +350,7 @@ class JiraContext(Context):
368350

369351
project: str
370352
issue: Optional[str]
353+
labels: Optional[list[str]]
371354

372355

373356
BugId = TypedDict("BugId", {"id": Optional[int]})

jbi/services/jira.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def raise_for_status(self, *args, **kwargs):
6969
get_permissions = instrumented_method(Jira.get_permissions)
7070
get_project_components = instrumented_method(Jira.get_project_components)
7171
projects = instrumented_method(Jira.projects)
72+
update_issue = instrumented_method(Jira.update_issue)
7273
update_issue_field = instrumented_method(Jira.update_issue_field)
7374
set_issue_status = instrumented_method(Jira.set_issue_status)
7475
issue_add_comment = instrumented_method(Jira.issue_add_comment)

tests/fixtures/factories.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
BugzillaBug,
88
BugzillaComment,
99
BugzillaWebhookEvent,
10+
BugzillaWebhookEventChange,
1011
BugzillaWebhookRequest,
1112
BugzillaWebhookUser,
1213
JiraContext,
@@ -74,6 +75,16 @@ def webhook_event_factory(**overrides):
7475
return BugzillaWebhookEvent.parse_obj(event)
7576

7677

78+
def webhook_event_change_factory(**overrides):
79+
event = {
80+
"field": "field",
81+
"removed": "old value",
82+
"added": "new value",
83+
**overrides,
84+
}
85+
return BugzillaWebhookEventChange.parse_obj(event)
86+
87+
7788
def webhook_factory(**overrides):
7889
webhook_event = {
7990
"bug": bug_factory(),

tests/unit/actions/test_steps.py

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010
from jbi.actions import default
1111
from jbi.environment import get_settings
12-
from jbi.models import ActionContext, BugzillaWebhookEventChange
12+
from jbi.models import ActionContext
1313
from jbi.services.jira import JiraCreateError
14-
from tests.fixtures.factories import comment_factory
14+
from tests.fixtures.factories import comment_factory, webhook_event_change_factory
1515

1616
ALL_STEPS = {
1717
"new": [
@@ -63,9 +63,7 @@ def test_created_public(
6363

6464
def test_modified_public(context_update_example: ActionContext, mocked_jira):
6565
context_update_example.event.changes = [
66-
BugzillaWebhookEventChange.parse_obj(
67-
{"field": "summary", "removed": "", "added": "JBI Test"}
68-
)
66+
webhook_event_change_factory(field="summary", removed="", added="JBI Test")
6967
]
7068

7169
callable_object = default.init(jira_project_key=context_update_example.jira.project)
@@ -74,11 +72,7 @@ def test_modified_public(context_update_example: ActionContext, mocked_jira):
7472

7573
assert context_update_example.bug.extract_from_see_also(), "see_also is not empty"
7674

77-
mocked_jira.update_issue_field.assert_any_call(
78-
key="JBI-234",
79-
fields={"labels": ["bugzilla", "devtest", "[devtest]"]},
80-
)
81-
mocked_jira.update_issue_field.assert_any_call(
75+
mocked_jira.update_issue_field.assert_called_once_with(
8276
key="JBI-234",
8377
fields={"summary": "JBI Test"},
8478
)
@@ -501,16 +495,57 @@ def test_sync_whiteboard_labels(context_create_example: ActionContext, mocked_ji
501495
)
502496
callable_object(context=context_create_example)
503497

504-
mocked_jira.update_issue_field.assert_called_once_with(
505-
key=context_create_example.jira.issue,
506-
fields={"labels": ["bugzilla", "devtest", "[devtest]"]},
498+
mocked_jira.update_issue.assert_called_once_with(
499+
issue_key=context_create_example.jira.issue,
500+
update={
501+
"update": {
502+
"labels": [
503+
{"add": "bugzilla"},
504+
{"add": "devtest"},
505+
{"add": "[devtest]"},
506+
]
507+
}
508+
},
509+
)
510+
511+
512+
def test_sync_whiteboard_labels_update(
513+
context_update_example: ActionContext, mocked_jira
514+
):
515+
context_update_example.event.changes = [
516+
webhook_event_change_factory(
517+
field="whiteboard",
518+
removed="[remotesettings] [server]",
519+
added="[remotesettings]",
520+
)
521+
]
522+
523+
callable_object = default.init(
524+
jira_project_key=context_update_example.jira.project,
525+
steps={"existing": ["sync_whiteboard_labels"]},
526+
)
527+
callable_object(context=context_update_example)
528+
529+
mocked_jira.update_issue.assert_called_once_with(
530+
issue_key=context_update_example.jira.issue,
531+
update={
532+
"update": {
533+
"labels": [
534+
{"add": "bugzilla"},
535+
{"add": "remotesettings"},
536+
{"add": "[remotesettings]"},
537+
{"remove": "[server]"},
538+
{"remove": "server"},
539+
]
540+
}
541+
},
507542
)
508543

509544

510545
def test_sync_whiteboard_labels_failing(
511546
context_update_example: ActionContext, mocked_jira, caplog
512547
):
513-
mocked_jira.update_issue_field.side_effect = requests.exceptions.HTTPError(
548+
mocked_jira.update_issue.side_effect = requests.exceptions.HTTPError(
514549
"some message", response=mock.MagicMock(status_code=400)
515550
)
516551

tests/unit/test_bugzilla.py

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,6 @@
55
from tests.fixtures.factories import bug_factory
66

77

8-
@pytest.mark.parametrize(
9-
"whiteboard,expected",
10-
[
11-
("", ["bugzilla"]),
12-
("[tag]", ["bugzilla", "tag", "[tag]"]),
13-
("[test whiteboard]", ["bugzilla", "test.whiteboard", "[test.whiteboard]"]),
14-
("[test-whiteboard]", ["bugzilla", "test-whiteboard", "[test-whiteboard]"]),
15-
(
16-
"[test whiteboard][test-no-space][test-both space-and-not",
17-
[
18-
"bugzilla",
19-
"test.whiteboard",
20-
"test-no-space",
21-
"test-both.space-and-not",
22-
"[test.whiteboard]",
23-
"[test-no-space]",
24-
"[test-both.space-and-not]",
25-
],
26-
),
27-
],
28-
)
29-
def test_jira_labels(whiteboard, expected):
30-
bug = bug_factory(whiteboard=whiteboard)
31-
assert bug.get_jira_labels() == expected
32-
33-
348
@pytest.mark.parametrize(
359
"see_also,expected",
3610
[

0 commit comments

Comments
 (0)