diff --git a/actions/release-notes-from-pull-requests/action.yml b/actions/release-notes-from-pull-requests/action.yml new file mode 100644 index 0000000..af6ef38 --- /dev/null +++ b/actions/release-notes-from-pull-requests/action.yml @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Release notes from pull requests +description: Build a Markdown change list from commits, preferring associated merged pull requests. + +inputs: + github-token: + description: Token used to resolve pull requests associated with commits. + required: true + repository: + description: Repository in owner/name form. + required: true + branch: + description: Preferred pull request base branch. + required: true + working-directory: + description: Git working tree used to enumerate commits. + required: false + default: '.' + from-ref: + description: Optional lower-bound Git ref. When empty, fallback-limit commits are used. + required: false + default: '' + to-ref: + description: Upper-bound Git ref. + required: false + default: 'HEAD' + fallback-limit: + description: Number of commits to inspect when from-ref is empty. + required: false + default: '10' + +outputs: + changes-file: + description: Path to the generated Markdown change list. + value: ${{ steps.generate.outputs.changes-file }} + change-count: + description: Number of rendered change entries. + value: ${{ steps.generate.outputs.change-count }} + pull-request-count: + description: Number of unique pull request entries. + value: ${{ steps.generate.outputs.pull-request-count }} + commit-fallback-count: + description: Number of direct commit fallback entries. + value: ${{ steps.generate.outputs.commit-fallback-count }} + +runs: + using: composite + steps: + - id: generate + name: Generate release note changes + shell: bash + env: + RELEASE_NOTES_GITHUB_TOKEN: ${{ inputs.github-token }} + RELEASE_NOTES_REPOSITORY: ${{ inputs.repository }} + RELEASE_NOTES_BRANCH: ${{ inputs.branch }} + RELEASE_NOTES_WORKING_DIRECTORY: ${{ inputs.working-directory }} + RELEASE_NOTES_FROM_REF: ${{ inputs.from-ref }} + RELEASE_NOTES_TO_REF: ${{ inputs.to-ref }} + RELEASE_NOTES_FALLBACK_LIMIT: ${{ inputs.fallback-limit }} + run: | + set -euo pipefail + python3 "${GITHUB_ACTION_PATH}/generate.py" diff --git a/actions/release-notes-from-pull-requests/generate.py b/actions/release-notes-from-pull-requests/generate.py new file mode 100644 index 0000000..fe3d946 --- /dev/null +++ b/actions/release-notes-from-pull-requests/generate.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Callable + +ApiRequest = Callable[[str], Any] + + +class ActionError(RuntimeError): + pass + + +def sanitize_markdown_text(value: str) -> str: + normalized = " ".join(value.replace("\r", "\n").splitlines()).strip() + for character in ("\\", "`", "*", "_", "{", "}", "[", "]", "<", ">"): + normalized = normalized.replace(character, f"\\{character}") + # PR titles and commit subjects can be contributor-controlled. Keep their + # visible text while preventing them from creating GitHub @mentions. + return normalized.replace("@", "@\u200b") + + +def choose_pull_request( + pull_requests: list[dict[str, Any]], + preferred_branch: str, +) -> dict[str, Any] | None: + merged = [ + pr + for pr in pull_requests + if pr.get("merged_at") and isinstance(pr.get("number"), int) + ] + if not merged: + return None + + preferred = [ + pr + for pr in merged + if isinstance(pr.get("base"), dict) + and pr["base"].get("ref") == preferred_branch + ] + candidates = preferred or merged + return min(candidates, key=lambda pr: int(pr["number"])) + + +def build_api_request(url: str, token: str) -> urllib.request.Request: + request = urllib.request.Request( + url, + method="GET", + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + request.add_unredirected_header("Authorization", f"Bearer {token}") + return request + + +def github_api_get(url: str, token: str) -> Any: + request = build_api_request(url, token) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read().decode("utf-8") + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise ActionError( + f"GitHub API request failed ({error.code}): {body}" + ) from error + return json.loads(body) + + +def git_lines(working_directory: Path, *args: str) -> list[str]: + result = subprocess.run( + ["git", *args], + cwd=working_directory, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + return [line for line in result.stdout.splitlines() if line] + + +def enumerate_commits( + working_directory: Path, + from_ref: str, + to_ref: str, + fallback_limit: int, +) -> list[str]: + if from_ref: + return git_lines( + working_directory, + "rev-list", + "--reverse", + f"{from_ref}..{to_ref}", + ) + return git_lines( + working_directory, + "rev-list", + "--reverse", + f"--max-count={fallback_limit}", + to_ref, + ) + + +def commit_subject(working_directory: Path, sha: str) -> str: + lines = git_lines(working_directory, "show", "-s", "--format=%s", sha) + if not lines: + raise ActionError(f"cannot resolve subject for commit {sha}") + return sanitize_markdown_text(lines[0]) + + +def generate_changes( + *, + commits: list[str], + repository: str, + branch: str, + server_url: str, + api_url: str, + token: str, + subject_lookup: Callable[[str], str], + request: Callable[[str, str], Any] = github_api_get, +) -> tuple[list[str], int, int]: + seen_pull_requests: set[int] = set() + lines: list[str] = [] + pull_request_count = 0 + commit_fallback_count = 0 + + owner, repo = repository.split("/", 1) + clean_server_url = server_url.rstrip("/") + clean_api_url = api_url.rstrip("/") + + for sha in commits: + pull_requests = request( + f"{clean_api_url}/repos/{owner}/{repo}/commits/{sha}/pulls", + token, + ) + if not isinstance(pull_requests, list): + raise ActionError( + f"unexpected pull request response for commit {sha}" + ) + + pull_request = choose_pull_request(pull_requests, branch) + if pull_request is not None: + number = int(pull_request["number"]) + if number in seen_pull_requests: + continue + seen_pull_requests.add(number) + title = sanitize_markdown_text(str(pull_request.get("title") or "")) + if not title: + title = f"Pull request #{number}" + url = f"{clean_server_url}/{repository}/pull/{number}" + lines.append(f"- {title} ([#{number}]({url}))") + pull_request_count += 1 + continue + + subject = subject_lookup(sha) + lines.append(f"- {subject} (`{sha[:7]}`)") + commit_fallback_count += 1 + + return lines, pull_request_count, commit_fallback_count + + +def write_output(name: str, value: str) -> None: + output = os.environ.get("GITHUB_OUTPUT") + if not output: + return + with Path(output).open("a", encoding="utf-8") as handle: + handle.write(f"{name}={value}\n") + + +def main() -> int: + token = os.environ.get("RELEASE_NOTES_GITHUB_TOKEN", "") + repository = os.environ.get("RELEASE_NOTES_REPOSITORY", "") + branch = os.environ.get("RELEASE_NOTES_BRANCH", "") + working_directory = Path( + os.environ.get("RELEASE_NOTES_WORKING_DIRECTORY", ".") + ).resolve() + from_ref = os.environ.get("RELEASE_NOTES_FROM_REF", "").strip() + to_ref = os.environ.get("RELEASE_NOTES_TO_REF", "HEAD").strip() or "HEAD" + fallback_limit_raw = os.environ.get("RELEASE_NOTES_FALLBACK_LIMIT", "10") + server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com") + + if not token: + raise ActionError("github token is required") + if repository.count("/") != 1: + raise ActionError("repository must be in owner/name form") + if not branch: + raise ActionError("branch is required") + if not working_directory.is_dir(): + raise ActionError(f"working directory does not exist: {working_directory}") + + try: + fallback_limit = int(fallback_limit_raw) + except ValueError as error: + raise ActionError("fallback-limit must be an integer") from error + if fallback_limit <= 0: + raise ActionError("fallback-limit must be greater than zero") + + commits = enumerate_commits( + working_directory, + from_ref, + to_ref, + fallback_limit, + ) + lines, pull_request_count, commit_fallback_count = generate_changes( + commits=commits, + repository=repository, + branch=branch, + server_url=server_url, + api_url=api_url, + token=token, + subject_lookup=lambda sha: commit_subject(working_directory, sha), + ) + + runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir())) + runner_temp.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + prefix="release-note-changes-", + suffix=".md", + dir=runner_temp, + delete=False, + ) as handle: + for line in lines: + handle.write(f"{line}\n") + changes_file = Path(handle.name) + + write_output("changes-file", str(changes_file)) + write_output("change-count", str(len(lines))) + write_output("pull-request-count", str(pull_request_count)) + write_output("commit-fallback-count", str(commit_fallback_count)) + + print( + f"Generated {len(lines)} change entries " + f"({pull_request_count} pull requests, " + f"{commit_fallback_count} direct commits)." + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ( + ActionError, + OSError, + subprocess.CalledProcessError, + json.JSONDecodeError, + ) as error: + print(f"::error::{error}") + raise SystemExit(1) from error diff --git a/tests/test_release_notes_from_pull_requests_action.py b/tests/test_release_notes_from_pull_requests_action.py new file mode 100644 index 0000000..675e00b --- /dev/null +++ b/tests/test_release_notes_from_pull_requests_action.py @@ -0,0 +1,221 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "actions" / "release-notes-from-pull-requests" / "generate.py" + +spec = importlib.util.spec_from_file_location( + "release_notes_from_pull_requests", + SCRIPT, +) +assert spec is not None and spec.loader is not None +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class FakeApi: + def __init__(self, responses: dict[str, list[dict[str, Any]]]) -> None: + self.responses = responses + self.urls: list[str] = [] + + def __call__(self, url: str, token: str) -> Any: + self.urls.append(url) + sha = url.split("/commits/", 1)[1].split("/pulls", 1)[0] + return self.responses.get(sha, []) + + +class ReleaseNotesFromPullRequestsTest(unittest.TestCase): + def test_prefers_merged_pr_for_requested_base_branch(self) -> None: + pull_requests = [ + { + "number": 20, + "title": "Wrong branch", + "merged_at": "2026-09-01T00:00:00Z", + "base": {"ref": "stable34"}, + }, + { + "number": 30, + "title": "Right branch", + "merged_at": "2026-09-02T00:00:00Z", + "base": {"ref": "stable35"}, + }, + ] + selected = module.choose_pull_request(pull_requests, "stable35") + self.assertEqual(selected["number"], 30) + + def test_ignores_unmerged_pull_requests(self) -> None: + selected = module.choose_pull_request( + [ + { + "number": 12, + "title": "Still open", + "merged_at": None, + "base": {"ref": "stable35"}, + } + ], + "stable35", + ) + self.assertIsNone(selected) + + def test_deduplicates_pull_request_across_multiple_commits(self) -> None: + api = FakeApi( + { + "a" * 40: [ + { + "number": 50, + "title": "Feature", + "merged_at": "2026-09-01T00:00:00Z", + "base": {"ref": "stable35"}, + } + ], + "b" * 40: [ + { + "number": 50, + "title": "Feature", + "merged_at": "2026-09-01T00:00:00Z", + "base": {"ref": "stable35"}, + } + ], + } + ) + lines, prs, fallbacks = module.generate_changes( + commits=["a" * 40, "b" * 40], + repository="LibreSign/libresign", + branch="stable35", + server_url="https://github.com", + api_url="https://api.github.com", + token="token", + subject_lookup=lambda sha: "unused", + request=api, + ) + self.assertEqual( + lines, + [ + "- Feature ([#50](https://github.com/LibreSign/libresign/pull/50))" + ], + ) + self.assertEqual(prs, 1) + self.assertEqual(fallbacks, 0) + + def test_direct_commit_is_kept_as_fallback(self) -> None: + sha = "abcdef0123456789abcdef0123456789abcdef01" + api = FakeApi({}) + lines, prs, fallbacks = module.generate_changes( + commits=[sha], + repository="LibreSign/libresign", + branch="stable35", + server_url="https://github.com", + api_url="https://api.github.com", + token="token", + subject_lookup=lambda value: "Direct maintenance commit", + request=api, + ) + self.assertEqual( + lines, + ["- Direct maintenance commit (`abcdef0`)"], + ) + self.assertEqual(prs, 0) + self.assertEqual(fallbacks, 1) + + def test_titles_are_sanitized_before_markdown_rendering(self) -> None: + sha = "c" * 40 + api = FakeApi( + { + sha: [ + { + "number": 77, + "title": "First *line*\n@maintainers [link](https://evil.example)", + "merged_at": "2026-09-01T00:00:00Z", + "base": {"ref": "stable35"}, + } + ] + } + ) + lines, _, _ = module.generate_changes( + commits=[sha], + repository="acme/app", + branch="stable35", + server_url="https://git.example", + api_url="https://git.example/api/v3", + token="token", + subject_lookup=lambda value: "unused", + request=api, + ) + self.assertEqual( + lines, + [ + "- First \\*line\\* @\u200bmaintainers " + "\\[link\\](https://evil.example) " + "([#77](https://git.example/acme/app/pull/77))" + ], + ) + + def test_direct_commit_subject_is_sanitized(self) -> None: + sha = "e" * 40 + api = FakeApi({}) + lines, _, _ = module.generate_changes( + commits=[sha], + repository="acme/app", + branch="stable35", + server_url="https://github.com", + api_url="https://api.github.com", + token="token", + subject_lookup=lambda value: module.sanitize_markdown_text( + "Fix *all* @maintainers" + ), + request=api, + ) + self.assertEqual( + lines, + ["- Fix \\*all\\* @\u200bmaintainers (`eeeeeee`)"], + ) + + def test_api_url_supports_github_enterprise(self) -> None: + sha = "d" * 40 + api = FakeApi({}) + module.generate_changes( + commits=[sha], + repository="acme/app", + branch="stable35", + server_url="https://git.example", + api_url="https://git.example/api/v3", + token="token", + subject_lookup=lambda value: "Commit", + request=api, + ) + self.assertEqual( + api.urls, + [f"https://git.example/api/v3/repos/acme/app/commits/{sha}/pulls"], + ) + + def test_authorization_header_is_not_forwarded_on_redirect(self) -> None: + request = module.build_api_request( + "https://api.github.com/repos/acme/app/commits/abc/pulls", + "secret-token", + ) + self.assertNotIn("Authorization", request.headers) + self.assertEqual( + request.unredirected_hdrs["Authorization"], + "Bearer secret-token", + ) + + def test_action_contract_is_generic(self) -> None: + content = ( + ROOT / "actions" / "release-notes-from-pull-requests" / "action.yml" + ).read_text(encoding="utf-8") + self.assertIn("from-ref:", content) + self.assertIn("to-ref:", content) + self.assertIn("branch:", content) + self.assertNotIn("nightly", content.lower()) + self.assertNotIn("nextcloud", content.lower()) + + +if __name__ == "__main__": + unittest.main()