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

337 lines
12 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
"""changelog_build.py — 从 fragment 源生成三层 changelog 视图。
设计目标(解决多人协作 + git merge 冲突):
- 唯一数据源是 ``.cursor/changelog/entries/<author>/<id>.md`` 一条一文件的 fragment。
- 三层视图full / recent / headlines全部由本脚本**确定性生成**,不再手写。
- 因为排序只依赖 fragment 内的 CL 时间戳,与 git merge 顺序无关,所有人重建后逐字节收敛。
只用标准库,兼容 Python 3.10+,可在任意平台运行。
"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
# ---- 路径约定 --------------------------------------------------------------
CHANGELOG_DIR_NAME = ".cursor/changelog"
ENTRIES_SUBDIR = "entries"
FULL_NAME = "changelog-full.md"
RECENT_NAME = "changelog-recent.md"
HEADLINES_NAME = "changelog-headlines.md"
BY_AUTHOR_NAME = "changelog-by-author.md"
RECENT_WINDOW = 20
HEADLINES_WINDOW = 50
# ---- fragment 解析 ---------------------------------------------------------
ID_RE = re.compile(r"^CL-(\d{8})-(\d{3,4})([a-z]*)(?:-(.+))?$")
FM_FENCE = "---"
SECTION_RE = re.compile(r"^<!--\s*(L1|L2|L3)\s*-->\s*$", re.M)
@dataclass
class Fragment:
fid: str
date: str = ""
author: str = "legacy"
author_name: str = ""
type: str = ""
merge_source: str = ""
status: str = "active"
superseded_by: str = ""
title: str = ""
source_chat: str = ""
tags: list[str] = field(default_factory=list)
affected_files: list[str] = field(default_factory=list)
l3: str = ""
l2: str = ""
l1: str = ""
path: Path | None = None
def sort_key(self) -> tuple[str, str, str, str]:
m = ID_RE.match(self.fid)
if not m:
return (self.fid, "", "", "")
date8, time4, suffix, _author = m.groups()
return (date8, time4.zfill(4), suffix, self.fid)
def date_only(self) -> str:
return self.date.split(" ")[0] if self.date else ""
def _parse_frontmatter(text: str) -> dict[str, object]:
data: dict[str, object] = {}
lines = text.splitlines()
i = 0
while i < len(lines):
line = lines[i]
if not line.strip():
i += 1
continue
m = re.match(r"^([A-Za-z_]+):\s*(.*)$", line)
if not m:
i += 1
continue
key, val = m.group(1), m.group(2).strip()
if val == "":
items: list[str] = []
j = i + 1
while j < len(lines) and re.match(r"^\s+-\s+", lines[j]):
items.append(lines[j].strip()[1:].strip())
j += 1
if items:
data[key] = items
i = j
else:
data[key] = ""
i += 1
elif val.startswith("[") and val.endswith("]"):
inner = val[1:-1].strip()
data[key] = [x.strip() for x in inner.split(",") if x.strip()] if inner else []
i += 1
else:
data[key] = val
i += 1
return data
def parse_fragment(path: Path) -> Fragment:
raw = path.read_text(encoding="utf-8")
if not raw.startswith(FM_FENCE):
raise ValueError(f"fragment 缺少 frontmatter: {path}")
rest = raw[len(FM_FENCE):]
end = rest.find("\n" + FM_FENCE)
if end == -1:
raise ValueError(f"fragment frontmatter 未闭合: {path}")
fm_text = rest[:end]
body = rest[end + len("\n" + FM_FENCE):].lstrip("\n")
fm = _parse_frontmatter(fm_text)
sections: dict[str, str] = {}
parts = SECTION_RE.split(body)
it = iter(parts[1:])
for marker, content in zip(it, it):
sections[marker] = content.strip("\n")
def _s(key: str) -> str:
v = fm.get(key, "")
return v if isinstance(v, str) else ""
def _l(key: str) -> list[str]:
v = fm.get(key, [])
return [str(x) for x in v] if isinstance(v, list) else []
frag = Fragment(
fid=_s("id"),
date=_s("date"),
author=_s("author") or "legacy",
author_name=_s("author_name"),
type=_s("type"),
merge_source=_s("merge_source"),
status=_s("status") or "active",
superseded_by=_s("superseded_by"),
title=_s("title"),
source_chat=_s("source_chat"),
tags=_l("tags"),
affected_files=_l("affected_files"),
l3=sections.get("L3", "").strip(),
l2=sections.get("L2", "").strip(),
l1=sections.get("L1", "").strip(),
path=path,
)
if not frag.fid:
raise ValueError(f"fragment 缺少 id: {path}")
return frag
def load_fragments(entries_dir: Path) -> list[Fragment]:
frags: list[Fragment] = []
seen: dict[str, Path] = {}
for path in sorted(entries_dir.rglob("*.md")):
frag = parse_fragment(path)
if frag.fid in seen:
raise ValueError(
f"重复的 CL id {frag.fid}: {path}{seen[frag.fid]}"
)
seen[frag.fid] = path
frags.append(frag)
frags.sort(key=lambda f: f.sort_key(), reverse=True)
return frags
# ---- 渲染 ------------------------------------------------------------------
def _author_tag(frag: Fragment) -> str:
return "" if frag.author in ("", "legacy") else f"@{frag.author} "
def render_headlines(frags: list[Fragment]) -> str:
head = (
"# Dev Changelog — Headlines\n\n"
"最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。\n"
"> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改。"
)
lines = []
for frag in frags[:HEADLINES_WINDOW]:
merge = f" [merge:{frag.merge_source}]" if frag.merge_source else ""
text = frag.l3 or frag.title
lines.append(f"- [{frag.fid}] {_author_tag(frag)}{text}{merge}")
body = "\n\n".join(lines)
return head + (f"\n\n{body}" if body else "") + "\n"
def render_recent(frags: list[Fragment]) -> str:
head = (
"# Dev Changelog — Recent\n\n"
"最近 ~20 次改动的摘要记录,按时间倒序排列。\n"
"当 Agent 检测到当前任务与近期改动相关时自动读取。\n"
"> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改。"
)
blocks = []
for frag in frags[:RECENT_WINDOW]:
b = [f"### [{frag.fid}] {frag.date_only()}{frag.title}"]
if frag.merge_source:
b.append(f"- **merge_source**: {frag.merge_source}")
if frag.author and frag.author != "legacy":
b.append(f"- **author**: {frag.author}")
if frag.tags:
b.append(f"- **tags**: {', '.join(frag.tags)}")
if frag.affected_files:
b.append(f"- **affected_files**: {''.join(frag.affected_files)}")
if frag.l2:
b.append(f"- **summary**: {frag.l2}")
if frag.source_chat:
b.append(f"- **source_chat**: {frag.source_chat}")
blocks.append("\n".join(b))
body = "\n\n".join(blocks)
return head + (f"\n\n{body}" if body else "") + "\n"
def render_full(frags: list[Fragment]) -> str:
head = (
"# Dev Changelog — Full\n\n"
"完整的开发改动记录,按时间倒序排列。作为主动 RAG 的数据源,用户手动唤醒时读取。\n"
"> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改;"
"新增/修改请编辑 `entries/<author>/<id>.md` 后重跑生成器。\n\n"
"## 记录"
)
blocks = []
for frag in frags:
b = [f"### [{frag.fid}] {frag.date}{frag.title}"]
if frag.merge_source:
b.append(f"- **merge_source**: {frag.merge_source}")
author_line = frag.author
if frag.author_name:
author_line = f"{frag.author} ({frag.author_name})"
if frag.author and frag.author != "legacy":
b.append(f"- **author**: {author_line}")
if frag.type:
b.append(f"- **type**: {frag.type}")
if frag.status and frag.status != "active":
sup = f"{frag.superseded_by}" if frag.superseded_by else ""
b.append(f"- **status**: {frag.status}{sup}")
if frag.tags:
b.append(f"- **tags**: {', '.join(frag.tags)}")
if frag.affected_files:
af = "\n".join(f" - {x}" for x in frag.affected_files)
b.append(f"- **affected_files**:\n{af}")
if frag.l1:
b.append(frag.l1)
if frag.source_chat:
b.append(f"- **source_chat**: {frag.source_chat}")
blocks.append("\n".join(b))
body = "\n\n".join(blocks)
return head + (f"\n\n{body}" if body else "") + "\n"
def render_by_author(frags: list[Fragment]) -> str:
head = (
"# Dev Changelog — By Author\n\n"
"按作者聚合的贡献概览,便于多人协作时区分提交来源。\n"
"> 本文件由 `tools/changelog_build.py` 自动生成,请勿手改。"
)
by: dict[str, list[Fragment]] = {}
for frag in frags:
by.setdefault(frag.author or "legacy", []).append(frag)
blocks = []
for author in sorted(by):
items = by[author]
name = next((f.author_name for f in items if f.author_name), "")
title = f"## {author}" + (f" ({name})" if name else "") + f"{len(items)}"
rows = [
f"- [{f.fid}] {f.date_only()}{f.title}" for f in items[:HEADLINES_WINDOW]
]
blocks.append(title + "\n" + "\n".join(rows))
body = "\n\n".join(blocks)
return head + (f"\n\n{body}" if body else "") + "\n"
# ---- 入口 ------------------------------------------------------------------
def find_changelog_dir(start: Path) -> Path:
cur = start.resolve()
for cand in [cur, *cur.parents]:
d = cand / CHANGELOG_DIR_NAME
if d.is_dir():
return d
# 脚本自身位于 .cursor/changelog/tools/ 下时回退
here = Path(__file__).resolve().parent.parent
if here.name == "changelog":
return here
raise SystemExit("找不到 .cursor/changelog 目录")
def build(changelog_dir: Path, check: bool = False) -> int:
entries_dir = changelog_dir / ENTRIES_SUBDIR
if not entries_dir.is_dir():
raise SystemExit(f"entries 目录不存在: {entries_dir}")
frags = load_fragments(entries_dir)
targets = {
HEADLINES_NAME: render_headlines(frags),
RECENT_NAME: render_recent(frags),
FULL_NAME: render_full(frags),
BY_AUTHOR_NAME: render_by_author(frags),
}
drift = False
for name, content in targets.items():
path = changelog_dir / name
old = path.read_text(encoding="utf-8") if path.exists() else None
if old != content:
drift = True
if check:
print(f"[drift] {name} 与 fragment 源不一致")
else:
path.write_text(content, encoding="utf-8", newline="\n")
print(f"[write] {name}")
if check:
if drift:
print("视图与 fragment 源不一致,请运行 changelog_build.py 重建。")
return 1
print("视图已与 fragment 源同步。")
return 0
print(f"完成:{len(frags)} 条 fragment → 4 个视图。")
return 0
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="从 fragment 源生成三层 changelog 视图")
ap.add_argument("changelog_dir", nargs="?", default=None, help=".cursor/changelog 路径")
ap.add_argument("--check", action="store_true", help="只校验是否漂移不写文件CI 用)")
args = ap.parse_args(argv)
changelog_dir = (
Path(args.changelog_dir) if args.changelog_dir else find_changelog_dir(Path.cwd())
)
return build(changelog_dir, check=args.check)
if __name__ == "__main__":
sys.exit(main())