mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 16:53:35 +08:00
🐛 fix(release): 修复自动更新日志缺少贡献者署名
- 基于关联 PR 与提交元数据解析外部贡献者账号 - 生成 GitHub 原生用户提及以展示贡献者头像名单 - 增加 API 熔断、版本历史告警与 CI 回归测试
This commit is contained in:
469
tools/generate-release-notes.py
Normal file
469
tools/generate-release-notes.py
Normal file
@@ -0,0 +1,469 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate categorized GitHub release notes with contributor attribution.
|
||||
|
||||
For every non-merge commit in the release range, the generator first resolves
|
||||
the author of an associated merged pull request. It falls back to GitHub's
|
||||
commit metadata and then to a GitHub noreply email address. External
|
||||
contributors are mentioned in the resulting Markdown so GitHub can render its
|
||||
native contributor names and avatar list on the release page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
GITHUB_LOGIN_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$")
|
||||
GITHUB_NOREPLY_RE = re.compile(
|
||||
r"^(?:\d+\+)?(?P<login>[A-Za-z0-9-]+(?:\[bot\])?)@users\.noreply\.github\.com$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
CONTRIBUTED_BY_RE = re.compile(
|
||||
r"\(\s*contributed\s+by\s+(?:\*\*)?@[A-Za-z0-9-]+(?:\*\*)?\s*\)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Commit:
|
||||
sha: str
|
||||
subject: str
|
||||
author_name: str
|
||||
author_email: str
|
||||
|
||||
|
||||
class GitHubUnavailableError(RuntimeError):
|
||||
"""Raised after GitHub API retries are exhausted."""
|
||||
|
||||
|
||||
class GitHubClient:
|
||||
"""Small read-only GitHub REST client used by the release workflow."""
|
||||
|
||||
def __init__(self, token: str = "", api_url: str = "https://api.github.com") -> None:
|
||||
self._token = token.strip()
|
||||
self._api_url = api_url.rstrip("/")
|
||||
self._available = True
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
def get_json(self, path: str) -> Any:
|
||||
if not self._available:
|
||||
raise GitHubUnavailableError("GitHub API disabled after an earlier request failure")
|
||||
|
||||
url = f"{self._api_url}/{path.lstrip('/')}"
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "GoNavi-release-notes",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
if self._token:
|
||||
headers["Authorization"] = f"Bearer {self._token}"
|
||||
|
||||
for attempt in range(3):
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
return json.load(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
retryable = exc.code in {429, 500, 502, 503, 504}
|
||||
if retryable and attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
if exc.code in {401, 403, 429} or exc.code >= 500:
|
||||
self._available = False
|
||||
raise GitHubUnavailableError(
|
||||
f"GitHub API returned HTTP {exc.code} for {path}"
|
||||
) from exc
|
||||
raise
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
self._available = False
|
||||
raise GitHubUnavailableError(
|
||||
f"GitHub API returned invalid JSON for {path}"
|
||||
) from exc
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
if attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
self._available = False
|
||||
raise GitHubUnavailableError(
|
||||
f"GitHub API could not be reached for {path}"
|
||||
) from exc
|
||||
|
||||
self._available = False
|
||||
raise GitHubUnavailableError(f"GitHub request failed: {path}")
|
||||
|
||||
|
||||
def run_git(*args: str) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", *args],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def resolve_previous_tag(tag: str) -> str:
|
||||
"""Return the preceding release tag by creation date.
|
||||
|
||||
Release branches are not guaranteed to have linear ancestry, so the
|
||||
nearest reachable tag can be much older than the actual previous release.
|
||||
This keeps the established release ordering while explicitly excluding the
|
||||
tag currently being published.
|
||||
"""
|
||||
|
||||
output = run_git("tag", "--list", "v*", "--sort=-creatordate")
|
||||
tags = [candidate for candidate in output.splitlines() if candidate]
|
||||
current = tag.casefold()
|
||||
for index, candidate in enumerate(tags):
|
||||
if candidate.casefold() == current:
|
||||
return tags[index + 1] if index + 1 < len(tags) else ""
|
||||
return tags[0] if tags else ""
|
||||
|
||||
|
||||
def warn_if_release_history_diverged(previous_tag: str, tag: str) -> None:
|
||||
if not previous_tag:
|
||||
return
|
||||
try:
|
||||
run_git("merge-base", "--is-ancestor", previous_tag, tag)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if exc.returncode != 1:
|
||||
raise
|
||||
print(
|
||||
f"warning: {previous_tag} is not an ancestor of {tag}; "
|
||||
"release notes will include commits reachable only from the new tag",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def read_commits(tag: str, previous_tag: str) -> list[Commit]:
|
||||
revision_range = f"{previous_tag}..{tag}" if previous_tag else tag
|
||||
output = run_git(
|
||||
"log",
|
||||
revision_range,
|
||||
"--no-merges",
|
||||
"-z",
|
||||
"--pretty=format:%H%x00%s%x00%an%x00%ae",
|
||||
)
|
||||
if not output:
|
||||
return []
|
||||
fields = output.split("\x00")
|
||||
if len(fields) % 4 != 0:
|
||||
raise ValueError("unexpected git log record while generating release notes")
|
||||
return [Commit(*fields[index : index + 4]) for index in range(0, len(fields), 4)]
|
||||
|
||||
|
||||
def login_from_noreply_email(author_email: str) -> str:
|
||||
match = GITHUB_NOREPLY_RE.fullmatch(author_email.strip())
|
||||
return match.group("login") if match else ""
|
||||
|
||||
|
||||
def _login_from_payload(payload: Any) -> str:
|
||||
if not isinstance(payload, Mapping):
|
||||
return ""
|
||||
login = payload.get("login")
|
||||
return login.strip() if isinstance(login, str) else ""
|
||||
|
||||
|
||||
def resolve_contributor_login(
|
||||
*,
|
||||
pulls: Any,
|
||||
commit_payload: Any,
|
||||
author_email: str,
|
||||
repository: str,
|
||||
) -> str:
|
||||
"""Resolve attribution, preferring the author of the merged pull request."""
|
||||
|
||||
candidates: list[Mapping[str, Any]] = []
|
||||
if isinstance(pulls, list):
|
||||
for pull in pulls:
|
||||
if not isinstance(pull, Mapping) or not pull.get("merged_at"):
|
||||
continue
|
||||
base = pull.get("base")
|
||||
base_repo = base.get("repo") if isinstance(base, Mapping) else None
|
||||
full_name = base_repo.get("full_name") if isinstance(base_repo, Mapping) else None
|
||||
if not isinstance(full_name, str) or full_name.casefold() != repository.casefold():
|
||||
continue
|
||||
if _login_from_payload(pull.get("user")):
|
||||
candidates.append(pull)
|
||||
|
||||
if candidates:
|
||||
# Prefer the PR that originally introduced the commit. A newer
|
||||
# backport or release-branch PR may otherwise steal attribution from
|
||||
# the original contributor.
|
||||
selected = min(candidates, key=lambda pull: str(pull.get("merged_at", "")))
|
||||
return _login_from_payload(selected.get("user"))
|
||||
|
||||
if isinstance(commit_payload, Mapping):
|
||||
login = _login_from_payload(commit_payload.get("author"))
|
||||
if login:
|
||||
return login
|
||||
|
||||
return login_from_noreply_email(author_email)
|
||||
|
||||
|
||||
def _api_path(repository: str, suffix: str) -> str:
|
||||
owner, name = repository.split("/", 1)
|
||||
return f"repos/{urllib.parse.quote(owner, safe='')}/{urllib.parse.quote(name, safe='')}/{suffix}"
|
||||
|
||||
|
||||
def infer_login_from_commit(commit: Commit, repository: str) -> str:
|
||||
login = login_from_noreply_email(commit.author_email)
|
||||
if login:
|
||||
return login
|
||||
owner = repository.split("/", 1)[0]
|
||||
if commit.author_name.strip().casefold() == owner.casefold():
|
||||
return owner
|
||||
return ""
|
||||
|
||||
|
||||
def fetch_commit_attribution(
|
||||
*,
|
||||
client: GitHubClient,
|
||||
repository: str,
|
||||
commit: Commit,
|
||||
) -> str:
|
||||
encoded_sha = urllib.parse.quote(commit.sha, safe="")
|
||||
pulls: Any = []
|
||||
commit_payload: Any = {}
|
||||
|
||||
try:
|
||||
pulls = client.get_json(_api_path(repository, f"commits/{encoded_sha}/pulls"))
|
||||
except (OSError, ValueError, GitHubUnavailableError) as exc:
|
||||
print(
|
||||
f"warning: unable to query associated pull requests for {commit.sha[:12]}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not client.available:
|
||||
return infer_login_from_commit(commit, repository)
|
||||
|
||||
# A merged PR author is the most accurate attribution. Avoid the second API
|
||||
# request when it is already available.
|
||||
login = resolve_contributor_login(
|
||||
pulls=pulls,
|
||||
commit_payload={},
|
||||
author_email="",
|
||||
repository=repository,
|
||||
)
|
||||
if login:
|
||||
return login
|
||||
|
||||
try:
|
||||
commit_payload = client.get_json(_api_path(repository, f"commits/{encoded_sha}"))
|
||||
except (OSError, ValueError, GitHubUnavailableError) as exc:
|
||||
print(
|
||||
f"warning: unable to query commit author for {commit.sha[:12]}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return resolve_contributor_login(
|
||||
pulls=pulls,
|
||||
commit_payload=commit_payload,
|
||||
author_email=commit.author_email,
|
||||
repository=repository,
|
||||
)
|
||||
|
||||
|
||||
def load_attributions(path: Path) -> dict[str, str]:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("attributions file must contain a JSON object")
|
||||
attributions: dict[str, str] = {}
|
||||
for sha, login in payload.items():
|
||||
if isinstance(sha, str) and isinstance(login, str):
|
||||
attributions[sha] = login.strip()
|
||||
return attributions
|
||||
|
||||
|
||||
def collect_attributions(
|
||||
*,
|
||||
commits: Sequence[Commit],
|
||||
repository: str,
|
||||
fixture_path: Path | None,
|
||||
) -> dict[str, str]:
|
||||
if fixture_path is not None:
|
||||
fixture = load_attributions(fixture_path)
|
||||
return {
|
||||
commit.sha: fixture.get(commit.sha, "") or login_from_noreply_email(commit.author_email)
|
||||
for commit in commits
|
||||
}
|
||||
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
if not token:
|
||||
print(
|
||||
"warning: GITHUB_TOKEN is not set; GitHub API rate limits may prevent complete attribution",
|
||||
file=sys.stderr,
|
||||
)
|
||||
client = GitHubClient(
|
||||
token=token,
|
||||
api_url=os.environ.get("GITHUB_API_URL", "https://api.github.com"),
|
||||
)
|
||||
attributions: dict[str, str] = {}
|
||||
for commit in commits:
|
||||
if client.available:
|
||||
attributions[commit.sha] = fetch_commit_attribution(
|
||||
client=client,
|
||||
repository=repository,
|
||||
commit=commit,
|
||||
)
|
||||
else:
|
||||
attributions[commit.sha] = infer_login_from_commit(commit, repository)
|
||||
return attributions
|
||||
|
||||
|
||||
def is_external_contributor(login: str, repository: str) -> bool:
|
||||
login = login.strip()
|
||||
owner = repository.split("/", 1)[0]
|
||||
if not login or login.casefold() == owner.casefold():
|
||||
return False
|
||||
lowered = login.casefold()
|
||||
if lowered.endswith("[bot]") or lowered in {"github-actions", "github-actions[bot]"}:
|
||||
return False
|
||||
return GITHUB_LOGIN_RE.fullmatch(login) is not None
|
||||
|
||||
|
||||
def category_for_subject(subject: str) -> str:
|
||||
lowered = subject.casefold()
|
||||
if subject.startswith("✨") or "feat" in lowered:
|
||||
return "feature"
|
||||
if subject.startswith("🐛") or "fix" in lowered:
|
||||
return "fix"
|
||||
if subject.startswith("⚡") or "perf" in lowered:
|
||||
return "performance"
|
||||
if subject.startswith("♻️") or "refactor" in lowered:
|
||||
return "refactor"
|
||||
if subject.startswith("🌐"):
|
||||
return "i18n"
|
||||
return "other"
|
||||
|
||||
|
||||
def render_release_notes(
|
||||
*,
|
||||
commits: Sequence[Commit],
|
||||
attributions: Mapping[str, str],
|
||||
repository: str,
|
||||
tag: str,
|
||||
previous_tag: str,
|
||||
repository_url: str,
|
||||
) -> str:
|
||||
sections = [
|
||||
("feature", "## ✨ 新功能"),
|
||||
("fix", "## 🐛 问题修复"),
|
||||
("performance", "## ⚡ 性能优化"),
|
||||
("refactor", "## ♻️ 重构"),
|
||||
("i18n", "## 🌐 国际化"),
|
||||
("other", "## 🔧 其他变更"),
|
||||
]
|
||||
categorized: dict[str, list[str]] = {key: [] for key, _ in sections}
|
||||
|
||||
for commit in commits:
|
||||
subject = commit.subject.strip()
|
||||
if not subject:
|
||||
continue
|
||||
login = attributions.get(commit.sha, "").strip()
|
||||
attribution = ""
|
||||
if is_external_contributor(login, repository) and not CONTRIBUTED_BY_RE.search(subject):
|
||||
# A real GitHub @mention makes the release page render its native,
|
||||
# deduplicated Contributors avatar row; do not duplicate it in HTML.
|
||||
attribution = f" (contributed by **@{login}**)"
|
||||
categorized[category_for_subject(subject)].append(f"- {subject}{attribution}")
|
||||
|
||||
blocks: list[str] = []
|
||||
for key, heading in sections:
|
||||
if categorized[key]:
|
||||
blocks.append(f"{heading}\n\n" + "\n".join(categorized[key]))
|
||||
|
||||
if not blocks:
|
||||
blocks.append("暂无提交记录。")
|
||||
|
||||
if previous_tag:
|
||||
base_url = repository_url.rstrip("/")
|
||||
previous_url = urllib.parse.quote(previous_tag, safe="")
|
||||
tag_url = urllib.parse.quote(tag, safe="")
|
||||
blocks.append(
|
||||
"---\n"
|
||||
f"**完整变更**: [{previous_tag}...{tag}]"
|
||||
f"({base_url}/compare/{previous_url}...{tag_url})"
|
||||
)
|
||||
|
||||
return "\n\n".join(blocks) + "\n"
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate categorized release notes with GitHub contributor mentions"
|
||||
)
|
||||
parser.add_argument("--repo", required=True, help="GitHub repository in owner/name form")
|
||||
parser.add_argument("--tag", required=True, help="Release tag")
|
||||
parser.add_argument(
|
||||
"--previous-tag",
|
||||
default="",
|
||||
help="Previous tag (default: preceding v* tag by creation date)",
|
||||
)
|
||||
parser.add_argument("--repository-url", required=True, help="Repository web URL")
|
||||
parser.add_argument(
|
||||
"--attributions-file",
|
||||
type=Path,
|
||||
help="Offline JSON mapping of commit SHA to GitHub login (tests/local use)",
|
||||
)
|
||||
parser.add_argument("--output", type=Path, required=True, help="Output Markdown file")
|
||||
args = parser.parse_args(argv)
|
||||
if not re.fullmatch(r"[^/\s]+/[^/\s]+", args.repo):
|
||||
parser.error("--repo must use owner/name format")
|
||||
return args
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
previous_tag = args.previous_tag.strip() or resolve_previous_tag(args.tag)
|
||||
warn_if_release_history_diverged(previous_tag, args.tag)
|
||||
revision_range = f"{previous_tag}..{args.tag}" if previous_tag else args.tag
|
||||
commits = read_commits(args.tag, previous_tag)
|
||||
attributions = collect_attributions(
|
||||
commits=commits,
|
||||
repository=args.repo,
|
||||
fixture_path=args.attributions_file,
|
||||
)
|
||||
body = render_release_notes(
|
||||
commits=commits,
|
||||
attributions=attributions,
|
||||
repository=args.repo,
|
||||
tag=args.tag,
|
||||
previous_tag=previous_tag,
|
||||
repository_url=args.repository_url,
|
||||
)
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(body, encoding="utf-8")
|
||||
contributors = {
|
||||
login
|
||||
for login in attributions.values()
|
||||
if is_external_contributor(login, args.repo)
|
||||
}
|
||||
print(
|
||||
f"wrote {args.output} ({len(commits)} commits, {len(contributors)} external contributors, "
|
||||
f"range={revision_range})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user