From 5d45ce07ec0b4bbfdf9e912b84420e6967f0863d Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 12 Aug 2026 07:42:34 +0000 Subject: [PATCH 1/2] build(release): add process-news command with PR and version marker support During release preparation, maintainers currently have to manually locate news fragments from merged PRs, assemble them into CHANGELOG.md, delete consumed news files, and update {versionadded}/{versionchanged} documentation markers. Doing this manually across many PRs is tedious and prone to human error. Add the process-news subcommand to the release tool in process_news.py to automate merging specific news files and PR changes into an existing release section in CHANGELOG.md, removing processed news files, and updating VERSION_NEXT_* markers to the target release version. --- RELEASING.md | 31 ++ tests/tools/private/release/BUILD.bazel | 29 ++ tests/tools/private/release/gh_test.py | 37 +- .../private/release/process_news_test.py | 311 +++++++++++++++++ tests/tools/private/release/utils_test.py | 18 + tools/private/release/BUILD.bazel | 1 + tools/private/release/gh.py | 319 +++++++++++++++++- tools/private/release/mock_gh.py | 39 ++- tools/private/release/process_news.py | 274 +++++++++++++++ tools/private/release/release.py | 2 + tools/private/release/utils.py | 62 +++- 11 files changed, 1092 insertions(+), 31 deletions(-) create mode 100644 tests/tools/private/release/process_news_test.py create mode 100644 tools/private/release/process_news.py diff --git a/RELEASING.md b/RELEASING.md index f16b13e582..9093c0fbeb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -129,6 +129,37 @@ The `` can be: or `https://github.com/bazel-contrib/rules_python/pull/124/files`) * Only URLs for the configured repository are accepted. +### Processing News Files and PR Changes for Backports + +To process and merge news files into an existing release in `CHANGELOG.md` +(e.g. after backporting a PR) and update any `VERSION_NEXT_*` markers added by +the PR: + +```shell +bazel run //tools/private/release -- \ + process-news [ ...] +``` + +The `` can be: +* A news file path (e.g., `news/3997.added.md`). +* A PR number (e.g., `3997` or `#3997`). +* A PR URL (e.g., `https://github.com/bazel-contrib/rules_python/pull/3997`). + +When a PR reference is passed, `process-news` resolves the files touched by the +PR, merges its news file(s) into `CHANGELOG.md`, deletes the processed news +file(s), and updates any `VERSION_NEXT_FEATURE` / `VERSION_NEXT_PATCH` +placeholders in the PR's files to ``. + +Examples: + +```shell +# Process a single news file +bazel run //tools/private/release -- process-news 2.3.0 news/3997.added.md + +# Process all news files and update version markers for a PR +bazel run //tools/private/release -- process-news 2.3.0 3997 +``` + ### Failure Behavior If a backport fails to process (e.g., due to cherry-pick conflicts): * The failed backport checklist item will remain unchecked with diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index dc02394960..2d5859129f 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -29,6 +29,7 @@ py_library( pytest_test( name = "add_backports_test", srcs = ["add_backports_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -40,6 +41,7 @@ pytest_test( pytest_test( name = "changelog_news_test", srcs = ["changelog_news_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -51,6 +53,7 @@ pytest_test( pytest_test( name = "complete_prepare_test", srcs = ["complete_prepare_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -62,6 +65,7 @@ pytest_test( pytest_test( name = "complete_sync_changelog_test", srcs = ["complete_sync_changelog_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -73,6 +77,7 @@ pytest_test( pytest_test( name = "create_release_branch_test", srcs = ["create_release_branch_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -84,6 +89,7 @@ pytest_test( pytest_test( name = "git_test", srcs = ["git_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -95,6 +101,7 @@ pytest_test( pytest_test( name = "on_pr_merged_test", srcs = ["on_pr_merged_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -106,6 +113,7 @@ pytest_test( pytest_test( name = "promote_test", srcs = ["promote_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -117,6 +125,7 @@ pytest_test( pytest_test( name = "release_issue_test", srcs = ["release_issue_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -128,6 +137,7 @@ pytest_test( pytest_test( name = "release_test", srcs = ["release_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -139,6 +149,7 @@ pytest_test( pytest_test( name = "backport_create_releases_test", srcs = ["backport_create_releases_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -150,6 +161,7 @@ pytest_test( pytest_test( name = "prepare_test", srcs = ["prepare_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -161,6 +173,7 @@ pytest_test( pytest_test( name = "gh_test", srcs = ["gh_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -172,6 +185,7 @@ pytest_test( pytest_test( name = "backport_prepare_test", srcs = ["backport_prepare_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -183,6 +197,7 @@ pytest_test( pytest_test( name = "process_backports_test", srcs = ["process_backports_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -194,6 +209,19 @@ pytest_test( pytest_test( name = "create_rc_test", srcs = ["create_rc_test.py"], + python_version = "3.14", + target_compatible_with = NOT_WINDOWS, + deps = [ + ":conftest", + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + +pytest_test( + name = "process_news_test", + srcs = ["process_news_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", @@ -205,6 +233,7 @@ pytest_test( pytest_test( name = "utils_test", srcs = ["utils_test.py"], + python_version = "3.14", target_compatible_with = NOT_WINDOWS, deps = [ ":conftest", diff --git a/tests/tools/private/release/gh_test.py b/tests/tools/private/release/gh_test.py index 7e19b2232c..555558c235 100644 --- a/tests/tools/private/release/gh_test.py +++ b/tests/tools/private/release/gh_test.py @@ -1,7 +1,13 @@ +import subprocess + import pytest from tools.private.release import shell -from tools.private.release.gh import GitHub +from tools.private.release.gh import ( + GetPrError, + GitHub, + InvalidPrRefError, +) from tools.private.release.git import Git pytest_plugins = ["tests.tools.private.release.release_test_helper"] @@ -51,14 +57,16 @@ def test_resolve_pr_number_url_other_repo(mocker, gh): mock_run_cmd = mocker.patch("tools.private.release.gh.run_cmd") # URL for a different repo should fail immediately without calling gh url = "https://github.com/other-owner/other-repo/pull/126" - with pytest.raises(ValueError, match="URL is not for the configured repository"): + with pytest.raises( + InvalidPrRefError, match="URL is not for the configured repository" + ): gh.resolve_pr_number(url) mock_run_cmd.assert_not_called() def test_resolve_pr_number_invalid(mocker, gh): mock_run_cmd = mocker.patch("tools.private.release.gh.run_cmd") - with pytest.raises(ValueError, match="Could not resolve PR reference"): + with pytest.raises(InvalidPrRefError, match="Could not resolve PR reference"): gh.resolve_pr_number("invalid-ref") mock_run_cmd.assert_not_called() @@ -94,3 +102,26 @@ def mock_run(*args, **kwargs): gh.update_issue_body(123, "new body content") auto_patch_cmd_helpers.run_gh.assert_called_once() assert captured_body["content"] == "new body content" + + +def test_get_pr_files(gh, auto_patch_cmd_helpers): + auto_patch_cmd_helpers.run_gh.return_value = ( + '{"files": [{"path": "news/123.added.md"}, {"path": "python/foo.py"}]}' + ) + files = gh.get_pr_files(123) + assert files == ["news/123.added.md", "python/foo.py"] + auto_patch_cmd_helpers.run_gh.assert_called_with( + "pr", + "view", + "123", + "--json=files", + "--repo=my-owner/my-repo", + check=True, + capture_output=True, + ) + + +def test_get_pr_files_not_found(gh, auto_patch_cmd_helpers): + auto_patch_cmd_helpers.run_gh.side_effect = subprocess.CalledProcessError(1, ["gh"]) + with pytest.raises(GetPrError, match="Failed to get PR #123 on my-owner/my-repo"): + gh.get_pr_files(123) diff --git a/tests/tools/private/release/process_news_test.py b/tests/tools/private/release/process_news_test.py new file mode 100644 index 0000000000..db83d64e75 --- /dev/null +++ b/tests/tools/private/release/process_news_test.py @@ -0,0 +1,311 @@ +import argparse + +from tools.private.release.process_news import ProcessNews +from tools.private.release.release import create_parser + +pytest_plugins = ["tests.tools.private.release.release_test_helper"] + + +_CHANGELOG_TEMPLATE = """# rules_python Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). + +{#v2-3-0} +## [2.3.0] - 2026-08-07 + +[2.3.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.3.0 + +{#v2-3-0-fixed} +### Fixed +* (pypi) Fixed something. + +{#v2-3-0-added} +### Added +* (cc) Added experimental feature. +""" + + +def test_process_news_single_file(tmp_path, monkeypatch, mock_gh): + monkeypatch.chdir(tmp_path) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8") + + news_dir = tmp_path / "news" + news_dir.mkdir() + news_file = news_dir / "3997.added.md" + news_file.write_text("(bzlmod) Added explicit_init_py tag class.", encoding="utf-8") + + args = argparse.Namespace( + version="2.3.0", + targets=[str(news_file)], + ) + + result = ProcessNews(args, gh=mock_gh).run() + + assert result == 0 + assert not news_file.exists() + + content = changelog.read_text(encoding="utf-8") + assert "* (bzlmod) Added explicit_init_py tag class." in content + assert "* (cc) Added experimental feature." in content + assert "## [2.3.0] - 2026-08-07" in content + + +def test_process_news_pr_number(tmp_path, monkeypatch, mock_gh): + monkeypatch.chdir(tmp_path) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8") + + news_dir = tmp_path / "news" + news_dir.mkdir() + news_file = news_dir / "3997.added.md" + news_file.write_text("(bzlmod) Added explicit_init_py tag class.", encoding="utf-8") + + code_dir = tmp_path / "python" / "extensions" + code_dir.mkdir(parents=True) + code_file = code_dir / "config.bzl" + code_file.write_text( + """:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + encoding="utf-8", + ) + + mock_gh.prs[3997] = { + "files": [ + {"path": "news/3997.added.md"}, + {"path": "python/extensions/config.bzl"}, + ] + } + + args = argparse.Namespace( + version="2.3.0", + targets=["3997"], + ) + + result = ProcessNews(args, gh=mock_gh).run() + + assert result == 0 + assert not news_file.exists() + + content = changelog.read_text(encoding="utf-8") + assert "* (bzlmod) Added explicit_init_py tag class." in content + + updated_code = code_file.read_text(encoding="utf-8") + assert ":::{versionadded} 2.3.0" in updated_code + assert "VERSION_NEXT_FEATURE" not in updated_code + + +def test_process_news_pr_ref_variants(tmp_path, monkeypatch, mock_gh): + monkeypatch.chdir(tmp_path) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8") + + news_dir = tmp_path / "news" + news_dir.mkdir() + news_file = news_dir / "3997.added.md" + news_file.write_text("(bzlmod) Added explicit_init_py tag class.", encoding="utf-8") + + code_file = tmp_path / "feature.py" + code_file.write_text("FEATURE_VERSION = 'VERSION_NEXT_PATCH'\n", encoding="utf-8") + + mock_gh.prs[3997] = { + "files": [ + {"path": "news/3997.added.md"}, + {"path": "feature.py"}, + ] + } + + args = argparse.Namespace( + version="2.3.0", + targets=["#3997"], + ) + + result = ProcessNews(args, gh=mock_gh).run() + + assert result == 0 + assert not news_file.exists() + assert "FEATURE_VERSION = '2.3.0'" in code_file.read_text(encoding="utf-8") + + +def test_process_news_version_normalization(tmp_path, monkeypatch, mock_gh): + monkeypatch.chdir(tmp_path) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8") + + news_dir = tmp_path / "news" + news_dir.mkdir() + news_file = news_dir / "3997.added.md" + news_file.write_text("(bzlmod) Added explicit_init_py tag class.", encoding="utf-8") + + # Pass 2.3 instead of 2.3.0 + args = argparse.Namespace( + version="2.3", + targets=[str(news_file)], + ) + + result = ProcessNews(args, gh=mock_gh).run() + + assert result == 0 + assert not news_file.exists() + + content = changelog.read_text(encoding="utf-8") + assert "* (bzlmod) Added explicit_init_py tag class." in content + + +def test_process_news_multiple_mixed_targets(tmp_path, monkeypatch, mock_gh): + monkeypatch.chdir(tmp_path) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8") + + news_dir = tmp_path / "news" + news_dir.mkdir() + file1 = news_dir / "101.added.md" + file1.write_text("(bzlmod) New feature A.", encoding="utf-8") + file2 = news_dir / "102.fixed.md" + file2.write_text("(gazelle) New fix B.", encoding="utf-8") + + code_file = tmp_path / "fix.py" + code_file.write_text("v = 'VERSION_NEXT_PATCH'", encoding="utf-8") + + mock_gh.prs[102] = {"files": [{"path": "news/102.fixed.md"}, {"path": "fix.py"}]} + + args = argparse.Namespace( + version="2.3.0", + targets=[str(file1), "102"], + ) + + result = ProcessNews(args, gh=mock_gh).run() + + assert result == 0 + assert not file1.exists() + assert not file2.exists() + + content = changelog.read_text(encoding="utf-8") + assert "* (bzlmod) New feature A." in content + assert "* (gazelle) New fix B." in content + assert "v = '2.3.0'" in code_file.read_text(encoding="utf-8") + + +def test_process_news_preserves_target_order(tmp_path, monkeypatch, mock_gh, mocker): + monkeypatch.chdir(tmp_path) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8") + + news_dir = tmp_path / "news" + news_dir.mkdir() + file1 = news_dir / "101.added.md" + file1.write_text("(bzlmod) First feature.", encoding="utf-8") + file2 = news_dir / "102.added.md" + file2.write_text("(gazelle) Second feature.", encoding="utf-8") + + mock_gh.prs[102] = {"files": [{"path": "news/102.added.md"}]} + + processed_order = [] + mocker.patch( + "tools.private.release.process_news.process_pr_target", + side_effect=lambda target, ver, p: processed_order.append( + f"PR:{target.pr_num}" + ), + ) + mocker.patch( + "tools.private.release.process_news.process_news_file_target", + side_effect=lambda target, ver, p: processed_order.append( + f"FILE:{target.path.name}" + ), + ) + + # Pass 102 first, then file1 + args = argparse.Namespace( + version="2.3.0", + targets=["102", str(file1)], + ) + + result = ProcessNews(args, gh=mock_gh).run() + + assert result == 0 + assert processed_order == ["PR:102", "FILE:101.added.md"] + + +def test_process_news_missing_news_file(tmp_path, monkeypatch, mock_gh): + monkeypatch.chdir(tmp_path) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8") + + args = argparse.Namespace( + version="2.3.0", + targets=["news/nonexistent.added.md"], + ) + + result = ProcessNews(args, gh=mock_gh).run() + + assert result == 1 + + +def test_process_news_invalid_target(tmp_path, monkeypatch, mock_gh): + monkeypatch.chdir(tmp_path) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8") + + invalid_file = tmp_path / "invalid.txt" + invalid_file.write_text("Not a news file", encoding="utf-8") + + args = argparse.Namespace( + version="2.3.0", + targets=[str(invalid_file)], + ) + + result = ProcessNews(args, gh=mock_gh).run() + + assert result == 1 + + +def test_process_news_pr_no_files_found(tmp_path, monkeypatch, mock_gh): + monkeypatch.chdir(tmp_path) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8") + + args = argparse.Namespace( + version="2.3.0", + targets=["9999"], + ) + + result = ProcessNews(args, gh=mock_gh).run() + + assert result == 1 + + +def test_process_news_version_not_in_changelog(tmp_path, monkeypatch, mock_gh): + monkeypatch.chdir(tmp_path) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8") + + news_dir = tmp_path / "news" + news_dir.mkdir() + news_file = news_dir / "3997.added.md" + news_file.write_text("Some feature", encoding="utf-8") + + args = argparse.Namespace( + version="3.9.0", + targets=[str(news_file)], + ) + + result = ProcessNews(args, gh=mock_gh).run() + + assert result == 1 + assert news_file.exists() + + +def test_process_news_cli_parser(): + parser = create_parser() + args = parser.parse_args(["process-news", "2.3.0", "news/3997.added.md", "3998"]) + assert args.version == "2.3.0" + assert args.targets == ["news/3997.added.md", "3998"] + assert args.command == ProcessNews.run_from_args diff --git a/tests/tools/private/release/utils_test.py b/tests/tools/private/release/utils_test.py index d39b5e108e..b7b545931f 100644 --- a/tests/tools/private/release/utils_test.py +++ b/tests/tools/private/release/utils_test.py @@ -311,3 +311,21 @@ def test_determine_next_version_ignores_agents_markers(mocker, release_tool_env) next_version = utils.determine_next_version() assert next_version == "1.2.4" + + +def test_replace_version_next_in_files(release_tool_env): + file1 = release_tool_env.git_root / "file1.py" + file1.write_text("v = 'VERSION_NEXT_FEATURE'\n", encoding="utf-8") + + file2 = release_tool_env.git_root / "file2.py" + file2.write_text("v = 'VERSION_NEXT_PATCH'\n", encoding="utf-8") + + file3 = release_tool_env.git_root / "file3.py" + file3.write_text("v = '1.0.0'\n", encoding="utf-8") + + modified = utils.replace_version_next_in_files([file1, file2, file3], "2.3.0") + + assert modified == [file1, file2] + assert file1.read_text(encoding="utf-8") == "v = '2.3.0'\n" + assert file2.read_text(encoding="utf-8") == "v = '2.3.0'\n" + assert file3.read_text(encoding="utf-8") == "v = '1.0.0'\n" diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index 5f80d1130a..d484c015ae 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -34,6 +34,7 @@ py_binary( name = "release", srcs = ["release.py"], main = "release.py", + python_version = "3.14", deps = [ ":release_lib", ], diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index c21041384e..36543d067f 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -1,11 +1,13 @@ """GitHub CLI helper functions for the release tool.""" +import abc import enum import json import os import re +import subprocess import tempfile -from typing import TypedDict +from typing import TypedDict, override from tools.private.release.release_issue import BackportTask from tools.private.release.shell import run_cmd @@ -72,6 +74,19 @@ class AutoMergeDict(TypedDict, total=False): merge_method: str +class PrFileDict(TypedDict, total=False): + """In-memory representation of a file in a GitHub Pull Request object. + + See GitHub API docs: + https://docs.github.com/en/rest/pulls/pulls#list-pull-requests-files + """ + + path: str + additions: int + deletions: int + changeType: str + + class PrDict(TypedDict, total=False): """In-memory representation of a GitHub Pull Request object. @@ -90,6 +105,7 @@ class PrDict(TypedDict, total=False): isDraft: bool mergeCommit: dict[str, str] auto_merge: AutoMergeDict | None + files: list[PrFileDict] class MultipleTrackingIssuesError(ValueError): @@ -104,7 +120,236 @@ class NoTrackingIssueError(ValueError): pass -class GitHub: +class GetPrError(ValueError): + """Raised when querying a pull request fails.""" + + pass + + +class InvalidPrRefError(ValueError): + """Raised when a PR reference cannot be resolved.""" + + pass + + +class GitHubInterface(abc.ABC): + """Abstract interface for GitHub operations.""" + + @abc.abstractmethod + def post_issue_comment(self, issue_num: int, comment_body: str) -> None: + """Posts a comment on an issue or PR. + + Args: + issue_num: The issue or PR number. + comment_body: The body content of the comment. + """ + + @abc.abstractmethod + def add_comment_reaction(self, comment_id: int, reaction: str) -> None: + """Adds a reaction to an issue or PR comment. + + Args: + comment_id: The comment ID. + reaction: The reaction type (e.g., "+1", "-1", "rocket"). + """ + + @abc.abstractmethod + def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None: + """Enables auto-merge for a PR. + + Args: + pr_num: The PR number. + method: The merge method ('squash', 'rebase', or 'merge'). + """ + + @abc.abstractmethod + def create_issue( + self, title: str, body: str, labels: list[str] | None = None + ) -> int: + """Creates an issue. + + Args: + title: Title of the issue. + body: Body text of the issue. + labels: Optional list of labels to add. + + Returns: + The created issue number. + """ + + @abc.abstractmethod + def create_release_tracking_issue(self, version: str, template_content: str) -> int: + """Creates a release tracking issue from a template. + + Args: + version: Release version string (e.g., "1.0.0"). + template_content: Content of the issue template markdown file. + + Returns: + The created issue number. + """ + + @abc.abstractmethod + def get_issue_body(self, issue_num: int) -> str: + """Gets the body content of an issue. + + Args: + issue_num: The issue number. + + Returns: + The body string of the issue. + """ + + @abc.abstractmethod + def get_issue_title(self, issue_num: int) -> str: + """Gets the title of an issue. + + Args: + issue_num: The issue number. + + Returns: + The title string of the issue. + """ + + @abc.abstractmethod + def update_issue_body(self, issue_num: int, body: str) -> None: + """Updates the body of an issue. + + Args: + issue_num: The issue number. + body: The new body content. + """ + + @abc.abstractmethod + def resolve_pr_number(self, pr_ref: str) -> int: + """Resolves a PR reference (number, #number, or GitHub URL) to a PR number. + + Args: + pr_ref: PR number string (e.g., "123", "#123") or URL. + + Returns: + The integer PR number. + + Raises: + InvalidPrRefError: If the PR reference cannot be resolved or is for + another repository. + """ + + @abc.abstractmethod + def get_release_tracking_issue(self, version: str) -> int: + """Finds the single open tracking issue for a given version. + + Args: + version: Version string (e.g., "1.0.0"). + + Returns: + The issue number. + + Raises: + NoTrackingIssueError: If no open tracking issue is found. + MultipleTrackingIssuesError: If multiple open tracking issues are + found. + """ + + @abc.abstractmethod + def create_pr( + self, + title: str, + body: str, + base: str = "main", + labels: list[str] | None = None, + ) -> str: + """Creates a pull request. + + Args: + title: Title of the PR. + body: Body of the PR. + base: Base branch to merge into (default: "main"). + labels: Optional list of labels to add. + + Returns: + The URL of the created PR. + """ + + @abc.abstractmethod + def get_open_pr(self, branch_name: str) -> PrDict | None: + """Finds an open PR for the given branch. + + Args: + branch_name: The head branch name to search for. + + Returns: + Dictionary containing PR details if open, else None. + """ + + @abc.abstractmethod + def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]: + """Finds open tracking issues for release. + + Args: + version: Optional specific version to match (e.g., "1.0.0"). + + Returns: + List of matching open release tracking issue dictionaries. + """ + + @abc.abstractmethod + def get_pr_info(self, pr_num: int) -> PrDict: + """Gets info about a PR. + + Args: + pr_num: The PR number. + + Returns: + Dictionary containing PR fields (state, isDraft, mergeCommit, etc.). + + Raises: + GetPrError: If querying the PR fails. + """ + + @abc.abstractmethod + def get_pr_files(self, pr_num: int) -> list[str]: + """Gets the list of file paths touched by a PR. + + Args: + pr_num: The PR number. + + Returns: + A list of file paths. + + Raises: + GetPrError: If querying the PR fails. + """ + + @abc.abstractmethod + def get_pr_comments(self, pr_num: int) -> list[dict]: + """Gets all comments for a PR. + + Args: + pr_num: The PR number. + + Returns: + List of comment objects. + + Raises: + GetPrError: If querying the PR fails. + """ + + @abc.abstractmethod + def get_merge_commits_for_prs( + self, pending_items: list[BackportTask] + ) -> list[BackportTask]: + """Resolves PR references in pending backports to their merge commit SHAs. + + Args: + pending_items: A list of BackportTask items to resolve. + + Returns: + The list of resolved BackportTask items. + """ + + +class GitHub(GitHubInterface): """GitHub CLI helper class for the release tool.""" def __init__(self, repo: str = "bazel-contrib/rules_python"): @@ -184,6 +429,7 @@ def list_issues( output = self._gh_issue(*cmd) return json.loads(output) if output else [] + @override def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]: """Finds open tracking issues for release. @@ -201,6 +447,7 @@ def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict search=search, ) + @override def get_release_tracking_issue(self, version: str) -> int: """Finds the single open tracking issue for a given version. @@ -227,6 +474,7 @@ def get_release_tracking_issue(self, version: str) -> int: ) return matching[0]["number"] + @override def create_issue( self, title: str, body: str, labels: list[str] | None = None ) -> int: @@ -251,6 +499,7 @@ def create_issue( # output is URL: https://github.com/owner/repo/issues/123 return int(output.rstrip("/").split("/")[-1]) + @override def create_release_tracking_issue(self, version: str, template_content: str) -> int: """Creates a release tracking issue from a template. @@ -271,6 +520,7 @@ def create_release_tracking_issue(self, version: str, template_content: str) -> return self.create_issue(title=title, body=issue_body, labels=[RELEASE_LABEL]) + @override def get_issue_body(self, issue_num: int) -> str: """Gets the body content of an issue. @@ -286,6 +536,7 @@ def get_issue_body(self, issue_num: int) -> str: data = json.loads(output) return data.get("body", "") + @override def get_issue_title(self, issue_num: int) -> str: """Gets the title of an issue. @@ -301,6 +552,7 @@ def get_issue_title(self, issue_num: int) -> str: data = json.loads(output) return data.get("title", "") + @override def update_issue_body(self, issue_num: int, body: str) -> None: """Updates the body of an issue. @@ -321,6 +573,7 @@ def update_issue_body(self, issue_num: int, body: str) -> None: if os.path.exists(temp_path): os.remove(temp_path) + @override def resolve_pr_number(self, pr_ref: str) -> int: """Resolves a PR reference (number, #number, or GitHub URL) to a PR number. @@ -331,7 +584,7 @@ def resolve_pr_number(self, pr_ref: str) -> int: The integer PR number. Raises: - ValueError: If the PR reference cannot be resolved or is for another repo. + InvalidPrRefError: If the PR reference cannot be resolved or is for another repo. """ clean_ref = pr_ref.lstrip("#") if clean_ref.isdigit(): @@ -342,12 +595,35 @@ def resolve_pr_number(self, pr_ref: str) -> int: match = re.search(pattern, pr_ref, re.IGNORECASE) if match: return int(match.group(1)) - raise ValueError( + raise InvalidPrRefError( f"URL is not for the configured repository ({self.repo}): {pr_ref}" ) - raise ValueError(f"Could not resolve PR reference: {pr_ref}") + raise InvalidPrRefError(f"Could not resolve PR reference: {pr_ref}") + + def _gh_pr_view(self, pr_num: int, *fields: str) -> str: + """Helper to run `gh pr view` with specified JSON fields. + + Args: + pr_num: The PR number. + *fields: JSON fields to request (e.g., "state", "files"). + + Returns: + The raw JSON output string from gh. + + Raises: + GetPrError: If querying the PR fails. + """ + args = ["view", str(pr_num)] + if fields: + args.append(f"--json={','.join(fields)}") + try: + output = self._gh_pr(*args) + return output or "" + except subprocess.CalledProcessError as e: + raise GetPrError(f"Failed to get PR #{pr_num} on {self.repo}: {e}") from e + @override def get_pr_info(self, pr_num: int) -> PrDict: """Gets info about a PR using gh CLI. @@ -357,9 +633,30 @@ def get_pr_info(self, pr_num: int) -> PrDict: Returns: Dictionary containing PR fields (state, isDraft, mergeCommit, etc.). """ - output = self._gh_pr("view", str(pr_num), "--json=state,isDraft,mergeCommit") + output = self._gh_pr_view(pr_num, "state", "isDraft", "mergeCommit") return json.loads(output) if output else {} + @override + def get_pr_files(self, pr_num: int) -> list[str]: + """Gets the list of file paths touched by a PR using gh CLI. + + Args: + pr_num: The PR number. + + Returns: + A list of file paths. + + Raises: + GetPrError: If querying the PR fails. + """ + output = self._gh_pr_view(pr_num, "files") + if not output: + return [] + data: PrDict = json.loads(output) + files = data.get("files", []) + return [f["path"] for f in files] + + @override def get_pr_comments(self, pr_num: int) -> list[dict]: """Gets all comments for a PR using gh CLI. @@ -369,12 +666,13 @@ def get_pr_comments(self, pr_num: int) -> list[dict]: Returns: List of comment objects (with body, author, etc.). """ - output = self._gh_pr("view", str(pr_num), "--json=comments") + output = self._gh_pr_view(pr_num, "comments") if not output: return [] data = json.loads(output) return data.get("comments", []) + @override def create_pr( self, title: str, @@ -405,6 +703,7 @@ def create_pr( output = self._gh_pr(*cmd) return output if output else "" + @override def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None: """Enables auto-merge for a PR. @@ -421,6 +720,7 @@ def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None: cmd.append("--merge") self._gh_pr(*cmd, capture_output=False) + @override def get_open_pr(self, branch_name: str) -> PrDict | None: """Finds an open PR for the given branch. @@ -440,6 +740,7 @@ def get_open_pr(self, branch_name: str) -> PrDict | None: prs = json.loads(output) if output else [] return prs[0] if prs else None + @override def post_issue_comment(self, issue_num: int, comment_body: str) -> None: """Posts a comment on an issue or PR. @@ -454,6 +755,7 @@ def post_issue_comment(self, issue_num: int, comment_body: str) -> None: capture_output=False, ) + @override def add_comment_reaction(self, comment_id: int, reaction: str) -> None: """Adds a reaction to an issue or PR comment. @@ -469,6 +771,7 @@ def add_comment_reaction(self, comment_id: int, reaction: str) -> None: capture_output=False, ) + @override def get_merge_commits_for_prs( self, pending_items: list[BackportTask] ) -> list[BackportTask]: @@ -486,7 +789,7 @@ def get_merge_commits_for_prs( def resolve_merge_commits_for_prs( - gh_client: GitHub, pending_items: list[BackportTask] + gh_client: GitHubInterface, pending_items: list[BackportTask] ) -> list[BackportTask]: """Resolves PR references in pending backports to their merge commit SHAs. diff --git a/tools/private/release/mock_gh.py b/tools/private/release/mock_gh.py index 0b5b672517..f16d3add0d 100644 --- a/tools/private/release/mock_gh.py +++ b/tools/private/release/mock_gh.py @@ -1,18 +1,23 @@ """In-memory fake for GitHub API.""" import re +from typing import override from tools.private.release.gh import ( RELEASE_LABEL, + GetPrError, + GitHubInterface, + InvalidPrRefError, IssueDict, MultipleTrackingIssuesError, NoTrackingIssueError, PrDict, resolve_merge_commits_for_prs, ) +from tools.private.release.release_issue import BackportTask -class MockGitHub: +class MockGitHub(GitHubInterface): def __init__(self, repo: str = "bazel-contrib/rules_python"): self.repo = repo self.issues: dict[int, IssueDict] = {} @@ -22,17 +27,21 @@ def __init__(self, repo: str = "bazel-contrib/rules_python"): self.reactions: dict[int, list[str]] = {} self.pr_comments: dict[int, list[dict]] = {} + @override def post_issue_comment(self, issue_num: int, comment_body: str) -> None: self.issue_comments.setdefault(issue_num, []).append(comment_body) + @override def add_comment_reaction(self, comment_id: int, reaction: str) -> None: self.reactions.setdefault(comment_id, []).append(reaction) + @override def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None: if pr_num not in self.prs: self.create_pr(title="", body="") self.prs[pr_num]["auto_merge"] = {"merge_method": method} + @override def create_issue( self, title: str, body: str, labels: list[str] | None = None ) -> int: @@ -47,6 +56,7 @@ def create_issue( } return issue_num + @override def create_release_tracking_issue(self, version: str, template_content: str) -> int: # Strip YAML frontmatter if present (simplified copy from gh.py) issue_body = template_content @@ -59,21 +69,25 @@ def create_release_tracking_issue(self, version: str, template_content: str) -> title=f"Release {version}", body=issue_body, labels=[RELEASE_LABEL] ) + @override def get_issue_body(self, issue_num: int) -> str: if issue_num not in self.issues: raise ValueError(f"Issue #{issue_num} not found in MockGitHub") return self.issues[issue_num]["body"] + @override def get_issue_title(self, issue_num: int) -> str: if issue_num not in self.issues: raise ValueError(f"Issue #{issue_num} not found in MockGitHub") return self.issues[issue_num]["title"] + @override def update_issue_body(self, issue_num: int, body: str): if issue_num not in self.issues: raise ValueError(f"Issue #{issue_num} not found in MockGitHub") self.issues[issue_num]["body"] = body + @override def resolve_pr_number(self, pr_ref: str) -> int: # Real algorithm copy (doesn't require RPCs) clean_ref = pr_ref.lstrip("#") @@ -85,11 +99,12 @@ def resolve_pr_number(self, pr_ref: str) -> int: match = re.search(pattern, pr_ref, re.IGNORECASE) if match: return int(match.group(1)) - raise ValueError( + raise InvalidPrRefError( f"URL is not for the configured repository ({self.repo}): {pr_ref}" ) - raise ValueError(f"Could not resolve PR ref: {pr_ref}") + raise InvalidPrRefError(f"Could not resolve PR ref: {pr_ref}") + @override def get_release_tracking_issue(self, version: str) -> int: search_title = f"Release {version}" matching = [ @@ -108,6 +123,7 @@ def get_release_tracking_issue(self, version: str) -> int: ) return matching[0] + @override def create_pr( self, title: str, @@ -129,12 +145,14 @@ def create_pr( } return url + @override def get_open_pr(self, branch_name: str) -> PrDict | None: for pr in self.prs.values(): if pr.get("head") == branch_name and pr.get("state") == "OPEN": return pr return None + @override def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]: results = [] for issue in self.issues.values(): @@ -146,6 +164,7 @@ def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict results.append(issue) return results + @override def get_pr_info(self, pr_num: int) -> PrDict: if pr_num in self.prs: return self.prs[pr_num] @@ -154,9 +173,19 @@ def get_pr_info(self, pr_num: int) -> PrDict: "mergeCommit": {"oid": f"mock_merge_sha_{pr_num}"}, } + @override + def get_pr_files(self, pr_num: int) -> list[str]: + if pr_num not in self.prs: + raise GetPrError(f"Pull Request #{pr_num} not found in MockGitHub") + files = self.prs[pr_num].get("files", []) + return [f["path"] for f in files] + + @override def get_pr_comments(self, pr_num: int) -> list[dict]: return self.pr_comments.get(pr_num, []) - def get_merge_commits_for_prs(self, pending_items: list) -> list: - # pyrefly: ignore[bad-argument-type] + @override + def get_merge_commits_for_prs( + self, pending_items: list[BackportTask] + ) -> list[BackportTask]: return resolve_merge_commits_for_prs(self, pending_items) diff --git a/tools/private/release/process_news.py b/tools/private/release/process_news.py new file mode 100644 index 0000000000..80ca394e8d --- /dev/null +++ b/tools/private/release/process_news.py @@ -0,0 +1,274 @@ +"""Subcommand to process news files and version markers for an existing release.""" + +import argparse +import dataclasses +import logging +import pathlib +import re +import sys + +from tools.private.release import changelog_news +from tools.private.release.gh import ( + GetPrError, + GitHub, + GitHubInterface, + InvalidPrRefError, +) +from tools.private.release.utils import replace_version_next_in_files + +logger = logging.getLogger(__name__) + + +def _release_version_type(value: str) -> str: + """Argparse type validator for release versions (X.Y or X.Y.Z).""" + if not re.match(r"^\d+\.\d+(\.\d+)?(rc\d+)?$", value): + raise argparse.ArgumentTypeError( + f"'{value}' is not a valid release version (X.Y or X.Y.Z)" + ) + return value + + +@dataclasses.dataclass(frozen=True) +class NewsFileTarget: + """Represents a direct news file target.""" + + path: pathlib.Path + + +@dataclasses.dataclass(frozen=True) +class PrTarget: + """Represents a PR target with its news files and code files.""" + + pr_num: int + news_files: tuple[pathlib.Path, ...] + code_files: tuple[pathlib.Path, ...] + + +ResolvedTarget = NewsFileTarget | PrTarget + + +def resolve_news_file_target(target: str) -> NewsFileTarget | None: + """Attempts to resolve a target as a direct news file path on disk.""" + path = pathlib.Path(target) + if path.exists() and changelog_news.is_news_file(path): + return NewsFileTarget(path=path) + return None + + +def resolve_pr_target(target: str, gh: GitHubInterface) -> PrTarget | None: + """Attempts to resolve a target as a PR reference and discover its files.""" + try: + pr_num = gh.resolve_pr_number(target) + except InvalidPrRefError: + return None + + logger.info("Resolving files for PR #%d via GitHub CLI...", pr_num) + try: + pr_file_paths = gh.get_pr_files(pr_num) + except GetPrError as e: + logger.error("Failed to get PR files for #%d: %s", pr_num, e) + print(f"::error::Failed to get files for PR #{pr_num}: {e}") + raise + + news_files: list[pathlib.Path] = [] + code_files: list[pathlib.Path] = [] + + # Check local news directory for any news/.*.md + news_dir = pathlib.Path("news") + if news_dir.is_dir(): + for p in news_dir.iterdir(): + if p.name.startswith(f"{pr_num}.") and changelog_news.is_news_file(p): + if p not in news_files: + news_files.append(p) + + for f in pr_file_paths: + p = pathlib.Path(f) + if changelog_news.is_news_file(p): + if p.exists() and p not in news_files: + news_files.append(p) + else: + if p.exists() and p not in code_files: + code_files.append(p) + + if not pr_file_paths and not news_files: + msg = f"No news files or PR files found for PR #{pr_num}." + logger.error(msg) + print(f"::error::{msg}") + raise GetPrError(msg) + + return PrTarget( + pr_num=pr_num, + news_files=tuple(news_files), + code_files=tuple(code_files), + ) + + +def resolve_target(target: str, gh: GitHubInterface) -> ResolvedTarget: + """Resolves a target string into a ResolvedTarget. + + Raises: + ValueError: If target cannot be resolved. + """ + if news_target := resolve_news_file_target(target): + return news_target + + try: + if pr_target := resolve_pr_target(target, gh): + return pr_target + except (InvalidPrRefError, GetPrError): + raise + + path = pathlib.Path(target) + if path.exists(): + msg = f"File is not a valid news file: {path} (expected ..md)" + else: + msg = ( + f"Target '{target}' is neither an existing news file nor a valid PR" + " reference." + ) + print(f"::error::{msg}") + raise ValueError(msg) + + +def process_news_file_target( + target: NewsFileTarget, version: str, changelog_path: pathlib.Path +) -> None: + """Processes a direct news file target.""" + logger.info("Processing news file: %s", target.path) + changelog_news.update_changelog( + version=version, + release_date="0000-00-00", + changelog_path=changelog_path, + news_files=[target.path], + delete_news=True, + ) + print(f"::notice::Processed news file {target.path} into {changelog_path}.") + + +def process_pr_target( + target: PrTarget, version: str, changelog_path: pathlib.Path +) -> None: + """Processes a PR target: merges news files and updates version markers.""" + logger.info("Processing PR #%d...", target.pr_num) + if target.news_files: + changelog_news.update_changelog( + version=version, + release_date="0000-00-00", + changelog_path=changelog_path, + news_files=list(target.news_files), + delete_news=True, + ) + news_list_str = ", ".join(str(f) for f in target.news_files) + print( + f"::notice::Processed news file(s) for PR #{target.pr_num} into" + f" {changelog_path}: {news_list_str}" + ) + + if target.code_files: + modified_files = replace_version_next_in_files(target.code_files, version) + if modified_files: + mod_str = ", ".join(str(f) for f in modified_files) + print( + f"::notice::Updated version-next markers for PR #{target.pr_num} in:" + f" {mod_str}" + ) + else: + logger.info( + "No version-next markers found to update for PR #%d.", + target.pr_num, + ) + + +class ProcessNews: + """Class to process news files into CHANGELOG.md for an existing version.""" + + def __init__(self, args, gh: GitHubInterface): + self.args = args + self.gh = gh + + def run(self) -> int: + """Executes the process-news subcommand.""" + args = self.args + version = args.version + if len(version.split(".")) == 2: + version = f"{version}.0" + + # Validate that target version exists in CHANGELOG.md + changelog_path = pathlib.Path("CHANGELOG.md") + if not changelog_path.exists(): + print( + f"::error::Changelog file not found at {changelog_path}", + file=sys.stderr, + ) + return 1 + + changelog_content = changelog_path.read_text(encoding="utf-8") + header_version = version.replace(".", "-") + version_anchor = f"{{#v{header_version}}}" + if version_anchor not in changelog_content: + print( + f"::error::Version {version} (anchor {version_anchor}) does not" + f" exist in {changelog_path}.", + file=sys.stderr, + ) + return 1 + + # Phase 1: Resolve all targets in order + resolved_targets: list[ResolvedTarget] = [] + for target in args.targets: + try: + resolved = resolve_target(target, self.gh) + resolved_targets.append(resolved) + except Exception as e: + logger.error("Failed to resolve target '%s': %s", target, e) + print( + f"::error::Failed to resolve target '{target}': {e}", + file=sys.stderr, + ) + return 1 + + # Phase 2: Process all resolved targets in the given order + for target in resolved_targets: + if isinstance(target, NewsFileTarget): + process_news_file_target(target, version, changelog_path) + elif isinstance(target, PrTarget): + process_pr_target(target, version, changelog_path) + else: + logger.warning( + "Unexpected target type encountered: %s (%r)", + type(target), + target, + ) + + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for process-news subcommand.""" + parser = subparsers.add_parser( + "process-news", + help=( + "Process news files and update version-next markers into" + " CHANGELOG.md for an existing version." + ), + ) + parser.add_argument( + "version", + type=_release_version_type, + help="The target existing release version (e.g., 2.3.0 or 2.3).", + ) + parser.add_argument( + "targets", + nargs="+", + metavar="TARGET", + help=( + "One or more news file paths (e.g., news/3997.added.md) or PR" + " references (e.g., 3997, #3997, or PR URL) to process." + ), + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + return cls(args, gh=GitHub()).run() diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 7f1896ecf4..c67dfb7cfe 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -16,6 +16,7 @@ from tools.private.release.on_pr_merged import OnPrMerged from tools.private.release.prepare import Prepare from tools.private.release.process_backports import ProcessBackports +from tools.private.release.process_news import ProcessNews from tools.private.release.promote import Promote cmds = [ @@ -27,6 +28,7 @@ CreateReleaseBranch, AddBackports, ProcessBackports, + ProcessNews, OnPrMerged, CreateRc, Promote, diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index ab1e0acef3..e2b1d0b5d0 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -1,8 +1,10 @@ """Utility functions for the release tool.""" import argparse +import collections.abc import fnmatch import os +import pathlib import re from packaging.version import parse as parse_version @@ -35,21 +37,26 @@ def semver_type(value): ] -def _iter_version_placeholder_files(): +def is_excluded_version_placeholder_path(path: pathlib.Path | str) -> bool: + """Checks if a path matches any version placeholder exclusion patterns.""" + path_str = str(path) + if not path_str.startswith("./") and not path_str.startswith("/"): + path_str = f"./{path_str}" + return any(fnmatch.fnmatch(path_str, pattern) for pattern in _EXCLUDE_PATTERNS) + + +def _iter_version_placeholder_files() -> collections.abc.Iterator[pathlib.Path]: for root, dirs, files in os.walk(".", topdown=True): - # Filter directories + # Filter directories in-place dirs[:] = [ d for d in dirs - if not any( - fnmatch.fnmatch(os.path.join(root, d), pattern) - for pattern in _EXCLUDE_PATTERNS - ) + if not is_excluded_version_placeholder_path(os.path.join(root, d)) ] for filename in files: - filepath = os.path.join(root, filename) - if any(fnmatch.fnmatch(filepath, pattern) for pattern in _EXCLUDE_PATTERNS): + filepath = pathlib.Path(root) / filename + if is_excluded_version_placeholder_path(filepath): continue yield filepath @@ -159,20 +166,45 @@ def determine_next_version(branch_name=None): return f"{major}.{minor}.{patch + 1}" -def replace_version_next(version): - """Replaces all VERSION_NEXT_* placeholders with the new version.""" - for filepath in _iter_version_placeholder_files(): +def replace_version_next_in_files( + filepaths: collections.abc.Iterable[pathlib.Path], version: str +) -> list[pathlib.Path]: + """Replaces VERSION_NEXT_* placeholders with version in the specified files. + + Args: + filepaths: An iterable of pathlib.Path objects to process. + version: The release version string to replace placeholders with. + + Returns: + List of pathlib.Path objects for files that were modified. + """ + modified: list[pathlib.Path] = [] + for path in filepaths: + if is_excluded_version_placeholder_path(path): + continue try: - with open(filepath, "r") as f: - content = f.read() + content = path.read_text(encoding="utf-8") except (IOError, UnicodeDecodeError): continue if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content: new_content = content.replace("VERSION_NEXT_FEATURE", version) new_content = new_content.replace("VERSION_NEXT_PATCH", version) - with open(filepath, "w") as f: - f.write(new_content) + path.write_text(new_content, encoding="utf-8") + modified.append(path) + return modified + + +def replace_version_next(version: str) -> list[pathlib.Path]: + """Replaces all VERSION_NEXT_* placeholders with the new version. + + Args: + version: The release version string to replace placeholders with. + + Returns: + List of pathlib.Path objects for files that were modified. + """ + return replace_version_next_in_files(_iter_version_placeholder_files(), version) def parse_pr_list(value: str) -> list[str]: From 92cab3334f75947ce0da88c5b9b643dd8ddb256c Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 16 Aug 2026 22:24:26 +0000 Subject: [PATCH 2/2] build(release): add pyrefly suppression for typing.override --- tools/private/release/gh.py | 5 ++++- tools/private/release/mock_gh.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index 36543d067f..465722e915 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -7,7 +7,10 @@ import re import subprocess import tempfile -from typing import TypedDict, override +from typing import ( + TypedDict, + override, # pyrefly: ignore[missing-module-attribute] -- override available in Python 3.12+ +) from tools.private.release.release_issue import BackportTask from tools.private.release.shell import run_cmd diff --git a/tools/private/release/mock_gh.py b/tools/private/release/mock_gh.py index f16d3add0d..f35d0db5e8 100644 --- a/tools/private/release/mock_gh.py +++ b/tools/private/release/mock_gh.py @@ -1,7 +1,9 @@ """In-memory fake for GitHub API.""" import re -from typing import override +from typing import ( + override, # pyrefly: ignore[missing-module-attribute] -- override available in Python 3.12+ +) from tools.private.release.gh import ( RELEASE_LABEL,