Coverage for little_loops / cli / migrate_labels.py: 16%

93 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:55 -0500

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

2 

3from __future__ import annotations 

4 

5import argparse 

6import re 

7import sys 

8from pathlib import Path 

9 

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 

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 

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

24 

25 

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

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 

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 

51 

52 

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

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

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

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