Files
xiaoxia-saas/scripts/generate_changelog.py
xiaoxia b05966ff48
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
feat(ci): 新增CHANGELOG自动生成脚本 (#307)
2026-07-14 15:20:31 +08:00

184 lines
5.6 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()