"""
GitCode 版本发布公告:按 tag 区间或 --since-date 拉取提交,按 feat/fix/docs/其他 分组,输出 Markdown 到 stdout。
仅使用 Python 3.7+ 标准库;需 GITCODE_TOKEN。
API: https://docs.gitcode.com/docs/apis/get-api-v-5-repos-owner-repo-commits
Usage:
python release_notes.py --repo owner/repo [--branch BRANCH] [--from TAG] [--to TAG] [--since-date YYYY-MM-DD]
"""
import json
import logging
import sys
import re
import argparse
import os
import subprocess
import time
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
OUTPUT_LOGGER = logging.getLogger("output")
OUTPUT_LOGGER.setLevel(logging.INFO)
OUTPUT_LOGGER.propagate = False
_output_handler = logging.StreamHandler(sys.stdout)
_output_handler.setFormatter(logging.Formatter("%(message)s"))
OUTPUT_LOGGER.addHandler(_output_handler)
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
GITCODE_API_BASE = "https://api.gitcode.com/api/v5"
SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_ROOT = SCRIPT_DIR.parent
DEFAULT_TIMEZONE = "Asia/Shanghai"
SHANGHAI_UTC_OFFSET_HOURS = 8
REQUEST_SLEEP_SEC = 0.2
REQUEST_TIMEOUT_SEC = 30
RETRY_TIMES = 2
RETRY_INTERVAL_SEC = 2
MAX_PER_CATEGORY_DEFAULT = 10
ENCODING_UTF8 = "utf-8"
TYPE_FEAT = "feat"
TYPE_FIX = "fix"
TYPE_DOCS = "docs"
TYPE_OTHER = "other"
KEY_MESSAGE = "message"
KEY_FROM_TAG = "from_tag"
MSG_FETCH_COMMITS_FAILED = "错误: 拉取提交失败: %s\n"
MSG_MULTI_ITEMS = "- 多项%s(共 %d 条)"
POWERSHELL_PATH = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
@dataclass
class JsonOutputParams:
owner: str
repo: str
commits: list
title_line: str
from_tag: str
to_tag: str
since_date: str
branch: str
def _get_token_windows(scope):
"""Windows 下读取用户级或系统级 GITCODE_TOKEN。"""
if sys.platform != "win32":
return None
try:
out = subprocess.check_output(
[
POWERSHELL_PATH,
"-NoProfile",
"-Command",
"[Environment]::GetEnvironmentVariable('GITCODE_TOKEN','%s')" % scope,
],
creationflags=0x08000000,
timeout=5,
stderr=subprocess.DEVNULL,
)
if out:
return out.decode(ENCODING_UTF8, errors="replace").strip()
except Exception as e:
logging.debug("读取 Windows 环境变量失败 (scope=%s): %s", scope, e)
return None
def get_token():
"""GITCODE_TOKEN:进程环境变量 → Windows 用户级 → 系统级。"""
token = os.environ.get("GITCODE_TOKEN")
if token:
return token.strip()
for scope in ("User", "Machine"):
t = _get_token_windows(scope)
if t:
return t
return None
def _err_detail(e, path, url=None):
"""构造详细错误信息。"""
parts = [path]
if isinstance(e, HTTPError):
parts.append("HTTP %s" % e.code)
try:
body = e.read().decode(ENCODING_UTF8, errors="replace")[:500]
if body.strip():
parts.append("body: %s" % body.strip())
except Exception as ex:
logging.debug("读取 HTTP 错误响应体失败: %s", ex)
else:
parts.append(str(e))
if url:
parts.append("url: %s" % url)
return " | ".join(parts)
def api_get(token, path, params=None, timeout_sec=REQUEST_TIMEOUT_SEC):
"""
请求 GitCode API;请求后 sleep;失败重试;429 时等待 Retry-After。
返回 (data, error_str)。data 为 list/dict,error 为 None 或详细错误字符串。
"""
url = GITCODE_API_BASE.rstrip("/") + "/" + path.lstrip("/")
if params:
url = url + ("&" if "?" in url else "?") + urlencode(params)
url = url.replace(" ", "%20")
req = Request(url, headers={"PRIVATE-TOKEN": token})
last_err = None
for _ in range(RETRY_TIMES + 1):
try:
with urlopen(req, timeout=timeout_sec) as resp:
time.sleep(REQUEST_SLEEP_SEC)
raw = resp.read().decode(ENCODING_UTF8)
return (json.loads(raw), None)
except HTTPError as e:
last_err = _err_detail(e, path, url)
if e.code == 429:
wait = 60
if e.headers.get("Retry-After"):
try:
wait = int(e.headers["Retry-After"])
except ValueError:
pass
time.sleep(wait)
else:
time.sleep(RETRY_INTERVAL_SEC)
except (URLError, OSError, ValueError) as e:
last_err = _err_detail(e, path, url)
time.sleep(RETRY_INTERVAL_SEC)
except Exception as e:
last_err = _err_detail(e, path, url)
break
return None, (last_err or "请求失败")
def get_branches(token, owner, repo):
"""GET /repos/:owner/:repo/branches,返回 (branches_list, error)。"""
return api_get(token, "repos/%s/%s/branches" % (owner, repo), {"per_page": 100})
def resolve_branch(token, owner, repo):
"""
未指定分支时依次尝试 master → develop → main;都不存在则报错。
返回 (branch_name, error)。
"""
data, err = get_branches(token, owner, repo)
if err:
return None, "获取分支列表失败: %s" % err
names = {b.get("name") for b in (data or []) if b.get("name")}
for candidate in ("master", "develop", "main"):
if candidate in names:
return candidate, None
return None, "仓库中未找到分支 master、develop 或 main,请使用 --branch 指定分支。当前分支列表: %s" % (", ".join(sorted(names)[:20]) or "无")
def get_tags(token, owner, repo):
"""GET /repos/:owner/:repo/tags,返回 (tags_list, error)。"""
return api_get(token, "repos/%s/%s/tags" % (owner, repo), {"per_page": 100})
def tag_to_sha(tags_list, tag_name):
"""从 tags 列表中按 name 查找,返回 commit sha。"""
tag_name = (tag_name or "").strip()
for t in tags_list or []:
if (t.get("name") or "").strip() == tag_name:
c = t.get("commit")
if isinstance(c, dict):
return (c.get("id") or c.get("sha") or "").strip()
if isinstance(c, str):
return c.strip()
return None
def get_commits_since_date(token, owner, repo, branch, since_date_utc_start_iso):
"""拉取 branch 上 since 之后的提交(API since 参数);不支持 since 时按日期过滤。"""
all_commits = []
page = 1
per_page = 100
while True:
params = {"sha": branch, "per_page": per_page, "page": page}
if since_date_utc_start_iso:
params["since"] = since_date_utc_start_iso
data, err = api_get(token, "repos/%s/%s/commits" % (owner, repo), params)
if err:
return all_commits, err
lst = data if isinstance(data, list) else []
if not lst:
break
all_commits.extend(lst)
if len(lst) < per_page:
break
page += 1
return all_commits, None
def get_commits_from_to(token, owner, repo, to_sha_or_branch, stop_at_sha=None):
"""
拉取从 to_sha_or_branch(ref)开始的提交,直到遇到 stop_at_sha(from_sha)或页数上限。
即:commits 顺序为从新到旧,收集到 stop_at_sha 之前(不包含 stop_at_sha)。
"""
all_commits = []
page = 1
per_page = 100
while True:
data, err = api_get(
token,
"repos/%s/%s/commits" % (owner, repo),
{"sha": to_sha_or_branch, "per_page": per_page, "page": page},
)
if err:
return all_commits, err
lst = data if isinstance(data, list) else []
if not lst:
break
for c in lst:
sha = (c.get("sha") or c.get("id") or "").strip()
if stop_at_sha and sha == stop_at_sha:
return all_commits, None
all_commits.append(c)
if len(lst) < per_page:
break
page += 1
return all_commits, None
_NOISE_WORDS = frozenset(["log", "label", "wip", "temp", "merge", "n/a", "-"])
_TYPE_ONLY_WORDS = frozenset(["feat", "fix", "docs", "feature", "bugfix", "doc"])
_MERGE_PATTERN = re.compile(r"^!?\d*\s*merge\s+.+\s+into\s+\w+", re.I)
def _is_merge_commit(first_line):
"""是否为合并提交(merge xxx into master)。"""
return bool(first_line and _MERGE_PATTERN.match(first_line.strip()))
def _strip_noise_prefix(desc):
"""
去掉展示用描述中的噪音前缀,得到给人看的说明。
- 去掉开头的 !数字、[模块名]、<doc>[xxx] 等
- 去掉末尾的 merge xxx into master
"""
if not desc or not isinstance(desc, str):
return ""
s = desc.strip()
s = re.sub(r"^!\d+\s*", "", s)
s = re.sub(r"^(\[([^\]]+)\]\s*)+", "", s)
s = re.sub(r"^<[^>]+>(\[[^\]]+\]\s*)*", "", s)
s = s.strip()
if _MERGE_PATTERN.match(s):
return ""
s = re.sub(r"\s*merge\s+.+\s+into\s+\w+\s*$", "", s, flags=re.I).strip()
return s[:200] if len(s) > 200 else s
def _should_skip_description(desc):
"""描述过短或无信息量则跳过。仅当整条描述恰好为噪音词(如单写 log/label)或纯类型词(fix/feat/docs)时才过滤。"""
if not desc or not isinstance(desc, str):
return True
s = desc.strip()
if len(s) < 3:
return True
if s.lower() in _NOISE_WORDS:
return True
if s.lower() in _TYPE_ONLY_WORDS:
return True
return False
def _first_line_from_body(msg):
"""从 commit message 正文(第二行起)取第一条非空、且非纯类型词的行,用作短描述兜底。"""
if not msg or not isinstance(msg, str):
return ""
for line in msg.split("\n")[1:]:
s = line.strip()
if len(s) < 4:
continue
if s.lower() in _TYPE_ONLY_WORDS:
continue
return s[:200] if len(s) > 200 else s
return ""
def parse_commit_message(msg):
"""
解析 commit message 第一行,返回 (type_key, short_desc, raw_first)。
type_key: 'feat'|'fix'|'docs'|'other'
短描述:去掉 type(scope): 或 type: 后的部分,无则整行;若仍无实质内容则尝试用正文首行兜底。
"""
first = (msg or "").split("\n")[0].strip()
if not first:
return TYPE_OTHER, "", ""
first_lower = first.lower()
if first_lower.startswith(TYPE_FEAT):
t = TYPE_FEAT
elif first_lower.startswith(TYPE_FIX):
t = TYPE_FIX
elif first_lower.startswith(TYPE_DOCS):
t = TYPE_DOCS
else:
t = TYPE_OTHER
colon = first.find(":")
paren = first.find(")")
if colon >= 0 and (paren < 0 or colon < paren):
desc = first[colon + 1 :].strip()
elif paren >= 0:
rest = first[paren + 1 :].lstrip()
if rest.startswith(":"):
rest = rest[1:].strip()
desc = rest
else:
if t != TYPE_OTHER:
desc = first[4:].lstrip()
if desc.startswith("("):
idx = desc.find(")")
if idx >= 0:
desc = desc[idx + 1 :].lstrip().lstrip(":")
desc = desc.strip()
else:
desc = first
short = (desc[:200] if len(desc) > 200 else desc) if desc else ""
if not short and msg:
short = _first_line_from_body(msg)
return t, short, first
def build_markdown(owner, repo, commits, title_line=None, max_per_category=MAX_PER_CATEGORY_DEFAULT):
"""
将 commits 按四组输出 Markdown;过滤无信息量项与纯 merge,规范化描述,合并展示。
每类最多展示 max_per_category 条;other 中多条 merge 合并为一条说明。
"""
base_url = "https://gitcode.com/%s/%s/commit/" % (owner, repo)
groups = {TYPE_FEAT: [], TYPE_FIX: [], TYPE_DOCS: [], TYPE_OTHER: []}
merge_count_other = 0
type_only_count = {TYPE_FEAT: 0, TYPE_FIX: 0, TYPE_DOCS: 0}
for c in commits:
sha = (c.get("sha") or c.get("id") or "").strip()
commit_obj = c.get("commit") or c
msg = commit_obj.get(KEY_MESSAGE) if isinstance(commit_obj, dict) else (c.get(KEY_MESSAGE) or "")
if not msg and isinstance(commit_obj, dict):
msg = (commit_obj.get(KEY_MESSAGE) or "")
t, raw_desc, raw_first = parse_commit_message(msg)
normalized = _strip_noise_prefix(raw_desc or raw_first)
is_merge_commit = _is_merge_commit(raw_first)
is_other_empty = (t == TYPE_OTHER and not normalized and raw_first.strip())
if is_merge_commit or is_other_empty:
if t == TYPE_OTHER:
merge_count_other += 1
continue
if normalized.strip().lower() in _TYPE_ONLY_WORDS and t in type_only_count:
type_only_count[t] += 1
continue
if _should_skip_description(normalized):
continue
link = "([%s](%s%s))" % (sha[:7] if len(sha) >= 7 else sha, base_url, sha)
line = "- %s %s" % (normalized, link)
if t in groups:
groups[t].append(line)
n = max(1, min(50, int(max_per_category)))
lines = []
if title_line:
lines.append(title_line)
lines.append("")
_type_only_labels = {TYPE_FEAT: "新特性与改进", TYPE_FIX: "修复", TYPE_DOCS: "文档更新"}
if groups[TYPE_FEAT] or type_only_count[TYPE_FEAT] > 0:
lines.append("### 🚀 新特性")
if type_only_count[TYPE_FEAT] > 0:
lines.append(MSG_MULTI_ITEMS % (_type_only_labels[TYPE_FEAT], type_only_count[TYPE_FEAT]))
lines.extend(groups[TYPE_FEAT][:n])
lines.append("")
if groups[TYPE_FIX] or type_only_count[TYPE_FIX] > 0:
lines.append("### 🐛 修复")
if type_only_count[TYPE_FIX] > 0:
lines.append(MSG_MULTI_ITEMS % (_type_only_labels[TYPE_FIX], type_only_count[TYPE_FIX]))
lines.extend(groups[TYPE_FIX][:n])
lines.append("")
if groups[TYPE_DOCS] or type_only_count[TYPE_DOCS] > 0:
lines.append("### 📚 文档")
if type_only_count[TYPE_DOCS] > 0:
lines.append(MSG_MULTI_ITEMS % (_type_only_labels[TYPE_DOCS], type_only_count[TYPE_DOCS]))
lines.extend(groups[TYPE_DOCS][:n])
lines.append("")
if groups[TYPE_OTHER] or merge_count_other > 0:
lines.append("### 🔧 其他更改")
if merge_count_other > 0:
lines.append("- 合并多项分支与依赖更新(共 %d 条)" % merge_count_other)
lines.extend(groups[TYPE_OTHER][:n])
lines.append("")
return "\n".join(lines).rstrip()
def since_date_to_utc_iso(date_str, tz_offset_hours=SHANGHAI_UTC_OFFSET_HOURS):
"""将 YYYY-MM-DD 转为该日 00:00 本地时间对应的 UTC ISO 字符串。"""
try:
local_dt = datetime.strptime(date_str.strip()[:10], "%Y-%m-%d")
utc_dt = local_dt - timedelta(hours=tz_offset_hours)
return utc_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
except ValueError:
return None
def _commit_date(c):
"""从 API 返回的 commit 对象中提取日期 YYYY-MM-DD。"""
co = c.get("commit") or c
if not isinstance(co, dict):
return None
ad = co.get("author") or co.get("committer") or {}
if not isinstance(ad, dict):
return None
dt = ad.get("date")
return dt[:10] if isinstance(dt, str) and len(dt) >= 10 else None
def build_json_output(params: JsonOutputParams):
"""
仅做获取与简单过滤后输出 JSON,供 Agent 总结与生成最终 release note。
简单过滤:排除空 message、排除纯 merge 提交;其余全部保留,含 type 与 first_line/body。
"""
base_url = "https://gitcode.com/%s/%s/commit/" % (params.owner, params.repo)
repo_spec = "%s/%s" % (params.owner, params.repo)
merge_count = 0
out_commits = []
for c in params.commits:
sha = (c.get("sha") or c.get("id") or "").strip()
commit_obj = c.get("commit") or c
msg = commit_obj.get(KEY_MESSAGE) if isinstance(commit_obj, dict) else (c.get(KEY_MESSAGE) or "")
if not msg and isinstance(commit_obj, dict):
msg = (commit_obj.get(KEY_MESSAGE) or "")
if not (msg or "").strip():
continue
first_line = (msg or "").split("\n")[0].strip()
if _is_merge_commit(first_line):
merge_count += 1
continue
t, _short, _raw = parse_commit_message(msg)
body_lines = (msg or "").split("\n")[1:]
body = "\n".join(body_lines).strip() if body_lines else ""
out_commits.append({
"sha": sha,
"short_sha": sha[:7] if len(sha) >= 7 else sha,
"url": base_url + sha,
KEY_MESSAGE: msg.strip(),
"first_line": first_line,
"body": body,
"type": t,
"date": _commit_date(c),
})
return {
"repo": repo_spec,
"branch": params.branch,
KEY_FROM_TAG: params.from_tag or None,
"to_tag": params.to_tag or None,
"since_date": params.since_date or None,
"title_line": params.title_line,
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"commits": out_commits,
"stats": {"merge_count": merge_count},
}
def main():
parser = argparse.ArgumentParser(
description="按 tag 或日期拉取提交,生成版本发布公告 Markdown(默认中文,每类最多展示若干条)",
)
parser.add_argument("--repo", required=True, metavar="owner/repo", help="仓库,格式 owner/repo(必填)")
parser.add_argument("--branch", default="", help="分支;未传时依次尝试 master、develop、main")
parser.add_argument("--since-date", metavar="YYYY-MM-DD", help="从该日 00:00(上海时间)至今的提交")
parser.add_argument("--from", dest="from_tag", metavar="TAG", help="起始 tag(到当前或 --to)")
parser.add_argument("--to", metavar="TAG", help="结束 tag,与 --from 一起表示区间")
parser.add_argument("--max-per-category", type=int, default=MAX_PER_CATEGORY_DEFAULT, metavar="N",
help="新特性/修复/文档/其他每类最多展示条数,默认 %s(5–10 条即可)" % MAX_PER_CATEGORY_DEFAULT)
parser.add_argument("--json", action="store_true",
help="输出 JSON(仅拉取与简单过滤),由 Agent 负责总结并生成最终 release note")
args = parser.parse_args()
if sys.platform == "win32":
try:
sys.stdout.reconfigure(encoding=ENCODING_UTF8)
sys.stderr.reconfigure(encoding=ENCODING_UTF8)
except Exception as e:
logging.debug("重新配置 stdout/stderr 编码失败: %s", e)
repo_spec = (args.repo or "").strip()
if "/" not in repo_spec:
logging.error("错误: --repo 必须为 owner/repo 格式")
sys.exit(1)
owner, repo = repo_spec.split("/", 1)
owner = owner.strip()
repo = repo.strip()
if not owner or not repo:
logging.error("错误: --repo 不能为空")
sys.exit(1)
token = get_token()
if not token:
logging.error("错误: 未配置 GITCODE_TOKEN。请到 https://gitcode.com/setting/token-classic 创建个人访问令牌并设置环境变量 GITCODE_TOKEN(当前进程或 Windows 用户/系统变量)。")
sys.exit(1)
branch = (args.branch or "").strip()
if not branch:
branch, err = resolve_branch(token, owner, repo)
if err:
logging.error("错误: %s", err)
sys.exit(1)
from_tag = (getattr(args, KEY_FROM_TAG) or "").strip()
to_tag = (args.to or "").strip()
since_date = (args.since_date or "").strip()
commits = []
title_line = None
if args.since_date:
since_iso = since_date_to_utc_iso(args.since_date)
if not since_iso:
logging.error("错误: --since-date 格式应为 YYYY-MM-DD")
sys.exit(1)
commits, err = get_commits_since_date(token, owner, repo, branch, since_iso)
if err:
logging.error(MSG_FETCH_COMMITS_FAILED.strip(), err)
sys.exit(1)
else:
from_tag = (getattr(args, KEY_FROM_TAG) or "").strip()
to_tag = (args.to or "").strip()
if not from_tag and not to_tag:
logging.error("错误: 请指定 --since-date 或 --from [--to] 以确定提交区间")
sys.exit(1)
tags_data, err = get_tags(token, owner, repo)
if err:
logging.error("错误: 获取标签列表失败: %s", err)
sys.exit(1)
if to_tag:
to_sha = tag_to_sha(tags_data, to_tag)
if not to_sha:
logging.error("错误: 未找到标签: %s", to_tag)
sys.exit(1)
from_sha = tag_to_sha(tags_data, from_tag) if from_tag else None
commits, err = get_commits_from_to(token, owner, repo, to_sha, from_sha)
if err:
logging.error(MSG_FETCH_COMMITS_FAILED.strip(), err)
sys.exit(1)
try:
last_date = None
for c in commits:
co = c.get("commit") or c
if isinstance(co, dict):
ad = co.get("author") or co.get("committer") or {}
dt = ad.get("date") if isinstance(ad, dict) else None
if dt:
last_date = dt[:10]
break
date_str = last_date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
except Exception as e:
logging.debug("获取最后提交日期失败: %s", e)
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
title_line = "## %s (%s)" % (to_tag, date_str)
else:
from_sha = tag_to_sha(tags_data, from_tag)
if not from_sha:
logging.error("错误: 未找到标签: %s", from_tag)
sys.exit(1)
commits, err = get_commits_from_to(token, owner, repo, branch, from_sha)
if err:
logging.error(MSG_FETCH_COMMITS_FAILED.strip(), err)
sys.exit(1)
title_line = None
if getattr(args, "json", False):
json_params = JsonOutputParams(
owner=owner,
repo=repo,
commits=commits,
title_line=title_line,
from_tag=from_tag,
to_tag=to_tag,
since_date=since_date,
branch=branch,
)
payload = build_json_output(json_params)
try:
sys.stdout.buffer.write(json.dumps(payload, ensure_ascii=False).encode(ENCODING_UTF8))
sys.stdout.buffer.flush()
except (AttributeError, OSError):
OUTPUT_LOGGER.info(json.dumps(payload, ensure_ascii=False))
return
out = build_markdown(owner, repo, commits, title_line, getattr(args, "max_per_category", MAX_PER_CATEGORY_DEFAULT))
try:
sys.stdout.buffer.write(out.encode(ENCODING_UTF8))
sys.stdout.buffer.write(b"\n")
sys.stdout.buffer.flush()
except (AttributeError, OSError):
OUTPUT_LOGGER.info(out)
if __name__ == "__main__":
main()