scopemate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ scopemate Task Analysis - Functions for analyzing and validating tasks
4
+
5
+ This module provides functions for analyzing task structures, validating
6
+ task relationships, and ensuring estimate consistency.
7
+ """
8
+ from typing import List, Dict, Tuple, Optional, Set
9
+
10
+ from .models import (
11
+ ScopeMateTask, SIZE_COMPLEXITY, TIME_COMPLEXITY, get_utc_now
12
+ )
13
+
14
+ def check_and_update_parent_estimates(tasks: List[ScopeMateTask]) -> List[ScopeMateTask]:
15
+ """
16
+ Check and update parent task estimates based on child task complexity.
17
+
18
+ Args:
19
+ tasks: List of ScopeMateTask objects
20
+
21
+ Returns:
22
+ Updated list of ScopeMateTask objects
23
+ """
24
+ # Create a map of tasks by ID for easy access
25
+ task_map = {t.id: t for t in tasks}
26
+
27
+ # Create a map of parent IDs for each task
28
+ parent_map = {t.id: t.parent_id for t in tasks}
29
+
30
+ # Map to track size and time complexity values
31
+ size_complexity_map = {}
32
+ time_complexity_map = {}
33
+
34
+ # First pass: Calculate complexity values for all tasks
35
+ for task in tasks:
36
+ size_complexity_map[task.id] = SIZE_COMPLEXITY.get(task.scope.size, 3)
37
+ time_complexity_map[task.id] = TIME_COMPLEXITY.get(task.scope.time_estimate, 4)
38
+
39
+ # Second pass: Check for inconsistencies and update parent estimates
40
+ inconsistencies_fixed = 0
41
+
42
+ # Create a list of child tasks (tasks with parents)
43
+ child_tasks = [t for t in tasks if t.parent_id]
44
+
45
+ # Process child tasks to update parents
46
+ for child in child_tasks:
47
+ if not child.parent_id or child.parent_id not in task_map:
48
+ continue
49
+
50
+ parent = task_map[child.parent_id]
51
+ parent_size_complexity = size_complexity_map[parent.id]
52
+ parent_time_complexity = time_complexity_map[parent.id]
53
+ child_size_complexity = size_complexity_map[child.id]
54
+ child_time_complexity = time_complexity_map[child.id]
55
+
56
+ # Check if child has higher complexity than parent
57
+ size_inconsistent = child_size_complexity > parent_size_complexity
58
+ time_inconsistent = child_time_complexity > parent_time_complexity
59
+
60
+ if size_inconsistent or time_inconsistent:
61
+ # Prepare update data
62
+ parent_copy = parent.model_copy(deep=True)
63
+ updated = False
64
+
65
+ # Update size estimate if needed
66
+ if size_inconsistent:
67
+ # Find the corresponding size value
68
+ for size_name, complexity in SIZE_COMPLEXITY.items():
69
+ if complexity >= child_size_complexity:
70
+ parent_copy.scope.size = size_name
71
+ updated = True
72
+ break
73
+
74
+ # Update time estimate if needed
75
+ if time_inconsistent:
76
+ # Find the corresponding time value
77
+ for time_name, complexity in TIME_COMPLEXITY.items():
78
+ if complexity >= child_time_complexity:
79
+ parent_copy.scope.time_estimate = time_name
80
+ updated = True
81
+ break
82
+
83
+ # Apply updates if needed
84
+ if updated:
85
+ parent_copy.meta.updated = get_utc_now()
86
+ task_map[parent.id] = parent_copy
87
+
88
+ # Update the complexity maps
89
+ size_complexity_map[parent.id] = SIZE_COMPLEXITY.get(parent_copy.scope.size, 3)
90
+ time_complexity_map[parent.id] = TIME_COMPLEXITY.get(parent_copy.scope.time_estimate, 4)
91
+
92
+ # Propagate changes to ancestors
93
+ _update_ancestors(
94
+ parent.id,
95
+ task_map,
96
+ parent_map,
97
+ update_size=size_inconsistent,
98
+ update_time=time_inconsistent,
99
+ size_value=SIZE_COMPLEXITY.get(parent_copy.scope.size, 3),
100
+ time_value=TIME_COMPLEXITY.get(parent_copy.scope.time_estimate, 4)
101
+ )
102
+
103
+ inconsistencies_fixed += 1
104
+
105
+ # Return the updated task list
106
+ updated_tasks = list(task_map.values())
107
+
108
+ if inconsistencies_fixed > 0:
109
+ print(f"✅ Fixed {inconsistencies_fixed} estimate inconsistencies")
110
+
111
+ return updated_tasks
112
+
113
+
114
+ def _update_ancestors(
115
+ task_id: str,
116
+ task_map: Dict[str, ScopeMateTask],
117
+ parent_map: Dict[str, str],
118
+ update_size: bool = False,
119
+ update_time: bool = False,
120
+ size_value: Optional[int] = None,
121
+ time_value: Optional[int] = None
122
+ ) -> None:
123
+ """
124
+ Recursively update ancestors' estimates.
125
+
126
+ Args:
127
+ task_id: ID of the task whose parent should be updated
128
+ task_map: Dictionary mapping task IDs to ScopeMateTask objects
129
+ parent_map: Dictionary mapping task IDs to parent IDs
130
+ update_size: Whether to update size estimate
131
+ update_time: Whether to update time estimate
132
+ size_value: Size complexity value to propagate
133
+ time_value: Time complexity value to propagate
134
+ """
135
+ # Exit if we have nothing to update
136
+ if not update_size and not update_time:
137
+ return
138
+
139
+ # Exit if no parent ID
140
+ if task_id not in parent_map or not parent_map[task_id]:
141
+ return
142
+
143
+ # Get parent ID and task
144
+ parent_id = parent_map[task_id]
145
+ if parent_id not in task_map:
146
+ return
147
+
148
+ parent = task_map[parent_id]
149
+ parent_copy = parent.model_copy(deep=True)
150
+ updated = False
151
+
152
+ # Update size estimate if needed
153
+ if update_size and size_value is not None:
154
+ parent_size_complexity = SIZE_COMPLEXITY.get(parent_copy.scope.size, 3)
155
+
156
+ if size_value > parent_size_complexity:
157
+ # Find the corresponding size value
158
+ for size_name, complexity in SIZE_COMPLEXITY.items():
159
+ if complexity >= size_value:
160
+ parent_copy.scope.size = size_name
161
+ updated = True
162
+ break
163
+
164
+ # Update time estimate if needed
165
+ if update_time and time_value is not None:
166
+ parent_time_complexity = TIME_COMPLEXITY.get(parent_copy.scope.time_estimate, 4)
167
+
168
+ if time_value > parent_time_complexity:
169
+ # Find the corresponding time value
170
+ for time_name, complexity in TIME_COMPLEXITY.items():
171
+ if complexity >= time_value:
172
+ parent_copy.scope.time_estimate = time_name
173
+ updated = True
174
+ break
175
+
176
+ # Apply updates if needed
177
+ if updated:
178
+ parent_copy.meta.updated = get_utc_now()
179
+ task_map[parent_id] = parent_copy
180
+
181
+ # Continue updating ancestors
182
+ _update_ancestors(
183
+ parent_id,
184
+ task_map,
185
+ parent_map,
186
+ update_size=update_size,
187
+ update_time=update_time,
188
+ size_value=SIZE_COMPLEXITY.get(parent_copy.scope.size, 3) if update_size else None,
189
+ time_value=TIME_COMPLEXITY.get(parent_copy.scope.time_estimate, 4) if update_time else None
190
+ )
191
+
192
+
193
+ def find_long_duration_leaf_tasks(tasks: List[ScopeMateTask]) -> List[ScopeMateTask]:
194
+ """
195
+ Find leaf tasks (tasks without children) that have long durations.
196
+
197
+ Args:
198
+ tasks: List of ScopeMateTask objects to analyze
199
+
200
+ Returns:
201
+ List of tasks with 'week', 'sprint' or 'multi-sprint' estimates that don't have subtasks,
202
+ sorted with longest durations first.
203
+ """
204
+ # Build task hierarchy maps
205
+ task_map = {t.id: t for t in tasks}
206
+ has_children = set()
207
+
208
+ for task in tasks:
209
+ if task.parent_id and task.parent_id in task_map:
210
+ has_children.add(task.parent_id)
211
+
212
+ # Find leaf tasks with long durations
213
+ long_durations = ["week", "sprint", "multi-sprint"]
214
+ leaf_tasks_with_long_durations = []
215
+
216
+ for task in tasks:
217
+ if task.id not in has_children and task.scope.time_estimate in long_durations:
218
+ leaf_tasks_with_long_durations.append(task)
219
+
220
+ # Sort by duration (longest first)
221
+ return sorted(
222
+ leaf_tasks_with_long_durations,
223
+ key=lambda t: TIME_COMPLEXITY.get(t.scope.time_estimate, 3),
224
+ reverse=True
225
+ )
226
+
227
+
228
+ def should_decompose_task(task: ScopeMateTask, depth: int, max_depth: int, is_leaf: bool = False) -> bool:
229
+ """
230
+ Determine if a task should be broken down based on complexity and time estimates.
231
+
232
+ Args:
233
+ task: The ScopeMateTask to evaluate
234
+ depth: Current depth in the task hierarchy
235
+ max_depth: Maximum allowed depth
236
+ is_leaf: Whether this is a leaf task (has no children)
237
+
238
+ Returns:
239
+ True if the task should be broken down, False otherwise
240
+ """
241
+ # Always respect max depth limit
242
+ if depth >= max_depth:
243
+ return False
244
+
245
+ # Define complexity thresholds
246
+ complex_sizes = ["complex", "uncertain", "pioneering"]
247
+ long_durations = ["week", "sprint", "multi-sprint"]
248
+
249
+ # Always break down complex tasks
250
+ if task.scope.size in complex_sizes:
251
+ return True
252
+
253
+ # Also break down long-duration tasks, even if they're not "complex"
254
+ if task.scope.time_estimate in long_durations:
255
+ # For leaf tasks, always consider breaking down if they're long
256
+ if is_leaf:
257
+ return True
258
+
259
+ # Break down tasks with "week" duration up to max_depth
260
+ if task.scope.time_estimate == "week":
261
+ return True
262
+
263
+ # If it's already at depth 2+, only break down multi-sprint tasks
264
+ if depth >= 2 and task.scope.time_estimate != "multi-sprint":
265
+ return False
266
+ return True
267
+
268
+ return False
269
+
270
+
271
+ def _initialize_task_depths(tasks: List[ScopeMateTask]) -> Dict[str, int]:
272
+ """
273
+ Initialize the depth tracking dictionary for tasks.
274
+
275
+ Args:
276
+ tasks: List of ScopeMateTask objects
277
+
278
+ Returns:
279
+ Dictionary mapping task IDs to depth values
280
+ """
281
+ task_depths = {}
282
+ parent_map = {t.id: t.parent_id for t in tasks}
283
+
284
+ # Initialize depths for all tasks
285
+ for task in tasks:
286
+ if not task.parent_id:
287
+ # Root tasks are at depth 0
288
+ task_depths[task.id] = 0
289
+
290
+ # Process tasks with parents
291
+ for task in tasks:
292
+ if task.id not in task_depths and task.parent_id:
293
+ # Traverse up to find a task with known depth
294
+ depth = 0
295
+ current_id = task.id
296
+ while current_id in parent_map and parent_map[current_id]:
297
+ depth += 1
298
+ current_id = parent_map[current_id]
299
+ # If we found a parent with known depth, use that
300
+ if current_id in task_depths:
301
+ task_depths[task.id] = task_depths[current_id] + depth
302
+ break
303
+
304
+ # If we couldn't find a parent with known depth, assume depth 0 for the root
305
+ if task.id not in task_depths:
306
+ task_depths[task.id] = depth
307
+
308
+ return task_depths
309
+
310
+
311
+ def get_task_depth(task: ScopeMateTask, task_depths: Dict[str, int], tasks: List[ScopeMateTask]) -> int:
312
+ """
313
+ Get the depth of a task in the hierarchy.
314
+
315
+ Args:
316
+ task: The ScopeMateTask to find depth for
317
+ task_depths: Dictionary mapping task IDs to depths
318
+ tasks: List of all ScopeMateTask objects
319
+
320
+ Returns:
321
+ The depth of the task
322
+ """
323
+ # If depth is already known, return it
324
+ if task.id in task_depths:
325
+ return task_depths[task.id]
326
+
327
+ # If task has no parent, it's at root level (0)
328
+ if not task.parent_id:
329
+ task_depths[task.id] = 0
330
+ return 0
331
+
332
+ # Find the parent task
333
+ parent = next((t for t in tasks if t.id == task.parent_id), None)
334
+ if parent:
335
+ # Get parent's depth (recursively if needed) and add 1
336
+ parent_depth = get_task_depth(parent, task_depths, tasks)
337
+ task_depths[task.id] = parent_depth + 1
338
+ return parent_depth + 1
339
+
340
+ # If parent not found, assume depth 0
341
+ task_depths[task.id] = 0
342
+ return 0
343
+
344
+
345
+ def is_leaf_task(task_id: str, tasks: List[ScopeMateTask]) -> bool:
346
+ """
347
+ Check if a task is a leaf task (has no children).
348
+
349
+ Args:
350
+ task_id: ID of the task to check
351
+ tasks: List of all ScopeMateTask objects
352
+
353
+ Returns:
354
+ True if task has no children, False otherwise
355
+ """
356
+ # A task is a leaf if no other task has it as a parent
357
+ return not any(t.parent_id == task_id for t in tasks)