1"""Sprint and sequence management for issue execution."""

2

3 import logging

4 import re

5 from dataclasses import dataclass, field

6 from datetime import UTC, datetime

7 from pathlib import Path

8 from typing import TYPE_CHECKING, Any

9

10 import yaml

11

12 logger = logging.getLogger(__name__)

13

14_EPIC_ID_RE = re.compile(r"^EPIC-\d+$", re.IGNORECASE)

15_ACTIVE_STATUSES: set[str] = {"open", "in_progress", "blocked"}

16

17 if TYPE_CHECKING:

18 from little_loops.config import BRConfig

19 from little_loops.issue_parser import IssueInfo

20

21

22@dataclass

23 class SprintOptions:

24 """Execution options for sprint runs.

25

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

31

32 max_iterations: int = 100

33 timeout: int = 3600

34 max_workers: int = 2

35

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 }

43

44 @classmethod

45 def from_dict(cls, data: dict | None) -> "SprintOptions":

46 """Create from dictionary (YAML deserialization).

47

48 Args:

49 data: Dictionary from YAML file or None for defaults

50

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 )

61

62

63@dataclass

64 class SprintState:

65 """Persistent state for sprint execution.

66

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

73

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

83

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

92

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 }

105

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 )

119

120

121@dataclass

122 class Sprint:

123 """A sprint is a named group of issues to execute together.

124

125 Sprints allow planning work in batches and executing them as a unit.

126 Execution is always dependency-aware with parallel waves.

127

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

135

136 name: str

137 description: str

138 issues: list[str]

139 created: str

140 options: SprintOptions | None = None

141

142 def to_dict(self) -> dict[str, str | list[str] | dict]:

143 """Convert to dictionary for YAML serialization.

144

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

157

158 @classmethod

159 def from_dict(cls, data: dict) -> "Sprint":

160 """Create from dictionary (YAML deserialization).

161

162 Args:

163 data: Dictionary from YAML file

164

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 )

175

176 def save(self, sprints_dir: Path) -> Path:

177 """Save sprint to YAML file.

178

179 Args:

180 sprints_dir: Directory containing sprint definitions

181

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

190

191 @classmethod

192 def load(cls, sprints_dir: Path, name: str) -> "Sprint | None":

193 """Load sprint from YAML file.

194

195 Args:

196 sprints_dir: Directory containing sprint definitions

197 name: Sprint name (without .yaml extension)

198

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)

208

209

210 class SprintManager:

211 """Manager for sprint CRUD operations.

212

213 Provides methods to create, load, list, and delete sprint definitions.

214 Also validates that issue IDs exist before executing sprints.

215 """

216

217 def __init__(self, sprints_dir: Path | None = None, config: "BRConfig | None" = None) -> None:

218 """Initialize SprintManager.

219

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)

233

234 def get_default_options(self) -> SprintOptions:

235 """Get default SprintOptions from config or hardcoded defaults.

236

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

246

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.

255

256 Args:

257 name: Sprint identifier

258 issues: List of issue IDs

259 description: Human-readable description

260 options: Optional execution options

261

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

274

275 def load(self, name: str) -> Sprint | None:

276 """Load a sprint by name.

277

278 Args:

279 name: Sprint name

280

281 Returns:

282 Sprint instance or None if not found

283 """

284 return Sprint.load(self.sprints_dir, name)

285

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.

288

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.

293

294 Args:

295 arg: Sprint name (file-based) or EPIC ID matching ^EPIC-\\d+$

296

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)

302

303 epic_id = arg.upper()

304

305 if not self.config:

306 return self.load(arg)

307

308 epic_path = self._find_issue_path(epic_id)

309 if epic_path is None:

310 return None

311

312 from little_loops.issue_parser import IssueParser, find_issues

313

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

320

321 # Forward lookup: relates_to on the EPIC file

322 forward_ids: set[str] = set(epic_info.relates_to)

323

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}

327

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]

332

333 sprint_name = f"epic-{epic_id.split('-', 1)[1]}"

334

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 )

343

344 from little_loops.dependency_graph import DependencyGraph

345

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 ]

355

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 )

362

363 def list_all(self) -> list[Sprint]:

364 """List all sprints.

365

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

375

376 def delete(self, name: str) -> bool:

377 """Delete a sprint.

378

379 Args:

380 name: Sprint name

381

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

390

391 def _find_issue_path(self, issue_id: str) -> Path | None:

392 """Find the filesystem path for an issue ID.

393

394 Searches all configured issue categories for a file matching the issue ID.

395

396 Args:

397 issue_id: Issue ID to locate (e.g. "BUG-001")

398

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

409

410 def validate_issues(self, issues: list[str]) -> dict[str, Path]:

411 """Validate that issue IDs exist.

412

413 Args:

414 issues: List of issue IDs to validate

415

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 {}

422

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

429

430 def load_issue_infos(self, issues: list[str]) -> list["IssueInfo"]:

431 """Load IssueInfo objects for the given issue IDs.

432

433 Args:

434 issues: List of issue IDs to load

435

436 Returns:

437 List of IssueInfo objects (only for issues that exist)

438 """

439 from little_loops.issue_parser import IssueParser

440

441 if not self.config:

442 return []

443

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