1"""ll-migrate-labels: Move freeform ## Labels body sections to labels: frontmatter."""

2

3 from __future__ import annotations

4

5 import argparse

6 import re

7 import sys

8 from pathlib import Path

9

10 from little_loops.cli_args import add_config_arg, add_dry_run_arg

11 from little_loops.frontmatter import parse_frontmatter

12 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context

13

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)

16

17

18 def _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))]

24

25

26 def _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}"

31

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)

36

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

43

44

45 def _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

51

52

53 def _migrate_content(content: str) -> tuple[str, list[str] | None]:

54 """Migrate ## Labels body section to frontmatter labels: field.

55

56 Returns:

57 (updated_content, migrated_labels) — migrated_labels is None when no change needed.

58 """

59 fm = parse_frontmatter(content)

60

61 body_labels = _parse_body_labels(content)

62 if not body_labels:

63 return content, None

64

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

72

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)

78

79 result = _set_labels_frontmatter(content, merged)

80 result = _remove_labels_section(result)

81 return result, merged

82

83

84 def main_migrate_labels() -> int:

85 """Entry point for ll-migrate-labels command.

86

87 Migrates freeform ## Labels body sections to labels: frontmatter in all issue files.

88

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

101 Examples:

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

109

110 dry_run: bool = args.dry_run

111 repo_root: Path = args.config or Path.cwd()

112

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

117

118 if dry_run:

119 print("[DRY RUN] No files will be modified.")

120

121 migrated = 0

122 errors: list[str] = []

123

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

131

132 updated, labels = _migrate_content(content)

133 if labels is None:

134 continue

135

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

139

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

149

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