Coverage for little_loops / cli / migrate_status.py: 85%
61 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
1"""ll-migrate-status: Normalize non-canonical status: values in issue files to canonical ones."""
3from __future__ import annotations
5import argparse
6import re
7from pathlib import Path
9from little_loops.cli_args import add_config_arg, add_dry_run_arg
10from little_loops.frontmatter import STATUS_SYNONYMS
12_STATUS_FIELD_RE = re.compile(r"^(status: )(.+)$", re.MULTILINE)
15def _migrate_content(content: str) -> tuple[str, list[str]]:
16 """Normalize status field in frontmatter. Returns (updated_content, list_of_changes)."""
17 changes: list[str] = []
19 def _replace(m: re.Match[str]) -> str:
20 prefix, value = m.group(1), m.group(2)
21 canonical = STATUS_SYNONYMS.get(value, value)
22 if canonical != value:
23 changes.append(f"status: {value!r} → status: {canonical!r}")
24 return f"{prefix}{canonical}"
25 return m.group(0)
27 updated = _STATUS_FIELD_RE.sub(_replace, content)
28 return updated, changes
31def main_migrate_status() -> int:
32 """Entry point for ll-migrate-status command.
34 Normalizes non-canonical status: values in all .issues/**/*.md files to their
35 canonical equivalents using STATUS_SYNONYMS from frontmatter.py.
37 Returns:
38 Exit code (0 = success, 1 = error)
39 """
40 parser = argparse.ArgumentParser(
41 prog="ll-migrate-status",
42 description=(
43 "Normalize non-canonical status: frontmatter values to canonical ones "
44 "(e.g. 'completed' → 'done', 'wip' → 'in_progress'). "
45 "One-time migration for ENH-1551."
46 ),
47 formatter_class=argparse.RawDescriptionHelpFormatter,
48 epilog="""
49Examples:
50 %(prog)s --dry-run # Preview all planned normalizations (strongly advised first)
51 %(prog)s # Execute migration
52""",
53 )
54 add_dry_run_arg(parser)
55 add_config_arg(parser)
56 args = parser.parse_args()
58 dry_run: bool = args.dry_run
59 repo_root: Path = args.config or Path.cwd()
61 issues_dir = repo_root / ".issues"
62 if not issues_dir.exists():
63 print(f"No .issues/ directory found at {repo_root}")
64 return 1
66 if dry_run:
67 print("[DRY RUN] No files will be modified.")
69 normalized = 0
70 errors: list[str] = []
72 for file_path in sorted(issues_dir.rglob("*.md")):
73 try:
74 content = file_path.read_text(encoding="utf-8")
75 except OSError as exc:
76 errors.append(str(file_path))
77 print(f" [ERROR] {file_path}: {exc}")
78 continue
80 updated, changes = _migrate_content(content)
81 if not changes:
82 continue
84 prefix = "[DRY RUN] " if dry_run else ""
85 rel = file_path.relative_to(repo_root)
86 for change in changes:
87 print(f" {prefix}NORMALIZE {rel}: {change}")
89 if not dry_run:
90 try:
91 file_path.write_text(updated, encoding="utf-8")
92 normalized += 1
93 except OSError as exc:
94 errors.append(str(file_path))
95 print(f" [ERROR] {file_path}: {exc}")
96 else:
97 normalized += 1
99 print()
100 print(f"Results: {normalized} files {'would be ' if dry_run else ''}updated.")
101 if errors:
102 print(f" Errors: {len(errors)}")
103 return 1
104 return 0