#!/usr/bin/env python3 """migrate_changelog.py — 一次性迁移:把旧的三层 changelog 文件拆成 fragment。 把现有 ``changelog-full.md`` 按 ``### [CL-...]`` 头切成一条一文件的 fragment, 写到 ``entries/legacy/.md``;L3/L2 内容尽量从旧的 headlines/recent 中回收, 回收不到时用标题兜底。迁移后由 ``changelog_build.py`` 重新生成视图。 幂等:重复运行会覆盖 entries/legacy/ 下的同名文件。只用标准库。 """ from __future__ import annotations import re import sys from pathlib import Path ENTRY_HEADER_RE = re.compile(r"^### \[(CL-[^\]]+)\]\s*(.*)$") DATE_TITLE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}[^—]*?)\s*—\s*(.*)$") ID_DIGITS_RE = re.compile(r"^CL-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})") HEADLINE_LINE_RE = re.compile(r"^- \[(CL-[^\]]+)\]\s*(.*)$") MERGE_TAG_RE = re.compile(r"\s*\[merge:[^\]]+\]\s*$") TYPE_BY_TAG = { "bugfix": "fix", "fix": "fix", "feature": "feat", "feat": "feat", "docs": "docs", "refactor": "refactor", "chore": "chore", } def split_entries(full_text: str) -> list[tuple[str, list[str]]]: """返回 [(id, [lines...]), ...],lines 含 header 行。""" lines = full_text.splitlines() entries: list[tuple[str, list[str]]] = [] cur_id: str | None = None cur: list[str] = [] for line in lines: m = ENTRY_HEADER_RE.match(line) if m: if cur_id is not None: entries.append((cur_id, cur)) cur_id = m.group(1) cur = [line] elif cur_id is not None: cur.append(line) if cur_id is not None: entries.append((cur_id, cur)) return entries def parse_headlines(text: str) -> dict[str, str]: out: dict[str, str] = {} for line in text.splitlines(): m = HEADLINE_LINE_RE.match(line) if m: body = MERGE_TAG_RE.sub("", m.group(2)).strip() out[m.group(1)] = body return out def parse_recent_summaries(text: str) -> dict[str, str]: out: dict[str, str] = {} cur_id: str | None = None for line in text.splitlines(): m = ENTRY_HEADER_RE.match(line) if m: cur_id = m.group(1) continue if cur_id and line.startswith("- **summary**:"): out[cur_id] = line[len("- **summary**:"):].strip() return out def strip_wrap_backticks(s: str) -> str: return re.sub(r"^`([^`]+)`$", r"\1", s.strip()) def derive_date(entry_id: str, header_rest: str) -> tuple[str, str]: """返回 (date, title)。""" m = DATE_TITLE_RE.match(header_rest) if m: return m.group(1).strip(), m.group(2).strip() # 无 date —— 标题就是整段,date 从 id 推 dm = ID_DIGITS_RE.match(entry_id) if dm: y, mo, d, hh, mm = dm.groups() return f"{y}-{mo}-{d} {hh}:{mm}", header_rest.strip() return "", header_rest.strip() def parse_body(lines: list[str]) -> dict[str, object]: """解析一个 L1 条目的 body(不含 header 行)。""" tags: list[str] = [] affected: list[str] = [] merge_source = "" source_chat = "" l1_lines: list[str] = [] i = 0 n = len(lines) while i < n: line = lines[i] stripped = line.strip() if stripped.startswith("- **merge_source**:"): merge_source = stripped[len("- **merge_source**:"):].strip() i += 1 continue if stripped.startswith("- **tags**:"): raw = stripped[len("- **tags**:"):].strip() tags = [strip_wrap_backticks(t) for t in raw.split(",") if t.strip()] i += 1 continue if stripped.startswith("- **affected_files**:"): inline = stripped[len("- **affected_files**:"):].strip() if inline: affected = [strip_wrap_backticks(x) for x in re.split(r"[、,]", inline) if x.strip()] i += 1 else: i += 1 while i < n and re.match(r"^\s+-\s+", lines[i]): item = lines[i].strip()[1:].strip() affected.append(strip_wrap_backticks(item)) i += 1 continue if stripped.startswith("- **source_chat**:"): source_chat = stripped[len("- **source_chat**:"):].strip() i += 1 continue l1_lines.append(line) i += 1 # 去掉首尾空行 while l1_lines and not l1_lines[0].strip(): l1_lines.pop(0) while l1_lines and not l1_lines[-1].strip(): l1_lines.pop() return { "tags": tags, "affected_files": affected, "merge_source": merge_source, "source_chat": source_chat, "l1": "\n".join(l1_lines), } def infer_type(tags: list[str]) -> str: for t in tags: if t.lower() in TYPE_BY_TAG: return TYPE_BY_TAG[t.lower()] return "" def fm_scalar(key: str, value: str) -> str: return f"{key}: {value}".rstrip() def fm_list(key: str, items: list[str]) -> str: if not items: return f"{key}:" body = "\n".join(f" - {x}" for x in items) return f"{key}:\n{body}" def build_fragment_text( *, fid: str, date: str, author: str, type_: str, merge_source: str, title: str, source_chat: str, tags: list[str], affected: list[str], l3: str, l2: str, l1: str, ) -> str: fm = [ "---", fm_scalar("id", fid), fm_scalar("date", date), fm_scalar("author", author), fm_scalar("author_name", ""), fm_scalar("type", type_), fm_scalar("merge_source", merge_source), fm_scalar("status", "active"), fm_scalar("superseded_by", ""), fm_scalar("title", title), fm_scalar("source_chat", source_chat), fm_list("tags", tags), fm_list("affected_files", affected), "---", ] parts = [ "\n".join(fm), "", "", l3, "", "", l2, "", "", l1, "", ] return "\n".join(parts) def main(argv: list[str] | None = None) -> int: argv = argv if argv is not None else sys.argv[1:] changelog_dir = Path(argv[0]) if argv else Path(__file__).resolve().parent.parent author = argv[1] if len(argv) > 1 else "legacy" full_path = changelog_dir / "changelog-full.md" recent_path = changelog_dir / "changelog-recent.md" headlines_path = changelog_dir / "changelog-headlines.md" if not full_path.exists(): raise SystemExit(f"找不到 {full_path}") full_text = full_path.read_text(encoding="utf-8") headlines = parse_headlines(headlines_path.read_text(encoding="utf-8")) if headlines_path.exists() else {} recent = parse_recent_summaries(recent_path.read_text(encoding="utf-8")) if recent_path.exists() else {} entries = split_entries(full_text) out_dir = changelog_dir / "entries" / author out_dir.mkdir(parents=True, exist_ok=True) count = 0 for fid, lines in entries: header_rest = ENTRY_HEADER_RE.match(lines[0]).group(2) date, title = derive_date(fid, header_rest) parsed = parse_body(lines[1:]) l3 = headlines.get(fid, "").strip() or title l2 = recent.get(fid, "").strip() or l3 text = build_fragment_text( fid=fid, date=date, author=author, type_=infer_type(parsed["tags"]), merge_source=str(parsed["merge_source"]), title=title, source_chat=str(parsed["source_chat"]), tags=list(parsed["tags"]), affected=list(parsed["affected_files"]), l3=l3, l2=l2, l1=str(parsed["l1"]), ) (out_dir / f"{fid}.md").write_text(text, encoding="utf-8", newline="\n") count += 1 print(f"迁移完成:{count} 条 → {out_dir}") return 0 if __name__ == "__main__": sys.exit(main())