From 3b6c4b77f0e93c7092e13a1cd47a75d5f1ea1673 Mon Sep 17 00:00:00 2001 From: xiaoxia-bot Date: Tue, 14 Jul 2026 11:14:56 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(ci):=20=E6=96=B0=E5=A2=9ECHANGELOG?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E8=84=9A=E6=9C=AC=20-=20?= =?UTF-8?q?=E4=BB=8EGitea=20API=E8=8E=B7=E5=8F=96=E5=90=88=E5=B9=B6PR?= =?UTF-8?q?=E5=B9=B6=E5=88=86=E7=B1=BB=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/generate_changelog.py | 176 ++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100755 scripts/generate_changelog.py diff --git a/scripts/generate_changelog.py b/scripts/generate_changelog.py new file mode 100755 index 000000000..ca89da625 --- /dev/null +++ b/scripts/generate_changelog.py @@ -0,0 +1,176 @@ +#!/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 os +import re +import sys +import json +import urllib.request +import urllib.error +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() -- 2.54.0 From 71351b6399d95e29c805e087b8c79a02189d5882 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 14 Jul 2026 15:01:17 +0800 Subject: [PATCH 2/3] style: black format generate_changelog.py --- scripts/generate_changelog.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/generate_changelog.py b/scripts/generate_changelog.py index ca89da625..3c68f983c 100755 --- a/scripts/generate_changelog.py +++ b/scripts/generate_changelog.py @@ -6,6 +6,7 @@ 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 os @@ -52,10 +53,7 @@ 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}" - ) + 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) @@ -87,7 +85,7 @@ def categorize_pr(title: str) -> tuple[str, str]: 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() + clean_title = title[m.end() :].strip() else: prefix = "" clean_title = title @@ -135,8 +133,17 @@ def generate_changelog(from_tag: str, to_tag: str, version: str = "") -> str: pr_num = pr["number"] categories.setdefault(cat, []).append((pr_num, title)) - order = ["🔒 安全修复", "✨ 功能", "🐛 Bug 修复", "⚡ 性能优化", - "🔄 重构与清理", "📝 文档", "🧪 测试", "🚀 CI/CD & 基础设施", "📌 其他"] + 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}", ""] -- 2.54.0 From 3b9c2b8ec30a0bc804108126a1e38c5820e5fc35 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 14 Jul 2026 15:10:00 +0800 Subject: [PATCH 3/3] style: isort + black format generate_changelog.py --- scripts/generate_changelog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/generate_changelog.py b/scripts/generate_changelog.py index 3c68f983c..40273dd67 100755 --- a/scripts/generate_changelog.py +++ b/scripts/generate_changelog.py @@ -9,12 +9,12 @@ from __future__ import annotations +import json import os import re import sys -import json -import urllib.request import urllib.error +import urllib.request from datetime import datetime GITEA_URL = os.environ.get("GITEA_URL", "https://git.xiaoxiajianji.com") -- 2.54.0