Coverage for little_loops/cli/sprint/manage.py: 9%

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

2

3 from __future__ import annotations

4

5 import argparse

6 from pathlib import Path

7 from typing import Any

8

9 from little_loops.cli.output import print_json

10 from little_loops.cli.sprint._helpers import _render_execution_plan

11 from little_loops.dependency_graph import DependencyGraph, refine_waves_for_contention

12 from little_loops.logger import Logger

13 from little_loops.sprint import SprintManager

14

15

16 def _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

55 def _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

66 def _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