From 93bddff5dfcdd8712929032a7a5d5650651e6fea Mon Sep 17 00:00:00 2001 From: jce Date: Fri, 20 Jun 2025 12:28:04 +0200 Subject: [PATCH 1/9] Use name of the package for the logger --- mlx/jira_interaction.py | 2 +- mlx/jira_traceability.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mlx/jira_interaction.py b/mlx/jira_interaction.py index 02dfad6..aa6519e 100644 --- a/mlx/jira_interaction.py +++ b/mlx/jira_interaction.py @@ -4,7 +4,7 @@ from jira import JIRA, JIRAError from sphinx.util.logging import getLogger -LOGGER = getLogger(__name__) +LOGGER = getLogger('mlx.jira_traceability') def fetch_user(jira, username): diff --git a/mlx/jira_traceability.py b/mlx/jira_traceability.py index 3a103c2..c70bdf7 100644 --- a/mlx/jira_traceability.py +++ b/mlx/jira_traceability.py @@ -2,7 +2,7 @@ from .jira_interaction import create_jira_issues -LOGGER = getLogger(__name__) +LOGGER = getLogger('mlx.jira_traceability') def jira_interaction(app): From ca6a87c2d78cef19281fadeaa7345dd0c0c9280e Mon Sep 17 00:00:00 2001 From: jce Date: Fri, 20 Jun 2025 12:28:49 +0200 Subject: [PATCH 2/9] Support Jira error without response info --- mlx/jira_interaction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mlx/jira_interaction.py b/mlx/jira_interaction.py index aa6519e..e26223b 100644 --- a/mlx/jira_interaction.py +++ b/mlx/jira_interaction.py @@ -169,8 +169,8 @@ def push_item_to_jira(jira, fields, item, attendees, assignee): try: jira.add_watcher(issue, account_id_or_name) except JIRAError as err: - LOGGER.warning("Jira interaction failed: item {}: error code {}: {}" - .format(item.identifier, err.status_code, err.response.text)) + LOGGER.warning(f"Jira interaction failed: item {item.identifier}: error code {err.status_code}: " + f"{getattr(err.response, 'text', '<>')}") if assignee: jira.assign_issue(issue, assignee) From 3a4ed1e86d68501a275a7f0017448759018cf45f Mon Sep 17 00:00:00 2001 From: jce Date: Fri, 20 Jun 2025 12:35:24 +0200 Subject: [PATCH 3/9] Support mlx.traceability>=11 --- tests/test_jira_interaction.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_jira_interaction.py b/tests/test_jira_interaction.py index 047d8c7..85611dd 100644 --- a/tests/test_jira_interaction.py +++ b/tests/test_jira_interaction.py @@ -4,9 +4,7 @@ from jira import JIRAError -from mlx.traceable_attribute import TraceableAttribute -from mlx.traceable_collection import TraceableCollection -from mlx.traceable_item import TraceableItem +from mlx.traceability import TraceableAttribute, TraceableCollection, TraceableItem import mlx.jira_interaction as dut From 3f653e0bf93fa3e5d67abe5507be961dda4e1d9a Mon Sep 17 00:00:00 2001 From: jce Date: Fri, 20 Jun 2025 12:35:40 +0200 Subject: [PATCH 4/9] Updated Logger name --- tests/test_jira_interaction.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_jira_interaction.py b/tests/test_jira_interaction.py index 85611dd..cdf046a 100644 --- a/tests/test_jira_interaction.py +++ b/tests/test_jira_interaction.py @@ -84,7 +84,7 @@ def test_missing_endpoint(self, *_): dut.create_jira_issues(self.settings, None) self.assertEqual( cm.output, - ["WARNING:sphinx.mlx.jira_interaction:Jira interaction failed: configuration is " + ["WARNING:sphinx.mlx.jira_traceability:Jira interaction failed: configuration is " "missing mandatory values for keys ['api_endpoint']"] ) @@ -94,7 +94,7 @@ def test_missing_username(self, *_): dut.create_jira_issues(self.settings, None) self.assertEqual( cm.output, - ["WARNING:sphinx.mlx.jira_interaction:Jira interaction failed: configuration is " + ["WARNING:sphinx.mlx.jira_traceability:Jira interaction failed: configuration is " "missing mandatory values for keys ['username']"] ) @@ -106,7 +106,7 @@ def test_missing_all_mandatory(self, *_): dut.create_jira_issues(self.settings, None) self.assertEqual( cm.output, - ["WARNING:sphinx.mlx.jira_interaction:Jira interaction failed: configuration is " + ["WARNING:sphinx.mlx.jira_traceability:Jira interaction failed: configuration is " "missing mandatory values for keys {}".format(mandatory_keys)] ) @@ -119,7 +119,7 @@ def test_missing_all_optional_one_mandatory(self, *_): dut.create_jira_issues(self.settings, None) self.assertEqual( cm.output, - ["WARNING:sphinx.mlx.jira_interaction:Jira interaction failed: configuration is " + ["WARNING:sphinx.mlx.jira_traceability:Jira interaction failed: configuration is " "missing mandatory values for keys ['password']"] ) @@ -242,10 +242,10 @@ def test_prevent_duplication(self, jira): self.assertEqual( cm.output, - ["WARNING:sphinx.mlx.jira_interaction:Won't create a Task for item " + ["WARNING:sphinx.mlx.jira_traceability:Won't create a Task for item " "'ACTION-12345_ACTION_1' because the Jira API query to check to prevent " "duplication returned ['Jira already contains this ticket']", - "WARNING:sphinx.mlx.jira_interaction:Won't create a Task for item " + "WARNING:sphinx.mlx.jira_traceability:Won't create a Task for item " "'ACTION-12345_ACTION_2' because the Jira API query to check to prevent " "duplication returned ['Jira already contains this ticket']"] ) @@ -305,7 +305,7 @@ def jira_add_watcher_mock(*_): with self.assertLogs(level=WARNING) as cm: dut.create_jira_issues(self.settings, self.coll) - error_msg = ("WARNING:sphinx.mlx.jira_interaction:Jira interaction failed: item ACTION-12345_ACTION_1: " + error_msg = ("WARNING:sphinx.mlx.jira_traceability:Jira interaction failed: item ACTION-12345_ACTION_1: " "error code 401: dummy msg") self.assertEqual( cm.output, From 76d5e7e554482627cb783daa1de52d92477fab44 Mon Sep 17 00:00:00 2001 From: jce Date: Fri, 20 Jun 2025 14:40:35 +0200 Subject: [PATCH 5/9] improve error handling --- mlx/jira_interaction.py | 16 ++++++----- mlx/jira_traceability.py | 2 +- mlx/jira_utils.py | 60 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 mlx/jira_utils.py diff --git a/mlx/jira_interaction.py b/mlx/jira_interaction.py index e26223b..4d1b776 100644 --- a/mlx/jira_interaction.py +++ b/mlx/jira_interaction.py @@ -3,6 +3,7 @@ from jira import JIRA, JIRAError from sphinx.util.logging import getLogger +from .jira_utils import format_jira_error LOGGER = getLogger('mlx.jira_traceability') @@ -65,8 +66,12 @@ def create_jira_issues(settings, traceability_collection): relevant_item_ids = traceability_collection.get_items(settings['item_to_ticket_regex']) if relevant_item_ids: - jira = JIRA({"server": settings['api_endpoint']}, basic_auth=(settings['username'], settings['password'])) - create_unique_issues(relevant_item_ids, jira, general_fields, settings, traceability_collection) + try: + jira = JIRA({"server": settings['api_endpoint']}, basic_auth=(settings['username'], settings['password'])) + create_unique_issues(relevant_item_ids, jira, general_fields, settings, traceability_collection) + except JIRAError as err: + error_msg = format_jira_error(err) + raise Exception(error_msg) from err def create_unique_issues(item_ids, jira, general_fields, settings, traceability_collection): @@ -159,6 +164,7 @@ def push_item_to_jira(jira, fields, item, attendees, assignee): try: issue.update(update={"timetracking": [{"edit": {"originalEstimate": effort}}]}) except JIRAError: + # If effort update fails, append to description instead issue.update(description="{}\n\nEffort estimate: {}".format(item.content, effort)) for attendee in attendees: @@ -166,11 +172,7 @@ def push_item_to_jira(jira, fields, item, attendees, assignee): if user is None: continue account_id_or_name = user.accountId if hasattr(user, 'accountId') else user.name - try: - jira.add_watcher(issue, account_id_or_name) - except JIRAError as err: - LOGGER.warning(f"Jira interaction failed: item {item.identifier}: error code {err.status_code}: " - f"{getattr(err.response, 'text', '<>')}") + jira.add_watcher(issue, account_id_or_name) if assignee: jira.assign_issue(issue, assignee) diff --git a/mlx/jira_traceability.py b/mlx/jira_traceability.py index c70bdf7..6d043b1 100644 --- a/mlx/jira_traceability.py +++ b/mlx/jira_traceability.py @@ -15,7 +15,7 @@ def jira_interaction(app): create_jira_issues(app.config.traceability_jira_automation, app.builder.env.traceability_collection) except Exception as err: # pylint: disable=broad-except if app.config.traceability_jira_automation.get('errors_to_warnings', True): - LOGGER.warning("Jira interaction failed: {}".format(err)) + LOGGER.warning("Jira interaction failed: %s", str(err)) else: raise err diff --git a/mlx/jira_utils.py b/mlx/jira_utils.py new file mode 100644 index 0000000..5329a25 --- /dev/null +++ b/mlx/jira_utils.py @@ -0,0 +1,60 @@ +"""Utility functions for JIRA error handling and formatting""" + +import json +from jira.resilientsession import parse_errors + + +def format_jira_error(err): + """Format a JIRAError for better error reporting. + + Args: + err (JIRAError): The JIRA error to format + + Returns: + str: Formatted error message + """ + try: + # Use parse_errors to get a proper error message from the response + if hasattr(err, 'response') and err.response is not None: + error_messages = parse_errors(err.response) + if error_messages: + status_code = getattr(err, 'status_code', 'unknown') + return f"JIRA API error (HTTP {status_code}): {'; '.join(error_messages)}" + + # Try to extract detailed error information from response text + status_code = getattr(err, 'status_code', 'unknown') + url = getattr(err, 'url', 'unknown URL') + + if hasattr(err, 'response') and err.response is not None: + try: + # Try to parse JSON error response + response_text = getattr(err.response, 'text', '') + if response_text: + error_data = json.loads(response_text) + error_parts = [] + + # Extract error messages + if 'errorMessages' in error_data and error_data['errorMessages']: + error_parts.extend(error_data['errorMessages']) + + # Extract field-specific errors + if 'errors' in error_data and error_data['errors']: + for field, message in error_data['errors'].items(): + error_parts.append(f"{field}: {message}") + + if error_parts: + return f"JIRA API error (HTTP {status_code}): {'; '.join(error_parts)}" + else: + return f"JIRA API error (HTTP {status_code}) at {url}: {response_text}" + else: + return f"JIRA API error (HTTP {status_code}) at {url}: no error description" + except (json.JSONDecodeError, AttributeError): + # Fallback to basic error text + error_text = getattr(err.response, 'text', 'no error description') + return f"JIRA API error (HTTP {status_code}) at {url}: {error_text}" + else: + return f"JIRA API error (HTTP {status_code}) at {url}: no response available" + + except Exception: + # If anything goes wrong with error formatting, fall back to string representation + return str(err) From bf77add4b79596247d0ad9174184c47cd3ecef47 Mon Sep 17 00:00:00 2001 From: jce Date: Fri, 20 Jun 2025 18:09:02 +0200 Subject: [PATCH 6/9] Jira API expects user name, not ID, when adding watcher --- mlx/jira_interaction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mlx/jira_interaction.py b/mlx/jira_interaction.py index 4d1b776..1fc30ab 100644 --- a/mlx/jira_interaction.py +++ b/mlx/jira_interaction.py @@ -171,8 +171,8 @@ def push_item_to_jira(jira, fields, item, attendees, assignee): user = fetch_user(jira, attendee) if user is None: continue - account_id_or_name = user.accountId if hasattr(user, 'accountId') else user.name - jira.add_watcher(issue, account_id_or_name) + user_identifier = user.displayName if hasattr(user, 'displayName') else user.accountId + jira.add_watcher(issue, user_identifier) if assignee: jira.assign_issue(issue, assignee) From 9fefd13c99c3ae0bf2165e57a1e894c0587401cb Mon Sep 17 00:00:00 2001 From: jce Date: Fri, 20 Jun 2025 18:09:27 +0200 Subject: [PATCH 7/9] Refactoring: use attributes of JIRAError --- mlx/jira_utils.py | 67 ++++++++++++----------------------------------- 1 file changed, 17 insertions(+), 50 deletions(-) diff --git a/mlx/jira_utils.py b/mlx/jira_utils.py index 5329a25..18ce385 100644 --- a/mlx/jira_utils.py +++ b/mlx/jira_utils.py @@ -1,60 +1,27 @@ """Utility functions for JIRA error handling and formatting""" -import json -from jira.resilientsession import parse_errors +from jira.exceptions import JIRAError def format_jira_error(err): - """Format a JIRAError for better error reporting. + """Format a JIRAError into a readable error message using its attributes.""" + if isinstance(err, JIRAError): + # Use the documented JIRAError attributes + parts = [] - Args: - err (JIRAError): The JIRA error to format + if hasattr(err, 'text') and err.text: + parts.append(err.text) - Returns: - str: Formatted error message - """ - try: - # Use parse_errors to get a proper error message from the response - if hasattr(err, 'response') and err.response is not None: - error_messages = parse_errors(err.response) - if error_messages: - status_code = getattr(err, 'status_code', 'unknown') - return f"JIRA API error (HTTP {status_code}): {'; '.join(error_messages)}" + if hasattr(err, 'status_code') and err.status_code: + parts.append(f"HTTP {err.status_code}") - # Try to extract detailed error information from response text - status_code = getattr(err, 'status_code', 'unknown') - url = getattr(err, 'url', 'unknown URL') + if hasattr(err, 'url') and err.url: + parts.append(f"at {err.url}") - if hasattr(err, 'response') and err.response is not None: - try: - # Try to parse JSON error response - response_text = getattr(err.response, 'text', '') - if response_text: - error_data = json.loads(response_text) - error_parts = [] - - # Extract error messages - if 'errorMessages' in error_data and error_data['errorMessages']: - error_parts.extend(error_data['errorMessages']) - - # Extract field-specific errors - if 'errors' in error_data and error_data['errors']: - for field, message in error_data['errors'].items(): - error_parts.append(f"{field}: {message}") - - if error_parts: - return f"JIRA API error (HTTP {status_code}): {'; '.join(error_parts)}" - else: - return f"JIRA API error (HTTP {status_code}) at {url}: {response_text}" - else: - return f"JIRA API error (HTTP {status_code}) at {url}: no error description" - except (json.JSONDecodeError, AttributeError): - # Fallback to basic error text - error_text = getattr(err.response, 'text', 'no error description') - return f"JIRA API error (HTTP {status_code}) at {url}: {error_text}" + if parts: + return f"JIRA error: {' - '.join(parts)}" else: - return f"JIRA API error (HTTP {status_code}) at {url}: no response available" - - except Exception: - # If anything goes wrong with error formatting, fall back to string representation - return str(err) + # Fallback to string representation if attributes are missing + return f"JIRA error: {str(err)}" + else: + return f"Error: {str(err)}" From aa911b0f75de9e34597ea2780798120cc1f6ead8 Mon Sep 17 00:00:00 2001 From: jce Date: Fri, 20 Jun 2025 20:32:20 +0200 Subject: [PATCH 8/9] Rely on 'jira' dependency to determine the user accountId --- mlx/jira_interaction.py | 44 +++++------------------------------------ setup.py | 2 +- 2 files changed, 6 insertions(+), 40 deletions(-) diff --git a/mlx/jira_interaction.py b/mlx/jira_interaction.py index 1fc30ab..df09aa1 100644 --- a/mlx/jira_interaction.py +++ b/mlx/jira_interaction.py @@ -8,36 +8,6 @@ LOGGER = getLogger('mlx.jira_traceability') -def fetch_user(jira, username): - """ Fetch Jira User based on username, including inactive users. - - If no matching user is found, a warning is logged and None is returned. - If multiple users are found, a warning is logged and the first returned user is used. - - Args: - jira (jira.JIRA): Jira interface object - username (str): Username (should be email address in case of Jira Cloud) - - Returns: - jira.User: User object found for username - None: No Jira user was found - """ - is_jira_cloud = '@' in username - if is_jira_cloud: - users = jira.search_users(query=username, includeInactive=True) - else: - users = jira.search_users(user=username, includeInactive=True) - if len(users) != 1: - if len(users) == 0: - warning_msg = f"Could not find any Jira user based on {username!r}" - else: - warning_msg = f"Could not find a deterministic Jira user based on {username!r}: got {users}. Using first." - LOGGER.warning(warning_msg, location=__file__) - if users: - return users[0] - return None - - def create_jira_issues(settings, traceability_collection): """ Creates Jira issues using configuration variable ``traceability_jira_automation``. @@ -130,9 +100,7 @@ def create_unique_issues(item_ids, jira, general_fields, settings, traceability_ fields['description'] = description if assignee and not settings.get('notify_watchers', False): - user = fetch_user(jira, assignee) - if user: - fields['assignee'] = {'id': user.accountId} if hasattr(user, 'accountId') else {'name': user.name} + fields['assignee'] = {'name': assignee} assignee = '' issue = push_item_to_jira(jira, {**fields, **general_fields}, item, attendees, assignee) @@ -168,12 +136,10 @@ def push_item_to_jira(jira, fields, item, attendees, assignee): issue.update(description="{}\n\nEffort estimate: {}".format(item.content, effort)) for attendee in attendees: - user = fetch_user(jira, attendee) - if user is None: - continue - user_identifier = user.displayName if hasattr(user, 'displayName') else user.accountId - jira.add_watcher(issue, user_identifier) - + try: + 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: jira.assign_issue(issue, assignee) return issue diff --git a/setup.py b/setup.py index d6cac11..6f73ef2 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ project_url = 'https://github.com/melexis/jira-traceability' -requires = ['Sphinx>=2.1', 'jira>=3.1.1', 'mlx.traceability>=9.0.0'] +requires = ['Sphinx>=2.1', 'jira>=3.2.0', 'mlx.traceability>=9.0.0'] setup( name='mlx.jira-traceability', From d21565b1e44def5133e315e1a4f516359eeced81 Mon Sep 17 00:00:00 2001 From: jce Date: Fri, 20 Jun 2025 20:38:29 +0200 Subject: [PATCH 9/9] Align test case --- tests/test_jira_interaction.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_jira_interaction.py b/tests/test_jira_interaction.py index cdf046a..d722bab 100644 --- a/tests/test_jira_interaction.py +++ b/tests/test_jira_interaction.py @@ -293,10 +293,9 @@ def test_default_project(self, jira): def test_add_watcher_jira_error(self, jira): self.maxDiff = None - Response = namedtuple('Response', 'text') def jira_add_watcher_mock(*_): - raise JIRAError(status_code=401, response=Response('dummy msg')) + raise JIRAError(status_code=401, text='dummy msg') jira_mock = jira.return_value jira_mock.search_issues.return_value = [] @@ -305,11 +304,14 @@ def jira_add_watcher_mock(*_): with self.assertLogs(level=WARNING) as cm: dut.create_jira_issues(self.settings, self.coll) - error_msg = ("WARNING:sphinx.mlx.jira_traceability:Jira interaction failed: item ACTION-12345_ACTION_1: " - "error code 401: dummy msg") + issue = jira_mock.create_issue.return_value + error_msg_abc = (f"WARNING:sphinx.mlx.jira_traceability:Could not add watcher ABC to issue " + f"{issue.key}: dummy msg") + error_msg_zzz = (f"WARNING:sphinx.mlx.jira_traceability:Could not add watcher ZZZ to issue " + f"{issue.key}: dummy msg") self.assertEqual( cm.output, - [error_msg, error_msg] + [error_msg_abc, error_msg_zzz] ) def test_tuple_for_relationship_to_parent(self, jira):