diff --git a/scripts/generate_changelog.py b/scripts/generate_changelog.py new file mode 100755 index 000000000..40273dd67 --- /dev/null +++ b/scripts/generate_changelog.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +自动生成 CHANGELOG 条目。 + +用法: + python3 scripts/generate_changelog.py v0.1.128 v0.1.129 + python3 scripts/generate_changelog.py v0.1.128 HEAD +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import urllib.error +import urllib.request +from datetime import datetime + +GITEA_URL = os.environ.get("GITEA_URL", "https://git.xiaoxiajianji.com") +REPO = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas") +TOKEN = os.environ.get("GITEA_TOKEN", "") + + +def gitea_api(path: str) -> dict | list: + url = f"{GITEA_URL}/api/v1{path}" + req = urllib.request.Request(url) + if TOKEN: + req.add_header("Authorization", f"token {TOKEN}") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + print(f"API Error: {e.code} {e.reason}", file=sys.stderr) + raise + + +def get_tag_date(tag: str) -> str: + try: + info = gitea_api(f"/repos/{REPO}/git/refs/tags/{tag}") + if isinstance(info, dict): + sha = info.get("object", {}).get("sha", "") + if sha: + commit = gitea_api(f"/repos/{REPO}/git/commits/{sha}") + if isinstance(commit, dict): + return commit.get("committer", {}).get("date", "")[:10] + except Exception: + pass + return "" + + +def get_merged_prs_between(from_tag: str, to_tag: str) -> list[dict]: + all_prs: list[dict] = [] + page = 1 + while True: + prs = gitea_api(f"/repos/{REPO}/pulls?state=closed&sort=merged&direction=desc" f"&per_page=50&page={page}") + if not isinstance(prs, list) or not prs: + break + all_prs.extend(prs) + if len(prs) < 50: + break + page += 1 + if page > 10: + break + + merged = [pr for pr in all_prs if pr.get("merged_at")] + from_date = get_tag_date(from_tag) + to_date = get_tag_date(to_tag) if not to_tag.startswith("HEAD") else datetime.now().strftime("%Y-%m-%d") + + if not from_date: + return merged[:50] + + result = [] + for pr in merged: + merged_at = pr.get("merged_at", "")[:10] + if from_date <= merged_at <= to_date: + result.append(pr) + return result + + +def categorize_pr(title: str) -> tuple[str, str]: + title = title.strip() + lower = title.lower() + + m = re.match(r"^(feat|fix|chore|perf|docs|refactor|test|ci|style|build|security)\s*[::]", title) + if m: + prefix = m.group(1) + clean_title = title[m.end() :].strip() + else: + prefix = "" + clean_title = title + + if prefix in ("feat", "feature"): + return "✨ 功能", clean_title + elif prefix == "fix": + return "🐛 Bug 修复", clean_title + elif prefix in ("refactor", "chore", "style"): + return "🔄 重构与清理", clean_title + elif prefix in ("perf", "performance"): + return "⚡ 性能优化", clean_title + elif prefix == "security": + return "🔒 安全修复", clean_title + elif prefix == "docs": + return "📝 文档", clean_title + elif prefix == "test": + return "🧪 测试", clean_title + elif prefix in ("ci", "build"): + return "🚀 CI/CD & 基础设施", clean_title + else: + if any(k in lower for k in ["安全", "security", "cve", "漏洞"]): + return "🔒 安全修复", clean_title + elif any(k in lower for k in ["修复", "bug"]): + return "🐛 Bug 修复", clean_title + elif any(k in lower for k in ["新增", "添加", "feat", "功能"]): + return "✨ 功能", clean_title + elif any(k in lower for k in ["ci", "构建", "workflow", "pipeline"]): + return "🚀 CI/CD & 基础设施", clean_title + elif any(k in lower for k in ["测试", "test", "e2e"]): + return "🧪 测试", clean_title + else: + return "📌 其他", clean_title + + +def generate_changelog(from_tag: str, to_tag: str, version: str = "") -> str: + if not version: + version = to_tag + + prs = get_merged_prs_between(from_tag, to_tag) + + categories: dict[str, list[tuple[int, str]]] = {} + for pr in prs: + cat, title = categorize_pr(pr["title"]) + pr_num = pr["number"] + categories.setdefault(cat, []).append((pr_num, title)) + + order = [ + "🔒 安全修复", + "✨ 功能", + "🐛 Bug 修复", + "⚡ 性能优化", + "🔄 重构与清理", + "📝 文档", + "🧪 测试", + "🚀 CI/CD & 基础设施", + "📌 其他", + ] + + date_str = get_tag_date(to_tag) if not to_tag.startswith("HEAD") else datetime.now().strftime("%Y-%m-%d") + lines = [f"## [{version}] - {date_str}", ""] + + for cat in order: + items = categories.get(cat, []) + if not items: + continue + lines.append(f"### {cat}") + lines.append("") + for num, title in sorted(items, key=lambda x: x[0]): + short_title = title.split(" — ")[0].split(" - ")[0] + if len(short_title) > 80: + short_title = short_title[:77] + "..." + lines.append(f"- #{num} {short_title}") + lines.append("") + + lines.append("---") + lines.append("") + return "\n".join(lines) + + +def main(): + if len(sys.argv) < 3: + print(f"用法: {sys.argv[0]} [version]") + sys.exit(1) + + from_tag = sys.argv[1] + to_tag = sys.argv[2] + version = sys.argv[3] if len(sys.argv) > 3 else "" + + changelog = generate_changelog(from_tag, to_tag, version) + print(changelog) + + +if __name__ == "__main__": + main()