Coverage for little_loops / cli / migrate_labels.py: 16%
93 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-04 12:21 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-04 12:21 -0500
1"""ll-migrate-labels: Move freeform ## Labels body sections to labels: frontmatter."""
3from __future__ import annotations
5import argparse
6import re
7import sys
8from pathlib import Path
10from little_loops.cli_args import add_config_arg, add_dry_run_arg
11from little_loops.frontmatter import parse_frontmatter
12from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
14_FM_FIELD_RE = re.compile(r"^---\s*$", re.MULTILINE)
15_LABELS_SECTION_RE = re.compile(r"^## Labels\s*\n(.*?)(?=\n## |\Z)", re.MULTILINE | re.DOTALL)
18def _parse_body_labels(content: str) -> list[str]:
19 """Extract backtick-wrapped labels from ## Labels body section."""
20 match = _LABELS_SECTION_RE.search(content)
21 if not match:
22 return []
23 return [m.lower() for m in re.findall(r"`([^`]+)`", match.group(1))]
26def _set_labels_frontmatter(content: str, labels: list[str]) -> str:
27 """Write labels: list field to frontmatter (avoids yaml roundtrip)."""
28 if not content.startswith("---\n"):
29 yaml_labels = "\n".join(f"- {lb}" for lb in labels)
30 return f"---\nlabels:\n{yaml_labels}\n---\n{content}"
32 labels_line = "labels:\n" + "\n".join(f"- {lb}" for lb in labels)
33 key_re = re.compile(r"^labels:.*?(?=\n\S|\n---)", re.MULTILINE | re.DOTALL)
34 if key_re.search(content):
35 return key_re.sub(labels_line, content)
37 # Insert before closing ---
38 markers = list(_FM_FIELD_RE.finditer(content))
39 if len(markers) >= 2:
40 pos = markers[1].start()
41 return content[:pos] + f"{labels_line}\n" + content[pos:]
42 return content
45def _remove_labels_section(content: str) -> str:
46 """Remove ## Labels body section after migration."""
47 result = _LABELS_SECTION_RE.sub("", content)
48 # Clean up excessive blank lines left by removal
49 result = re.sub(r"\n{3,}", "\n\n", result)
50 return result
53def _migrate_content(content: str) -> tuple[str, list[str] | None]:
54 """Migrate ## Labels body section to frontmatter labels: field.
56 Returns:
57 (updated_content, migrated_labels) — migrated_labels is None when no change needed.
58 """
59 fm = parse_frontmatter(content)
61 body_labels = _parse_body_labels(content)
62 if not body_labels:
63 return content, None
65 existing_fm_labels: list[str] = []
66 raw = fm.get("labels")
67 if raw:
68 if isinstance(raw, list):
69 existing_fm_labels = [str(lb) for lb in raw]
70 else:
71 existing_fm_labels = [lb.strip() for lb in str(raw).split(",") if lb.strip()]
73 # Merge: keep frontmatter labels, add any body labels not already present
74 merged = list(existing_fm_labels)
75 for lb in body_labels:
76 if lb not in merged:
77 merged.append(lb)
79 result = _set_labels_frontmatter(content, merged)
80 result = _remove_labels_section(result)
81 return result, merged
84def main_migrate_labels() -> int:
85 """Entry point for ll-migrate-labels command.
87 Migrates freeform ## Labels body sections to labels: frontmatter in all issue files.
89 Returns:
90 Exit code (0 = success, 1 = error)
91 """
92 with cli_event_context(DEFAULT_DB_PATH, "ll-migrate-labels", sys.argv[1:]):
93 parser = argparse.ArgumentParser(
94 prog="ll-migrate-labels",
95 description=(
96 "Migrate freeform ## Labels body sections → labels: frontmatter "
97 "in all issue files. One-time migration for ENH-1392."
98 ),
99 formatter_class=argparse.RawDescriptionHelpFormatter,
100 epilog="""
101Examples:
102 %(prog)s --dry-run # Preview all planned migrations (strongly advised first)
103 %(prog)s # Execute migration
104""",
105 )
106 add_dry_run_arg(parser)
107 add_config_arg(parser)
108 args = parser.parse_args()
110 dry_run: bool = args.dry_run
111 repo_root: Path = args.config or Path.cwd()
113 issues_dir = repo_root / ".issues"
114 if not issues_dir.exists():
115 print(f"No .issues/ directory found at {repo_root}")
116 return 1
118 if dry_run:
119 print("[DRY RUN] No files will be modified.")
121 migrated = 0
122 errors: list[str] = []
124 for file_path in sorted(issues_dir.rglob("*.md")):
125 try:
126 content = file_path.read_text(encoding="utf-8")
127 except OSError as exc:
128 errors.append(str(file_path))
129 print(f" [ERROR] {file_path}: {exc}")
130 continue
132 updated, labels = _migrate_content(content)
133 if labels is None:
134 continue
136 prefix = "[DRY RUN] " if dry_run else ""
137 rel = file_path.relative_to(repo_root)
138 print(f" {prefix}MIGRATE {rel}: ## Labels → labels: {labels}")
140 if not dry_run:
141 try:
142 file_path.write_text(updated, encoding="utf-8")
143 migrated += 1
144 except OSError as exc:
145 errors.append(str(file_path))
146 print(f" [ERROR] {file_path}: {exc}")
147 else:
148 migrated += 1
150 print()
151 print(f"Results: {migrated} files {'would be ' if dry_run else ''}updated.")
152 if errors:
153 print(f" Errors: {len(errors)}")
154 return 1
155 return 0