Coverage for little_loops / cli / sprint / manage.py: 9%
131 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
1"""ll-sprint list, delete, and analyze subcommands."""
3from __future__ import annotations
5import argparse
6from pathlib import Path
7from typing import Any
9from little_loops.cli.output import print_json
10from little_loops.cli.sprint._helpers import _render_execution_plan
11from little_loops.dependency_graph import DependencyGraph, refine_waves_for_contention
12from little_loops.logger import Logger
13from little_loops.sprint import SprintManager
16def _cmd_sprint_list(args: argparse.Namespace, manager: SprintManager) -> int:
17 """List all sprints."""
18 sprints = manager.list_all()
20 if not sprints:
21 if getattr(args, "json", False):
22 print_json([])
23 return 0
24 print("No sprints defined")
25 return 0
27 if getattr(args, "json", False):
28 print_json(
29 [
30 {
31 "name": sprint.name,
32 "path": str(manager.sprints_dir / f"{sprint.name}.yaml"),
33 "issues": len(sprint.issues),
34 }
35 for sprint in sprints
36 ]
37 )
38 return 0
40 print(f"Available sprints ({len(sprints)}):")
42 for sprint in sprints:
43 if args.verbose:
44 print(f"\n{sprint.name}:")
45 print(f" Description: {sprint.description or '(none)'}")
46 print(f" Issues: {', '.join(sprint.issues)}")
47 print(f" Created: {sprint.created}")
48 else:
49 desc = f" - {sprint.description}" if sprint.description else ""
50 print(f" {sprint.name}{desc}")
52 return 0
55def _cmd_sprint_delete(args: argparse.Namespace, manager: SprintManager) -> int:
56 """Delete a sprint."""
57 logger = Logger()
58 if not manager.delete(args.sprint):
59 logger.error(f"Sprint not found: {args.sprint}")
60 return 1
62 logger.success(f"Deleted sprint: {args.sprint}")
63 return 0
66def _cmd_sprint_analyze(args: argparse.Namespace, manager: SprintManager) -> int:
67 """Analyze sprint for file conflicts between issues."""
68 import json as _json
70 from little_loops.parallel.file_hints import FileHints, extract_file_hints
72 logger = Logger()
73 sprint = manager.load(args.sprint)
74 if not sprint:
75 logger.error(f"Sprint not found: {args.sprint}")
76 return 1
78 # Validate issues
79 valid = manager.validate_issues(sprint.issues)
80 invalid = set(sprint.issues) - set(valid.keys())
82 if invalid:
83 logger.warning(f"Issue IDs not found: {', '.join(sorted(invalid))}")
85 # Load full IssueInfo objects
86 issue_infos = manager.load_issue_infos(list(valid.keys()))
87 if not issue_infos:
88 logger.error("No valid issue files found")
89 return 1
91 # Gather all known IDs
92 from little_loops.dependency_mapper import gather_all_issue_ids
94 config = manager.config
95 issues_dir = config.project_root / config.issues.base_dir if config else Path(".issues")
96 all_known_ids = gather_all_issue_ids(issues_dir, config=config)
98 # Build dependency graph
99 dep_graph = DependencyGraph.from_issues(issue_infos, all_known_ids=all_known_ids)
100 has_cycles = dep_graph.has_cycles()
102 if has_cycles:
103 cycles = dep_graph.detect_cycles()
104 for cycle in cycles:
105 logger.error(f"Dependency cycle detected: {' -> '.join(cycle)}")
106 return 1
108 # Generate waves and refine for contention
109 waves = dep_graph.get_execution_waves()
110 dep_config = config.dependency_mapping if config else None
111 waves, contention_notes = refine_waves_for_contention(waves, config=dep_config)
113 # Extract file hints and detect pairwise conflicts
114 hints: dict[str, FileHints] = {}
115 for info in issue_infos:
116 content = info.path.read_text() if info.path.exists() else ""
117 hints[info.issue_id] = extract_file_hints(content, info.issue_id)
119 conflict_pairs: list[dict[str, Any]] = []
120 for i, a in enumerate(issue_infos):
121 for b in issue_infos[i + 1 :]:
122 if hints[a.issue_id].overlaps_with(hints[b.issue_id], config=dep_config):
123 overlapping = sorted(
124 hints[a.issue_id].get_overlapping_paths(hints[b.issue_id], config=dep_config)
125 )
126 conflict_pairs.append(
127 {
128 "issue_a": a.issue_id,
129 "issue_b": b.issue_id,
130 "overlapping_files": overlapping,
131 }
132 )
134 # Build parallel-safe groups from waves (issues in same wave with no conflicts)
135 conflicting_ids = set()
136 for pair in conflict_pairs:
137 conflicting_ids.add(pair["issue_a"])
138 conflicting_ids.add(pair["issue_b"])
140 parallel_safe: list[list[str]] = []
141 notes_list = contention_notes or [None] * len(waves)
142 for idx, wave in enumerate(waves):
143 note = notes_list[idx] if idx < len(notes_list) else None
144 if note is None and len(wave) > 1:
145 # Non-contention wave with multiple issues = parallel-safe group
146 group = [issue.issue_id for issue in wave]
147 parallel_safe.append(group)
149 has_conflicts = len(conflict_pairs) > 0
151 # Build wave plan for report
152 wave_plan: list[dict[str, Any]] = []
153 for idx, wave in enumerate(waves):
154 note = notes_list[idx] if idx < len(notes_list) else None
155 wave_info: dict[str, Any] = {
156 "wave": idx + 1,
157 "issues": [issue.issue_id for issue in wave],
158 }
159 if note is not None:
160 wave_info["serialized"] = True
161 wave_info["sub_wave"] = note.sub_wave_index + 1
162 wave_info["total_sub_waves"] = note.total_sub_waves
163 wave_info["contended_paths"] = note.contended_paths
164 else:
165 wave_info["serialized"] = False
166 wave_plan.append(wave_info)
168 if args.format == "json":
169 data = {
170 "sprint": sprint.name,
171 "issue_count": len(issue_infos),
172 "has_conflicts": has_conflicts,
173 "conflicts": conflict_pairs,
174 "waves": wave_plan,
175 "parallel_safe_groups": parallel_safe,
176 }
177 print(_json.dumps(data, indent=2))
178 else:
179 # Text report
180 total_issues = len(issue_infos)
181 print(f"Sprint: {sprint.name}")
182 print(f"Issues: {total_issues}")
183 print()
184 print("=" * 70)
185 print("CONFLICT ANALYSIS")
186 print("=" * 70)
188 if not has_conflicts:
189 print()
190 print("No file conflicts detected. All issues can run in parallel.")
191 else:
192 print()
193 print(f"Conflicts found: {len(conflict_pairs)} pair(s)")
195 for i, pair in enumerate(conflict_pairs, 1):
196 print()
197 print(f" {i}. {pair['issue_a']} <-> {pair['issue_b']}")
198 files_str = ", ".join(pair["overlapping_files"][:3])
199 if len(pair["overlapping_files"]) > 3:
200 files_str += f" +{len(pair['overlapping_files']) - 3} more"
201 print(f" Overlapping files: {files_str}")
202 print(" Recommendation: Serialize execution")
204 # Execution plan
205 print()
206 print(_render_execution_plan(waves, dep_graph, contention_notes))
208 # Parallel-safe groups
209 if parallel_safe:
210 print()
211 print("Parallel-safe groups:")
212 for group in parallel_safe:
213 print(f" - {', '.join(group)} (no shared files)")
215 return 1 if has_conflicts else 0