Coverage for little_loops / sprint.py: 34%
164 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""Sprint and sequence management for issue execution."""
3import logging
4import re
5from dataclasses import dataclass, field
6from datetime import UTC, datetime
7from pathlib import Path
8from typing import TYPE_CHECKING, Any
10import yaml
12logger = logging.getLogger(__name__)
14_EPIC_ID_RE = re.compile(r"^EPIC-\d+$", re.IGNORECASE)
15_ACTIVE_STATUSES: set[str] = {"open", "in_progress", "blocked"}
17if TYPE_CHECKING:
18 from little_loops.config import BRConfig
19 from little_loops.issue_parser import IssueInfo
22@dataclass
23class SprintOptions:
24 """Execution options for sprint runs.
26 Attributes:
27 max_iterations: Maximum Claude iterations per issue
28 timeout: Per-issue timeout in seconds
29 max_workers: Worker count for parallel execution within waves
30 """
32 max_iterations: int = 100
33 timeout: int = 3600
34 max_workers: int = 2
36 def to_dict(self) -> dict:
37 """Convert to dictionary for YAML serialization."""
38 return {
39 "max_iterations": self.max_iterations,
40 "timeout": self.timeout,
41 "max_workers": self.max_workers,
42 }
44 @classmethod
45 def from_dict(cls, data: dict | None) -> "SprintOptions":
46 """Create from dictionary (YAML deserialization).
48 Args:
49 data: Dictionary from YAML file or None for defaults
51 Returns:
52 SprintOptions instance
53 """
54 if data is None:
55 return cls()
56 return cls(
57 max_iterations=data.get("max_iterations", 100),
58 timeout=data.get("timeout", 3600),
59 max_workers=data.get("max_workers", 2),
60 )
63@dataclass
64class SprintState:
65 """Persistent state for sprint execution.
67 Enables resume capability after interruption by tracking:
68 - Sprint name being executed
69 - Current wave number
70 - Completed issues
71 - Failed issues with reasons
72 - Timing information
74 Attributes:
75 sprint_name: Name of the sprint being executed
76 current_wave: Wave number currently being processed (1-indexed)
77 completed_issues: List of completed issue IDs
78 failed_issues: Mapping of issue ID to failure reason
79 timing: Per-issue timing breakdown
80 started_at: ISO 8601 timestamp when sprint started
81 last_checkpoint: ISO 8601 timestamp of last state save
82 """
84 sprint_name: str = ""
85 current_wave: int = 0
86 completed_issues: list[str] = field(default_factory=list)
87 failed_issues: dict[str, str] = field(default_factory=dict)
88 skipped_blocked_issues: dict[str, str] = field(default_factory=dict)
89 timing: dict[str, dict[str, float]] = field(default_factory=dict)
90 started_at: str = ""
91 last_checkpoint: str = ""
93 def to_dict(self) -> dict[str, Any]:
94 """Convert state to dictionary for JSON serialization."""
95 return {
96 "sprint_name": self.sprint_name,
97 "current_wave": self.current_wave,
98 "completed_issues": self.completed_issues,
99 "failed_issues": self.failed_issues,
100 "skipped_blocked_issues": self.skipped_blocked_issues,
101 "timing": self.timing,
102 "started_at": self.started_at,
103 "last_checkpoint": self.last_checkpoint,
104 }
106 @classmethod
107 def from_dict(cls, data: dict[str, Any]) -> "SprintState":
108 """Create state from dictionary (JSON deserialization)."""
109 return cls(
110 sprint_name=data.get("sprint_name", ""),
111 current_wave=data.get("current_wave", 0),
112 completed_issues=data.get("completed_issues", []),
113 failed_issues=data.get("failed_issues", {}),
114 skipped_blocked_issues=data.get("skipped_blocked_issues", {}),
115 timing=data.get("timing", {}),
116 started_at=data.get("started_at", ""),
117 last_checkpoint=data.get("last_checkpoint", ""),
118 )
121@dataclass
122class Sprint:
123 """A sprint is a named group of issues to execute together.
125 Sprints allow planning work in batches and executing them as a unit.
126 Execution is always dependency-aware with parallel waves.
128 Attributes:
129 name: Sprint identifier (used as filename)
130 description: Human-readable purpose
131 issues: List of issue IDs (e.g., BUG-001, FEAT-010)
132 created: ISO 8601 timestamp of creation
133 options: Execution options (timeout, max_workers, etc.)
134 """
136 name: str
137 description: str
138 issues: list[str]
139 created: str
140 options: SprintOptions | None = None
142 def to_dict(self) -> dict[str, str | list[str] | dict]:
143 """Convert to dictionary for YAML serialization.
145 Returns:
146 Dictionary representation suitable for yaml.dump()
147 """
148 data: dict[str, str | list[str] | dict] = {
149 "name": self.name,
150 "description": self.description,
151 "created": self.created,
152 "issues": self.issues,
153 }
154 if self.options:
155 data["options"] = self.options.to_dict()
156 return data
158 @classmethod
159 def from_dict(cls, data: dict) -> "Sprint":
160 """Create from dictionary (YAML deserialization).
162 Args:
163 data: Dictionary from YAML file
165 Returns:
166 Sprint instance
167 """
168 return cls(
169 name=data["name"],
170 description=data.get("description", ""),
171 issues=data.get("issues", []),
172 created=data.get("created", datetime.now(UTC).isoformat()),
173 options=SprintOptions.from_dict(data.get("options")),
174 )
176 def save(self, sprints_dir: Path) -> Path:
177 """Save sprint to YAML file.
179 Args:
180 sprints_dir: Directory containing sprint definitions
182 Returns:
183 Path to saved file
184 """
185 sprints_dir.mkdir(parents=True, exist_ok=True)
186 sprint_path = sprints_dir / f"{self.name}.yaml"
187 with open(sprint_path, "w") as f:
188 yaml.dump(self.to_dict(), f, default_flow_style=False, sort_keys=False)
189 return sprint_path
191 @classmethod
192 def load(cls, sprints_dir: Path, name: str) -> "Sprint | None":
193 """Load sprint from YAML file.
195 Args:
196 sprints_dir: Directory containing sprint definitions
197 name: Sprint name (without .yaml extension)
199 Returns:
200 Sprint instance or None if not found
201 """
202 sprint_path = sprints_dir / f"{name}.yaml"
203 if not sprint_path.exists():
204 return None
205 with open(sprint_path) as f:
206 data = yaml.safe_load(f)
207 return cls.from_dict(data)
210class SprintManager:
211 """Manager for sprint CRUD operations.
213 Provides methods to create, load, list, and delete sprint definitions.
214 Also validates that issue IDs exist before executing sprints.
215 """
217 def __init__(self, sprints_dir: Path | None = None, config: "BRConfig | None" = None) -> None:
218 """Initialize SprintManager.
220 Args:
221 sprints_dir: Directory for sprint definitions (overrides config)
222 config: Project configuration for settings and issue validation
223 """
224 self.config = config
225 # Derive sprints_dir: explicit arg > config > default
226 if sprints_dir is not None:
227 self.sprints_dir = sprints_dir
228 elif config is not None:
229 self.sprints_dir = Path(config.sprints.sprints_dir)
230 else:
231 self.sprints_dir = Path(".sprints")
232 self.sprints_dir.mkdir(parents=True, exist_ok=True)
234 def get_default_options(self) -> SprintOptions:
235 """Get default SprintOptions from config or hardcoded defaults.
237 Returns:
238 SprintOptions with values from config if available, else defaults
239 """
240 if self.config is not None:
241 return SprintOptions(
242 timeout=self.config.sprints.default_timeout,
243 max_workers=self.config.sprints.default_max_workers,
244 )
245 return SprintOptions()
247 def create(
248 self,
249 name: str,
250 issues: list[str],
251 description: str = "",
252 options: SprintOptions | None = None,
253 ) -> Sprint:
254 """Create a new sprint.
256 Args:
257 name: Sprint identifier
258 issues: List of issue IDs
259 description: Human-readable description
260 options: Optional execution options
262 Returns:
263 Created Sprint instance
264 """
265 sprint = Sprint(
266 name=name,
267 description=description,
268 issues=[i.strip().upper() for i in issues],
269 created=datetime.now(UTC).isoformat(),
270 options=options,
271 )
272 sprint.save(self.sprints_dir)
273 return sprint
275 def load(self, name: str) -> Sprint | None:
276 """Load a sprint by name.
278 Args:
279 name: Sprint name
281 Returns:
282 Sprint instance or None if not found
283 """
284 return Sprint.load(self.sprints_dir, name)
286 def load_or_resolve(self, arg: str) -> "Sprint | None":
287 """Load a sprint by name or resolve an EPIC ID to an ephemeral Sprint.
289 If `arg` matches ^EPIC-\\d+$ (case-insensitive), resolves the EPIC's
290 active children via union of forward (relates_to:) and backward (parent:)
291 lookups, filtered to active statuses and ordered by dependency graph.
292 Otherwise falls through to the file-based load() path.
294 Args:
295 arg: Sprint name (file-based) or EPIC ID matching ^EPIC-\\d+$
297 Returns:
298 Sprint instance, or None if not found / EPIC not found
299 """
300 if not _EPIC_ID_RE.match(arg):
301 return self.load(arg)
303 epic_id = arg.upper()
305 if not self.config:
306 return self.load(arg)
308 epic_path = self._find_issue_path(epic_id)
309 if epic_path is None:
310 return None
312 from little_loops.issue_parser import IssueParser, find_issues
314 parser = IssueParser(self.config)
315 try:
316 epic_info = parser.parse_file(epic_path)
317 except Exception as e:
318 logger.warning("Failed to parse EPIC file %s: %s", epic_path, e)
319 return None
321 # Forward lookup: relates_to on the EPIC file
322 forward_ids: set[str] = set(epic_info.relates_to)
324 # Backward lookup: scan all active issues for parent == epic_id
325 all_active = find_issues(self.config, status_filter=_ACTIVE_STATUSES)
326 backward_ids = {info.issue_id for info in all_active if info.parent == epic_id}
328 # Union + dedup; intersect with active set so forward refs to done issues are dropped
329 active_ids_set = {info.issue_id for info in all_active}
330 child_ids = (forward_ids | backward_ids) & active_ids_set
331 child_infos = [info for info in all_active if info.issue_id in child_ids]
333 sprint_name = f"epic-{epic_id.split('-', 1)[1]}"
335 if not child_infos:
336 logger.info("EPIC %s has no active children", epic_id)
337 return Sprint(
338 name=sprint_name,
339 description=f"Resolved from {epic_id}",
340 issues=[],
341 created=datetime.now(UTC).isoformat(),
342 )
344 from little_loops.dependency_graph import DependencyGraph
346 dep_graph = DependencyGraph.from_issues(child_infos, all_known_ids=active_ids_set)
347 try:
348 waves = dep_graph.get_execution_waves()
349 ordered_ids = [issue.issue_id for wave in waves for issue in wave]
350 except ValueError:
351 ordered_ids = [
352 info.issue_id
353 for info in sorted(child_infos, key=lambda i: (i.priority or "P5", i.issue_id))
354 ]
356 return Sprint(
357 name=sprint_name,
358 description=f"Resolved from {epic_id}: {epic_info.title}",
359 issues=ordered_ids,
360 created=datetime.now(UTC).isoformat(),
361 )
363 def list_all(self) -> list[Sprint]:
364 """List all sprints.
366 Returns:
367 List of Sprint instances, sorted by name
368 """
369 sprints = []
370 for path in sorted(self.sprints_dir.glob("*.yaml")):
371 sprint = Sprint.load(self.sprints_dir, path.stem)
372 if sprint:
373 sprints.append(sprint)
374 return sprints
376 def delete(self, name: str) -> bool:
377 """Delete a sprint.
379 Args:
380 name: Sprint name
382 Returns:
383 True if deleted, False if not found
384 """
385 sprint_path = self.sprints_dir / f"{name}.yaml"
386 if not sprint_path.exists():
387 return False
388 sprint_path.unlink()
389 return True
391 def _find_issue_path(self, issue_id: str) -> Path | None:
392 """Find the filesystem path for an issue ID.
394 Searches all configured issue categories for a file matching the issue ID.
396 Args:
397 issue_id: Issue ID to locate (e.g. "BUG-001")
399 Returns:
400 Path to the issue file, or None if not found
401 """
402 if not self.config:
403 return None
404 for category in self.config.issue_categories:
405 issue_dir = self.config.get_issue_dir(category)
406 for path in issue_dir.glob(f"*-{issue_id}-*.md"):
407 return path
408 return None
410 def validate_issues(self, issues: list[str]) -> dict[str, Path]:
411 """Validate that issue IDs exist.
413 Args:
414 issues: List of issue IDs to validate
416 Returns:
417 Dictionary mapping valid issue IDs to their file paths
418 """
419 if not self.config:
420 # No config provided, skip validation
421 return {}
423 valid = {}
424 for issue_id in issues:
425 path = self._find_issue_path(issue_id)
426 if path is not None:
427 valid[issue_id] = path
428 return valid
430 def load_issue_infos(self, issues: list[str]) -> list["IssueInfo"]:
431 """Load IssueInfo objects for the given issue IDs.
433 Args:
434 issues: List of issue IDs to load
436 Returns:
437 List of IssueInfo objects (only for issues that exist)
438 """
439 from little_loops.issue_parser import IssueParser
441 if not self.config:
442 return []
444 parser = IssueParser(self.config)
445 result: list[IssueInfo] = []
446 for issue_id in issues:
447 path = self._find_issue_path(issue_id)
448 if path is not None:
449 try:
450 info = parser.parse_file(path)
451 result.append(info)
452 except Exception as e:
453 logger.warning("Failed to parse issue file %s: %s", path, e)
454 return result