Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b9c2b8ec3 | |||
| 71351b6399 | |||
| 3b6c4b77f0 |
Executable
+183
@@ -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]} <from_tag> <to_tag> [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()
|
||||
Reference in New Issue
Block a user