#!/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()