Skip to content
56 changes: 12 additions & 44 deletions mlx/jira_traceability/jira_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,9 @@

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

LOGGER = getLogger(__name__)


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
LOGGER = getLogger('mlx.jira_traceability')


def create_jira_issues(settings, traceability_collection):
Expand Down Expand Up @@ -65,8 +36,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):
Expand Down Expand Up @@ -125,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)
Expand Down Expand Up @@ -159,19 +132,14 @@ 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:
user = fetch_user(jira, attendee)
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)
jira.add_watcher(issue, attendee)
except JIRAError as err:
LOGGER.warning("Jira interaction failed: item {}: error code {}: {}"
.format(item.identifier, err.status_code, err.response.text))

LOGGER.warning("Could not add watcher {} to issue {}: {}".format(attendee, issue.key, err.text))
if assignee:
jira.assign_issue(issue, assignee)
return issue
Expand Down
4 changes: 2 additions & 2 deletions mlx/jira_traceability/jira_traceability.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from .jira_interaction import create_jira_issues

LOGGER = getLogger(__name__)
LOGGER = getLogger('mlx.jira_traceability')


def jira_interaction(app):
Expand All @@ -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

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

from jira.exceptions import JIRAError


def format_jira_error(err):
"""Format a JIRAError into a readable error message using its attributes."""
if isinstance(err, JIRAError):
# Use the documented JIRAError attributes
parts = []

if hasattr(err, 'text') and err.text:
parts.append(err.text)

if hasattr(err, 'status_code') and err.status_code:
parts.append(f"HTTP {err.status_code}")

if hasattr(err, 'url') and err.url:
parts.append(f"at {err.url}")

if parts:
return f"JIRA error: {' - '.join(parts)}"
else:
# Fallback to string representation if attributes are missing
return f"JIRA error: {str(err)}"
else:
return f"Error: {str(err)}"
24 changes: 13 additions & 11 deletions tests/test_jira_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def test_missing_endpoint(self, *_):
dut.create_jira_issues(self.settings, None)
self.assertEqual(
cm.output,
["WARNING:sphinx.mlx.jira_traceability.jira_interaction:Jira interaction failed: configuration is "
["WARNING:sphinx.mlx.jira_traceability:Jira interaction failed: configuration is "
"missing mandatory values for keys ['api_endpoint']"]
)

Expand All @@ -94,7 +94,7 @@ def test_missing_username(self, *_):
dut.create_jira_issues(self.settings, None)
self.assertEqual(
cm.output,
["WARNING:sphinx.mlx.jira_traceability.jira_interaction:Jira interaction failed: configuration is "
["WARNING:sphinx.mlx.jira_traceability:Jira interaction failed: configuration is "
"missing mandatory values for keys ['username']"]
)

Expand All @@ -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_traceability.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)]
)

Expand All @@ -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_traceability.jira_interaction:Jira interaction failed: configuration is "
["WARNING:sphinx.mlx.jira_traceability:Jira interaction failed: configuration is "
"missing mandatory values for keys ['password']"]
)

Expand Down Expand Up @@ -242,10 +242,10 @@ def test_prevent_duplication(self, jira):

self.assertEqual(
cm.output,
["WARNING:sphinx.mlx.jira_traceability.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_traceability.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']"]
)
Expand Down Expand Up @@ -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 = []
Expand All @@ -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: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):
Expand Down