Coverage for little_loops / cli / migrate.py: 93%
121 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: One-time migration of completed/deferred issues to type-based directories."""
3from __future__ import annotations
5import argparse
6import re
7import subprocess
8import warnings
9from pathlib import Path
11from little_loops.cli_args import add_config_arg, add_dry_run_arg
12from little_loops.frontmatter import parse_frontmatter
13from little_loops.issue_lifecycle import _is_git_tracked
15_FILENAME_PREFIX_RE = re.compile(r"^(?:P\d+-)?([A-Z]+)-\d+")
16_FM_FIELD_RE = re.compile(r"^---\s*$", re.MULTILINE)
19def _set_fields(content: str, fields: dict[str, str]) -> str:
20 """Set frontmatter fields via direct string manipulation (avoids yaml roundtrip).
22 Replaces existing fields in-place; inserts missing fields before the closing
23 ``---`` marker. Prepends a new frontmatter block if none exists.
24 """
25 if not content.startswith("---\n"):
26 lines = "\n".join(f"{k}: {v}" for k, v in fields.items())
27 return f"---\n{lines}\n---\n{content}"
29 result = content
30 for key, value in fields.items():
31 line = f"{key}: {value}"
32 key_re = re.compile(rf"^{re.escape(key)}:.*$", re.MULTILINE)
33 if key_re.search(result):
34 result = key_re.sub(line, result)
35 else:
36 # Insert before the closing --- of the frontmatter
37 markers = list(_FM_FIELD_RE.finditer(result))
38 if len(markers) >= 2:
39 # markers[0] is opening ---, markers[1] is closing ---
40 pos = markers[1].start()
41 result = result[:pos] + f"{line}\n" + result[pos:]
42 return result
45def _extract_prefix_from_filename(name: str) -> str | None:
46 """Extract issue type prefix from filename (e.g. 'P2-ENH-1420-...' → 'ENH')."""
47 m = _FILENAME_PREFIX_RE.match(name)
48 return m.group(1) if m else None
51def _get_git_completion_date(file_path: Path) -> str | None:
52 """Return ISO-8601 date from git log for file_path, or None if unavailable."""
53 try:
54 result = subprocess.run(
55 ["git", "log", "--format=%as", "-1", "--", str(file_path)],
56 capture_output=True,
57 text=True,
58 timeout=30,
59 )
60 if result.returncode == 0:
61 date_str = result.stdout.strip()
62 if date_str:
63 return f"{date_str}T00:00:00Z"
64 except (subprocess.TimeoutExpired, OSError):
65 pass
66 return None
69def _move_file(src: Path, dst: Path, updated_content: str) -> None:
70 """Move src to dst, writing updated_content. Prefers git mv for tracked files."""
71 if _is_git_tracked(src):
72 result = subprocess.run(
73 ["git", "mv", str(src), str(dst)],
74 capture_output=True,
75 text=True,
76 timeout=30,
77 )
78 if result.returncode == 0:
79 dst.write_text(updated_content, encoding="utf-8")
80 return
81 # Untracked or git mv failed — write then rename
82 src.write_text(updated_content, encoding="utf-8")
83 src.rename(dst)
86def main_migrate() -> int:
87 """Entry point for ll-migrate command.
89 Moves files from completed/ and deferred/ into their type-based directories,
90 backfills completed_at for completed files missing it, and sets correct status
91 frontmatter.
93 Returns:
94 Exit code (0 = success, 1 = error)
95 """
96 parser = argparse.ArgumentParser(
97 prog="ll-migrate",
98 description=(
99 "Migrate completed/ and deferred/ issues to type-based directories "
100 "with correct status frontmatter. One-time migration for ENH-1390."
101 ),
102 formatter_class=argparse.RawDescriptionHelpFormatter,
103 epilog="""
104Examples:
105 %(prog)s --dry-run # Preview all planned moves (strongly advised first)
106 %(prog)s # Execute migration
107""",
108 )
109 add_dry_run_arg(parser)
110 add_config_arg(parser)
111 args = parser.parse_args()
113 dry_run: bool = args.dry_run
114 repo_root: Path = args.config or Path.cwd()
116 from little_loops.config import BRConfig
118 config = BRConfig(repo_root)
120 with warnings.catch_warnings():
121 warnings.simplefilter("ignore", DeprecationWarning)
122 completed_dir: Path = config.get_completed_dir()
123 deferred_dir: Path = config.get_deferred_dir()
125 # Build prefix → category-key mapping (e.g. "BUG" → "bugs")
126 prefix_to_key: dict[str, str] = {
127 cat.prefix.upper(): key for key, cat in config._issues.categories.items()
128 }
130 if dry_run:
131 print("[DRY RUN] No files will be moved or modified.")
133 moved = 0
134 backfilled = 0
135 untyped: list[str] = []
136 errors: list[str] = []
138 sources: list[tuple[Path, str]] = []
139 if completed_dir.exists():
140 sources += [(f, "done") for f in sorted(completed_dir.glob("*.md"))]
141 if deferred_dir.exists():
142 sources += [(f, "deferred") for f in sorted(deferred_dir.glob("*.md"))]
144 for file_path, status in sources:
145 content = file_path.read_text(encoding="utf-8")
146 fm = parse_frontmatter(content)
148 updates: dict[str, str | int] = {"status": status}
150 if status == "done" and "completed_at" not in fm:
151 date = _get_git_completion_date(file_path)
152 if date:
153 updates["completed_at"] = date
154 backfilled += 1
156 # Determine type prefix from frontmatter or filename
157 type_prefix: str | None = fm.get("type") if fm else None
158 if not type_prefix:
159 type_prefix = _extract_prefix_from_filename(file_path.name)
161 if not type_prefix:
162 untyped.append(str(file_path))
163 print(f" [UNTYPED] {file_path.name} — cannot determine type, skipped")
164 continue
166 category_key = prefix_to_key.get(type_prefix.upper())
167 if category_key is None:
168 untyped.append(str(file_path))
169 print(f" [UNTYPED] {file_path.name} — unknown prefix '{type_prefix}', skipped")
170 continue
172 target_dir = config.get_issue_dir(category_key)
173 target_path = target_dir / file_path.name
175 if target_path.exists():
176 errors.append(str(file_path))
177 print(f" [SKIP] {file_path.name} — target already exists: {target_path}")
178 continue
180 updated_content = _set_fields(content, {k: str(v) for k, v in updates.items()})
182 prefix = "[DRY RUN] " if dry_run else ""
183 print(
184 f" {prefix}MOVE {file_path.relative_to(repo_root)} → {target_path.relative_to(repo_root)}"
185 )
187 if not dry_run:
188 try:
189 target_dir.mkdir(parents=True, exist_ok=True)
190 _move_file(file_path, target_path, updated_content)
191 moved += 1
192 except Exception as exc:
193 errors.append(str(file_path))
194 print(f" [ERROR] {file_path.name}: {exc}")
195 else:
196 moved += 1
198 print()
199 print(
200 f"Results: {moved} files {'would be ' if dry_run else ''}moved, "
201 f"{backfilled} completed_at fields backfilled."
202 )
203 if untyped:
204 print(f" Untyped (needs manual review): {len(untyped)}")
205 for p in untyped:
206 print(f" {p}")
207 if errors:
208 print(f" Errors: {len(errors)}")
209 return 1
210 return 0