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
184 lines
5.6 KiB
Python
Executable File
184 lines
5.6 KiB
Python
Executable File
#!/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()
|