221 lines
7.6 KiB
Python
221 lines
7.6 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
PLUGIN_JSON_PATH = ROOT / "novelmaster" / ".claude-plugin" / "plugin.json"
|
|
MARKETPLACE_JSON_PATH = ROOT / ".claude-plugin" / "marketplace.json"
|
|
README_PATH = ROOT / "README.md"
|
|
PLUGIN_NAME = "novelmaster"
|
|
VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
|
|
README_ROW_PATTERN = re.compile(
|
|
r"^\| \*\*v(?P<version>[^\s*]+)(?P<current> \(当前\))?\*\* \| (?P<notes>.*) \|$"
|
|
)
|
|
README_HEADER = "| 版本 | 说明 |"
|
|
README_SEPARATOR = "|------|------|"
|
|
|
|
|
|
def load_json(path: Path) -> dict[str, Any]:
|
|
with path.open("r", encoding="utf-8") as file:
|
|
return json.load(file)
|
|
|
|
|
|
def save_json(path: Path, payload: dict[str, Any]) -> None:
|
|
with path.open("w", encoding="utf-8", newline="\n") as file:
|
|
json.dump(payload, file, ensure_ascii=False, indent=2)
|
|
file.write("\n")
|
|
|
|
|
|
def load_text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def save_text(path: Path, content: str) -> None:
|
|
path.write_text(content, encoding="utf-8", newline="\n")
|
|
|
|
|
|
def get_marketplace_plugin(payload: dict[str, Any]) -> dict[str, Any]:
|
|
plugins = payload.get("plugins", [])
|
|
for plugin in plugins:
|
|
if plugin.get("name") == PLUGIN_NAME:
|
|
return plugin
|
|
raise ValueError(f"Plugin {PLUGIN_NAME} not found in marketplace.json")
|
|
|
|
|
|
def parse_readme_rows(lines: list[str]) -> list[dict[str, Any]]:
|
|
rows: list[dict[str, Any]] = []
|
|
for index, line in enumerate(lines):
|
|
match = README_ROW_PATTERN.match(line.strip())
|
|
if not match:
|
|
continue
|
|
rows.append(
|
|
{
|
|
"index": index,
|
|
"version": match.group("version"),
|
|
"notes": match.group("notes"),
|
|
"is_current": bool(match.group("current")),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def format_readme_row(version: str, notes: str, is_current: bool) -> str:
|
|
marker = " (当前)" if is_current else ""
|
|
return f"| **v{version}{marker}** | {notes.strip()} |"
|
|
|
|
|
|
def get_readme_current_version(content: str) -> str:
|
|
rows = parse_readme_rows(content.splitlines())
|
|
current_rows = [row for row in rows if row["is_current"]]
|
|
if len(current_rows) != 1:
|
|
raise ValueError("README.md must contain exactly one current release row")
|
|
return str(current_rows[0]["version"])
|
|
|
|
|
|
def update_readme_release(content: str, version: str, release_notes: str | None) -> str:
|
|
lines = content.splitlines()
|
|
|
|
try:
|
|
header_index = next(index for index, line in enumerate(lines) if line.strip() == README_HEADER)
|
|
except StopIteration as error:
|
|
raise ValueError("README.md release table header not found") from error
|
|
|
|
separator_index = header_index + 1
|
|
if separator_index >= len(lines) or lines[separator_index].strip() != README_SEPARATOR:
|
|
raise ValueError("README.md release table separator not found")
|
|
|
|
rows = parse_readme_rows(lines)
|
|
target_row = next((row for row in rows if row["version"] == version), None)
|
|
|
|
for row in rows:
|
|
is_target = row["version"] == version
|
|
notes = release_notes if is_target and release_notes is not None else row["notes"]
|
|
lines[row["index"]] = format_readme_row(row["version"], notes, is_target)
|
|
|
|
if target_row is None:
|
|
if not release_notes:
|
|
raise ValueError(
|
|
"Release notes are required when the target version does not exist in README.md"
|
|
)
|
|
lines.insert(separator_index + 1, format_readme_row(version, release_notes, True))
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def sync_versions(version: str | None = None, release_notes: str | None = None) -> tuple[str, str, bool]:
|
|
plugin_payload = load_json(PLUGIN_JSON_PATH)
|
|
marketplace_payload = load_json(MARKETPLACE_JSON_PATH)
|
|
readme_content = load_text(README_PATH)
|
|
marketplace_plugin = get_marketplace_plugin(marketplace_payload)
|
|
|
|
previous_version = str(plugin_payload.get("version", ""))
|
|
target_version = version or previous_version
|
|
changed = False
|
|
|
|
if plugin_payload.get("version") != target_version:
|
|
plugin_payload["version"] = target_version
|
|
changed = True
|
|
|
|
if marketplace_plugin.get("version") != target_version:
|
|
marketplace_plugin["version"] = target_version
|
|
changed = True
|
|
|
|
updated_readme = update_readme_release(readme_content, target_version, release_notes)
|
|
if updated_readme != readme_content:
|
|
save_text(README_PATH, updated_readme)
|
|
changed = True
|
|
|
|
if changed:
|
|
save_json(PLUGIN_JSON_PATH, plugin_payload)
|
|
save_json(MARKETPLACE_JSON_PATH, marketplace_payload)
|
|
|
|
return previous_version, target_version, changed
|
|
|
|
|
|
def check_versions(expected_version: str | None = None) -> int:
|
|
plugin_payload = load_json(PLUGIN_JSON_PATH)
|
|
marketplace_payload = load_json(MARKETPLACE_JSON_PATH)
|
|
readme_content = load_text(README_PATH)
|
|
marketplace_plugin = get_marketplace_plugin(marketplace_payload)
|
|
|
|
plugin_version = str(plugin_payload.get("version", ""))
|
|
marketplace_version = str(marketplace_plugin.get("version", ""))
|
|
readme_version = get_readme_current_version(readme_content)
|
|
|
|
mismatches: list[str] = []
|
|
if plugin_version != marketplace_version:
|
|
mismatches.append(
|
|
f"plugin.json={plugin_version}, marketplace.json={marketplace_version}"
|
|
)
|
|
if plugin_version != readme_version:
|
|
mismatches.append(f"plugin.json={plugin_version}, README.md={readme_version}")
|
|
if expected_version and plugin_version != expected_version:
|
|
mismatches.append(
|
|
f"expected={expected_version}, current release metadata={plugin_version}"
|
|
)
|
|
|
|
if mismatches:
|
|
print("Version mismatch detected:")
|
|
for mismatch in mismatches:
|
|
print(f"- {mismatch}")
|
|
return 1
|
|
|
|
print(f"Versions are in sync: {plugin_version}")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Sync Claude plugin release metadata")
|
|
parser.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="Check whether plugin metadata and README release info are in sync",
|
|
)
|
|
parser.add_argument(
|
|
"--version",
|
|
help="Update release metadata to the given semantic version",
|
|
)
|
|
parser.add_argument(
|
|
"--expected-version",
|
|
help="When used with --check, require the current release metadata to match this version",
|
|
)
|
|
parser.add_argument(
|
|
"--release-notes",
|
|
help="Release notes used for the README current release row",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.version and not VERSION_PATTERN.fullmatch(args.version):
|
|
parser.error("--version must look like X.Y.Z")
|
|
if args.expected_version and not VERSION_PATTERN.fullmatch(args.expected_version):
|
|
parser.error("--expected-version must look like X.Y.Z")
|
|
if args.expected_version and not args.check:
|
|
parser.error("--expected-version can only be used together with --check")
|
|
|
|
try:
|
|
if args.check:
|
|
return check_versions(expected_version=args.expected_version)
|
|
|
|
previous_version, target_version, changed = sync_versions(
|
|
version=args.version,
|
|
release_notes=args.release_notes,
|
|
)
|
|
except ValueError as error:
|
|
print(f"Error: {error}")
|
|
return 1
|
|
|
|
if changed:
|
|
print(f"Updated release metadata: {previous_version} -> {target_version}")
|
|
else:
|
|
print(f"No changes needed. Current version: {target_version}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|