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

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 STATUS_SYNONYMS

12 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context

13

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

15

16

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

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

19 changes: list[str] = []

20

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

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

23 canonical = STATUS_SYNONYMS.get(value, value)

24 if canonical != value:

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

26 return f"{prefix}{canonical}"

27 return m.group(0)

28

29 updated = _STATUS_FIELD_RE.sub(_replace, content)

30 return updated, changes

31

32

33 def main_migrate_status() -> int:

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

35

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

37 canonical equivalents using STATUS_SYNONYMS from frontmatter.py.

38

39 Returns:

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

41 """

42 with cli_event_context(DEFAULT_DB_PATH, "ll-migrate-status", sys.argv[1:]):

43 parser = argparse.ArgumentParser(

44 prog="ll-migrate-status",

45 description=(

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

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

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

49 ),

50 formatter_class=argparse.RawDescriptionHelpFormatter,

51 epilog="""

52 Examples:

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

54 %(prog)s # Execute migration

55""",

56 )

57 add_dry_run_arg(parser)

58 add_config_arg(parser)

59 args = parser.parse_args()

60

61 dry_run: bool = args.dry_run

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

63

64 issues_dir = repo_root / ".issues"

65 if not issues_dir.exists():

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

67 return 1

68

69 if dry_run:

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

71

72 normalized = 0

73 errors: list[str] = []

74

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

76 try:

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

78 except OSError as exc:

79 errors.append(str(file_path))

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

81 continue

82

83 updated, changes = _migrate_content(content)

84 if not changes:

85 continue

86

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

88 rel = file_path.relative_to(repo_root)

89 for change in changes:

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

91

92 if not dry_run:

93 try:

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

95 normalized += 1

96 except OSError as exc:

97 errors.append(str(file_path))

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

99 else:

100 normalized += 1

101

102 print()

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

104 if errors:

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

106 return 1

107 return 0