diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index f5346a33..90060088 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -34,6 +34,7 @@ jobs: python3 tools/package-driver-release-assets.test.py python3 tools/legal-release-assets.test.py python3 tools/windows-release-artifacts.test.py + python3 tools/generate-release-notes.test.py - name: Setup Node uses: actions/setup-node@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f944abc4..1e7df92b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + pull-requests: read jobs: frontend: @@ -30,6 +31,7 @@ jobs: python3 tools/package-driver-release-assets.test.py python3 tools/legal-release-assets.test.py python3 tools/windows-release-artifacts.test.py + python3 tools/generate-release-notes.test.py - name: Setup Node uses: actions/setup-node@v5 @@ -1298,65 +1300,19 @@ jobs: - name: Generate Changelog id: changelog + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail cd repo-for-changelog - TAG="${{ github.ref_name }}" - # 获取上一个 tag - PREV_TAG=$(git tag --sort=-creatordate | grep -E '^v' | sed -n '2p' || true) - if [ -z "$PREV_TAG" ]; then - echo "⚠️ 未找到上一个 tag,使用全部 commit" - RANGE="$TAG" - else - RANGE="${PREV_TAG}..${TAG}" - fi - - echo "📋 生成更新日志:$RANGE" - - # 提取 commit 消息(排除 merge commit) - COMMITS=$(git log "$RANGE" --no-merges --pretty=format:'%s' 2>/dev/null || true) - if [ -z "$COMMITS" ]; then - BODY="暂无提交记录。" - else - CAT_FEAT="" - CAT_FIX="" - CAT_PERF="" - CAT_REFACTOR="" - CAT_I18N="" - CAT_OTHER="" - - while IFS= read -r line; do - [ -z "$line" ] && continue - case "$line" in - ✨*|*feat*) CAT_FEAT="${CAT_FEAT}\n- ${line}" ;; - 🐛*|*fix*) CAT_FIX="${CAT_FIX}\n- ${line}" ;; - ⚡*|*perf*) CAT_PERF="${CAT_PERF}\n- ${line}" ;; - ♻️*|*refactor*) CAT_REFACTOR="${CAT_REFACTOR}\n- ${line}" ;; - 🌐*) CAT_I18N="${CAT_I18N}\n- ${line}" ;; - 🔧*|🔨*|*chore*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;; - *) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;; - esac - done <<< "$COMMITS" - - BODY="" - [ -n "$CAT_FEAT" ] && BODY="${BODY}## ✨ 新功能\n${CAT_FEAT}\n\n" - [ -n "$CAT_FIX" ] && BODY="${BODY}## 🐛 问题修复\n${CAT_FIX}\n\n" - [ -n "$CAT_PERF" ] && BODY="${BODY}## ⚡ 性能优化\n${CAT_PERF}\n\n" - [ -n "$CAT_REFACTOR" ] && BODY="${BODY}## ♻️ 重构\n${CAT_REFACTOR}\n\n" - [ -n "$CAT_I18N" ] && BODY="${BODY}## 🌐 国际化\n${CAT_I18N}\n\n" - [ -n "$CAT_OTHER" ] && BODY="${BODY}## 🔧 其他变更\n${CAT_OTHER}\n\n" - - # 附加 compare 链接 - if [ -n "$PREV_TAG" ]; then - REPO_URL="${{ github.server_url }}/${{ github.repository }}" - BODY="${BODY}---\n**完整变更**: [${PREV_TAG}...${TAG}](${REPO_URL}/compare/${PREV_TAG}...${TAG})\n" - fi - fi - - # 写入到文件避免多行环境变量问题 - printf '%b' "$BODY" > /tmp/changelog.md - echo "changelog_file=/tmp/changelog.md" >> "$GITHUB_OUTPUT" + CHANGELOG_FILE="$RUNNER_TEMP/changelog.md" + python3 tools/generate-release-notes.py \ + --repo "${{ github.repository }}" \ + --tag "${{ github.ref_name }}" \ + --repository-url "${{ github.server_url }}/${{ github.repository }}" \ + --output "$CHANGELOG_FILE" + echo "changelog_file=$CHANGELOG_FILE" >> "$GITHUB_OUTPUT" - name: Create Release uses: softprops/action-gh-release@v3 diff --git a/.gitignore b/.gitignore index 80791186..8b32cecb 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ build/bin/ # wails / node artifacts (按需) node_modules/ frontend/wailsjs/tsconfig.json +__pycache__/ +*.py[cod] dist/ .DS_Store diff --git a/tools/generate-release-notes.py b/tools/generate-release-notes.py new file mode 100644 index 00000000..dc21784a --- /dev/null +++ b/tools/generate-release-notes.py @@ -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[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()) diff --git a/tools/generate-release-notes.test.py b/tools/generate-release-notes.test.py new file mode 100644 index 00000000..c2abb266 --- /dev/null +++ b/tools/generate-release-notes.test.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 + +import importlib.util +import io +import json +import os +import pathlib +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stderr +from unittest import mock + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MODULE_PATH = pathlib.Path(__file__).with_name("generate-release-notes.py") +SPEC = importlib.util.spec_from_file_location("generate_release_notes", MODULE_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class GenerateReleaseNotesTests(unittest.TestCase): + def test_resolves_previous_release_tag_even_when_history_is_not_linear(self) -> None: + with mock.patch.object( + MODULE, + "run_git", + return_value="v1.2.0\nv1.1.0\nv1.0.0\nv0.9.0\n", + ): + self.assertEqual(MODULE.resolve_previous_tag("v1.1.0"), "v1.0.0") + + def test_warns_when_release_tags_have_diverged(self) -> None: + error = subprocess.CalledProcessError(1, ["git", "merge-base"]) + stderr = io.StringIO() + with mock.patch.object(MODULE, "run_git", side_effect=error), redirect_stderr(stderr): + MODULE.warn_if_release_history_diverged("v1.0.0", "v1.1.0") + + self.assertIn("v1.0.0 is not an ancestor of v1.1.0", stderr.getvalue()) + + def test_renders_external_contributor_mentions_without_tagging_owner_or_bots(self) -> None: + external_sha = "a" * 40 + owner_sha = "b" * 40 + bot_sha = "c" * 40 + body = MODULE.render_release_notes( + commits=[ + MODULE.Commit(external_sha, "✨ feat(export): add contributor notes", "Outside", "outside@example.test"), + MODULE.Commit(owner_sha, "🐛 fix(release): keep owner entry", "Syngnat", "owner@example.test"), + MODULE.Commit(bot_sha, "🔧 chore(deps): update action", "Bot", "bot@example.test"), + ], + attributions={ + external_sha: "OutsideUser", + owner_sha: "Syngnat", + bot_sha: "dependabot[bot]", + }, + repository="Syngnat/GoNavi", + tag="v1.1.0", + previous_tag="v1.0.0", + repository_url="https://github.com/Syngnat/GoNavi", + ) + + self.assertIn( + "- ✨ feat(export): add contributor notes (contributed by **@OutsideUser**)", + body, + ) + self.assertIn("- 🐛 fix(release): keep owner entry", body) + self.assertNotIn("keep owner entry (contributed by", body) + self.assertNotIn("dependabot[bot]", body) + self.assertEqual(body.count("**@OutsideUser**"), 1) + + def test_prefers_merged_pull_request_author_for_commit_attribution(self) -> None: + pulls = [ + { + "number": 600, + "merged_at": "2026-07-17T13:49:59Z", + "user": {"login": "OutsideUser"}, + "base": {"repo": {"full_name": "Syngnat/GoNavi"}}, + }, + { + "number": 666, + "merged_at": "2026-07-18T13:49:59Z", + "user": {"login": "Syngnat"}, + "base": {"repo": {"full_name": "Syngnat/GoNavi"}}, + } + ] + commit_payload = {"author": {"login": "Syngnat"}} + + login = MODULE.resolve_contributor_login( + pulls=pulls, + commit_payload=commit_payload, + author_email="owner@example.test", + repository="Syngnat/GoNavi", + ) + + self.assertEqual(login, "OutsideUser") + + def test_api_outage_uses_noreply_fallback_and_opens_the_circuit(self) -> None: + class UnavailableClient: + available = True + calls = 0 + + def get_json(self, _path: str): + self.calls += 1 + self.available = False + raise MODULE.GitHubUnavailableError("offline") + + client = UnavailableClient() + commit = MODULE.Commit( + "d" * 40, + "✨ feat(release): external contribution (#666)", + "Outside User", + "123+OutsideUser@users.noreply.github.com", + ) + with redirect_stderr(io.StringIO()): + login = MODULE.fetch_commit_attribution( + client=client, + repository="Syngnat/GoNavi", + commit=commit, + ) + + self.assertEqual(login, "OutsideUser") + self.assertEqual(client.calls, 1) + + def test_commit_api_login_wins_over_historical_noreply_login(self) -> None: + class RecordingClient: + available = True + paths: list[str] = [] + + def get_json(self, path: str): + self.paths.append(path) + if path.endswith("/pulls"): + return [] + return {"author": {"login": "CurrentLogin"}} + + client = RecordingClient() + commit = MODULE.Commit( + "f" * 40, + "🐛 fix(release): preserve current login", + "Old Login", + "123+OldLogin@users.noreply.github.com", + ) + login = MODULE.fetch_commit_attribution( + client=client, + repository="Syngnat/GoNavi", + commit=commit, + ) + + self.assertEqual(login, "CurrentLogin") + self.assertEqual(len(client.paths), 2) + + def test_real_client_opens_circuit_for_incomplete_http_response(self) -> None: + client = MODULE.GitHubClient(token="test-token") + incomplete = MODULE.http.client.IncompleteRead(b"", 1) + with mock.patch.object( + MODULE.urllib.request, + "urlopen", + side_effect=incomplete, + ) as urlopen, mock.patch.object(MODULE.time, "sleep"): + with self.assertRaises(MODULE.GitHubUnavailableError): + client.get_json("repos/Syngnat/GoNavi/commits/deadbeef") + with self.assertRaises(MODULE.GitHubUnavailableError): + client.get_json("repos/Syngnat/GoNavi/commits/deadbeef") + + self.assertFalse(client.available) + self.assertEqual(urlopen.call_count, 3) + + def test_git_log_parser_allows_control_separators_in_subject(self) -> None: + sha = "e" * 40 + output = f"{sha}\0✨ feat: keep \x1e and \x1f\0Outside User\0outside@example.test" + with mock.patch.object(MODULE, "run_git", return_value=output): + commits = MODULE.read_commits("v1.1.0", "v1.0.0") + + self.assertEqual(len(commits), 1) + self.assertEqual(commits[0].subject, "✨ feat: keep \x1e and \x1f") + + def test_cli_generates_notes_from_git_range_and_offline_attribution_fixture(self) -> None: + with tempfile.TemporaryDirectory(prefix="gonavi-release-notes-") as tmp: + repo = pathlib.Path(tmp) + self.run_git(repo, "init") + self.run_git(repo, "config", "user.name", "Syngnat") + self.run_git(repo, "config", "user.email", "owner@example.test") + + (repo / "fixture.txt").write_text("baseline\n", encoding="utf-8") + self.run_git(repo, "add", "fixture.txt") + self.run_git(repo, "commit", "-m", "🔧 chore: baseline") + self.run_git(repo, "tag", "v1.0.0") + + (repo / "fixture.txt").write_text("baseline\nowner\n", encoding="utf-8") + self.run_git(repo, "add", "fixture.txt") + self.run_git(repo, "commit", "-m", "🐛 fix(release): owner fix") + owner_sha = self.run_git(repo, "rev-parse", "HEAD").strip() + + (repo / "fixture.txt").write_text("baseline\nowner\nexternal\n", encoding="utf-8") + self.run_git(repo, "add", "fixture.txt") + contributor_env = { + **os.environ, + "GIT_AUTHOR_NAME": "Outside User", + "GIT_AUTHOR_EMAIL": "123+OutsideUser@users.noreply.github.com", + "GIT_COMMITTER_NAME": "Outside User", + "GIT_COMMITTER_EMAIL": "123+OutsideUser@users.noreply.github.com", + } + self.run_git(repo, "commit", "-m", "✨ feat(release): external feature (#666)", env=contributor_env) + external_sha = self.run_git(repo, "rev-parse", "HEAD").strip() + self.run_git(repo, "tag", "v1.1.0") + + attributions = repo / "attributions.json" + attributions.write_text( + json.dumps({owner_sha: "Syngnat", external_sha: "OutsideUser"}), + encoding="utf-8", + ) + output = repo / "release-notes.md" + subprocess.check_call( + [ + sys.executable, + str(MODULE_PATH), + "--repo", + "Syngnat/GoNavi", + "--tag", + "v1.1.0", + "--previous-tag", + "v1.0.0", + "--repository-url", + "https://github.com/Syngnat/GoNavi", + "--attributions-file", + str(attributions), + "--output", + str(output), + ], + cwd=repo, + env={key: value for key, value in os.environ.items() if key != "GITHUB_TOKEN"}, + ) + + body = output.read_text(encoding="utf-8") + self.assertIn("## ✨ 新功能", body) + self.assertIn("external feature (#666) (contributed by **@OutsideUser**)", body) + self.assertIn("## 🐛 问题修复", body) + self.assertNotIn("owner fix (contributed by", body) + self.assertIn( + "[v1.0.0...v1.1.0](https://github.com/Syngnat/GoNavi/compare/v1.0.0...v1.1.0)", + body, + ) + + def test_release_workflow_uses_tested_generator_and_pull_request_metadata(self) -> None: + release = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + dev_build = (ROOT / ".github" / "workflows" / "dev-build.yml").read_text(encoding="utf-8") + + self.assertIn("pull-requests: read", release) + self.assertIn("python3 tools/generate-release-notes.py", release) + self.assertNotIn("git log \"$RANGE\" --no-merges --pretty=format:'%s'", release) + changelog_step = release.split("- name: Generate Changelog", 1)[1].split( + "- name: Create Release", 1 + )[0] + for expected in ( + "GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}", + 'CHANGELOG_FILE="$RUNNER_TEMP/changelog.md"', + '--repo "${{ github.repository }}"', + '--tag "${{ github.ref_name }}"', + '--repository-url "${{ github.server_url }}/${{ github.repository }}"', + '--output "$CHANGELOG_FILE"', + 'echo "changelog_file=$CHANGELOG_FILE" >> "$GITHUB_OUTPUT"', + ): + self.assertIn(expected, changelog_step) + create_release_step = release.split("- name: Create Release", 1)[1] + self.assertIn( + "body_path: ${{ steps.changelog.outputs.changelog_file }}", + create_release_step, + ) + for workflow in (release, dev_build): + self.assertIn("python3 tools/generate-release-notes.test.py", workflow) + + @staticmethod + def run_git(repo: pathlib.Path, *args: str, env=None) -> str: + return subprocess.check_output( + ["git", *args], + cwd=repo, + env=env, + text=True, + encoding="utf-8", + stderr=subprocess.STDOUT, + ) + + +if __name__ == "__main__": + unittest.main()