diff --git a/mlx/jira_traceability/jira_interaction.py b/mlx/jira_traceability/jira_interaction.py index df09aa1..f659dda 100644 --- a/mlx/jira_traceability/jira_interaction.py +++ b/mlx/jira_traceability/jira_interaction.py @@ -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') @@ -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) @@ -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())) diff --git a/mlx/jira_traceability/jira_utils.py b/mlx/jira_traceability/jira_utils.py index 18ce385..af56a2d 100644 --- a/mlx/jira_traceability/jira_utils.py +++ b/mlx/jira_traceability/jira_utils.py @@ -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): @@ -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 diff --git a/tests/test_jira_interaction.py b/tests/test_jira_interaction.py index 73cf043..de29850 100644 --- a/tests/test_jira_interaction.py +++ b/tests/test_jira_interaction.py @@ -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): @@ -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) @@ -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): @@ -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) @@ -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) @@ -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) @@ -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( @@ -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) @@ -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) @@ -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')