diff --git a/pontos/release/parser.py b/pontos/release/parser.py index 85bcf56b..9dd5d747 100644 --- a/pontos/release/parser.py +++ b/pontos/release/parser.py @@ -28,6 +28,7 @@ from typing import Callable, Optional, Tuple, Type from pontos.release.helper import ReleaseType +from pontos.release.show import OutputFormat, show from pontos.version.schemes import ( VERSIONING_SCHEMES, PEP440VersioningScheme, @@ -231,13 +232,61 @@ def parse_args(args) -> Tuple[Optional[str], Optional[str], Namespace]: "--dry-run", action="store_true", help="Do not upload signed files." ) + show_parser = subparsers.add_parser( + "show", + help="Show release information about the current release version and " + "determine the next release version", + ) + show_parser.set_defaults(func=show) + show_parser.add_argument( + "--versioning-scheme", + help="Versioning scheme to use for parsing and handling version " + f"information. Choices are {', '.join(VERSIONING_SCHEMES.keys())}. " + "Default: %(default)s", + default="pep440", + type=versioning_scheme_argument_type, + ) + show_parser.add_argument( + "--release-type", + help="Select the release type for calculating the release version. " + f"Possible choices are: {to_choices(ReleaseType)}.", + type=enum_type(ReleaseType), + ) + show_parser.add_argument( + "--release-version", + help=( + "Will release changelog as version. " + "Default: lookup version in project definition." + ), + action=ReleaseVersionAction, + ) + show_parser.add_argument( + "--release-series", + help="Create a release for a release series. Setting a release series " + "is required if the latest tag version is newer then the to be " + 'released version. Examples: "1.2", "2", "22.4"', + ) + show_parser.add_argument( + "--git-tag-prefix", + default="v", + const="", + nargs="?", + help="Prefix for git tag versions. Default: %(default)s", + ) + show_parser.add_argument( + "--output-format", + help="Print in the desired output format. " + f"Possible choices are: {to_choices(OutputFormat)}.", + type=enum_type(OutputFormat), + ) + parsed_args = parser.parse_args(args) scheme: type[VersioningScheme] = getattr( parsed_args, "versioning_scheme", PEP440VersioningScheme ) - if parsed_args.func in (create_release,): + if parsed_args.func in (create_release, show): # check for release-type if not getattr(parsed_args, "release_type", None): parser.error("--release-type is required.") diff --git a/pontos/release/show.py b/pontos/release/show.py new file mode 100644 index 00000000..0715dfbf --- /dev/null +++ b/pontos/release/show.py @@ -0,0 +1,177 @@ +# SPDX-FileCopyrightText: 2023 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +from argparse import Namespace +from enum import Enum, IntEnum, auto +from typing import Optional + +from pontos.errors import PontosError +from pontos.git import Git +from pontos.github.actions import ActionIO +from pontos.release.command import Command +from pontos.release.helper import ReleaseType, get_next_release_version +from pontos.terminal import Terminal +from pontos.typing import SupportsStr +from pontos.version import Version, VersionError +from pontos.version.helper import get_last_release_version +from pontos.version.schemes import VersioningScheme + + +class ShowReleaseReturnValue(IntEnum): + """ + Possible return values of ReleaseCommand + """ + + SUCCESS = 0 + NO_LAST_RELEASE_VERSION = auto() + NO_RELEASE_VERSION = auto() + + +class OutputFormat(Enum): + ENV = "env" + JSON = "json" + GITHUB_ACTION = "github-action" + + +class ShowReleaseCommand(Command): + def __init__(self, *, terminal: Terminal, error_terminal: Terminal) -> None: + super().__init__(terminal=terminal, error_terminal=error_terminal) + self.git = Git() + + def run( # type: ignore[override] + self, + *, + output_format: OutputFormat = OutputFormat.ENV, + versioning_scheme: VersioningScheme, + release_type: ReleaseType, + release_version: Optional[Version], + release_series: Optional[str] = None, + git_tag_prefix: Optional[str] = None, + ) -> int: + git_tag_prefix = git_tag_prefix or "" + + try: + last_release_version = get_last_release_version( + parse_version=versioning_scheme.parse_version, + git_tag_prefix=git_tag_prefix, + tag_name=f"{git_tag_prefix}{release_series}.*" + if release_series + else None, + ) + except PontosError as e: + last_release_version = None + self.print_warning(f"Could not determine last release version. {e}") + + if not last_release_version and not release_version: + self.print_error("Unable to determine last release version.") + return ShowReleaseReturnValue.NO_LAST_RELEASE_VERSION + + calculator = versioning_scheme.calculator() + + try: + release_version = get_next_release_version( + last_release_version=last_release_version, + calculator=calculator, + release_type=release_type, + release_version=release_version, + ) + except VersionError as e: + self.print_error(f"Unable to determine release version. {e}") + return ShowReleaseReturnValue.NO_RELEASE_VERSION + + if last_release_version: + last_release_version_dict = { + "last_release_version": str(last_release_version), + "last_release_version_major": last_release_version.major, + "last_release_version_minor": last_release_version.minor, + "last_release_version_patch": last_release_version.patch, + } + else: + last_release_version_dict = { + "last_release_version": "", + "last_release_version_major": "", + "last_release_version_minor": "", + "last_release_version_patch": "", + } + + if output_format == OutputFormat.JSON: + release_dict = { + "release_version": str(release_version), + "release_version_major": release_version.major, + "release_version_minor": release_version.minor, + "release_version_patch": release_version.patch, + } + release_dict.update(last_release_version_dict) + self.terminal.print(json.dumps(release_dict, indent=2)) + elif output_format == OutputFormat.GITHUB_ACTION: + with ActionIO.out() as output: + output.write( + "last_release_version", + last_release_version_dict["last_release_version"], + ) + output.write( + "last_release_version_major", + last_release_version_dict["last_release_version_major"], + ) + output.write( + "last_release_version_minor", + last_release_version_dict["last_release_version_minor"], + ) + output.write( + "last_release_version_patch", + last_release_version_dict["last_release_version_patch"], + ) + output.write("release_version_major", release_version.major) + output.write("release_version_minor", release_version.minor) + output.write("release_version_patch", release_version.patch) + output.write("release_version", release_version) + else: + self.terminal.print( + "LAST_RELEASE_VERSION=" + f"{last_release_version_dict['last_release_version']}" + ) + self.terminal.print( + "LAST_RELEASE_VERSION_MAJOR=" + f"{last_release_version_dict['last_release_version_major']}" + ) + self.terminal.print( + "LAST_RELEASE_VERSION_MINOR=" + f"{last_release_version_dict['last_release_version_minor']}" + ) + self.terminal.print( + "LAST_RELEASE_VERSION_PATCH=" + f"{last_release_version_dict['last_release_version_patch']}" + ) + self.terminal.print(f"RELEASE_VERSION={release_version}") + self.terminal.print( + f"RELEASE_VERSION_MAJOR={release_version.major}" + ) + self.terminal.print( + f"RELEASE_VERSION_MINOR={release_version.minor}" + ) + self.terminal.print( + f"RELEASE_VERSION_PATCH={release_version.patch}" + ) + + return ShowReleaseReturnValue.SUCCESS + + +def show( + args: Namespace, + terminal: Terminal, + error_terminal: Terminal, + **_kwargs, +) -> SupportsStr: + return ShowReleaseCommand( + terminal=terminal, + error_terminal=error_terminal, + ).run( + versioning_scheme=args.versioning_scheme, + release_type=args.release_type, + release_version=args.release_version, + git_tag_prefix=args.git_tag_prefix, + release_series=args.release_series, + output_format=args.output_format, + ) diff --git a/tests/release/test_parser.py b/tests/release/test_parser.py index b65807f2..1bf5a2ed 100644 --- a/tests/release/test_parser.py +++ b/tests/release/test_parser.py @@ -24,8 +24,9 @@ from pontos.release.create import create_release from pontos.release.helper import ReleaseType from pontos.release.parser import DEFAULT_SIGNING_KEY, parse_args +from pontos.release.show import OutputFormat, show from pontos.release.sign import sign -from pontos.version.schemes._pep440 import PEP440Version +from pontos.version.schemes._pep440 import PEP440Version, PEP440VersioningScheme class ParseArgsTestCase(unittest.TestCase): @@ -290,3 +291,130 @@ def test_release_series(self): _, _, args = parse_args(["sign", "--release-series", "22.4"]) self.assertEqual(args.release_series, "22.4") + + +class ShowParseArgsTestCase(unittest.TestCase): + def test_show_func(self): + _, _, args = parse_args(["show", "--release-type", "patch"]) + + self.assertEqual(args.func, show) + + def test_defaults(self): + _, _, args = parse_args(["show", "--release-type", "patch"]) + + self.assertEqual(args.git_tag_prefix, "v") + self.assertEqual(args.versioning_scheme, PEP440VersioningScheme) + + def test_release_series(self): + _, _, args = parse_args( + ["show", "--release-type", "patch", "--release-series", "1.2"] + ) + + self.assertEqual(args.release_series, "1.2") + + def test_release_type(self): + _, _, args = parse_args(["show", "--release-type", "patch"]) + + self.assertEqual(args.release_type, ReleaseType.PATCH) + + _, _, args = parse_args(["show", "--release-type", "calendar"]) + + self.assertEqual(args.release_type, ReleaseType.CALENDAR) + + _, _, args = parse_args(["show", "--release-type", "minor"]) + + self.assertEqual(args.release_type, ReleaseType.MINOR) + + _, _, args = parse_args(["show", "--release-type", "major"]) + + self.assertEqual(args.release_type, ReleaseType.MAJOR) + + _, _, args = parse_args(["show", "--release-type", "alpha"]) + + self.assertEqual(args.release_type, ReleaseType.ALPHA) + + _, _, args = parse_args(["show", "--release-type", "beta"]) + + self.assertEqual(args.release_type, ReleaseType.BETA) + + _, _, args = parse_args(["show", "--release-type", "release-candidate"]) + + self.assertEqual(args.release_type, ReleaseType.RELEASE_CANDIDATE) + + with self.assertRaises(SystemExit), redirect_stderr(StringIO()): + parse_args(["show", "--release-type", "foo"]) + + def test_git_tag_prefix(self): + _, _, args = parse_args( + ["show", "--git-tag-prefix", "a", "--release-type", "patch"] + ) + + self.assertEqual(args.git_tag_prefix, "a") + + _, _, args = parse_args( + ["show", "--git-tag-prefix", "", "--release-type", "patch"] + ) + + self.assertEqual(args.git_tag_prefix, "") + + _, _, args = parse_args( + ["show", "--git-tag-prefix", "--release-type", "patch"] + ) + + self.assertEqual(args.git_tag_prefix, "") + + def test_release_version(self): + _, _, args = parse_args(["show", "--release-version", "1.2.3"]) + + self.assertEqual(args.release_version, PEP440Version("1.2.3")) + self.assertEqual(args.release_type, ReleaseType.VERSION) + + with self.assertRaises(SystemExit), redirect_stderr(StringIO()): + parse_args( + [ + "show", + "--release-version", + "1.2.3", + "--release-type", + "patch", + ] + ) + + with self.assertRaises(SystemExit), redirect_stderr(StringIO()): + parse_args( + [ + "show", + "--release-version", + "1.2.3", + "--release-type", + "calendar", + ] + ) + + def test_output_format(self): + _, _, args = parse_args( + ["show", "--release-type", "patch", "--output-format", "env"] + ) + + self.assertEqual(args.output_format, OutputFormat.ENV) + + _, _, args = parse_args( + ["show", "--release-type", "patch", "--output-format", "json"] + ) + + self.assertEqual(args.output_format, OutputFormat.JSON) + + _, _, args = parse_args( + [ + "show", + "--release-type", + "patch", + "--output-format", + "github-action", + ] + ) + + with patch.dict( + "os.environ", {"GITHUB_OUTPUT": "/tmp/output"}, clear=True + ): + self.assertEqual(args.output_format, OutputFormat.GITHUB_ACTION) diff --git a/tests/release/test_show.py b/tests/release/test_show.py new file mode 100644 index 00000000..0e81c847 --- /dev/null +++ b/tests/release/test_show.py @@ -0,0 +1,360 @@ +# SPDX-FileCopyrightText: 2023 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +import unittest +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +from pontos.git import Git +from pontos.release.helper import ReleaseType +from pontos.release.show import ( + OutputFormat, + ShowReleaseCommand, + ShowReleaseReturnValue, +) +from pontos.testing import temp_file, temp_git_repository +from pontos.version.schemes import PEP440VersioningScheme + + +def setup_git_repo(temp_git: Path) -> None: + some_file = temp_git / "some-file.txt" + some_file.touch() + + git = Git() + git.add(some_file) + git.commit("Add some file", gpg_sign=False, verify=False) + git.tag("v1.0.0", sign=False) + + +class ShowTestCase(unittest.TestCase): + def test_env_output(self): + terminal = MagicMock() + with temp_git_repository() as temp_git: + setup_git_repo(temp_git) + + show_cmd = ShowReleaseCommand( + terminal=terminal, error_terminal=MagicMock() + ) + + return_val = show_cmd.run( + versioning_scheme=PEP440VersioningScheme, + release_type=ReleaseType.PATCH, + release_version=None, + git_tag_prefix="v", + output_format=OutputFormat.ENV, + ) + + self.assertEqual(return_val, ShowReleaseReturnValue.SUCCESS) + + terminal.print.assert_has_calls( + [ + call("LAST_RELEASE_VERSION=1.0.0"), + call("LAST_RELEASE_VERSION_MAJOR=1"), + call("LAST_RELEASE_VERSION_MINOR=0"), + call("LAST_RELEASE_VERSION_PATCH=0"), + call("RELEASE_VERSION=1.0.1"), + call("RELEASE_VERSION_MAJOR=1"), + call("RELEASE_VERSION_MINOR=0"), + call("RELEASE_VERSION_PATCH=1"), + ] + ) + + return_val = show_cmd.run( + versioning_scheme=PEP440VersioningScheme, + release_type=ReleaseType.MINOR, + release_version=None, + git_tag_prefix="v", + output_format=OutputFormat.ENV, + ) + + self.assertEqual(return_val, ShowReleaseReturnValue.SUCCESS) + + terminal.print.assert_has_calls( + [ + call("LAST_RELEASE_VERSION=1.0.0"), + call("LAST_RELEASE_VERSION_MAJOR=1"), + call("LAST_RELEASE_VERSION_MINOR=0"), + call("LAST_RELEASE_VERSION_PATCH=0"), + call("RELEASE_VERSION=1.1.0"), + call("RELEASE_VERSION_MAJOR=1"), + call("RELEASE_VERSION_MINOR=1"), + call("RELEASE_VERSION_PATCH=0"), + ] + ) + + return_val = show_cmd.run( + versioning_scheme=PEP440VersioningScheme, + release_type=ReleaseType.MAJOR, + release_version=None, + git_tag_prefix="v", + output_format=OutputFormat.ENV, + ) + + self.assertEqual(return_val, ShowReleaseReturnValue.SUCCESS) + + terminal.print.assert_has_calls( + [ + call("LAST_RELEASE_VERSION=1.0.0"), + call("LAST_RELEASE_VERSION_MAJOR=1"), + call("LAST_RELEASE_VERSION_MINOR=0"), + call("LAST_RELEASE_VERSION_PATCH=0"), + call("RELEASE_VERSION=2.0.0"), + call("RELEASE_VERSION_MAJOR=2"), + call("RELEASE_VERSION_MINOR=0"), + call("RELEASE_VERSION_PATCH=0"), + ] + ) + + def test_json_output(self): + terminal = MagicMock() + with temp_git_repository() as temp_git: + setup_git_repo(temp_git) + + show_cmd = ShowReleaseCommand( + terminal=terminal, error_terminal=MagicMock() + ) + + return_val = show_cmd.run( + versioning_scheme=PEP440VersioningScheme, + release_type=ReleaseType.PATCH, + release_version=None, + git_tag_prefix="v", + output_format=OutputFormat.JSON, + ) + + expected = """{ + "release_version": "1.0.1", + "release_version_major": 1, + "release_version_minor": 0, + "release_version_patch": 1, + "last_release_version": "1.0.0", + "last_release_version_major": 1, + "last_release_version_minor": 0, + "last_release_version_patch": 0 +}""" + + self.assertEqual(return_val, ShowReleaseReturnValue.SUCCESS) + + terminal.print.assert_called_once_with(expected) + terminal.reset_mock() + + return_val = show_cmd.run( + versioning_scheme=PEP440VersioningScheme, + release_type=ReleaseType.MINOR, + release_version=None, + git_tag_prefix="v", + output_format=OutputFormat.JSON, + ) + + expected = """{ + "release_version": "1.1.0", + "release_version_major": 1, + "release_version_minor": 1, + "release_version_patch": 0, + "last_release_version": "1.0.0", + "last_release_version_major": 1, + "last_release_version_minor": 0, + "last_release_version_patch": 0 +}""" + + self.assertEqual(return_val, ShowReleaseReturnValue.SUCCESS) + + terminal.print.assert_called_once_with(expected) + terminal.reset_mock() + + return_val = show_cmd.run( + versioning_scheme=PEP440VersioningScheme, + release_type=ReleaseType.MAJOR, + release_version=None, + git_tag_prefix="v", + output_format=OutputFormat.JSON, + ) + + expected = """{ + "release_version": "2.0.0", + "release_version_major": 2, + "release_version_minor": 0, + "release_version_patch": 0, + "last_release_version": "1.0.0", + "last_release_version_major": 1, + "last_release_version_minor": 0, + "last_release_version_patch": 0 +}""" + + self.assertEqual(return_val, ShowReleaseReturnValue.SUCCESS) + + terminal.print.assert_called_once_with(expected) + + def test_github_action_output(self): + terminal = MagicMock() + with temp_git_repository() as temp_git: + setup_git_repo(temp_git) + + show_cmd = ShowReleaseCommand( + terminal=terminal, error_terminal=MagicMock() + ) + + output_file = temp_git.absolute() / "output" + + with patch.dict( + "os.environ", {"GITHUB_OUTPUT": f"{output_file}"}, clear=True + ): + return_val = show_cmd.run( + versioning_scheme=PEP440VersioningScheme, + release_type=ReleaseType.PATCH, + release_version=None, + git_tag_prefix="v", + output_format=OutputFormat.GITHUB_ACTION, + ) + + self.assertEqual(return_val, ShowReleaseReturnValue.SUCCESS) + + expected = """last_release_version=1.0.0 +last_release_version_major=1 +last_release_version_minor=0 +last_release_version_patch=0 +release_version_major=1 +release_version_minor=0 +release_version_patch=1 +release_version=1.0.1 +""" + actual = output_file.read_text(encoding="utf8") + + self.assertEqual(expected, actual) + + output_file.unlink() + + return_val = show_cmd.run( + versioning_scheme=PEP440VersioningScheme, + release_type=ReleaseType.MINOR, + release_version=None, + git_tag_prefix="v", + output_format=OutputFormat.GITHUB_ACTION, + ) + + self.assertEqual(return_val, ShowReleaseReturnValue.SUCCESS) + + expected = """last_release_version=1.0.0 +last_release_version_major=1 +last_release_version_minor=0 +last_release_version_patch=0 +release_version_major=1 +release_version_minor=1 +release_version_patch=0 +release_version=1.1.0 +""" + actual = output_file.read_text(encoding="utf8") + + self.assertEqual(expected, actual) + + output_file.unlink() + + return_val = show_cmd.run( + versioning_scheme=PEP440VersioningScheme, + release_type=ReleaseType.MAJOR, + release_version=None, + git_tag_prefix="v", + output_format=OutputFormat.GITHUB_ACTION, + ) + + self.assertEqual(return_val, ShowReleaseReturnValue.SUCCESS) + + expected = """last_release_version=1.0.0 +last_release_version_major=1 +last_release_version_minor=0 +last_release_version_patch=0 +release_version_major=2 +release_version_minor=0 +release_version_patch=0 +release_version=2.0.0 +""" + actual = output_file.read_text(encoding="utf8") + + self.assertEqual(expected, actual) + + output_file.unlink() + + def test_initial_release(self): + terminal = MagicMock() + with temp_git_repository(): + show_cmd = ShowReleaseCommand( + terminal=terminal, error_terminal=MagicMock() + ) + + return_val = show_cmd.run( + versioning_scheme=PEP440VersioningScheme, + release_type=ReleaseType.VERSION, + release_version=PEP440VersioningScheme.parse_version("1.0.0"), + git_tag_prefix="v", + output_format=OutputFormat.ENV, + ) + + self.assertEqual(return_val, ShowReleaseReturnValue.SUCCESS) + + terminal.print.assert_has_calls( + [ + call("LAST_RELEASE_VERSION="), + call("LAST_RELEASE_VERSION_MAJOR="), + call("LAST_RELEASE_VERSION_MINOR="), + call("LAST_RELEASE_VERSION_PATCH="), + call("RELEASE_VERSION=1.0.0"), + call("RELEASE_VERSION_MAJOR=1"), + call("RELEASE_VERSION_MINOR=0"), + call("RELEASE_VERSION_PATCH=0"), + ] + ) + + terminal.reset_mock() + + return_val = show_cmd.run( + versioning_scheme=PEP440VersioningScheme, + release_type=ReleaseType.VERSION, + release_version=PEP440VersioningScheme.parse_version("1.0.0"), + git_tag_prefix="v", + output_format=OutputFormat.JSON, + ) + + expected = """{ + "release_version": "1.0.0", + "release_version_major": 1, + "release_version_minor": 0, + "release_version_patch": 0, + "last_release_version": "", + "last_release_version_major": "", + "last_release_version_minor": "", + "last_release_version_patch": "" +}""" + + self.assertEqual(return_val, ShowReleaseReturnValue.SUCCESS) + + terminal.print.assert_called_once_with(expected) + + with temp_file(name="output") as output_file, patch.dict( + "os.environ", {"GITHUB_OUTPUT": f"{output_file}"}, clear=True + ): + return_val = show_cmd.run( + versioning_scheme=PEP440VersioningScheme, + release_type=ReleaseType.VERSION, + release_version=PEP440VersioningScheme.parse_version( + "1.0.0" + ), + git_tag_prefix="v", + output_format=OutputFormat.GITHUB_ACTION, + ) + + self.assertEqual(return_val, ShowReleaseReturnValue.SUCCESS) + + expected = """last_release_version= +last_release_version_major= +last_release_version_minor= +last_release_version_patch= +release_version_major=1 +release_version_minor=0 +release_version_patch=0 +release_version=1.0.0 +""" + actual = output_file.read_text(encoding="utf8") + + self.assertEqual(expected, actual)