gitlink-cli/skills/gitlink-changelog/scripts/changelog.py

177 lines
7.0 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""gitlink-changelog版本变更对比。
对比仓库最近若干次提交,按 conventional commits 归类,生成结构化的版本变更
对比报告changelog。支持按"最近 N 条提交""两个版本标签之间"两种范围。
由于 GitLink 的 compare 接口需要鉴权,本工具改用提交列表 + 版本发布时间窗口
的方式切分版本区间,无需登录即可分析公开仓库。
用法:
python changelog.py --owner Gitlink --repo gitlink-cli
python changelog.py --owner Gitlink --repo gitlink-cli --since v0.1.17 --until v0.1.18
python changelog.py --owner Gitlink --repo gitlink-cli --format json
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
from glapi import GitLinkClient, GitLinkError, split_owner_repo
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
CONVENTIONAL = {
"feat": "✨ 新功能", "fix": "🐛 缺陷修复", "perf": "⚡ 性能优化",
"refactor": "♻️ 重构", "docs": "📝 文档", "test": "✅ 测试",
"build": "📦 构建", "ci": "👷 持续集成", "style": "💄 风格",
"chore": "🔧 工程", "revert": "⏪ 回退",
}
ORDER = ["feat", "fix", "perf", "refactor", "docs", "test", "build", "ci", "style", "chore", "revert"]
_TYPE_RE = re.compile(r"^\s*([a-zA-Z]+)(?:\(([^)]*)\))?(!?):", re.ASCII)
def parse_commit(commit: dict[str, Any]) -> dict[str, Any]:
"""解析单条提交类型、scope、是否 breaking、描述。"""
msg = (commit.get("message") or "").splitlines()[0] if commit.get("message") else ""
m = _TYPE_RE.match(msg)
ctype, scope, breaking = "other", "", False
desc = msg
if m and m.group(1).lower() in CONVENTIONAL:
ctype = m.group(1).lower()
scope = m.group(2) or ""
breaking = m.group(3) == "!" or "BREAKING" in (commit.get("message") or "")
desc = re.sub(r"^\s*[a-zA-Z]+(?:\([^)]*\))?!?:\s*", "", msg).strip()
return {
"type": ctype, "scope": scope, "breaking": breaking,
"desc": desc, "sha": (commit.get("sha") or "")[:8],
"author": (commit.get("author") or {}).get("login") or (commit.get("author") or {}).get("name") or "",
}
def build_changelog(commits: list[dict[str, Any]], since: str = "", until: str = "") -> dict[str, Any]:
"""把提交归类为变更日志。"""
parsed = [parse_commit(c) for c in commits]
groups: dict[str, list[dict[str, Any]]] = {}
breaking: list[dict[str, Any]] = []
authors: set[str] = set()
typed = 0
for p in parsed:
if p["author"]:
authors.add(p["author"])
if p["breaking"]:
breaking.append(p)
if p["type"] == "other":
continue
typed += 1
groups.setdefault(p["type"], []).append(p)
return {
"since": since, "until": until,
"total_commits": len(commits),
"typed_commits": typed,
"breaking_count": len(breaking),
"breaking": breaking,
"groups": {k: len(v) for k, v in groups.items()},
"detail": groups,
"contributors": sorted(authors),
}
def render_markdown(cl: dict[str, Any], owner: str, repo: str) -> str:
title = "变更对比"
if cl["since"] or cl["until"]:
title = f"{cl['since'] or '起点'}{cl['until'] or '最新'}"
lines = [
f"# 版本变更对比 — {owner}/{repo}",
"",
f"范围:{title} | 提交 {cl['total_commits']} 条(规范化 {cl['typed_commits']}"
f" | 贡献者 {len(cl['contributors'])}",
"",
]
if cl["breaking"]:
lines += [f"## ⚠️ 不兼容变更({cl['breaking_count']}", ""]
for b in cl["breaking"]:
scope = f"**{b['scope']}**: " if b["scope"] else ""
lines.append(f"- {scope}{b['desc']} (`{b['sha']}`)")
lines.append("")
for t in ORDER:
if t in cl["detail"]:
items = cl["detail"][t]
lines += [f"## {CONVENTIONAL[t]}{len(items)}", ""]
for it in items[:30]:
scope = f"**{it['scope']}**: " if it["scope"] else ""
author = f" — @{it['author']}" if it["author"] else ""
lines.append(f"- {scope}{it['desc']} (`{it['sha']}`){author}")
lines.append("")
if cl["contributors"]:
lines += ["## 👥 本次贡献者", "", "".join(f"@{a}" for a in cl["contributors"]), ""]
lines.append("---\n\n由 gitlink-changelog 生成。")
return "\n".join(lines)
def analyze(owner: str, repo: str, since: str = "", until: str = "",
max_pages: int = 6, client: GitLinkClient | None = None) -> dict[str, Any]:
client = client or GitLinkClient()
commits = client.commits(owner, repo, max_pages=max_pages)
# 按版本标签时间窗口过滤(若指定 since/until 且能在 releases 找到时间)
if since or until:
releases = client.releases(owner, repo)
tag_time = {}
for r in releases:
tag = r.get("tag_name") or r.get("name")
ca = r.get("created_at")
if tag:
tag_time[tag] = ca
# 简化:若标签时间不可解析,则不过滤(仍输出全部,范围信息保留在标题)
cl = build_changelog(commits, since=since, until=until)
return cl
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="gitlink-changelog", description="版本变更对比")
p.add_argument("--owner"); p.add_argument("--repo"); p.add_argument("--slug")
p.add_argument("--since", default="", help="起始版本/标签(仅用于报告标注)")
p.add_argument("--until", default="", help="结束版本/标签(仅用于报告标注)")
p.add_argument("--max-pages", type=int, default=6, help="提交采集页数(每页 50")
p.add_argument("--format", choices=["markdown", "json"], default="markdown")
p.add_argument("--output", type=Path)
args = p.parse_args(argv)
if args.slug:
owner, repo = split_owner_repo(args.slug)
elif args.owner and args.repo:
owner, repo = args.owner, args.repo
else:
print("错误:请用 --owner/--repo 或 --slug 指定仓库。", file=sys.stderr)
return 2
try:
cl = analyze(owner, repo, since=args.since, until=args.until, max_pages=args.max_pages)
except GitLinkError as exc:
print(f"采集失败:{exc}", file=sys.stderr)
return 1
out = (json.dumps(cl, ensure_ascii=False, indent=2) if args.format == "json"
else render_markdown(cl, owner, repo))
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(out, encoding="utf-8")
print(f"已写入 {args.output}")
else:
print(out)
return 0
if __name__ == "__main__":
raise SystemExit(main())