Skip to content

Commit 8ca5f09

Browse files
committed
Container tasks are not allowed an effort or duration attribute. Init a custom logger instance
1 parent 7421b5d commit 8ca5f09

File tree

1 file changed

+151
-53
lines changed

1 file changed

+151
-53
lines changed

src/mlx/jira_juggler/jira_juggler.py

Lines changed: 151 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727

2828
TAB = ' ' * 4
2929

30+
# Module logger
31+
LOGGER = logging.getLogger('jira-juggler')
32+
3033

3134
def fetch_credentials():
3235
""" Fetches the credentials from the .env file by default or, alternatively, from the user's input
@@ -42,7 +45,7 @@ def fetch_credentials():
4245
if not api_token:
4346
password = config('JIRA_PASSWORD', default='')
4447
if password:
45-
logging.warning('Basic authentication with a JIRA password may be deprecated. '
48+
LOGGER.warning('Basic authentication with a JIRA password may be deprecated. '
4649
'Consider defining an API token as environment variable JIRA_API_TOKEN instead.')
4750
return username, password
4851
else:
@@ -59,7 +62,13 @@ def set_logging_level(loglevel):
5962
numeric_level = getattr(logging, loglevel.upper(), None)
6063
if not isinstance(numeric_level, int):
6164
raise ValueError('Invalid log level: %s' % loglevel)
62-
logging.basicConfig(level=numeric_level)
65+
# Configure the named logger
66+
LOGGER.setLevel(numeric_level)
67+
if not LOGGER.handlers:
68+
handler = logging.StreamHandler()
69+
formatter = logging.Formatter('%(levelname)s:%(name)s:%(message)s')
70+
handler.setFormatter(formatter)
71+
LOGGER.addHandler(handler)
6372

6473

6574
def to_identifier(key):
@@ -152,8 +161,8 @@ def determine_username(user):
152161
elif getattr(user, 'displayName', ''):
153162
full_name = user.displayName
154163
username = f'"{full_name}"'
155-
logging.error(f"Failed to fetch email address of {full_name!r}: they restricted its visibility; "
156-
f"using identifier {username!r} as fallback value.")
164+
LOGGER.error(f"Failed to fetch email address of {full_name!r}: they restricted its visibility; "
165+
f"using identifier {username!r} as fallback value.")
157166
else:
158167
raise Exception(f"Failed to determine username of {user}")
159168
return username
@@ -168,8 +177,8 @@ def determine_default_links(link_types_per_name):
168177
default_links.append(link)
169178
break
170179
else:
171-
logging.warning("Failed to find any of these default jira-juggler issue link types in your Jira project "
172-
f"configuration: {list(link_types)}. Use --links if you think this is a problem.")
180+
LOGGER.warning("Failed to find any of these default jira-juggler issue link types in your Jira project "
181+
f"configuration: {list(link_types)}. Use --links if you think this is a problem.")
173182
return default_links
174183

175184

@@ -183,7 +192,7 @@ def determine_links(jira_link_types, input_links):
183192
all_jira_links = chain.from_iterable((link_type.inward, link_type.outward) for link_type in jira_link_types)
184193
missing_links = unique_input_links.difference(all_jira_links)
185194
if missing_links:
186-
logging.warning(f"Failed to find links {missing_links} in your configuration in Jira")
195+
LOGGER.warning(f"Failed to find links {missing_links} in your configuration in Jira")
187196
valid_links = unique_input_links - missing_links
188197
return valid_links
189198

@@ -327,7 +336,7 @@ def load_from_jira_issue(self, jira_issue):
327336
self.value = 0
328337
else:
329338
self.value = self.DEFAULT_VALUE
330-
logging.warning('No estimate found for %s, assuming %s%s', jira_issue.key, self.DEFAULT_VALUE, self.UNIT)
339+
LOGGER.warning('No estimate found for %s, assuming %s%s', jira_issue.key, self.DEFAULT_VALUE, self.UNIT)
331340

332341
def validate(self, task, tasks):
333342
"""Validates (and corrects) the current task property
@@ -337,11 +346,16 @@ def validate(self, task, tasks):
337346
tasks (list): Modifiable list of JugglerTask instances to which the current task belongs. Will be used to
338347
verify relations to other tasks.
339348
"""
349+
# If effort is None, this is a container task; assert and skip validation.
350+
if self.value is None:
351+
assert getattr(task, 'children', None), (
352+
f"Effort is None only allowed for container tasks; {task.key} has no children")
353+
return
340354
if self.value == 0:
341-
logging.warning('Estimate for %s, is 0. Excluding', task.key)
355+
LOGGER.warning('Estimate for %s, is 0. Excluding', task.key)
342356
tasks.remove(task)
343357
elif self.value < self.MINIMAL_VALUE:
344-
logging.warning('Estimate %s%s too low for %s, assuming %s%s', self.value, self.UNIT, task.key, self.MINIMAL_VALUE, self.UNIT)
358+
LOGGER.warning('Estimate %s%s too low for %s, assuming %s%s', self.value, self.UNIT, task.key, self.MINIMAL_VALUE, self.UNIT)
345359
self.value = self.MINIMAL_VALUE
346360

347361

@@ -387,7 +401,7 @@ def validate(self, task, tasks):
387401
task_ids = [to_identifier(tsk.key) for tsk in tasks]
388402
for val in list(self.value):
389403
if val not in task_ids:
390-
logging.warning('Removing link to %s for %s, as not within scope', val, task.key)
404+
LOGGER.warning('Removing link to %s for %s, as not within scope', val, task.key)
391405
self.value.remove(val)
392406

393407
def __str__(self):
@@ -457,7 +471,7 @@ class JugglerTask:
457471

458472
def __init__(self, jira_issue=None):
459473
if jira_issue:
460-
logging.info('Create JugglerTask for %s', jira_issue.key)
474+
LOGGER.info('Create JugglerTask for %s', jira_issue.key)
461475

462476
self.key = self.DEFAULT_KEY
463477
self.summary = self.DEFAULT_SUMMARY
@@ -505,7 +519,7 @@ def validate(self, tasks, property_identifier):
505519
property_identifier (str): Identifier of property type
506520
"""
507521
if self.key == self.DEFAULT_KEY:
508-
logging.error('Found a task which is not initialized')
522+
LOGGER.error('Found a task which is not initialized')
509523
self.properties[property_identifier].validate(self, tasks)
510524

511525
def __str__(self):
@@ -619,19 +633,11 @@ def calculate_rolled_up_effort(self):
619633
Returns:
620634
float: Total effort including children
621635
"""
622-
base_effort = self.properties['effort'].value if not self.properties['effort'].is_empty else 0
623-
624-
# For epics and parent tasks, include child effort
636+
# For container tasks (any task with children), the effort is the sum of the children only.
637+
# Parent/container tasks are not allowed to have their own effort.
625638
if self.children:
626-
child_effort = sum(child.calculate_rolled_up_effort() for child in self.children)
627-
# If this is a container task (epic or parent with children),
628-
# the effort should be the sum of children unless it has its own effort
629-
if self.is_epic or (self.children and base_effort == JugglerTaskEffort.DEFAULT_VALUE):
630-
return child_effort
631-
else:
632-
return base_effort + child_effort
633-
634-
return base_effort
639+
return sum(child.calculate_rolled_up_effort() for child in self.children)
640+
return self.properties['effort'].value if not self.properties['effort'].is_empty else 0
635641

636642

637643
class JiraJuggler:
@@ -649,11 +655,11 @@ def __init__(self, endpoint, user, token, query, links=None):
649655
"""
650656
global id_to_username_mapping
651657
id_to_username_mapping = {}
652-
logging.info('Jira endpoint: %s', endpoint)
658+
LOGGER.info('Jira endpoint: %s', endpoint)
653659

654660
global jirahandle
655661
jirahandle = JIRA(endpoint, basic_auth=(user, token))
656-
logging.info('Query: %s', query)
662+
LOGGER.info('Query: %s', query)
657663
self.query = query
658664
self.issue_count = 0
659665

@@ -683,48 +689,72 @@ def load_issues_from_jira(self, depend_on_preceding=False, sprint_field_name='',
683689
list: A list of JugglerTask instances
684690
"""
685691
tasks = []
686-
busy = True
687-
while busy:
692+
next_page_token = None
693+
694+
while True:
688695
try:
689-
issues = jirahandle.search_issues(self.query, maxResults=JIRA_PAGE_SIZE, startAt=self.issue_count,
690-
expand='changelog')
696+
# Use enhanced_search_issues for API v3 compatibility
697+
result = jirahandle.enhanced_search_issues(
698+
jql_str=self.query,
699+
maxResults=JIRA_PAGE_SIZE,
700+
nextPageToken=next_page_token,
701+
expand='changelog'
702+
)
703+
704+
# enhanced_search_issues returns a ResultList, extract issues
705+
if hasattr(result, 'iterable'):
706+
issues = list(result.iterable)
707+
else:
708+
issues = list(result)
709+
691710
except JIRAError as err:
692-
logging.error(f'Failed to query JIRA: {err}')
711+
LOGGER.error(f'Failed to query JIRA: {err}')
693712
if err.status_code == 401:
694-
logging.error('Please check your JIRA credentials in the .env file or environment variables.')
713+
LOGGER.error('Please check your JIRA credentials in the .env file or environment variables.')
695714
elif err.status_code == 403:
696-
logging.error('You do not have permission to access this JIRA project or query.')
715+
LOGGER.error('You do not have permission to access this JIRA project or query.')
697716
elif err.status_code == 404:
698-
logging.error('The JIRA endpoint is not found. Please check the endpoint URL.')
699-
elif err.status_code == 400:
717+
LOGGER.error('The JIRA endpoint is not found. Please check the endpoint URL.')
718+
elif err.status_code == 400 or err.status_code == 410:
700719
# Parse and display the specific JQL errors more clearly
701720
try:
702721
error_data = err.response.json()
703722
if 'errorMessages' in error_data:
704723
for error_msg in error_data['errorMessages']:
705-
logging.error(f'JIRA query error: {error_msg}')
724+
LOGGER.error(f'JIRA query error: {error_msg}')
706725
except Exception:
707726
pass # Fall back to generic error if JSON parsing fails
708727

709-
logging.error('Invalid JQL query syntax. Please check your query.')
728+
if err.status_code == 410:
729+
LOGGER.error('JIRA API v2 has been deprecated. Using enhanced search API.')
730+
else:
731+
LOGGER.error('Invalid JQL query syntax. Please check your query.')
710732
else:
711-
logging.error(f'An unexpected error occurred: {err}')
733+
LOGGER.error(f'An unexpected error occurred: {err}')
712734
return None
713735

714736
if len(issues) <= 0:
715-
busy = False
737+
break
716738

717739
self.issue_count += len(issues)
718740
for issue in issues:
719-
logging.debug(f'Retrieved {issue.key}: {issue.fields.summary}')
741+
LOGGER.debug(f'Retrieved {issue.key}: {issue.fields.summary}')
720742
tasks.append(JugglerTask(issue))
721743

722-
self.validate_tasks(tasks)
744+
# Check if there are more pages
745+
if hasattr(result, 'nextPageToken') and result.nextPageToken:
746+
next_page_token = result.nextPageToken
747+
else:
748+
break
723749

724-
# Build hierarchical relationships if enabled
750+
# Build hierarchical relationships if enabled BEFORE validation so epic rules
751+
# can consider zero-effort children.
725752
if enable_epics:
726753
tasks = self.build_hierarchical_tasks(tasks)
727754

755+
# Now validate tasks (may exclude remaining zero-effort tasks where appropriate)
756+
self.validate_tasks(tasks)
757+
728758
if sprint_field_name:
729759
self.sort_tasks_on_sprint(tasks, sprint_field_name)
730760
tasks.sort(key=cmp_to_key(self.compare_status))
@@ -750,20 +780,21 @@ def build_hierarchical_tasks(self, tasks):
750780
if task.parent_key and task.parent_key in task_by_key:
751781
parent_task = task_by_key[task.parent_key]
752782
parent_task.add_child(task)
753-
logging.debug(f'Added {task.key} as child of parent {parent_task.key}')
783+
LOGGER.debug(f'Added {task.key} as child of parent {parent_task.key}')
754784

755785
# Handle story/task -> epic relationship
756786
elif task.epic_key and task.epic_key in task_by_key:
757787
epic_task = task_by_key[task.epic_key]
758788
epic_task.add_child(task)
759-
logging.debug(f'Added {task.key} as child of epic {epic_task.key}')
789+
LOGGER.debug(f'Added {task.key} as child of epic {epic_task.key}')
760790

761-
# Update effort calculations for parent tasks
791+
# Process epics with special logic
792+
tasks = self._process_epic_logic(tasks, task_by_key)
793+
794+
# Container tasks are not allowed an effort attribute; drop it for any task with children
762795
for task in tasks:
763-
if task.children:
764-
rolled_up_effort = task.calculate_rolled_up_effort()
765-
task.properties['effort'].value = rolled_up_effort
766-
logging.debug(f'Updated effort for {task.key} to {rolled_up_effort}d (including children)')
796+
if task.children and 'effort' in task.properties:
797+
task.properties['effort'].value = None
767798

768799
# Return only top-level tasks (those without parents)
769800
top_level_tasks = []
@@ -778,9 +809,76 @@ def build_hierarchical_tasks(self, tasks):
778809
if not is_child:
779810
top_level_tasks.append(task)
780811

781-
logging.info(f'Built hierarchy: {len(tasks)} total tasks, {len(top_level_tasks)} top-level tasks')
812+
LOGGER.info(f'Built hierarchy: {len(tasks)} total tasks, {len(top_level_tasks)} top-level tasks')
782813
return top_level_tasks
783814

815+
def _process_epic_logic(self, tasks, task_by_key):
816+
"""Process epic-specific logic for effort estimation and child handling.
817+
818+
Implements the following rules:
819+
1. If an epic has one or more children with effort at 0, discard the children and treat the epic as a single task
820+
2. If that epic has no effort estimate set, exclude it and warn about it like we do for regular tasks
821+
3. When both epic and children have estimates and the sum doesn't match the epic estimate, log a warning with the difference
822+
823+
Args:
824+
tasks (list): List of JugglerTask instances
825+
task_by_key (dict): Dictionary mapping task keys to task instances
826+
827+
Returns:
828+
list: Modified list of tasks after applying epic logic
829+
"""
830+
tasks_to_remove = set()
831+
832+
for task in tasks:
833+
if not task.is_epic or not task.children:
834+
continue
835+
836+
# Check if any children have zero (or effectively zero) effort
837+
# Consider effectively-zero when original Jira estimates are None/0 and computed effort <= MINIMAL_VALUE
838+
def _is_effectively_zero(child_task):
839+
try:
840+
fields = child_task.issue.fields if child_task.issue else None
841+
orig = getattr(fields, 'timeoriginalestimate', None) if fields else None
842+
rem = getattr(fields, 'timeestimate', None) if fields else None
843+
eff = child_task.properties['effort'].value
844+
return (orig in (None, 0)) and (rem in (None, 0)) and (eff is not None and eff <= JugglerTaskEffort.MINIMAL_VALUE)
845+
except Exception:
846+
return False
847+
848+
children_with_zero_effort = [child for child in task.children if _is_effectively_zero(child)]
849+
850+
if children_with_zero_effort:
851+
# Rule 1: Epic has children with 0 effort - discard all children and treat epic as single task
852+
LOGGER.info(f'Epic {task.key} has {len(children_with_zero_effort)} children with 0 effort. '
853+
f'Discarding all children and treating epic as single task.')
854+
855+
# Remove children from the epic
856+
for child in task.children:
857+
tasks_to_remove.add(child)
858+
LOGGER.debug(f'Removing child {child.key} from epic {task.key}')
859+
860+
task.children = []
861+
862+
# Rule 2: If epic has no effort estimate, exclude it and warn
863+
if task.properties['effort'].value == 0:
864+
LOGGER.warning(f'Estrimate for epic {task.key}, is 0. Excluding')
865+
tasks_to_remove.add(task)
866+
continue
867+
868+
else:
869+
# Rule 3: Check if epic effort matches sum of children efforts
870+
epic_effort = task.properties['effort'].value
871+
children_total_effort = sum(child.calculate_rolled_up_effort() for child in task.children)
872+
873+
if (epic_effort is not None and epic_effort > 0) and children_total_effort > 0 and abs(epic_effort - children_total_effort) > 0.01:
874+
# Epic and children both have estimates, but they don't match
875+
difference = epic_effort - children_total_effort
876+
LOGGER.warning(f'Epic {task.key} effort estimate ({epic_effort}d) differs from sum of children '
877+
f'({children_total_effort}d) by {difference:+.2f}d')
878+
879+
# Remove tasks that should be excluded
880+
return [task for task in tasks if task not in tasks_to_remove]
881+
784882
def juggle(self, output=None, **kwargs):
785883
"""Queries JIRA and generates task-juggler output from given issues
786884
@@ -893,7 +991,7 @@ def sort_tasks_on_sprint(self, tasks, sprint_field_name):
893991
task.sprint_priority = prio
894992
if hasattr(sprint_info, 'startDate'):
895993
task.sprint_start_date = parser.parse(sprint_info.startDate)
896-
logging.debug("Sorting tasks based on sprint information...")
994+
LOGGER.debug("Sorting tasks based on sprint information...")
897995
tasks.sort(key=cmp_to_key(self.compare_sprint_priority))
898996

899997
@staticmethod
@@ -914,7 +1012,7 @@ def extract_start_date(sprint_info, issue_key):
9141012
try:
9151013
return parser.parse(start_date_match.group(1))
9161014
except parser.ParserError as err:
917-
logging.debug("Failed to parse start date of sprint of issue %s: %s", issue_key, err)
1015+
LOGGER.debug("Failed to parse start date of sprint of issue %s: %s", issue_key, err)
9181016
return None
9191017

9201018
@staticmethod

0 commit comments

Comments
 (0)