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
18 changes: 16 additions & 2 deletions mlx/jira_traceability/jira_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from jira import JIRA, JIRAError
from sphinx.util.logging import getLogger
from .jira_utils import format_jira_error
from .jira_utils import format_jira_error, validate_components

LOGGER = getLogger('mlx.jira_traceability')

Expand Down Expand Up @@ -56,6 +56,9 @@ def create_unique_issues(item_ids, jira, general_fields, settings, traceability_
settings (dict): Configuration for this feature
traceability_collection (TraceableCollection): Collection of all traceability items
"""
# Cache for validated components per project to avoid repeated validation
validated_components_cache = {}

for item_id in item_ids:
fields = {}
item = traceability_collection.get_item(item_id)
Expand Down Expand Up @@ -103,7 +106,18 @@ def create_unique_issues(item_ids, jira, general_fields, settings, traceability_
fields['assignee'] = {'name': assignee}
assignee = ''

issue = push_item_to_jira(jira, {**fields, **general_fields}, item, attendees, assignee)
# Validate components against Jira project (cached per project)
project_general_fields = general_fields.copy()
if 'components' in project_general_fields:
if project_id_or_key not in validated_components_cache:
# Validate components for this project and cache the result
validated_components_cache[project_id_or_key] = validate_components(
jira, project_id_or_key, project_general_fields['components']
)
# Use cached validated components
project_general_fields['components'] = validated_components_cache[project_id_or_key]

issue = push_item_to_jira(jira, {**fields, **project_general_fields}, item, attendees, assignee)
print("mlx.jira-traceability: created Jira ticket for item {} here: {}".format(item_id, issue.permalink()))


Expand Down
43 changes: 42 additions & 1 deletion mlx/jira_traceability/jira_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Utility functions for JIRA error handling and formatting"""

from jira.exceptions import JIRAError
from jira import JIRAError
from sphinx.util.logging import getLogger

LOGGER = getLogger('mlx.jira_traceability')


def format_jira_error(err):
Expand All @@ -25,3 +28,41 @@ def format_jira_error(err):
return f"JIRA error: {str(err)}"
else:
return f"Error: {str(err)}"


def validate_components(jira, project_id_or_key, components):
"""Validate a list of components against a Jira project's available components.

Attempts to use original component names first, falling back to stripped versions (removing '[' and ']').

Args:
jira: Jira interface object
project_id_or_key (str): Project key or ID to validate components against
components (list): List of component dictionaries with 'name' key

Returns:
list: List of valid component dictionaries, using stripped names where applicable
"""
try:
valid_components = jira.project_components(project_id_or_key)
valid_component_names = [c.name for c in valid_components]
invalid_components = []
final_components = []
for comp in components:
comp_name = comp['name']
if comp_name in valid_component_names:
final_components.append({'name': comp_name})
else:
stripped_name = comp_name.strip('[]')
if stripped_name != comp_name and stripped_name in valid_component_names:
final_components.append({'name': stripped_name})
LOGGER.info(f"Using stripped component name '{stripped_name}' instead of "
f"'{comp_name}' for project {project_id_or_key}")
else:
invalid_components.append(comp_name)
if invalid_components:
LOGGER.warning(f"Invalid components found for project {project_id_or_key}: {', '.join(invalid_components)}")
return final_components
except JIRAError as err:
LOGGER.warning(f"Failed to validate components: {err.text}")
return components # Return original components if validation fails
138 changes: 138 additions & 0 deletions tests/test_jira_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ def produce_fake_users(**kwargs):
return users


def produce_fake_components():
"""Produce fake components for project_components mock"""
Component = namedtuple('Component', 'name')
return [
Component('[SW]'),
Component('[HW]'),
]


@mock.patch('mlx.jira_traceability.jira_interaction.JIRA')
class TestJiraInteraction(TestCase):
def setUp(self):
Expand Down Expand Up @@ -127,6 +136,7 @@ def test_create_jira_issues_unique(self, jira):
jira_mock = jira.return_value
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:
warning('Dummy log')
dut.create_jira_issues(self.settings, self.coll)
Expand Down Expand Up @@ -185,6 +195,7 @@ def test_notify_watchers(self, jira):
"""
jira_mock = jira.return_value
jira_mock.search_issues.return_value = []
jira_mock.project_components.return_value = produce_fake_components()
self.settings['notify_watchers'] = True

with self.assertLogs(level=WARNING):
Expand Down Expand Up @@ -222,6 +233,7 @@ def jira_update_mock(update={}, **_):

jira_mock = jira.return_value
jira_mock.search_issues.return_value = []
jira_mock.project_components.return_value = produce_fake_components()
issue = jira_mock.create_issue.return_value
issue.update.side_effect = jira_update_mock
dut.create_jira_issues(self.settings, self.coll)
Expand All @@ -237,6 +249,7 @@ def jira_update_mock(update={}, **_):
def test_prevent_duplication(self, jira):
jira_mock = jira.return_value
jira_mock.search_issues.return_value = ['Jira already contains this ticket']
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 @@ -255,6 +268,7 @@ def test_no_warning_about_duplication(self, jira):
self.settings.pop('warn_if_exists')
jira_mock = jira.return_value
jira_mock.search_issues.return_value = ['Jira already contains this ticket']
jira_mock.project_components.return_value = produce_fake_components()
with self.assertLogs(level=WARNING) as cm:
warning('Dummy log')
dut.create_jira_issues(self.settings, self.coll)
Expand All @@ -272,6 +286,7 @@ def test_default_project(self, jira):
jira_mock = jira.return_value
jira_mock.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)

self.assertEqual(
Expand Down Expand Up @@ -301,6 +316,7 @@ def jira_add_watcher_mock(*_):
jira_mock.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 @@ -327,6 +343,7 @@ def test_tuple_for_relationship_to_parent(self, jira):
jira_mock = jira.return_value
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:
warning('Dummy log')
dut.create_jira_issues(self.settings, self.coll)
Expand Down Expand Up @@ -385,3 +402,124 @@ def test_get_info_from_relationship_str(self, _):

self.assertEqual(attendees, ['ABC', 'ZZZ'])
self.assertEqual(jira_field, 'MEETING-12345_2: Action 1\'s caption?')

def test_component_stripping(self, jira):
""" Test that component names get stripped of square brackets when the original doesn't exist """
def produce_stripped_components():
Component = namedtuple('Component', 'name')
return [
Component('SW'), # Note: no brackets
Component('HW'), # Note: no brackets
]

jira_mock = jira.return_value
jira_mock.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
with self.assertLogs(level='INFO') as cm:
dut.create_jira_issues(self.settings, self.coll)

# Check that info messages about component stripping are logged
stripped_logs = [log for log in cm.output if 'Using stripped component name' in log]
self.assertEqual(len(stripped_logs), 2) # Should have 2 stripped components

# Check that the create_issue calls use the stripped component names
out = jira_mock.create_issue.call_args_list

# Expected components should be stripped
expected_general_fields = self.general_fields.copy()
expected_general_fields['components'] = [{'name': 'SW'}, {'name': 'HW'}]

ref = [
mock.call(
summary='MEETING-12345_2: Action 1\'s caption?',
description='Description for action 1',
assignee={'name': 'ABC'},
**expected_general_fields
),
mock.call(
summary='Caption for action 2',
description='Caption for action 2',
assignee={'name': 'ZZZ'},
**expected_general_fields
),
]
self.assertEqual(out, ref)

def test_invalid_components_warning(self, jira):
""" Test that invalid components generate warnings """
def produce_different_components():
Component = namedtuple('Component', 'name')
return [
Component('DOCS'), # Different component names
Component('QA'),
]

jira_mock = jira.return_value
jira_mock.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:
dut.create_jira_issues(self.settings, self.coll)

# Check that warning about invalid components is logged
# With caching optimization, validation happens once per project
invalid_component_logs = [log for log in cm.output if 'Invalid components found' in log]
self.assertEqual(len(invalid_component_logs), 1) # Should warn once per project

# Verify the warning message contains the invalid component names
self.assertIn('[SW], [HW]', invalid_component_logs[0])

def test_component_validation_failure(self, jira):
""" Test that component validation failure falls back to original components """
def jira_project_components_error(*_):
raise JIRAError(status_code=404, text='Project not found')

jira_mock = jira.return_value
jira_mock.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:
dut.create_jira_issues(self.settings, self.coll)

# Check that warning about validation failure is logged
# With caching optimization, validation happens once per project
validation_failure_logs = [log for log in cm.output if 'Failed to validate components' in log]
self.assertEqual(len(validation_failure_logs), 1) # Should warn once per project

# Check that the create_issue calls use the original component names (fallback behavior)
out = jira_mock.create_issue.call_args_list
ref = [
mock.call(
summary='MEETING-12345_2: Action 1\'s caption?',
description='Description for action 1',
assignee={'name': 'ABC'},
**self.general_fields # Original components should be used
),
mock.call(
summary='Caption for action 2',
description='Caption for action 2',
assignee={'name': 'ZZZ'},
**self.general_fields # Original components should be used
),
]
self.assertEqual(out, ref)

def test_component_validation_caching(self, jira):
""" Test that component validation is cached per project """
jira_mock = jira.return_value
jira_mock.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)

# Verify that project_components was called only once (cached for subsequent items)
self.assertEqual(jira_mock.project_components.call_count, 1)

# Verify it was called with the correct project
jira_mock.project_components.assert_called_with('MLX12345')