5e6a974a5d
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
647 lines
20 KiB
Python
647 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""migrate_issues_frontmatter — migrate dev/issues/*.md from inline **Key:** value
|
|
metadata to canonical YAML frontmatter (issue 0100).
|
|
|
|
Idempotent: files that already have id + domain + scope in frontmatter are skipped.
|
|
Files with partial frontmatter get missing keys merged in without overwriting existing ones.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry path setup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _find_registry_root() -> Path:
|
|
here = Path(__file__).resolve()
|
|
for parent in (here, *here.parents):
|
|
if (parent / "registry.db").exists():
|
|
return parent
|
|
return Path.cwd()
|
|
|
|
|
|
_REGISTRY_ROOT = _find_registry_root()
|
|
sys.path.insert(0, str(_REGISTRY_ROOT / "python" / "functions"))
|
|
|
|
from core.core import extract_frontmatter # noqa: E402
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
TODAY = date.today().isoformat()
|
|
|
|
_SKIP_NAMES = {"README.md", "template.md", "README", "template"}
|
|
|
|
_STATUS_ALIASES = {
|
|
"pendiente": "pendiente",
|
|
"pending": "pendiente",
|
|
"in-progress": "in-progress",
|
|
"en-progreso": "in-progress",
|
|
"en_progreso": "in-progress",
|
|
"bloqueado": "bloqueado",
|
|
"blocked": "bloqueado",
|
|
"completado": "completado",
|
|
"done": "completado",
|
|
"completed": "completado",
|
|
"deferred": "deferred",
|
|
"diferido": "deferred",
|
|
"closed": "completado",
|
|
}
|
|
|
|
_TYPE_ALIASES = {
|
|
"app": "app",
|
|
"feature": "feature",
|
|
"bugfix": "bugfix",
|
|
"bug": "bugfix",
|
|
"refactor": "refactor",
|
|
"chore": "chore",
|
|
"docs": "docs",
|
|
"doc": "docs",
|
|
"spike": "spike",
|
|
"epic": "epic",
|
|
"infra": "infra",
|
|
"planning": "planning",
|
|
}
|
|
|
|
_PRIORITY_ALIASES = {
|
|
"alta": "alta",
|
|
"high": "alta",
|
|
"media": "media",
|
|
"medium": "media",
|
|
"baja": "baja",
|
|
"low": "baja",
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Heuristics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _infer_domain(filename: str) -> list[str]:
|
|
"""Return list of canonical domain tags based on filename heuristics."""
|
|
f = filename.lower()
|
|
domains: list[str] = []
|
|
|
|
if re.search(r"^cpp-|(-cpp-)|imgui|glfw|glsl|altsnap|sizemove", f):
|
|
domains.append("cpp-stack")
|
|
if re.search(r"^kanban-|kanban", f):
|
|
domains.append("kanban")
|
|
if re.search(r"^trading-|0088[a-z]?-trading", f):
|
|
domains.append("trading")
|
|
if re.search(r"^gamedev-|0072[a-z]?-gamedev", f):
|
|
domains.append("gamedev")
|
|
if re.search(r"osint|odr-", f):
|
|
domains.append("osint")
|
|
if re.search(r"metabase|bigquery|datafactory|data-factory|navegator|cdp-", f):
|
|
domains.append("data-ingest")
|
|
if re.search(r"notify|telegram|matrix", f):
|
|
domains.append("notify")
|
|
if re.search(r"imagegen|sd-cpp|stable-diffusion", f):
|
|
domains.append("imagegen")
|
|
if re.search(r"dag-engine|dagu", f):
|
|
if "cpp" in f or "imgui" in f:
|
|
domains.append("cpp-stack")
|
|
else:
|
|
domains.append("data-ingest")
|
|
if re.search(r"audit-|registry-first|uses.functions|nested-app-md", f):
|
|
domains.append("registry-quality")
|
|
if re.search(r"autonomous|e2e-validation|registry-call|delegation|capability|call-monitor|mcp-", f):
|
|
domains.append("meta")
|
|
if re.search(r"deploy|vps", f):
|
|
domains.append("deploy")
|
|
if re.search(r"fn-run|gradle.run|(?<![a-z])dev-(?!_console)", f):
|
|
domains.append("dev-ux")
|
|
if re.search(r"browser|chrome|cdp-", f):
|
|
domains.append("browser")
|
|
if re.search(r"datahub|app-hub|launcher|app-locations", f):
|
|
domains.append("apps-infra")
|
|
if re.search(r"frontend|react", f):
|
|
domains.append("frontend")
|
|
if re.search(r"0100|frontmatter|migrate-issues|extract-|audit-", f):
|
|
if "registry-quality" not in domains:
|
|
domains.append("registry-quality")
|
|
|
|
# deduplicate while preserving order
|
|
seen: set[str] = set()
|
|
result: list[str] = []
|
|
for d in domains:
|
|
if d not in seen:
|
|
seen.add(d)
|
|
result.append(d)
|
|
return result
|
|
|
|
|
|
def _infer_scope(filename: str, inline_type: str) -> str:
|
|
f = filename.lower()
|
|
if re.search(r"roadmap", f):
|
|
return "cross-stack"
|
|
if re.search(r"extract-|migrate-|audit-", f):
|
|
return "registry-only"
|
|
if inline_type == "app" or re.search(r"-app[-.]|app-", f):
|
|
return "app-scoped"
|
|
return "multi-app"
|
|
|
|
|
|
def _infer_type(filename: str) -> str:
|
|
f = filename.lower()
|
|
if re.search(r"roadmap", f):
|
|
return "epic"
|
|
if re.search(r"audit-|cleanup-", f):
|
|
return "chore"
|
|
if re.search(r"fix-|bugfix-|bug-", f):
|
|
return "bugfix"
|
|
return "feature"
|
|
|
|
|
|
def _infer_priority_from_mtime(path: Path) -> str:
|
|
mtime = path.stat().st_mtime
|
|
mtime_date = date.fromtimestamp(mtime)
|
|
delta = (date.today() - mtime_date).days
|
|
if delta <= 14:
|
|
return "alta"
|
|
return "media"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Inline parser
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_H1_RE = re.compile(r"^#\s+(.+)$", re.MULTILINE)
|
|
_BOLD_KEY_RE = re.compile(r"^\*\*([A-Za-z]+)\*\*:\s*(.*)$")
|
|
_TABLE_META_RE = re.compile(r"^\|\s*\*\*([A-Za-z]+)\*\*\s*\|\s*(.+?)\s*\|")
|
|
# Match "NNNN" or "NNNN — rest"
|
|
_ID_FROM_H1_RE = re.compile(r"^(\d{4}[a-z]?)\s*[—-]\s*(.+)$")
|
|
_ISSUE_REF_RE = re.compile(r"\b(\d{4}[a-z]?)\b")
|
|
|
|
|
|
def _extract_h1_title(content: str) -> tuple[str, str]:
|
|
"""Return (issue_id, clean_title) from H1. Falls back to ('', content first line)."""
|
|
m = _H1_RE.search(content)
|
|
if not m:
|
|
return "", ""
|
|
h1 = m.group(1).strip()
|
|
id_m = _ID_FROM_H1_RE.match(h1)
|
|
if id_m:
|
|
return id_m.group(1), id_m.group(2).strip()
|
|
return "", h1
|
|
|
|
|
|
def _parse_inline_meta(content: str) -> dict[str, str]:
|
|
"""Parse **Key:** value lines and table metadata from first ~40 lines."""
|
|
meta: dict[str, str] = {}
|
|
lines = content.splitlines()[:40]
|
|
for line in lines:
|
|
# Bold inline: **Status:** value
|
|
bm = _BOLD_KEY_RE.match(line.strip())
|
|
if bm:
|
|
key = bm.group(1).lower()
|
|
val = bm.group(2).strip()
|
|
meta[key] = val
|
|
continue
|
|
# Table row: | **Estado** | value |
|
|
tm = _TABLE_META_RE.match(line.strip())
|
|
if tm:
|
|
key = tm.group(1).lower()
|
|
val = tm.group(2).strip()
|
|
# Strip markdown bold from value
|
|
val = re.sub(r"\*\*(.+?)\*\*", r"\1", val)
|
|
meta[key] = val
|
|
return meta
|
|
|
|
|
|
def _parse_issue_ids(raw: str) -> list[str]:
|
|
"""Extract issue IDs (NNNN or NNNNa) from a raw string like '0096, 0097 — DONE'."""
|
|
if not raw or raw.strip() in ("—", "-", "", "ninguna", "none"):
|
|
return []
|
|
return _ISSUE_REF_RE.findall(raw)
|
|
|
|
|
|
def _normalize_status(raw: str) -> str:
|
|
raw = raw.lower().strip()
|
|
# extract first word-token
|
|
token = re.split(r"[\s,;—-]", raw)[0]
|
|
return _STATUS_ALIASES.get(token, _STATUS_ALIASES.get(raw, "pendiente"))
|
|
|
|
|
|
def _normalize_type(raw: str) -> str:
|
|
raw = raw.lower().strip()
|
|
# strip trailing annotation like "feature — apps/kanban/"
|
|
token = re.split(r"[\s,;—-]", raw)[0]
|
|
return _TYPE_ALIASES.get(token, "")
|
|
|
|
|
|
def _normalize_priority(raw: str) -> str:
|
|
raw = raw.lower().strip()
|
|
token = re.split(r"[\s,;]", raw)[0]
|
|
return _PRIORITY_ALIASES.get(token, "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frontmatter builder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_frontmatter(
|
|
issue_id: str,
|
|
title: str,
|
|
status: str,
|
|
type_: str,
|
|
domain: list[str],
|
|
scope: str,
|
|
priority: str,
|
|
depends: list[str],
|
|
blocks: list[str],
|
|
related: list[str],
|
|
created: str,
|
|
updated: str,
|
|
tags: list[str],
|
|
) -> str:
|
|
"""Render canonical YAML frontmatter block as a string."""
|
|
lines: list[str] = ["---"]
|
|
lines.append(f"id: \"{issue_id}\"")
|
|
lines.append(f"title: \"{title}\"")
|
|
lines.append(f"status: {status}")
|
|
lines.append(f"type: {type_}")
|
|
|
|
if domain:
|
|
lines.append("domain:")
|
|
for d in domain:
|
|
lines.append(f" - {d}")
|
|
else:
|
|
lines.append("domain: []")
|
|
|
|
lines.append(f"scope: {scope}")
|
|
lines.append(f"priority: {priority}")
|
|
|
|
if depends:
|
|
lines.append("depends:")
|
|
for d in depends:
|
|
lines.append(f" - \"{d}\"")
|
|
else:
|
|
lines.append("depends: []")
|
|
|
|
if blocks:
|
|
lines.append("blocks:")
|
|
for b in blocks:
|
|
lines.append(f" - \"{b}\"")
|
|
else:
|
|
lines.append("blocks: []")
|
|
|
|
if related:
|
|
lines.append("related:")
|
|
for r in related:
|
|
lines.append(f" - \"{r}\"")
|
|
else:
|
|
lines.append("related: []")
|
|
|
|
lines.append(f"created: {created}")
|
|
lines.append(f"updated: {updated}")
|
|
|
|
if tags:
|
|
lines.append("tags:")
|
|
for t in tags:
|
|
lines.append(f" - {t}")
|
|
else:
|
|
lines.append("tags: []")
|
|
|
|
lines.append("---")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-file processing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
FileResult = dict[str, Any]
|
|
|
|
|
|
def _process_file(
|
|
path: Path,
|
|
backup_dir: Path | None,
|
|
dry_run: bool,
|
|
) -> FileResult:
|
|
result: FileResult = {
|
|
"path": str(path),
|
|
"action": "skipped",
|
|
"domain_inferred": [],
|
|
"scope_inferred": "",
|
|
"warnings": [],
|
|
}
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
body_without_fm, existing_fm = extract_frontmatter(content)
|
|
|
|
# Determine issue_id from filename
|
|
stem = path.stem # e.g. "0099-datahub-app-launcher"
|
|
id_from_file_m = re.match(r"^(\d{4}[a-z]?)", stem)
|
|
file_issue_id = id_from_file_m.group(1) if id_from_file_m else stem
|
|
|
|
# Check if already fully migrated
|
|
if existing_fm and isinstance(existing_fm, dict):
|
|
fm = existing_fm
|
|
has_id = "id" in fm
|
|
has_domain = "domain" in fm
|
|
has_scope = "scope" in fm
|
|
if has_id and has_domain and has_scope:
|
|
result["action"] = "skipped"
|
|
return result
|
|
# Partial frontmatter — merge missing keys
|
|
result["action"] = "merged"
|
|
_fill_missing_keys(path, fm, body_without_fm, file_issue_id, result, backup_dir, dry_run)
|
|
return result
|
|
|
|
# No frontmatter: parse inline and build canonical
|
|
result["action"] = "migrated"
|
|
_migrate_from_inline(path, content, file_issue_id, result, backup_dir, dry_run)
|
|
return result
|
|
|
|
|
|
def _fill_missing_keys(
|
|
path: Path,
|
|
fm: dict,
|
|
body: str,
|
|
file_issue_id: str,
|
|
result: FileResult,
|
|
backup_dir: Path | None,
|
|
dry_run: bool,
|
|
) -> None:
|
|
"""Merge missing canonical keys into an existing partial frontmatter."""
|
|
warnings = result["warnings"]
|
|
filename = path.name
|
|
|
|
issue_id = str(fm.get("id", file_issue_id))
|
|
title = str(fm.get("title", _extract_h1_title(body)[1] or filename))
|
|
status = _normalize_status(str(fm.get("status", fm.get("estado", "pendiente"))))
|
|
type_ = _normalize_type(str(fm.get("type", fm.get("tipo", ""))))
|
|
if not type_:
|
|
type_ = _infer_type(filename)
|
|
if not type_:
|
|
warnings.append(f"type missing, inferred: {type_ or '(empty)'}")
|
|
|
|
domain_raw = fm.get("domain", fm.get("dominio", []))
|
|
if isinstance(domain_raw, str):
|
|
domain = [domain_raw] if domain_raw else []
|
|
elif isinstance(domain_raw, list):
|
|
domain = domain_raw
|
|
else:
|
|
domain = []
|
|
|
|
if not domain:
|
|
domain = _infer_domain(filename)
|
|
result["domain_inferred"] = domain
|
|
if not domain:
|
|
warnings.append("domain could not be inferred")
|
|
|
|
scope = str(fm.get("scope", ""))
|
|
if not scope:
|
|
scope = _infer_scope(filename, type_)
|
|
result["scope_inferred"] = scope
|
|
|
|
priority = _normalize_priority(str(fm.get("priority", fm.get("priority", ""))))
|
|
if not priority:
|
|
priority = _infer_priority_from_mtime(path)
|
|
warnings.append(f"priority missing, inferred from mtime: {priority}")
|
|
|
|
depends = _coerce_id_list(fm.get("depends", fm.get("depends_on", [])))
|
|
blocks = _coerce_id_list(fm.get("blocks", []))
|
|
related = _coerce_id_list(fm.get("related", []))
|
|
created = str(fm.get("created", TODAY))
|
|
tags_raw = fm.get("tags", [])
|
|
tags = tags_raw if isinstance(tags_raw, list) else []
|
|
|
|
new_fm = _build_frontmatter(
|
|
issue_id=issue_id,
|
|
title=title,
|
|
status=status,
|
|
type_=type_,
|
|
domain=domain,
|
|
scope=scope,
|
|
priority=priority,
|
|
depends=depends,
|
|
blocks=blocks,
|
|
related=related,
|
|
created=created,
|
|
updated=TODAY,
|
|
tags=tags,
|
|
)
|
|
new_content = new_fm + body
|
|
|
|
if not dry_run:
|
|
_backup_file(path, backup_dir)
|
|
path.write_text(new_content, encoding="utf-8")
|
|
|
|
|
|
def _coerce_id_list(val: Any) -> list[str]:
|
|
if isinstance(val, list):
|
|
return [str(v) for v in val]
|
|
if isinstance(val, str):
|
|
return _parse_issue_ids(val)
|
|
return []
|
|
|
|
|
|
def _migrate_from_inline(
|
|
path: Path,
|
|
content: str,
|
|
file_issue_id: str,
|
|
result: FileResult,
|
|
backup_dir: Path | None,
|
|
dry_run: bool,
|
|
) -> None:
|
|
"""Parse inline metadata and write YAML frontmatter."""
|
|
warnings = result["warnings"]
|
|
filename = path.name
|
|
|
|
inline = _parse_inline_meta(content)
|
|
h1_id, h1_title = _extract_h1_title(content)
|
|
issue_id = h1_id or file_issue_id
|
|
title = h1_title or filename
|
|
|
|
# Status
|
|
status_raw = inline.get("status", inline.get("estado", ""))
|
|
status = _normalize_status(status_raw) if status_raw else "pendiente"
|
|
|
|
# Type
|
|
type_raw = inline.get("type", inline.get("tipo", ""))
|
|
type_ = _normalize_type(type_raw) if type_raw else ""
|
|
if not type_:
|
|
type_ = _infer_type(filename)
|
|
warnings.append(f"type missing/unknown ({type_raw!r}), inferred: {type_}")
|
|
|
|
# Domain
|
|
domain_raw = inline.get("domain", inline.get("dominio", ""))
|
|
if domain_raw and domain_raw not in ("—", "-"):
|
|
domain = [d.strip() for d in re.split(r"[,;]", domain_raw) if d.strip()]
|
|
else:
|
|
domain = _infer_domain(filename)
|
|
result["domain_inferred"] = domain
|
|
if not domain:
|
|
warnings.append("domain could not be inferred from filename")
|
|
|
|
# Scope
|
|
scope_raw = inline.get("scope", inline.get("alcance", ""))
|
|
scope = scope_raw if scope_raw and scope_raw not in ("—", "-") else _infer_scope(filename, type_)
|
|
result["scope_inferred"] = scope
|
|
|
|
# Priority
|
|
priority_raw = inline.get("priority", inline.get("prioridad", ""))
|
|
priority = _normalize_priority(priority_raw) if priority_raw else ""
|
|
if not priority:
|
|
priority = _infer_priority_from_mtime(path)
|
|
warnings.append(f"priority missing, inferred from mtime: {priority}")
|
|
|
|
# Depends / Blocks / Related
|
|
depends = _parse_issue_ids(inline.get("depends", inline.get("depends_on", inline.get("depende", ""))))
|
|
blocks = _parse_issue_ids(inline.get("blocks", inline.get("bloquea", "")))
|
|
related = _parse_issue_ids(inline.get("related", inline.get("relacionado", inline.get("relacionados", ""))))
|
|
|
|
# Created date
|
|
created_raw = inline.get("created", inline.get("fecha", ""))
|
|
created = created_raw.strip() if created_raw and re.match(r"\d{4}-\d{2}-\d{2}", created_raw) else TODAY
|
|
|
|
new_fm = _build_frontmatter(
|
|
issue_id=issue_id,
|
|
title=title,
|
|
status=status,
|
|
type_=type_,
|
|
domain=domain,
|
|
scope=scope,
|
|
priority=priority,
|
|
depends=depends,
|
|
blocks=blocks,
|
|
related=related,
|
|
created=created,
|
|
updated=TODAY,
|
|
tags=[],
|
|
)
|
|
new_content = new_fm + content
|
|
|
|
if not dry_run:
|
|
_backup_file(path, backup_dir)
|
|
path.write_text(new_content, encoding="utf-8")
|
|
|
|
|
|
def _backup_file(path: Path, backup_dir: Path | None) -> None:
|
|
if backup_dir is None:
|
|
return
|
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
dest = backup_dir / path.name
|
|
if not dest.exists():
|
|
shutil.copy2(path, dest)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def migrate_issues_frontmatter(
|
|
issues_dir: str,
|
|
backup_dir: str = "",
|
|
dry_run: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Migrate dev/issues/*.md from inline metadata to canonical YAML frontmatter.
|
|
|
|
Args:
|
|
issues_dir: Path to the issues directory (e.g. "dev/issues").
|
|
backup_dir: Where to copy originals before writing. Defaults to
|
|
<issues_dir>/.backup_pre_0100/. Pass "" for default.
|
|
dry_run: If True, compute changes but do not write files.
|
|
|
|
Returns:
|
|
{
|
|
"migrated": N,
|
|
"merged": N,
|
|
"skipped": N,
|
|
"warnings": [...],
|
|
"files": [{"path": ..., "action": ..., "domain_inferred": ..., "scope_inferred": ..., "warnings": [...]}, ...]
|
|
}
|
|
"""
|
|
issues_path = Path(issues_dir).expanduser()
|
|
if not issues_path.is_dir():
|
|
raise FileNotFoundError(f"issues_dir not found: {issues_path}")
|
|
|
|
# Determine backup directory
|
|
if dry_run:
|
|
bk_path = None
|
|
elif backup_dir:
|
|
bk_path = Path(backup_dir).expanduser()
|
|
else:
|
|
bk_path = issues_path / ".backup_pre_0100"
|
|
|
|
# Collect all .md files from issues_dir and issues_dir/completed/
|
|
all_files: list[Path] = []
|
|
for md in sorted(issues_path.glob("*.md")):
|
|
if md.name in _SKIP_NAMES:
|
|
continue
|
|
all_files.append(md)
|
|
completed_dir = issues_path / "completed"
|
|
if completed_dir.is_dir():
|
|
for md in sorted(completed_dir.glob("*.md")):
|
|
if md.name in _SKIP_NAMES:
|
|
continue
|
|
all_files.append(md)
|
|
|
|
results: list[FileResult] = []
|
|
all_warnings: list[str] = []
|
|
|
|
for path in all_files:
|
|
try:
|
|
r = _process_file(path, bk_path, dry_run)
|
|
except Exception as e:
|
|
r = {
|
|
"path": str(path),
|
|
"action": "warning",
|
|
"domain_inferred": [],
|
|
"scope_inferred": "",
|
|
"warnings": [f"ERROR: {e}"],
|
|
}
|
|
if r["warnings"]:
|
|
for w in r["warnings"]:
|
|
all_warnings.append(f"{path.name}: {w}")
|
|
results.append(r)
|
|
|
|
migrated = sum(1 for r in results if r["action"] == "migrated")
|
|
merged = sum(1 for r in results if r["action"] == "merged")
|
|
skipped = sum(1 for r in results if r["action"] == "skipped")
|
|
warnings_count = sum(1 for r in results if r["action"] == "warning")
|
|
|
|
# Summary table
|
|
prefix = "[DRY RUN] " if dry_run else ""
|
|
print(f"\n{prefix}migrate_issues_frontmatter summary")
|
|
print(f" issues_dir : {issues_path}")
|
|
print(f" files found: {len(results)}")
|
|
print(f" migrated : {migrated}")
|
|
print(f" merged : {merged}")
|
|
print(f" skipped : {skipped}")
|
|
print(f" warnings : {len(all_warnings)}")
|
|
if all_warnings:
|
|
print("\nWarnings:")
|
|
for w in all_warnings:
|
|
print(f" - {w}")
|
|
|
|
return {
|
|
"migrated": migrated,
|
|
"merged": merged,
|
|
"skipped": skipped,
|
|
"warnings": all_warnings,
|
|
"files": results,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI entry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
if __name__ == "__main__":
|
|
issues_dir_arg = sys.argv[1] if len(sys.argv) > 1 else "dev/issues"
|
|
dry = "--dry-run" in sys.argv
|
|
result = migrate_issues_frontmatter(issues_dir_arg, dry_run=dry)
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|