Coverage for little_loops / cli / migrate_status.py: 85%

61 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -0500

1"""ll-migrate-status: Normalize non-canonical status: values in issue files to canonical ones.""" 

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 STATUS_SYNONYMS 

11 

12_STATUS_FIELD_RE = re.compile(r"^(status: )(.+)$", re.MULTILINE) 

13 

14 

15def _migrate_content(content: str) -> tuple[str, list[str]]: 

16 """Normalize status field in frontmatter. Returns (updated_content, list_of_changes).""" 

17 changes: list[str] = [] 

18 

19 def _replace(m: re.Match[str]) -> str: 

20 prefix, value = m.group(1), m.group(2) 

21 canonical = STATUS_SYNONYMS.get(value, value) 

22 if canonical != value: 

23 changes.append(f"status: {value!r} → status: {canonical!r}") 

24 return f"{prefix}{canonical}" 

25 return m.group(0) 

26 

27 updated = _STATUS_FIELD_RE.sub(_replace, content) 

28 return updated, changes 

29 

30 

31def main_migrate_status() -> int: 

32 """Entry point for ll-migrate-status command. 

33 

34 Normalizes non-canonical status: values in all .issues/**/*.md files to their 

35 canonical equivalents using STATUS_SYNONYMS from frontmatter.py. 

36 

37 Returns: 

38 Exit code (0 = success, 1 = error) 

39 """ 

40 parser = argparse.ArgumentParser( 

41 prog="ll-migrate-status", 

42 description=( 

43 "Normalize non-canonical status: frontmatter values to canonical ones " 

44 "(e.g. 'completed' → 'done', 'wip' → 'in_progress'). " 

45 "One-time migration for ENH-1551." 

46 ), 

47 formatter_class=argparse.RawDescriptionHelpFormatter, 

48 epilog=""" 

49Examples: 

50 %(prog)s --dry-run # Preview all planned normalizations (strongly advised first) 

51 %(prog)s # Execute migration 

52""", 

53 ) 

54 add_dry_run_arg(parser) 

55 add_config_arg(parser) 

56 args = parser.parse_args() 

57 

58 dry_run: bool = args.dry_run 

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

60 

61 issues_dir = repo_root / ".issues" 

62 if not issues_dir.exists(): 

63 print(f"No .issues/ directory found at {repo_root}") 

64 return 1 

65 

66 if dry_run: 

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

68 

69 normalized = 0 

70 errors: list[str] = [] 

71 

72 for file_path in sorted(issues_dir.rglob("*.md")): 

73 try: 

74 content = file_path.read_text(encoding="utf-8") 

75 except OSError as exc: 

76 errors.append(str(file_path)) 

77 print(f" [ERROR] {file_path}: {exc}") 

78 continue 

79 

80 updated, changes = _migrate_content(content) 

81 if not changes: 

82 continue 

83 

84 prefix = "[DRY RUN] " if dry_run else "" 

85 rel = file_path.relative_to(repo_root) 

86 for change in changes: 

87 print(f" {prefix}NORMALIZE {rel}: {change}") 

88 

89 if not dry_run: 

90 try: 

91 file_path.write_text(updated, encoding="utf-8") 

92 normalized += 1 

93 except OSError as exc: 

94 errors.append(str(file_path)) 

95 print(f" [ERROR] {file_path}: {exc}") 

96 else: 

97 normalized += 1 

98 

99 print() 

100 print(f"Results: {normalized} files {'would be ' if dry_run else ''}updated.") 

101 if errors: 

102 print(f" Errors: {len(errors)}") 

103 return 1 

104 return 0