validate_release_notes.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. from __future__ import annotations
  4. import argparse
  5. import json
  6. import re
  7. import subprocess
  8. from pathlib import Path
  9. from typing import Any
  10. import sync_plugin_version
  11. ROOT = Path(__file__).resolve().parent.parent.parent
  12. VERSION_RE = sync_plugin_version.VERSION_PATTERN
  13. REQUIRED_RELEASE_HEADINGS = (
  14. "## 发版范围",
  15. "## 给作者看的变化",
  16. "## 是否需要改旧项目",
  17. "## 给维护者",
  18. "## 验证",
  19. )
  20. AUTHOR_WORDS = ("作者", "写章", "网文", "故事", "正文")
  21. def _issue(code: str, *, message: str, path: str = "", repair: str = "") -> dict[str, str]:
  22. return {"code": code, "message": message, "path": path, "repair": repair}
  23. def _load_text(path: Path) -> tuple[str, str]:
  24. try:
  25. return path.read_text(encoding="utf-8"), ""
  26. except FileNotFoundError:
  27. return "", "missing"
  28. except OSError as exc:
  29. return "", f"read_error:{exc}"
  30. def _current_version(root: Path) -> str:
  31. payload = sync_plugin_version.load_json(root / "webnovel-writer" / ".claude-plugin" / "plugin.json")
  32. return str(payload.get("version") or "")
  33. def _parse_version_tag(tag: str) -> tuple[int, int, int] | None:
  34. raw = tag[1:] if tag.startswith("v") else tag
  35. if not VERSION_RE.fullmatch(raw):
  36. return None
  37. major, minor, patch = raw.split(".")
  38. return int(major), int(minor), int(patch)
  39. def _infer_previous_tag(root: Path, version: str) -> str:
  40. current = _parse_version_tag(version)
  41. if current is None:
  42. return ""
  43. try:
  44. completed = subprocess.run(
  45. ["git", "-C", str(root), "tag", "--list", "v*"],
  46. check=False,
  47. capture_output=True,
  48. text=True,
  49. encoding="utf-8",
  50. )
  51. except OSError:
  52. return ""
  53. if completed.returncode != 0:
  54. return ""
  55. candidates: list[tuple[tuple[int, int, int], str]] = []
  56. for line in completed.stdout.splitlines():
  57. tag = line.strip()
  58. parsed = _parse_version_tag(tag)
  59. if parsed and parsed < current:
  60. candidates.append((parsed, tag))
  61. if not candidates:
  62. return ""
  63. return sorted(candidates)[-1][1]
  64. def _changelog_section(text: str, version: str) -> str:
  65. heading_re = re.compile(rf"^##\s+v{re.escape(version)}(?:\s|$)", re.MULTILINE)
  66. match = heading_re.search(text)
  67. if not match:
  68. return ""
  69. next_match = re.search(r"^##\s+", text[match.end():], re.MULTILINE)
  70. end = match.end() + next_match.start() if next_match else len(text)
  71. return text[match.start():end]
  72. def validate_release_notes(
  73. root: str | Path | None = None,
  74. *,
  75. version: str | None = None,
  76. previous_tag: str | None = None,
  77. ) -> dict[str, Any]:
  78. repo_root = Path(root) if root is not None else ROOT
  79. target_version = version or _current_version(repo_root)
  80. previous = previous_tag or _infer_previous_tag(repo_root, target_version)
  81. issues: list[dict[str, str]] = []
  82. if not VERSION_RE.fullmatch(target_version):
  83. issues.append(_issue("version.invalid", message=f"invalid version: {target_version}", repair="使用 X.Y.Z 版本号。"))
  84. release_path = repo_root / "releases" / f"v{target_version}.md"
  85. release_text, release_error = _load_text(release_path)
  86. if release_error:
  87. issues.append(
  88. _issue(
  89. "release_note.missing",
  90. message=f"release note {release_path.name} {release_error}",
  91. path=str(release_path),
  92. repair="新增 releases/vX.Y.Z.md,并覆盖上个 tag 到本次发布的全部变化。",
  93. )
  94. )
  95. else:
  96. expected_title = f"# v{target_version} - "
  97. if not release_text.startswith(expected_title):
  98. issues.append(
  99. _issue(
  100. "release_note.title",
  101. message=f"release note title must start with {expected_title!r}",
  102. path=str(release_path),
  103. repair="标题使用 '# vX.Y.Z - 一句中文用户收益'。",
  104. )
  105. )
  106. for heading in REQUIRED_RELEASE_HEADINGS:
  107. if heading not in release_text:
  108. issues.append(
  109. _issue(
  110. "release_note.heading",
  111. message=f"missing heading: {heading}",
  112. path=str(release_path),
  113. repair="使用 releases/README.md 中的固定模板。",
  114. )
  115. )
  116. if previous and previous not in release_text:
  117. issues.append(
  118. _issue(
  119. "release_note.range",
  120. message=f"previous tag {previous} not mentioned",
  121. path=str(release_path),
  122. repair="在“发版范围”中写明从上个正式 tag 到本次发布的范围。",
  123. )
  124. )
  125. if not any(word in release_text for word in AUTHOR_WORDS):
  126. issues.append(
  127. _issue(
  128. "release_note.audience",
  129. message="release note does not look author-facing",
  130. path=str(release_path),
  131. repair="发布说明顶部必须使用中文网文作者能理解的场景语言。",
  132. )
  133. )
  134. changelog_path = repo_root / "CHANGELOG.md"
  135. changelog_text, changelog_error = _load_text(changelog_path)
  136. if changelog_error:
  137. issues.append(
  138. _issue(
  139. "changelog.missing",
  140. message=f"CHANGELOG.md {changelog_error}",
  141. path=str(changelog_path),
  142. repair="新增 CHANGELOG.md,记录每个正式版本的用户可感知变化。",
  143. )
  144. )
  145. else:
  146. current_changelog_section = _changelog_section(changelog_text, target_version)
  147. if not current_changelog_section:
  148. issues.append(
  149. _issue(
  150. "changelog.version",
  151. message=f"CHANGELOG.md missing v{target_version}",
  152. path=str(changelog_path),
  153. repair="在 CHANGELOG.md 中新增当前版本小节。",
  154. )
  155. )
  156. if previous and current_changelog_section and previous not in current_changelog_section:
  157. issues.append(
  158. _issue(
  159. "changelog.range",
  160. message=f"CHANGELOG.md does not mention previous tag {previous}",
  161. path=str(changelog_path),
  162. repair="在当前版本小节写明发版范围。",
  163. )
  164. )
  165. return {
  166. "schema_version": "webnovel-release-notes-validator/v1",
  167. "ok": not issues,
  168. "root": str(repo_root),
  169. "version": target_version,
  170. "previous_tag": previous,
  171. "release_note": str(release_path),
  172. "changelog": str(changelog_path),
  173. "issues": issues,
  174. }
  175. def format_report(report: dict[str, Any], output_format: str = "text") -> str:
  176. if output_format == "json":
  177. return json.dumps(report, ensure_ascii=False, indent=2)
  178. status = "OK" if report.get("ok") else "ERROR"
  179. lines = [
  180. f"{status} release notes",
  181. f"version: {report.get('version')}",
  182. f"previous_tag: {report.get('previous_tag') or 'unknown'}",
  183. ]
  184. for item in report.get("issues") or []:
  185. lines.append(f"ERROR {item.get('code')}: {item.get('message')}")
  186. if item.get("path"):
  187. lines.append(f" path: {item.get('path')}")
  188. if item.get("repair"):
  189. lines.append(f" repair: {item.get('repair')}")
  190. return "\n".join(lines)
  191. def main() -> int:
  192. parser = argparse.ArgumentParser(description="Validate author-facing release notes and changelog")
  193. parser.add_argument("--root", default="", help="仓库根目录,默认自动推断")
  194. parser.add_argument("--version", default="", help="目标版本;默认读取 plugin.json")
  195. parser.add_argument("--previous-tag", default="", help="上一个正式 tag;默认从 git tag 推断")
  196. parser.add_argument("--format", choices=["text", "json"], default="text")
  197. args = parser.parse_args()
  198. report = validate_release_notes(
  199. args.root or None,
  200. version=args.version or None,
  201. previous_tag=args.previous_tag or None,
  202. )
  203. print(format_report(report, args.format))
  204. return 0 if report.get("ok") else 1
  205. if __name__ == "__main__":
  206. raise SystemExit(main())