2727
2828TAB = ' ' * 4
2929
30+ # Module logger
31+ LOGGER = logging .getLogger ('jira-juggler' )
32+
3033
3134def 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
6574def 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
637643class 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