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

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

2 

3from __future__ import annotations 

4 

5import argparse 

6import re 

7from pathlib import Path 

8 

9from little_loops.cli_args import add_config_arg, add_dry_run_arg 

10from little_loops.frontmatter import parse_frontmatter 

11 

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) 

14 

15 

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

22 

23 

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

29 

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) 

34 

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 

41 

42 

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 

49 

50 

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

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

53 

54 Returns: 

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

56 """ 

57 fm = parse_frontmatter(content) 

58 

59 body_labels = _parse_body_labels(content) 

60 if not body_labels: 

61 return content, None 

62 

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

70 

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) 

76 

77 result = _set_labels_frontmatter(content, merged) 

78 result = _remove_labels_section(result) 

79 return result, merged 

80 

81 

82def main_migrate_labels() -> int: 

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

84 

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

86 

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

106 

107 dry_run: bool = args.dry_run 

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

109 

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 

114 

115 if dry_run: 

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

117 

118 migrated = 0 

119 errors: list[str] = [] 

120 

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 

128 

129 updated, labels = _migrate_content(content) 

130 if labels is None: 

131 continue 

132 

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

136 

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 

146 

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