Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 8 additions & 24 deletions mlx/jira_traceability/jira_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,8 @@ def create_unique_issues(item_ids, jira, general_fields, settings, traceability_
fields['description'] = description

if assignee and not settings.get('notify_watchers', False):
# Try to resolve accountId (Jira Cloud) and fall back to username (Server/DC)
account_id = resolve_account_id(jira, assignee)
if account_id:
fields['assignee'] = {'accountId': account_id}
else:
fields['assignee'] = {'name': assignee}
# Let the JIRA library handle user resolution automatically
fields['assignee'] = {'name': assignee}
assignee = ''

# Validate components against Jira project (cached per project)
Expand Down Expand Up @@ -169,13 +165,16 @@ def push_item_to_jira(jira, fields, item, attendees, assignee):

for attendee in attendees:
try:
# Let the JIRA library handle user resolution automatically
jira.add_watcher(issue, attendee)
except JIRAError as err:
LOGGER.warning("Could not add watcher {} to issue {}: {}".format(attendee, issue.key, err.text))
if assignee:
# Try assign using accountId if resolvable; otherwise pass through the provided value
account_id = resolve_account_id(jira, assignee)
jira.assign_issue(issue, account_id or assignee)
try:
# Let the JIRA library handle user resolution automatically
jira.assign_issue(issue, assignee)
except JIRAError as err:
LOGGER.warning("Could not assign issue {} to {}: {}".format(issue.key, assignee, err.text))
return issue


Expand Down Expand Up @@ -255,18 +254,3 @@ def escape_special_characters(input_string):
return prepared_string


def resolve_account_id(jira, username_or_email):
"""Attempt to resolve a Jira Cloud accountId from a username or email.

Returns an accountId string or empty string if not resolvable.
"""
try:
# search_users supports both username fragments and emails depending on Jira config
candidates = jira.search_users(query=username_or_email, maxResults=2)
for user in candidates:
account_id = getattr(user, 'accountId', '') or getattr(user, 'account_id', '')
if account_id:
return account_id
except Exception: # noqa: BLE001 - be defensive against older server APIs
return ''
return ''
65 changes: 9 additions & 56 deletions tests/test_jira_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,6 @@
import mlx.jira_traceability.jira_interaction as dut


def produce_fake_users(**kwargs):
users = []
if kwargs.get('user') is not None:
User = namedtuple('User', 'name')
users.append(User(kwargs['user']))
if kwargs.get('query') is not None:
# Only return accountId for 'ABC', not for 'ZZZ'
if kwargs['query'] == 'ABC':
User = namedtuple('User', 'accountId')
users.append(User('bf3157418d89e30046118185'))
# For other users like 'ZZZ', return empty list (no accountId found)
return users


def produce_fake_components():
Expand Down Expand Up @@ -138,7 +126,6 @@ def test_missing_all_optional_one_mandatory(self, *_):
def test_create_jira_issues_unique(self, jira):
jira_mock = jira.return_value
jira_mock.enhanced_search_issues.return_value = []
jira_mock.search_users.side_effect = produce_fake_users
jira_mock.project_components.return_value = produce_fake_components()
with self.assertLogs(level=WARNING) as cm:
warning('Dummy log')
Expand All @@ -162,12 +149,12 @@ def test_create_jira_issues_unique(self, jira):
issue = jira_mock.create_issue.return_value
out = jira_mock.create_issue.call_args_list

# Updated expected fields structure
# Updated expected fields structure - now all assignees use name format
expected_fields_1 = {
'project': {'key': 'MLX12345'},
'summary': 'MEETING-12345_2: Action 1\'s caption?',
'description': 'Description for action 1',
'assignee': {'accountId': 'bf3157418d89e30046118185'},
'assignee': {'name': 'ABC'},
'components': [{'name': '[SW]'}, {'name': '[HW]'}],
'issuetype': {'name': 'Task'},
}
Expand Down Expand Up @@ -208,7 +195,6 @@ def test_notify_watchers(self, jira):
jira_mock = jira.return_value
jira_mock.enhanced_search_issues.return_value = []
jira_mock.project_components.return_value = produce_fake_components()
jira_mock.search_users.side_effect = produce_fake_users
self.settings['notify_watchers'] = True

with self.assertLogs(level=WARNING):
Expand Down Expand Up @@ -239,10 +225,10 @@ def test_notify_watchers(self, jira):
])
# Additional call to set assignee should be made after the issue has been created
issue = jira_mock.create_issue.return_value
# Check assign_issue calls - ABC should use accountId, ZZZ should use name
# Check assign_issue calls - now all users use username format
self.assertEqual(len(jira_mock.assign_issue.call_args_list), 2)
self.assertEqual(jira_mock.assign_issue.call_args_list[0].args[1], 'bf3157418d89e30046118185') # ABC -> accountId
self.assertEqual(jira_mock.assign_issue.call_args_list[1].args[1], 'ZZZ') # ZZZ -> name (no accountId found)
self.assertEqual(jira_mock.assign_issue.call_args_list[0].args[1], 'ABC') # ABC -> username
self.assertEqual(jira_mock.assign_issue.call_args_list[1].args[1], 'ZZZ') # ZZZ -> username

def test_create_issue_timetracking_unavailable(self, jira):
""" Value of effort attribute should be appended to description when setting timetracking field raises error """
Expand All @@ -253,7 +239,6 @@ def jira_update_mock(fields=None, **_):
jira_mock = jira.return_value
jira_mock.enhanced_search_issues.return_value = []
jira_mock.project_components.return_value = produce_fake_components()
jira_mock.search_users.side_effect = produce_fake_users
issue = jira_mock.create_issue.return_value
issue.update.side_effect = jira_update_mock
dut.create_jira_issues(self.settings, self.coll)
Expand Down Expand Up @@ -304,15 +289,14 @@ def test_default_project(self, jira):

jira_mock = jira.return_value
jira_mock.enhanced_search_issues.return_value = []
jira_mock.search_users.side_effect = produce_fake_users
jira_mock.project_components.return_value = produce_fake_components()
dut.create_jira_issues(self.settings, self.coll)

expected_fields_1 = {
'project': {'key': 'SWCC'},
'summary': 'MEETING-12345_2: Action 1\'s caption?',
'description': 'Description for action 1',
'assignee': {'accountId': 'bf3157418d89e30046118185'},
'assignee': {'name': 'ABC'},
'components': [{'name': '[SW]'}, {'name': '[HW]'}],
'issuetype': {'name': 'Task'},
}
Expand Down Expand Up @@ -341,7 +325,6 @@ def jira_add_watcher_mock(*_):
jira_mock = jira.return_value
jira_mock.enhanced_search_issues.return_value = []
jira_mock.add_watcher.side_effect = jira_add_watcher_mock
jira_mock.search_users.side_effect = produce_fake_users
jira_mock.project_components.return_value = produce_fake_components()
with self.assertLogs(level=WARNING) as cm:
dut.create_jira_issues(self.settings, self.coll)
Expand All @@ -368,7 +351,6 @@ def test_tuple_for_relationship_to_parent(self, jira):

jira_mock = jira.return_value
jira_mock.enhanced_search_issues.return_value = []
jira_mock.search_users.side_effect = produce_fake_users
jira_mock.project_components.return_value = produce_fake_components()
with self.assertLogs(level=WARNING) as cm:
warning('Dummy log')
Expand All @@ -391,7 +373,7 @@ def test_tuple_for_relationship_to_parent(self, jira):
'project': {'key': 'MLX12345'},
'summary': 'ZZZ-TO_BE_PRIORITIZED: Action 1\'s caption?',
'description': 'Description for action 1',
'assignee': {'accountId': 'bf3157418d89e30046118185'},
'assignee': {'name': 'ABC'},
'components': [{'name': '[SW]'}, {'name': '[HW]'}],
'issuetype': {'name': 'Task'},
}
Expand Down Expand Up @@ -448,7 +430,6 @@ def produce_stripped_components():

jira_mock = jira.return_value
jira_mock.enhanced_search_issues.return_value = []
jira_mock.search_users.side_effect = produce_fake_users
jira_mock.project_components.return_value = produce_stripped_components()

# Use INFO level to capture the stripped component messages
Expand All @@ -466,7 +447,7 @@ def produce_stripped_components():
'project': {'key': 'MLX12345'},
'summary': 'MEETING-12345_2: Action 1\'s caption?',
'description': 'Description for action 1',
'assignee': {'accountId': 'bf3157418d89e30046118185'},
'assignee': {'name': 'ABC'},
'components': [{'name': 'SW'}, {'name': 'HW'}],
'issuetype': {'name': 'Task'},
}
Expand Down Expand Up @@ -495,7 +476,6 @@ def produce_different_components():

jira_mock = jira.return_value
jira_mock.enhanced_search_issues.return_value = []
jira_mock.search_users.side_effect = produce_fake_users
jira_mock.project_components.return_value = produce_different_components()

with self.assertLogs(level=WARNING) as cm:
Expand All @@ -516,7 +496,6 @@ def jira_project_components_error(*_):

jira_mock = jira.return_value
jira_mock.enhanced_search_issues.return_value = []
jira_mock.search_users.side_effect = produce_fake_users
jira_mock.project_components.side_effect = jira_project_components_error

with self.assertLogs(level=WARNING) as cm:
Expand All @@ -534,7 +513,7 @@ def jira_project_components_error(*_):
'project': {'key': 'MLX12345'},
'summary': 'MEETING-12345_2: Action 1\'s caption?',
'description': 'Description for action 1',
'assignee': {'accountId': 'bf3157418d89e30046118185'},
'assignee': {'name': 'ABC'},
'components': [{'name': '[SW]'}, {'name': '[HW]'}], # Original components should be used
'issuetype': {'name': 'Task'},
}
Expand All @@ -556,7 +535,6 @@ def test_component_validation_caching(self, jira):
""" Test that component validation is cached per project """
jira_mock = jira.return_value
jira_mock.enhanced_search_issues.return_value = []
jira_mock.search_users.side_effect = produce_fake_users
jira_mock.project_components.return_value = produce_fake_components()

dut.create_jira_issues(self.settings, self.coll)
Expand All @@ -567,38 +545,13 @@ def test_component_validation_caching(self, jira):
# Verify it was called with the correct project
jira_mock.project_components.assert_called_with('MLX12345')

def test_resolve_account_id_success(self, _):
""" Test that resolve_account_id returns accountId when user is found """
jira_mock = mock.MagicMock()
User = namedtuple('User', 'accountId')
jira_mock.search_users.return_value = [User('bf3157418d89e30046118185')]

result = dut.resolve_account_id(jira_mock, 'testuser')
self.assertEqual(result, 'bf3157418d89e30046118185')

def test_resolve_account_id_not_found(self, _):
""" Test that resolve_account_id returns empty string when user is not found """
jira_mock = mock.MagicMock()
jira_mock.search_users.return_value = []

result = dut.resolve_account_id(jira_mock, 'nonexistent')
self.assertEqual(result, '')

def test_resolve_account_id_exception(self, _):
""" Test that resolve_account_id returns empty string when search_users raises exception """
jira_mock = mock.MagicMock()
jira_mock.search_users.side_effect = Exception('API error')

result = dut.resolve_account_id(jira_mock, 'testuser')
self.assertEqual(result, '')

def test_enhanced_search_issues_fallback(self, jira):
""" Test that the code falls back to search_issues when enhanced_search_issues is not available """
jira_mock = jira.return_value
# Simulate enhanced_search_issues not being available
del jira_mock.enhanced_search_issues
jira_mock.search_issues.return_value = []
jira_mock.search_users.side_effect = produce_fake_users
jira_mock.project_components.return_value = produce_fake_components()

with self.assertLogs(level=WARNING) as cm:
Expand Down