From 26b38beb8d507e4a4ee3c062639a96230c33dd92 Mon Sep 17 00:00:00 2001 From: Reinier Schoof Date: Sat, 7 Jan 2023 11:13:06 +0100 Subject: [PATCH 1/3] feat(bump): added support for running arbitrary hooks during bump To make commitizen integrate even better, new configuration keys pre_bump_hooks and post_bump_hooks were added to allow to run arbitrary commands prior to and right after running bump Closes: #292 --- .github/workflows/pythonpackage.yml | 2 +- commitizen/cmd.py | 3 +- commitizen/commands/bump.py | 32 ++++++++++++++++- commitizen/defaults.py | 4 +++ commitizen/exceptions.py | 5 +++ commitizen/hooks.py | 34 ++++++++++++++++++ docs/bump.md | 55 +++++++++++++++++++++++++++++ tests/test_bump_hooks.py | 7 ++++ tests/test_conf.py | 12 +++++++ 9 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 commitizen/hooks.py create mode 100644 tests/test_bump_hooks.py diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 57d8b8d457..5cf248cfc0 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -6,7 +6,7 @@ jobs: python-check: strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10"] platform: [ubuntu-20.04, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: diff --git a/commitizen/cmd.py b/commitizen/cmd.py index 656dea07a4..51ef4523ff 100644 --- a/commitizen/cmd.py +++ b/commitizen/cmd.py @@ -27,13 +27,14 @@ def _try_decode(bytes_: bytes) -> str: raise CharacterSetDecodeError() from e -def run(cmd: str) -> Command: +def run(cmd: str, env=None) -> Command: process = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE, + env=env, ) stdout, stderr = process.communicate() return_code = process.returncode diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 14e339c669..918dfaa002 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -5,7 +5,7 @@ import questionary from packaging.version import InvalidVersion, Version -from commitizen import bump, cmd, defaults, factory, git, out +from commitizen import bump, cmd, defaults, factory, git, hooks, out from commitizen.commands.changelog import Changelog from commitizen.config import BaseConfig from commitizen.exceptions import ( @@ -58,6 +58,8 @@ def __init__(self, config: BaseConfig, arguments: dict): self.no_verify = arguments["no_verify"] self.check_consistency = arguments["check_consistency"] self.retry = arguments["retry"] + self.pre_bump_hooks = self.config.settings["pre_bump_hooks"] + self.post_bump_hooks = self.config.settings["post_bump_hooks"] def is_initial_tag(self, current_tag_version: str, is_yes: bool = False) -> bool: """Check if reading the whole git tree up to HEAD is needed.""" @@ -272,6 +274,20 @@ def __call__(self): # noqa: C901 self.config.set_key("version", str(new_version)) + if self.pre_bump_hooks: + hooks.run( + self.pre_bump_hooks, + _env_prefix="CZ_PRE_", + is_initial=is_initial, + current_version=current_version, + current_tag_version=current_tag_version, + new_version=new_version.public, + new_tag_version=new_tag_version, + message=message, + increment=increment, + changelog_file_name=changelog_cmd.file_name if self.changelog else None, + ) + if is_files_only: raise ExpectedExit() @@ -300,6 +316,20 @@ def __call__(self): # noqa: C901 if c.return_code != 0: raise BumpTagFailedError(c.err) + if self.post_bump_hooks: + hooks.run( + self.post_bump_hooks, + _env_prefix="CZ_POST_", + was_initial=is_initial, + previous_version=current_version, + previous_tag_version=current_tag_version, + current_version=new_version.public, + current_tag_version=new_tag_version, + message=message, + increment=increment, + changelog_file_name=changelog_cmd.file_name if self.changelog else None, + ) + # TODO: For v3 output this only as diagnostic and remove this if if self.changelog_to_stdout: out.diagnostic("Done!") diff --git a/commitizen/defaults.py b/commitizen/defaults.py index bdc853e1eb..b18c5ac75c 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -40,6 +40,8 @@ class Settings(TypedDict, total=False): style: Optional[List[Tuple[str, str]]] customize: CzSettings major_version_zero: bool + pre_bump_hooks: Optional[List[str]] + post_bump_hooks: Optional[List[str]] name: str = "cz_conventional_commits" @@ -65,6 +67,8 @@ class Settings(TypedDict, total=False): "update_changelog_on_bump": False, "use_shortcuts": False, "major_version_zero": False, + "pre_bump_hooks": [], + "post_bump_hooks": [], } MAJOR = "MAJOR" diff --git a/commitizen/exceptions.py b/commitizen/exceptions.py index f2615ff90e..c7d0b50e69 100644 --- a/commitizen/exceptions.py +++ b/commitizen/exceptions.py @@ -30,6 +30,7 @@ class ExitCode(enum.IntEnum): GIT_COMMAND_ERROR = 23 INVALID_MANUAL_VERSION = 24 INIT_FAILED = 25 + RUN_HOOK_FAILED = 26 class CommitizenException(Exception): @@ -168,3 +169,7 @@ class InvalidManualVersion(CommitizenException): class InitFailedError(CommitizenException): exit_code = ExitCode.INIT_FAILED + + +class RunHookError(CommitizenException): + exit_code = ExitCode.RUN_HOOK_FAILED diff --git a/commitizen/hooks.py b/commitizen/hooks.py new file mode 100644 index 0000000000..f5efb8071d --- /dev/null +++ b/commitizen/hooks.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from commitizen import cmd, out +from commitizen.exceptions import RunHookError + + +def run(hooks, _env_prefix="CZ_", **env): + if isinstance(hooks, str): + hooks = [hooks] + + for hook in hooks: + out.info(f"Running hook '{hook}'") + + c = cmd.run(hook, env=_format_env(_env_prefix, env)) + + if c.out: + out.write(c.out) + if c.err: + out.error(c.err) + + if c.return_code != 0: + raise RunHookError(f"Running hook '{hook}' failed") + + +def _format_env(prefix: str, env: dict[str, str]) -> dict[str, str]: + """_format_env() prefixes all given environment variables with the given + prefix so it can be passed directly to cmd.run().""" + penv = dict() + for name, value in env.items(): + name = prefix + name.upper() + value = str(value) if value is not None else "" + penv[name] = value + + return penv diff --git a/docs/bump.md b/docs/bump.md index e94b96cb63..aa9839fc76 100644 --- a/docs/bump.md +++ b/docs/bump.md @@ -411,6 +411,61 @@ Defaults to: `false` major_version_zero = true ``` +--- + +### `pre_bump_hooks` + +A list of optional commands that will run right _after_ updating `version_files` +and _before_ actual committing and tagging the release. + +Useful when you need to generate documentation based on the new version. During +execution of the script, some environment variables are available: + +| Variable | Description | +| ---------------------------- | ---------------------------------------------------------- | +| `CZ_PRE_IS_INITIAL` | `True` when this is the initial release, `False` otherwise | +| `CZ_PRE_CURRENT_VERSION` | Current version, before the bump | +| `CZ_PRE_CURRENT_TAG_VERSION` | Current version tag, before the bump | +| `CZ_PRE_NEW_VERSION` | New version, after the bump | +| `CZ_PRE_NEW_TAG_VERSION` | New version tag, after the bump | +| `CZ_PRE_MESSAGE` | Commit message of the bump | +| `CZ_PRE_INCREMENT` | Whether this is a `MAJOR`, `MINOR` or `PATH` release | +| `CZ_PRE_CHANGELOG_FILE_NAME` | Path to the changelog file, if available | + +```toml +[tool.commitizen] +pre_bump_hooks = [ + "scripts/generate_documentation.sh" +] +``` + +--- + +### `post_bump_hooks` + +A list of optional commands that will run right _after_ committing and tagging the release. + +Useful when you need to send notifications about a release, or further automate deploying the +release. During execution of the script, some environment variables are available: + +| Variable | Description | +| ------------------------------ | ----------------------------------------------------------- | +| `CZ_POST_WAS_INITIAL` | `True` when this was the initial release, `False` otherwise | +| `CZ_POST_PREVIOUS_VERSION` | Previous version, before the bump | +| `CZ_POST_PREVIOUS_TAG_VERSION` | Previous version tag, before the bump | +| `CZ_POST_CURRENT_VERSION` | Current version, after the bump | +| `CZ_POST_CURRENT_TAG_VERSION` | Current version tag, after the bump | +| `CZ_POST_MESSAGE` | Commit message of the bump | +| `CZ_POST_INCREMENT` | Whether this wass a `MAJOR`, `MINOR` or `PATH` release | +| `CZ_POST_CHANGELOG_FILE_NAME` | Path to the changelog file, if available | + +```toml +[tool.commitizen] +post_bump_hooks = [ + "scripts/slack_notification.sh" +] +``` + ## Custom bump Read the [customizing section](./customization.md). diff --git a/tests/test_bump_hooks.py b/tests/test_bump_hooks.py new file mode 100644 index 0000000000..72f2d99b6e --- /dev/null +++ b/tests/test_bump_hooks.py @@ -0,0 +1,7 @@ +from commitizen import hooks + + +def test_format_env(): + result = hooks._format_env("TEST_", {"foo": "bar", "bar": "baz"}) + assert "TEST_FOO" in result and result["TEST_FOO"] == "bar" + assert "TEST_BAR" in result and result["TEST_BAR"] == "baz" diff --git a/tests/test_conf.py b/tests/test_conf.py index 746cde2401..458a3fb8cc 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -19,6 +19,10 @@ ["pointer", "reverse"], ["question", "underline"] ] +pre_bump_hooks = [ + "scripts/generate_documentation.sh" +] +post_bump_hooks = ["scripts/slack_notification.sh"] [tool.black] line-length = 88 @@ -31,6 +35,8 @@ "version": "1.0.0", "version_files": ["commitizen/__version__.py", "pyproject.toml"], "style": [["pointer", "reverse"], ["question", "underline"]], + "pre_bump_hooks": ["scripts/generate_documentation.sh"], + "post_bump_hooks": ["scripts/slack_notification.sh"], } } @@ -49,6 +55,8 @@ "update_changelog_on_bump": False, "use_shortcuts": False, "major_version_zero": False, + "pre_bump_hooks": ["scripts/generate_documentation.sh"], + "post_bump_hooks": ["scripts/slack_notification.sh"], } _new_settings = { @@ -65,6 +73,8 @@ "update_changelog_on_bump": False, "use_shortcuts": False, "major_version_zero": False, + "pre_bump_hooks": ["scripts/generate_documentation.sh"], + "post_bump_hooks": ["scripts/slack_notification.sh"], } _read_settings = { @@ -73,6 +83,8 @@ "version_files": ["commitizen/__version__.py", "pyproject.toml"], "style": [["pointer", "reverse"], ["question", "underline"]], "changelog_file": "CHANGELOG.md", + "pre_bump_hooks": ["scripts/generate_documentation.sh"], + "post_bump_hooks": ["scripts/slack_notification.sh"], } From ba2efac9adafd5c58077be468932274d3ee74154 Mon Sep 17 00:00:00 2001 From: Wei Lee Date: Fri, 27 Jan 2023 12:34:53 +0800 Subject: [PATCH 2/3] test(commands/bump): add test case from bump hooks --- tests/commands/test_bump_command.py | 59 ++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index dcdca163b2..a9854a4b32 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -1,13 +1,13 @@ import inspect import sys from typing import Tuple -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call import pytest from pytest_mock import MockFixture import commitizen.commands.bump as bump -from commitizen import cli, cmd, git +from commitizen import cli, cmd, git, hooks from commitizen.exceptions import ( BumpTagFailedError, CommitizenException, @@ -759,3 +759,58 @@ def test_bump_manual_version_disallows_major_version_zero(mocker): "--major-version-zero cannot be combined with MANUAL_VERSION" ) assert expected_error_message in str(excinfo.value) + + +@pytest.mark.parametrize("commit_msg", ("feat: new file", "feat(user): new file")) +def test_bump_with_pre_bump_hooks( + commit_msg, mocker: MockFixture, tmp_commitizen_project +): + pre_bump_hook = "scripts/pre_bump_hook.sh" + post_bump_hook = "scripts/post_bump_hook.sh" + + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + tmp_commitizen_cfg_file.write( + f"{tmp_commitizen_cfg_file.read()}\n" + f'pre_bump_hooks = ["{pre_bump_hook}"]\n' + f'post_bump_hooks = ["{post_bump_hook}"]\n' + ) + + run_mock = mocker.Mock() + mocker.patch.object(hooks, "run", run_mock) + + create_file_and_commit(commit_msg) + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + run_mock.assert_has_calls( + [ + call( + [pre_bump_hook], + _env_prefix="CZ_PRE_", + is_initial=True, + current_version="0.1.0", + current_tag_version="0.1.0", + new_version="0.2.0", + new_tag_version="0.2.0", + message="bump: version 0.1.0 → 0.2.0", + increment="MINOR", + changelog_file_name=None, + ), + call( + [post_bump_hook], + _env_prefix="CZ_POST_", + was_initial=True, + previous_version="0.1.0", + previous_tag_version="0.1.0", + current_version="0.2.0", + current_tag_version="0.2.0", + message="bump: version 0.1.0 → 0.2.0", + increment="MINOR", + changelog_file_name=None, + ), + ] + ) From e26293c9f3a065ab3a47eb36a07a3fd24c9fc787 Mon Sep 17 00:00:00 2001 From: Wei Lee Date: Sat, 28 Jan 2023 11:18:53 +0800 Subject: [PATCH 3/3] test(hooks): add test case for run --- tests/test_bump_hooks.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/test_bump_hooks.py b/tests/test_bump_hooks.py index 72f2d99b6e..af1f2a2d35 100644 --- a/tests/test_bump_hooks.py +++ b/tests/test_bump_hooks.py @@ -1,4 +1,35 @@ -from commitizen import hooks +from unittest.mock import call + +import pytest +from pytest_mock import MockFixture + +from commitizen import cmd, hooks +from commitizen.exceptions import RunHookError + + +def test_run(mocker: MockFixture): + bump_hooks = ["pre_bump_hook", "pre_bump_hook_1"] + + cmd_run_mock = mocker.Mock() + cmd_run_mock.return_value.return_code = 0 + mocker.patch.object(cmd, "run", cmd_run_mock) + + hooks.run(bump_hooks) + + cmd_run_mock.assert_has_calls( + [call("pre_bump_hook", env={}), call("pre_bump_hook_1", env={})] + ) + + +def test_run_error(mocker: MockFixture): + bump_hooks = ["pre_bump_hook", "pre_bump_hook_1"] + + cmd_run_mock = mocker.Mock() + cmd_run_mock.return_value.return_code = 1 + mocker.patch.object(cmd, "run", cmd_run_mock) + + with pytest.raises(RunHookError): + hooks.run(bump_hooks) def test_format_env():