Files
CursorInitGeneral/.cursor/changelog/tools/migrate_changelog.py

253 lines
7.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""migrate_changelog.py — 一次性迁移:把旧的三层 changelog 文件拆成 fragment。
把现有 ``changelog-full.md`` 按 ``### [CL-...]`` 头切成一条一文件的 fragment
写到 ``entries/legacy/<id>.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 -->",
l3,
"",
"<!-- L2 -->",
l2,
"",
"<!-- L1 -->",
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())