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

1"""ll-sprint list, delete, and analyze subcommands.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6from pathlib import Path 

7from typing import Any 

8 

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 

14 

15 

16def _cmd_sprint_list(args: argparse.Namespace, manager: SprintManager) -> int: 

17 """List all sprints.""" 

18 sprints = manager.list_all() 

19 

20 if not sprints: 

21 if getattr(args, "json", False): 

22 print_json([]) 

23 return 0 

24 print("No sprints defined") 

25 return 0 

26 

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 

39 

40 print(f"Available sprints ({len(sprints)}):") 

41 

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}") 

51 

52 return 0 

53 

54 

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 

61 

62 logger.success(f"Deleted sprint: {args.sprint}") 

63 return 0 

64 

65 

66def _cmd_sprint_analyze(args: argparse.Namespace, manager: SprintManager) -> int: 

67 """Analyze sprint for file conflicts between issues.""" 

68 import json as _json 

69 

70 from little_loops.parallel.file_hints import FileHints, extract_file_hints 

71 

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 

77 

78 # Validate issues 

79 valid = manager.validate_issues(sprint.issues) 

80 invalid = set(sprint.issues) - set(valid.keys()) 

81 

82 if invalid: 

83 logger.warning(f"Issue IDs not found: {', '.join(sorted(invalid))}") 

84 

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 

90 

91 # Gather all known IDs 

92 from little_loops.dependency_mapper import gather_all_issue_ids 

93 

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) 

97 

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() 

101 

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 

107 

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) 

112 

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) 

118 

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 ) 

133 

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"]) 

139 

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) 

148 

149 has_conflicts = len(conflict_pairs) > 0 

150 

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) 

167 

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) 

187 

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)") 

194 

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") 

203 

204 # Execution plan 

205 print() 

206 print(_render_execution_plan(waves, dep_graph, contention_notes)) 

207 

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)") 

214 

215 return 1 if has_conflicts else 0