diff --git a/pontos/release/create.py b/pontos/release/create.py index 0f5683f07..e0feeddaf 100644 --- a/pontos/release/create.py +++ b/pontos/release/create.py @@ -28,6 +28,7 @@ find_signing_key, get_git_repository_name, get_next_release_version, + repository_split, ) @@ -62,6 +63,7 @@ class CreateReleaseReturnValue(IntEnum): CREATE_RELEASE_ERROR = auto() UPDATE_VERSION_ERROR = auto() UPDATE_VERSION_AFTER_RELEASE_ERROR = auto() + INVALID_REPOSITORY = auto() class CreateReleaseCommand(AsyncCommand): @@ -87,7 +89,7 @@ def _create_changelog( cc_config: Optional[Path], ) -> str: changelog_builder = ChangelogBuilder( - space=self.space, + space=self.space, # type: ignore[arg-type] project=self.project, config=cc_config, git_tag_prefix=self.git_tag_prefix, @@ -126,7 +128,8 @@ async def async_run( # type: ignore[override] self, *, token: str, - space: str, + repository: Optional[str], + space: Optional[str], project_name: Optional[str], versioning_scheme: VersioningScheme, release_type: ReleaseType, @@ -146,6 +149,8 @@ async def async_run( # type: ignore[override] Args: token: A token for creating a release on GitHub + repository: GitHub repository (owner/name). Overrides space and + project. space: GitHub username or organization. Required for generating links in the changelog. project: Name of the project to release. If not set it will be @@ -182,12 +187,25 @@ async def async_run( # type: ignore[override] else find_signing_key(self.terminal) ) self.git_tag_prefix = git_tag_prefix or "" + + if repository: + if space: + self.print_warning( + f"Repository {repository} overrides space setting {space}" + ) + + try: + space, project_name = repository_split(repository) + except ValueError as e: + self.print_error(str(e)) + return CreateReleaseReturnValue.INVALID_REPOSITORY + + self.space = space self.project = ( project_name if project_name is not None else get_git_repository_name() ) - self.space = space self.terminal.info(f"Using versioning scheme {versioning_scheme.name}") @@ -390,6 +408,7 @@ def create_release( terminal=terminal, error_terminal=error_terminal ).run( token=token, + repository=args.repository, space=args.space, project_name=args.project, versioning_scheme=args.versioning_scheme, diff --git a/pontos/release/helper.py b/pontos/release/helper.py index b7833916c..724a5a11c 100644 --- a/pontos/release/helper.py +++ b/pontos/release/helper.py @@ -127,3 +127,15 @@ def get_next_release_version( return calculator.next_release_candidate_version(last_release_version) raise VersionError(f"Unsupported release type {release_type.value}.") + + +def repository_split(repository: str) -> tuple[str, str]: + """ + Split a GitHub repository (owner/name) into a space, project tuple + """ + splitted_repo = repository.split("/") + if len(splitted_repo) != 2: + raise ValueError( + f"Invalid repository {repository}. Format must be " "owner/name." + ) + return splitted_repo[0], splitted_repo[1] diff --git a/pontos/release/parser.py b/pontos/release/parser.py index f1c4cfbdf..e3cadbc5e 100644 --- a/pontos/release/parser.py +++ b/pontos/release/parser.py @@ -147,15 +147,27 @@ def parse_args(args) -> Tuple[Optional[str], Optional[str], Namespace]: help="The key to sign the commits and tag for a release", default=os.environ.get("GPG_SIGNING_KEY"), ) - create_parser.add_argument( - "--project", - help="The github project", + + repo_group = create_parser.add_argument_group( + "Repository", + description="Where to publish the new release. Either a full repository" + " name or a space/project combination.", ) - create_parser.add_argument( + repo_group.add_argument( + "--repository", + help="GitHub repository name (owner/name). For example " + "octocat/Hello-World", + ) + repo_group.add_argument( "--space", default="greenbone", - help="User/Team name in github", + help="Owner (User/Team/Organization) name at GitHub", + ) + repo_group.add_argument( + "--project", + help="The GitHub project", ) + create_parser.add_argument( "--local", action="store_true", @@ -218,16 +230,25 @@ def parse_args(args) -> Tuple[Optional[str], Optional[str], Namespace]: nargs="?", help="Prefix for git tag versions. Default: %(default)s", ) - sign_parser.add_argument( - "--project", - help="The github project", + repo_group = sign_parser.add_argument_group( + "Repository", + description="Where to publish the new release. Either a full repository" + " name or a space/project combination.", ) - sign_parser.add_argument( + repo_group.add_argument( + "--repository", + help="GitHub repository name (owner/name). For example " + "octocat/Hello-World", + ) + repo_group.add_argument( "--space", default="greenbone", - help="user/team name in github", + help="Owner (User/Team/Organization) name at GitHub", + ) + repo_group.add_argument( + "--project", + help="The GitHub project", ) - sign_parser.add_argument( "--passphrase", help=( diff --git a/pontos/release/sign.py b/pontos/release/sign.py index 0a542a661..9f5fdbe0f 100644 --- a/pontos/release/sign.py +++ b/pontos/release/sign.py @@ -8,7 +8,7 @@ import subprocess from argparse import Namespace from asyncio.subprocess import Process -from enum import IntEnum +from enum import IntEnum, auto from os import PathLike from pathlib import Path from typing import AsyncContextManager, Optional, SupportsInt, Union @@ -27,7 +27,7 @@ from pontos.version.helper import get_last_release_version from pontos.version.schemes import VersioningScheme -from .helper import get_git_repository_name +from .helper import get_git_repository_name, repository_split class SignReturnValue(IntEnum): @@ -36,12 +36,13 @@ class SignReturnValue(IntEnum): """ SUCCESS = 0 - TOKEN_MISSING = 1 - NO_PROJECT = 2 - NO_RELEASE_VERSION = 3 - NO_RELEASE = 4 - UPLOAD_ASSET_ERROR = 5 - SIGNATURE_GENERATION_FAILED = 6 + TOKEN_MISSING = auto() + NO_PROJECT = auto() + NO_RELEASE_VERSION = auto() + NO_RELEASE = auto() + UPLOAD_ASSET_ERROR = auto() + SIGNATURE_GENERATION_FAILED = auto() + INVALID_REPOSITORY = auto() class SignatureError(PontosError): @@ -175,12 +176,13 @@ async def async_run( # type: ignore[override] self, *, token: str, - space: str, + repository: Optional[str], + space: Optional[str], + project: Optional[str], versioning_scheme: VersioningScheme, signing_key: str, passphrase: str, dry_run: Optional[bool] = False, - project: Optional[str], git_tag_prefix: Optional[str], release_version: Optional[Version], release_series: Optional[str] = None, @@ -190,13 +192,15 @@ async def async_run( # type: ignore[override] Args: token: A token for creating a release on GitHub - dry_run: True to not upload the signature files + repository: GitHub repository (owner/name). Overrides space and + project. space: GitHub username or organization. Required for generating links in the changelog. project: Name of the project to release. If not set it will be gathered via the git remote url. versioning_scheme: The versioning scheme to use for version parsing and calculation + dry_run: True to not upload the signature files git_tag_prefix: An optional prefix to use for handling a git tag from the release version. release_version: Optional release version to use. If not set the @@ -217,6 +221,18 @@ async def async_run( # type: ignore[override] self.terminal.info(f"Using versioning scheme {versioning_scheme.name}") + if repository: + if space: + self.print_warning( + f"Repository {repository} overrides space setting {space}" + ) + + try: + space, project = repository_split(repository) + except ValueError as e: + self.print_error(str(e)) + return SignReturnValue.INVALID_REPOSITORY + try: project = ( project if project is not None else get_git_repository_name() @@ -373,12 +389,13 @@ def sign( *, terminal: Terminal, error_terminal: Terminal, - token: str, + token: Optional[str], **_kwargs, ) -> SupportsInt: return SignCommand(terminal=terminal, error_terminal=error_terminal).run( token=token, dry_run=args.dry_run, + repository=args.repository, project=args.project, space=args.space, versioning_scheme=args.versioning_scheme, diff --git a/tests/release/test_create.py b/tests/release/test_create.py index 15ee59cf8..4c77bb932 100644 --- a/tests/release/test_create.py +++ b/tests/release/test_create.py @@ -245,6 +245,112 @@ def test_release_version( self.assertEqual(released, CreateReleaseReturnValue.SUCCESS) + @patch("pontos.release.create.Git", autospec=True) + @patch("pontos.release.create.get_last_release_version", autospec=True) + @patch( + "pontos.release.create.CreateReleaseCommand._create_release", + autospec=True, + ) + @patch( + "pontos.release.create.CreateReleaseCommand._create_changelog", + autospec=True, + ) + @patch("pontos.release.create.Project._gather_commands", autospec=True) + def test_release_with_repository( + self, + gather_commands_mock: MagicMock, + create_changelog_mock: MagicMock, + create_release_mock: AsyncMock, + get_last_release_version_mock: MagicMock, + git_mock: MagicMock, + ): + current_version = PEP440Version("0.0.1") + release_version = PEP440Version("0.0.2") + next_version = PEP440Version("1.0.0.dev1") + command_mock = MagicMock(spec=GoVersionCommand) + gather_commands_mock.return_value = [command_mock] + create_changelog_mock.return_value = "A Changelog" + get_last_release_version_mock.return_value = current_version + command_mock.update_version.side_effect = [ + VersionUpdate( + previous=current_version, + new=release_version, + changed_files=[Path("MyProject.conf")], + ), + VersionUpdate( + previous=release_version, + new=next_version, + changed_files=[Path("MyProject.conf")], + ), + ] + git_instance_mock: MagicMock = git_mock.return_value + git_instance_mock.status.return_value = [ + StatusEntry("M MyProject.conf") + ] + + _, token, args = parse_args( + [ + "release", + "--repository", + "foo/bar", + "--release-version", + "0.0.2", + "--next-version", + "1.0.0.dev1", + ] + ) + + with temp_git_repository(): + released = create_release( + terminal=mock_terminal(), + error_terminal=mock_terminal(), + args=args, + token=token, # type: ignore[arg-type] + ) + + git_instance_mock.push.assert_has_calls( + [ + call(follow_tags=True, remote=None), + call(follow_tags=True, remote=None), + ], + ) + + command_mock.update_version.assert_has_calls( + [ + call(release_version, force=False), + call(next_version, force=False), + ] + ) + + self.assertEqual( + create_release_mock.await_args.args[1:], # type: ignore[union-attr] + (release_version, "foo", "A Changelog", False), + ) + + git_instance_mock.add.assert_has_calls( + [call(Path("MyProject.conf")), call(Path("MyProject.conf"))] + ) + git_instance_mock.commit.assert_has_calls( + [ + call( + "Automatic release to 0.0.2", + verify=False, + gpg_signing_key="1234", + ), + call( + "Automatic adjustments after release\n\n" + "* Update to version 1.0.0.dev1\n", + verify=False, + gpg_signing_key="1234", + ), + ] + ) + git_instance_mock.tag.assert_called_once_with( + "v0.0.2", gpg_key_id="1234", message="Automatic release to 0.0.2" + ) + + self.assertEqual(released, CreateReleaseReturnValue.SUCCESS) + @patch("pontos.release.create.Git", autospec=True) @patch("pontos.release.create.get_last_release_version", autospec=True) @patch( @@ -1157,6 +1263,47 @@ def test_no_token( self.assertEqual(released, CreateReleaseReturnValue.TOKEN_MISSING) + def test_invalid_repository( + self, + ): + _, _, args = parse_args( + [ + "release", + "--repository", + "foo/bar/baz", + "--release-version", + "0.0.1", + ] + ) + + released = create_release( + terminal=mock_terminal(), + error_terminal=mock_terminal(), + args=args, + token="token", + ) + + self.assertEqual(released, CreateReleaseReturnValue.INVALID_REPOSITORY) + + _, _, args = parse_args( + [ + "release", + "--repository", + "foo_bar_baz", + "--release-version", + "0.0.1", + ] + ) + + released = create_release( + terminal=mock_terminal(), + error_terminal=mock_terminal(), + args=args, + token="token", + ) + + self.assertEqual(released, CreateReleaseReturnValue.INVALID_REPOSITORY) + @patch("pontos.release.create.get_last_release_version", autospec=True) def test_no_project_settings( self, diff --git a/tests/release/test_helper.py b/tests/release/test_helper.py index 97d662f53..2459c3ea4 100644 --- a/tests/release/test_helper.py +++ b/tests/release/test_helper.py @@ -17,6 +17,7 @@ find_signing_key, get_git_repository_name, get_next_release_version, + repository_split, ) from pontos.testing import temp_git_repository from pontos.version import VersionError @@ -293,3 +294,24 @@ def test_release_version_with_invalid_release_type(self): release_type=ReleaseType.ALPHA, release_version=release_version, ) + + +class RepositorySplitTestCase(unittest.TestCase): + def test_invalid_repository(self): + with self.assertRaisesRegex( + ValueError, + r"Invalid repository foo/bar/baz. Format must be owner/name.", + ): + repository_split("foo/bar/baz") + + with self.assertRaisesRegex( + ValueError, + r"Invalid repository foo_bar_baz. Format must be owner/name.", + ): + repository_split("foo_bar_baz") + + def test_repository(self): + space, project = repository_split("foo/bar") + + self.assertEqual(space, "foo") + self.assertEqual(project, "bar") diff --git a/tests/release/test_sign.py b/tests/release/test_sign.py index f0c4e3b46..1b16ae97b 100644 --- a/tests/release/test_sign.py +++ b/tests/release/test_sign.py @@ -80,6 +80,25 @@ def test_no_release_error(self): self.assertEqual(result, SignReturnValue.NO_RELEASE_VERSION) + def test_invalid_repository(self): + with temp_directory(change_into=True): + _, token, args = parse_args( + [ + "sign", + "--repository", + "foo_bar", + ] + ) + + result = sign( + terminal=mock_terminal(), + error_terminal=mock_terminal(), + args=args, + token=token, + ) + + self.assertEqual(result, SignReturnValue.INVALID_REPOSITORY) + @patch("pontos.release.sign.get_last_release_version", autospec=True) def test_no_release_version(self, get_last_release_version_mock: MagicMock): get_last_release_version_mock.return_value = None @@ -234,6 +253,114 @@ def test_sign_success( ], ) + @patch("pontos.release.sign.cmd_runner", autospec=True) + @patch("pontos.release.sign.SignCommand.download_asset", autospec=True) + @patch("pontos.release.sign.SignCommand.download_tar", autospec=True) + @patch("pontos.release.sign.SignCommand.download_zip", autospec=True) + @patch("pontos.release.sign.GitHubAsyncRESTApi.releases", autospec=True) + def test_sign_success_with_repository( + self, + github_releases_mock: AsyncMock, + download_zip_mock: AsyncMock, + download_tar_mock: AsyncMock, + download_asset_mock: AsyncMock, + cmd_runner_mock: AsyncMock, + ): + tar_file = Path("file.tar") + zip_file = Path("file.zip") + some_asset = Path("file1") + other_asset = Path("file2") + download_tar_mock.return_value = tar_file + download_zip_mock.return_value = zip_file + download_asset_mock.side_effect = [some_asset, other_asset] + github_releases_mock.exists = AsyncMock(return_value=True) + github_releases_mock.download_release_assets.return_value = ( + AsyncIteratorMock( + [ + ( + "foo", + MagicMock(), + ), + ("bar", MagicMock()), + ] + ) + ) + process = AsyncMock(spec=Process, returncode=0) + process.communicate.return_value = ("", "") + cmd_runner_mock.return_value = process + + with temp_directory(change_into=True): + _, token, args = parse_args( + [ + "sign", + "--repository", + "foo/bar", + "--release-version", + "1.2.3", + ] + ) + + result = sign( + terminal=mock_terminal(), + error_terminal=mock_terminal(), + args=args, + token=token, + ) + + self.assertEqual(result, SignReturnValue.SUCCESS) + + cmd_runner_mock.assert_has_calls( + [ + call( + "gpg", + "--default-key", + "0ED1E580", + "--yes", + "--detach-sign", + "--armor", + zip_file, + ), + call( + "gpg", + "--default-key", + "0ED1E580", + "--yes", + "--detach-sign", + "--armor", + tar_file, + ), + call( + "gpg", + "--default-key", + "0ED1E580", + "--yes", + "--detach-sign", + "--armor", + some_asset, + ), + call( + "gpg", + "--default-key", + "0ED1E580", + "--yes", + "--detach-sign", + "--armor", + other_asset, + ), + ] + ) + + github_releases_mock.upload_release_assets.assert_called_once_with( + "foo/bar", + "v1.2.3", + [ + (Path("file.zip.asc"), "application/pgp-signature"), + (Path("file.tar.asc"), "application/pgp-signature"), + (Path("file1.asc"), "application/pgp-signature"), + (Path("file2.asc"), "application/pgp-signature"), + ], + ) + @patch("pontos.version.helper.Git", autospec=True) @patch("pontos.release.sign.cmd_runner", autospec=True) @patch("pontos.release.sign.SignCommand.download_asset", autospec=True)