Coverage for little_loops / cli / migrate_labels.py: 86%
90 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-labels: Move freeform ## Labels body sections to labels: frontmatter."""
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 parse_frontmatter
12_FM_FIELD_RE = re.compile(r"^---\s*$", re.MULTILINE)
13_LABELS_SECTION_RE = re.compile(r"^## Labels\s*\n(.*?)(?=\n## |\Z)", re.MULTILINE | re.DOTALL)
16def _parse_body_labels(content: str) -> list[str]:
17 """Extract backtick-wrapped labels from ## Labels body section."""
18 match = _LABELS_SECTION_RE.search(content)
19 if not match:
20 return []
21 return [m.lower() for m in re.findall(r"`([^`]+)`", match.group(1))]
24def _set_labels_frontmatter(content: str, labels: list[str]) -> str:
25 """Write labels: list field to frontmatter (avoids yaml roundtrip)."""
26 if not content.startswith("---\n"):
27 yaml_labels = "\n".join(f"- {lb}" for lb in labels)
28 return f"---\nlabels:\n{yaml_labels}\n---\n{content}"
30 labels_line = "labels:\n" + "\n".join(f"- {lb}" for lb in labels)
31 key_re = re.compile(r"^labels:.*?(?=\n\S|\n---)", re.MULTILINE | re.DOTALL)
32 if key_re.search(content):
33 return key_re.sub(labels_line, content)
35 # Insert before closing ---
36 markers = list(_FM_FIELD_RE.finditer(content))
37 if len(markers) >= 2:
38 pos = markers[1].start()
39 return content[:pos] + f"{labels_line}\n" + content[pos:]
40 return content
43def _remove_labels_section(content: str) -> str:
44 """Remove ## Labels body section after migration."""
45 result = _LABELS_SECTION_RE.sub("", content)
46 # Clean up excessive blank lines left by removal
47 result = re.sub(r"\n{3,}", "\n\n", result)
48 return result
51def _migrate_content(content: str) -> tuple[str, list[str] | None]:
52 """Migrate ## Labels body section to frontmatter labels: field.
54 Returns:
55 (updated_content, migrated_labels) — migrated_labels is None when no change needed.
56 """
57 fm = parse_frontmatter(content)
59 body_labels = _parse_body_labels(content)
60 if not body_labels:
61 return content, None
63 existing_fm_labels: list[str] = []
64 raw = fm.get("labels")
65 if raw:
66 if isinstance(raw, list):
67 existing_fm_labels = [str(lb) for lb in raw]
68 else:
69 existing_fm_labels = [lb.strip() for lb in str(raw).split(",") if lb.strip()]
71 # Merge: keep frontmatter labels, add any body labels not already present
72 merged = list(existing_fm_labels)
73 for lb in body_labels:
74 if lb not in merged:
75 merged.append(lb)
77 result = _set_labels_frontmatter(content, merged)
78 result = _remove_labels_section(result)
79 return result, merged
82def main_migrate_labels() -> int:
83 """Entry point for ll-migrate-labels command.
85 Migrates freeform ## Labels body sections to labels: frontmatter in all issue files.
87 Returns:
88 Exit code (0 = success, 1 = error)
89 """
90 parser = argparse.ArgumentParser(
91 prog="ll-migrate-labels",
92 description=(
93 "Migrate freeform ## Labels body sections → labels: frontmatter "
94 "in all issue files. One-time migration for ENH-1392."
95 ),
96 formatter_class=argparse.RawDescriptionHelpFormatter,
97 epilog="""
98Examples:
99 %(prog)s --dry-run # Preview all planned migrations (strongly advised first)
100 %(prog)s # Execute migration
101""",
102 )
103 add_dry_run_arg(parser)
104 add_config_arg(parser)
105 args = parser.parse_args()
107 dry_run: bool = args.dry_run
108 repo_root: Path = args.config or Path.cwd()
110 issues_dir = repo_root / ".issues"
111 if not issues_dir.exists():
112 print(f"No .issues/ directory found at {repo_root}")
113 return 1
115 if dry_run:
116 print("[DRY RUN] No files will be modified.")
118 migrated = 0
119 errors: list[str] = []
121 for file_path in sorted(issues_dir.rglob("*.md")):
122 try:
123 content = file_path.read_text(encoding="utf-8")
124 except OSError as exc:
125 errors.append(str(file_path))
126 print(f" [ERROR] {file_path}: {exc}")
127 continue
129 updated, labels = _migrate_content(content)
130 if labels is None:
131 continue
133 prefix = "[DRY RUN] " if dry_run else ""
134 rel = file_path.relative_to(repo_root)
135 print(f" {prefix}MIGRATE {rel}: ## Labels → labels: {labels}")
137 if not dry_run:
138 try:
139 file_path.write_text(updated, encoding="utf-8")
140 migrated += 1
141 except OSError as exc:
142 errors.append(str(file_path))
143 print(f" [ERROR] {file_path}: {exc}")
144 else:
145 migrated += 1
147 print()
148 print(f"Results: {migrated} files {'would be ' if dry_run else ''}updated.")
149 if errors:
150 print(f" Errors: {len(errors)}")
151 return 1
152 return 0