Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion tests/tools/private/release/gh_test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest

from tools.private.release import shell
from tools.private.release.gh import GitHub
from tools.private.release.gh import CreatePrError, GitHub
from tools.private.release.git import Git

pytest_plugins = ["tests.tools.private.release.release_test_helper"]
Expand Down Expand Up @@ -94,3 +94,77 @@ 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_create_pr_success(gh, auto_patch_cmd_helpers):
auto_patch_cmd_helpers.run_gh.return_value = (
"https://github.com/my-owner/my-repo/pull/123"
)
url = gh.create_pr(
title="feat: my feature",
body="PR body",
base="main",
labels=["type: sync-changelog"],
)
assert url == "https://github.com/my-owner/my-repo/pull/123"
auto_patch_cmd_helpers.run_gh.assert_called_with(
"pr",
"create",
"--title=feat: my feature",
"--body=PR body",
"--base=main",
"--label=type: sync-changelog",
"--repo=my-owner/my-repo",
check=True,
capture_output=True,
)


def test_create_pr_failure_raises_create_pr_error(gh, auto_patch_cmd_helpers):
import subprocess

err = subprocess.CalledProcessError(
1,
["gh", "pr", "create"],
output="my stdout",
stderr="pull request already exists",
)
auto_patch_cmd_helpers.run_gh.side_effect = err

with pytest.raises(CreatePrError) as exc_info:
gh.create_pr(title="feat: my feature", body="PR body")

assert (
"Failed to create PR 'feat: my feature': Command '['gh', 'pr',"
" 'create']' returned non-zero exit status 1." in str(exc_info.value)
)
assert "==================== STDOUT BEGIN ====================" in str(
exc_info.value
)
assert "my stdout" in str(exc_info.value)
assert "==================== STDOUT END ====================" in str(exc_info.value)
assert "==================== STDERR BEGIN ====================" in str(
exc_info.value
)
assert "pull request already exists" in str(exc_info.value)
assert "==================== STDERR END ====================" in str(exc_info.value)
assert exc_info.value.__cause__ is err


def test_create_pr_empty_output_raises_create_pr_error(gh, auto_patch_cmd_helpers):
auto_patch_cmd_helpers.run_gh.return_value = ""
with pytest.raises(CreatePrError, match="gh pr create returned no output"):
gh.create_pr(title="feat: my feature", body="PR body")


def test_create_pr_generic_exception_raises_create_pr_error(gh, auto_patch_cmd_helpers):
err = RuntimeError("network disconnected")
auto_patch_cmd_helpers.run_gh.side_effect = err

with pytest.raises(CreatePrError) as exc_info:
gh.create_pr(title="feat: my feature", body="PR body")

assert "Failed to create PR 'feat: my feature': network disconnected" in str(
exc_info.value
)
assert exc_info.value.__cause__ is err
87 changes: 87 additions & 0 deletions tests/tools/private/release/process_backports_test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import argparse
import datetime
import logging
from unittest.mock import ANY, call

from tools.private.release.gh import CreatePrError
from tools.private.release.process_backports import ProcessBackports

pytest_plugins = ["tests.tools.private.release.release_test_helper"]
Expand Down Expand Up @@ -491,3 +493,88 @@ def test_process_backports_version_sync_failure(mocker, mock_git, mock_gh):
updated_body = mock_gh.get_issue_body(123)
assert "- [ ] Sync Changelog #124 | status=pending pr=#1001" in updated_body
assert "- [ ] Sync Changelog #125 | status=pending pr=#1001" in updated_body


def test_process_backports_sync_changelog_create_pr_failure(
mocker, mock_git, mock_gh, capsys, caplog
):
mocker.patch("tools.private.release.process_backports.changelog_news")
mocker.patch("tools.private.release.process_backports.replace_version_next")
mock_datetime = mocker.patch("tools.private.release.process_backports.datetime")
mock_datetime.date.today.return_value = datetime.date(2026, 7, 1)

args = argparse.Namespace(
issue=123,
remote="origin",
dry_run=False,
add=None,
triggering_comment=5297050431,
)
mock_gh.issues[123] = {
"title": "Release 2.0.0",
"body": """
## Checklist
- [ ] Prepare Release
- [ ] Create Release branch
- [ ] Sync Changelog #124
- [ ] Tag Final

## Backports
- [ ] #124 | status=pending
""",
"labels": ["type: release"],
}
mock_gh.prs[124] = {
"state": "MERGED",
"mergeCommit": {"oid": "abcdef12"},
}
mock_git.get_remote_tags.return_value = []
mock_git.sort_commits_chronologically.return_value = ["abcdef12"]
mock_git.get_commit_sha.side_effect = ["12345678", "12345678", "main_sha"]
mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"'
mock_git.get_modified_files.return_value = ["news/124.fixed.md"]
mock_git.diff.return_value = "version diff for 124"
mock_git.apply_check.return_value = True

# Make create_pr raise CreatePrError
err = CreatePrError(
"Failed to create PR 'chore(release): sync changelog for v2.0.0 backports': "
"Command '['gh', 'pr', 'create']' returned non-zero exit status 1.\n"
"Error running command: gh pr create ...\nStdout: \nStderr: pull request already exists"
)
mocker.patch.object(mock_gh, "create_pr", side_effect=err)

with caplog.at_level(logging.ERROR):
result = ProcessBackports(args, mock_git, mock_gh).run()

assert result == 1
assert mock_gh.reactions.get(5297050431) == ["-1"]

captured = capsys.readouterr()
assert (
"Unexpected error: Failed to create PR 'chore(release): sync changelog for v2.0.0 backports'"
in caplog.text
)
assert "Error running command: gh pr create ..." in captured.err
assert "Stderr: pull request already exists" in captured.err


def test_process_backports_logs_no_pending(mock_git, mock_gh, caplog):
args = argparse.Namespace(
issue=123,
remote="origin",
dry_run=False,
add=None,
triggering_comment=None,
)
mock_gh.issues[123] = {
"title": "Release 2.0.0",
"body": "No backports here",
"labels": ["type: release"],
}

with caplog.at_level(logging.INFO):
result = ProcessBackports(args, mock_git, mock_gh).run()

assert result == 0
assert "No pending backports found." in caplog.text
11 changes: 11 additions & 0 deletions tests/tools/private/release/release_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,14 @@ def test_invalid_version():
releaser.create_parser().parse_args(["prepare", "0.28"])
with pytest.raises(SystemExit):
releaser.create_parser().parse_args(["prepare", "a.b.c"])


def test_main_runs_command(mocker):
mocker.patch("sys.argv", ["release", "prepare", "0.28.0"])
mock_cmd = mocker.patch(
"tools.private.release.prepare.Prepare.run_from_args", return_value=0
)
with pytest.raises(SystemExit) as exc_info:
releaser.main()
assert exc_info.value.code == 0
mock_cmd.assert_called_once()
20 changes: 20 additions & 0 deletions tests/tools/private/release/utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,23 @@ def test_determine_next_version_on_main_with_is_patch(mocker, release_tool_env):
assert utils.determine_next_version(is_patch=False) == "1.3.0"
# With is_patch=True, it produces a patch bump
assert utils.determine_next_version(is_patch=True) == "1.2.4"


def test_format_exception_no_notes():
e = ValueError("something went wrong")
assert utils.format_exception(e) == "something went wrong"


def test_format_exception_with_notes():
e = RuntimeError("failed to execute")
e.add_note("Note 1: additional details")
e.add_note("Note 2: more info")
assert utils.format_exception(e) == (
"failed to execute\nNote 1: additional details\nNote 2: more info"
)


def test_format_exception_empty_message_with_notes():
e = Exception()
e.add_note("Note only")
assert utils.format_exception(e) == "Note only"
36 changes: 34 additions & 2 deletions tools/private/release/gh.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import os
import re
import subprocess
import tempfile
from typing import TypedDict

Expand Down Expand Up @@ -104,6 +105,12 @@ class NoTrackingIssueError(ValueError):
pass


class CreatePrError(Exception):
"""Raised when creating a pull request fails."""

pass


class GitHub:
"""GitHub CLI helper class for the release tool."""

Expand Down Expand Up @@ -392,6 +399,9 @@ def create_pr(

Returns:
The URL of the created PR.

Raises:
CreatePrError: If creating the pull request fails.
"""
cmd = [
"create",
Expand All @@ -402,8 +412,30 @@ def create_pr(
if labels:
for label in labels:
cmd.append(f"--label={label}")
output = self._gh_pr(*cmd)
return output if output else ""
try:
output = self._gh_pr(*cmd)
except subprocess.CalledProcessError as e:
msg = f"Failed to create PR '{title}': {e}"
if e.stdout:
msg += (
f"\n{'=' * 20} STDOUT BEGIN {'=' * 20}\n"
f"{e.stdout}\n"
f"{'=' * 20} STDOUT END {'=' * 20}"
)
if e.stderr:
msg += (
f"\n{'=' * 20} STDERR BEGIN {'=' * 20}\n"
f"{e.stderr}\n"
f"{'=' * 20} STDERR END {'=' * 20}"
)
raise CreatePrError(msg) from e
except Exception as e:
raise CreatePrError(f"Failed to create PR '{title}': {e}") from e
if not output:
raise CreatePrError(
f"Failed to create PR '{title}': gh pr create returned no output"
)
return output

def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None:
"""Enables auto-merge for a PR.
Expand Down
Loading