diff --git a/docs/commands/deploy.md b/docs/commands/deploy.md index 3f3241de..25675948 100644 --- a/docs/commands/deploy.md +++ b/docs/commands/deploy.md @@ -165,6 +165,8 @@ optional arguments: --merge-method MERGE_METHOD Merge Method (e.g., 'squash', 'rebase', 'merge') (default: merge) + --json + Print a JSON object containing deployment information -v [VERBOSE], --verbose [VERBOSE] Verbose exception logging ``` diff --git a/gitopscli/cliparser.py b/gitopscli/cliparser.py index 9dd8faa8..b581bccb 100644 --- a/gitopscli/cliparser.py +++ b/gitopscli/cliparser.py @@ -105,6 +105,13 @@ def __create_deploy_parser() -> ArgumentParser: type=str, default="merge", ) + parser.add_argument( + "--json", + help="Print a JSON object containing deployment information", + nargs="?", + type=__parse_bool, + default=False, + ) __add_verbose_arg(parser) return parser diff --git a/gitopscli/commands/deploy.py b/gitopscli/commands/deploy.py index 9aca11a8..adc4abef 100644 --- a/gitopscli/commands/deploy.py +++ b/gitopscli/commands/deploy.py @@ -1,7 +1,8 @@ +import json import logging import uuid from dataclasses import dataclass -from typing import Any, Dict, Optional, Tuple, Literal +from typing import Any, Dict, Optional, Tuple, Literal, List from gitopscli.git_api import GitApiConfig, GitRepo, GitRepoApi, GitRepoApiFactory from gitopscli.io_api.yaml_util import update_yaml_file, yaml_dump, YAMLException from gitopscli.gitops_exception import GitOpsException @@ -25,10 +26,13 @@ class Args(GitApiConfig): create_pr: bool auto_merge: bool + json: bool + merge_method: Literal["squash", "rebase", "merge"] = "merge" def __init__(self, args: Args) -> None: self.__args = args + self.__commit_hashes: List[str] = [] def execute(self) -> None: git_repo_api = self.__create_git_repo_api() @@ -54,6 +58,9 @@ def execute(self) -> None: git_repo_api.merge_pull_request(pr_id, self.__args.merge_method) git_repo_api.delete_branch(pr_branch) + if self.__args.json: + print(json.dumps({"commits": [{"hash": h} for h in self.__commit_hashes]}, indent=4)) + def __create_git_repo_api(self) -> GitRepoApi: return GitRepoApiFactory.create(self.__args, self.__args.organisation, self.__args.repository_name) @@ -106,4 +113,6 @@ def __create_pull_request_title_and_description(self, updated_values: Dict[str, return title, description def __commit(self, git_repo: GitRepo, message: str) -> None: - git_repo.commit(self.__args.git_user, self.__args.git_email, message) + commit_hash = git_repo.commit(self.__args.git_user, self.__args.git_email, message) + if commit_hash: + self.__commit_hashes.append(commit_hash) diff --git a/gitopscli/git_api/git_repo.py b/gitopscli/git_api/git_repo.py index 4c9a5734..ac89a433 100644 --- a/gitopscli/git_api/git_repo.py +++ b/gitopscli/git_api/git_repo.py @@ -67,7 +67,7 @@ def new_branch(self, branch: str) -> None: except GitError as ex: raise GitOpsException(f"Error creating new branch '{branch}'.") from ex - def commit(self, git_user: str, git_email: str, message: str) -> None: + def commit(self, git_user: str, git_email: str, message: str) -> Optional[str]: repo = self.__get_repo() try: repo.git.add("--all") @@ -76,8 +76,10 @@ def commit(self, git_user: str, git_email: str, message: str) -> None: repo.config_writer().set_value("user", "name", git_user).release() repo.config_writer().set_value("user", "email", git_email).release() repo.git.commit("-m", message, "--author", f"{git_user} <{git_email}>") + return str(repo.head.commit.hexsha) except GitError as ex: raise GitOpsException(f"Error creating commit.") from ex + return None def push(self, branch: Optional[str] = None) -> None: repo = self.__get_repo() diff --git a/tests/commands/test_deploy.py b/tests/commands/test_deploy.py index 99408dff..34630b75 100644 --- a/tests/commands/test_deploy.py +++ b/tests/commands/test_deploy.py @@ -1,8 +1,11 @@ +from io import StringIO import logging +from textwrap import dedent import uuid import unittest -from uuid import UUID +from unittest import mock from unittest.mock import call +from uuid import UUID import pytest from gitopscli.gitops_exception import GitOpsException from gitopscli.commands.deploy import DeployCommand @@ -40,13 +43,15 @@ def setUp(self): self.git_repo_mock.__exit__.return_value = False self.git_repo_mock.clone.return_value = None self.git_repo_mock.new_branch.return_value = None - self.git_repo_mock.commit.return_value = None + self.example_commit_hash = "5f3a443e7ecb3723c1a71b9744e2993c0b6dfc00" + self.git_repo_mock.commit.return_value = self.example_commit_hash self.git_repo_mock.push.return_value = None self.git_repo_mock.get_full_file_path.side_effect = lambda x: f"/tmp/created-tmp-dir/{x}" self.seal_mocks() - def test_happy_flow(self): + @mock.patch("sys.stdout", new_callable=StringIO) + def test_happy_flow(self, mock_print): args = DeployCommand.Args( file="test/file.yml", values={"a.b.c": "foo", "a.b.d": "bar"}, @@ -62,6 +67,7 @@ def test_happy_flow(self): git_provider=GitProvider.GITHUB, git_provider_url=None, commit_message=None, + json=False, ) DeployCommand(args).execute() @@ -79,7 +85,11 @@ def test_happy_flow(self): call.GitRepo.push(), ] - def test_create_pr_single_value_change_happy_flow(self): + no_output = "" + self.assertMultiLineEqual(mock_print.getvalue(), no_output) + + @mock.patch("sys.stdout", new_callable=StringIO) + def test_create_pr_single_value_change_happy_flow_with_output(self, mock_print): args = DeployCommand.Args( file="test/file.yml", values={"a.b.c": "foo"}, @@ -95,6 +105,7 @@ def test_create_pr_single_value_change_happy_flow(self): git_provider=GitProvider.GITHUB, git_provider_url=None, commit_message=None, + json=True, ) DeployCommand(args).execute() @@ -116,7 +127,19 @@ def test_create_pr_single_value_change_happy_flow(self): ), ] - def test_create_pr_multiple_value_changes_happy_flow(self): + expected_output = f"""\ + {{ + "commits": [ + {{ + "hash": "{self.example_commit_hash}" + }} + ] + }} + """ + self.assertMultiLineEqual(mock_print.getvalue(), dedent(expected_output)) + + @mock.patch("sys.stdout", new_callable=StringIO) + def test_create_pr_multiple_value_changes_happy_flow_with_output(self, mock_print): args = DeployCommand.Args( file="test/file.yml", values={"a.b.c": "foo", "a.b.d": "bar"}, @@ -132,6 +155,7 @@ def test_create_pr_multiple_value_changes_happy_flow(self): git_provider=GitProvider.GITHUB, git_provider_url=None, commit_message=None, + json=True, ) DeployCommand(args).execute() @@ -156,7 +180,22 @@ def test_create_pr_multiple_value_changes_happy_flow(self): ), ] - def test_create_pr_and_merge_happy_flow(self): + expected_output = f"""\ + {{ + "commits": [ + {{ + "hash": "{self.example_commit_hash}" + }}, + {{ + "hash": "{self.example_commit_hash}" + }} + ] + }} + """ + self.assertMultiLineEqual(mock_print.getvalue(), dedent(expected_output)) + + @mock.patch("sys.stdout", new_callable=StringIO) + def test_create_pr_and_merge_happy_flow(self, mock_print): args = DeployCommand.Args( file="test/file.yml", values={"a.b.c": "foo", "a.b.d": "bar"}, @@ -172,6 +211,7 @@ def test_create_pr_and_merge_happy_flow(self): git_provider=GitProvider.GITHUB, git_provider_url=None, commit_message=None, + json=False, ) DeployCommand(args).execute() @@ -198,7 +238,11 @@ def test_create_pr_and_merge_happy_flow(self): call.GitRepoApi.delete_branch("gitopscli-deploy-b973b5bb"), ] - def test_single_commit_happy_flow(self): + no_output = "" + self.assertMultiLineEqual(mock_print.getvalue(), no_output) + + @mock.patch("sys.stdout", new_callable=StringIO) + def test_single_commit_happy_flow(self, mock_print): args = DeployCommand.Args( file="test/file.yml", values={"a.b.c": "foo", "a.b.d": "bar"}, @@ -214,6 +258,7 @@ def test_single_commit_happy_flow(self): git_provider=GitProvider.GITHUB, git_provider_url=None, commit_message=None, + json=False, ) DeployCommand(args).execute() @@ -226,11 +271,19 @@ def test_single_commit_happy_flow(self): call.logging.info("Updated yaml property %s to %s", "a.b.c", "foo"), call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.d", "bar"), call.logging.info("Updated yaml property %s to %s", "a.b.d", "bar"), - call.GitRepo.commit("GIT_USER", "GIT_EMAIL", "updated 2 values in test/file.yml\n\na.b.c: foo\na.b.d: bar"), + call.GitRepo.commit( + "GIT_USER", + "GIT_EMAIL", + "updated 2 values in test/file.yml\n\na.b.c: foo\na.b.d: bar", + ), call.GitRepo.push(), ] - def test_single_commit_single_value_change_happy_flow(self): + no_output = "" + self.assertMultiLineEqual(mock_print.getvalue(), no_output) + + @mock.patch("sys.stdout", new_callable=StringIO) + def test_single_commit_single_value_change_happy_flow(self, mock_print): args = DeployCommand.Args( file="test/file.yml", values={"a.b.c": "foo"}, @@ -246,6 +299,7 @@ def test_single_commit_single_value_change_happy_flow(self): git_provider=GitProvider.GITHUB, git_provider_url=None, commit_message=None, + json=False, ) DeployCommand(args).execute() @@ -260,7 +314,11 @@ def test_single_commit_single_value_change_happy_flow(self): call.GitRepo.push(), ] - def test_commit_message_multiple_value_changes_happy_flow(self): + no_output = "" + self.assertMultiLineEqual(mock_print.getvalue(), no_output) + + @mock.patch("sys.stdout", new_callable=StringIO) + def test_commit_message_multiple_value_changes_happy_flow(self, mock_print): args = DeployCommand.Args( file="test/file.yml", values={"a.b.c": "foo", "a.b.d": "bar"}, @@ -276,6 +334,7 @@ def test_commit_message_multiple_value_changes_happy_flow(self): git_provider=GitProvider.GITHUB, git_provider_url=None, commit_message="testcommit", + json=False, ) DeployCommand(args).execute() @@ -292,6 +351,9 @@ def test_commit_message_multiple_value_changes_happy_flow(self): call.GitRepo.push(), ] + no_output = "" + self.assertEqual(mock_print.getvalue(), no_output) + def test_clone_error(self): clone_exception = GitOpsException("dummy clone error") self.git_repo_mock.clone.side_effect = clone_exception @@ -311,6 +373,7 @@ def test_clone_error(self): git_provider=GitProvider.GITHUB, git_provider_url=None, commit_message=None, + json=False, ) with pytest.raises(GitOpsException) as ex: DeployCommand(args).execute() @@ -340,6 +403,7 @@ def test_file_not_found(self): git_provider=GitProvider.GITHUB, git_provider_url=None, commit_message=None, + json=False, ) with pytest.raises(GitOpsException) as ex: DeployCommand(args).execute() @@ -371,6 +435,7 @@ def test_file_parse_error(self): git_provider=GitProvider.GITHUB, git_provider_url=None, commit_message=None, + json=False, ) with pytest.raises(GitOpsException) as ex: DeployCommand(args).execute() @@ -402,6 +467,7 @@ def test_key_not_found(self): git_provider=GitProvider.GITHUB, git_provider_url=None, commit_message=None, + json=False, ) with pytest.raises(GitOpsException) as ex: DeployCommand(args).execute() @@ -433,6 +499,7 @@ def test_nothing_to_update(self): git_provider=GitProvider.GITHUB, git_provider_url=None, commit_message=None, + json=False, ) DeployCommand(args).execute() diff --git a/tests/git_api/test_git_repo.py b/tests/git_api/test_git_repo.py index 82eb1b41..707aa18d 100644 --- a/tests/git_api/test_git_repo.py +++ b/tests/git_api/test_git_repo.py @@ -106,7 +106,9 @@ def test_clone_branch(self, logging_mock): self.assertFalse(path.exists(tmp_dir)) logging_mock.info.assert_called_once_with( - "Cloning repository: %s (branch: %s)", self.__mock_repo_api.get_clone_url(), "xyz" + "Cloning repository: %s (branch: %s)", + self.__mock_repo_api.get_clone_url(), + "xyz", ) @patch("gitopscli.git_api.git_repo.logging") @@ -115,11 +117,14 @@ def test_clone_unknown_branch(self, logging_mock): with pytest.raises(GitOpsException) as ex: testee.clone("unknown") self.assertEqual( - f"Error cloning branch 'unknown' of '{self.__mock_repo_api.get_clone_url()}'", str(ex.value) + f"Error cloning branch 'unknown' of '{self.__mock_repo_api.get_clone_url()}'", + str(ex.value), ) logging_mock.info.assert_called_once_with( - "Cloning repository: %s (branch: %s)", self.__mock_repo_api.get_clone_url(), "unknown" + "Cloning repository: %s (branch: %s)", + self.__mock_repo_api.get_clone_url(), + "unknown", ) @patch("gitopscli.git_api.git_repo.logging") @@ -163,7 +168,10 @@ def test_clone_unknown_url(self, logging_mock): def test_get_full_file_path(self): with GitRepo(self.__mock_repo_api) as testee: testee.clone() - self.assertRegex(testee.get_full_file_path("foo.bar"), r"^/tmp/gitopscli/[0-9a-f\-]+/repo/foo\.bar$") + self.assertRegex( + testee.get_full_file_path("foo.bar"), + r"^/tmp/gitopscli/[0-9a-f\-]+/repo/foo\.bar$", + ) @patch("gitopscli.git_api.git_repo.logging") def test_new_branch(self, logging_mock): @@ -199,10 +207,13 @@ def test_commit(self, logging_mock): outfile.write("new file") with open(testee.get_full_file_path("README.md"), "w") as outfile: outfile.write("new content") - testee.commit(git_user="john doe", git_email="john@doe.com", message="new commit") + commit_hash = testee.commit(git_user="john doe", git_email="john@doe.com", message="new commit") repo = Repo(testee.get_full_file_path(".")) commits = list(repo.iter_commits("master")) + + self.assertIsNotNone(commit_hash) + self.assertRegex(commit_hash, "^[a-f0-9]{40}$", "Not a long commit hash") self.assertEqual(2, len(commits)) self.assertEqual("new commit\n", commits[0].message) self.assertEqual("john doe", commits[0].author.name) @@ -217,10 +228,11 @@ def test_commit_nothing_to_commit(self, logging_mock): testee.clone() logging_mock.reset_mock() - testee.commit(git_user="john doe", git_email="john@doe.com", message="empty commit") - + commit_hash = testee.commit(git_user="john doe", git_email="john@doe.com", message="empty commit") repo = Repo(testee.get_full_file_path(".")) commits = list(repo.iter_commits("master")) + + self.assertIsNone(commit_hash) self.assertEqual(1, len(commits)) self.assertEqual("initial commit\n", commits[0].message) logging_mock.assert_not_called() @@ -281,7 +293,10 @@ def test_push_commit_hook_error_reason_is_shown(self, logging_mock): repo_dir = self.__origin.working_dir with open(f"{repo_dir}/.git/hooks/pre-receive", "w") as pre_receive_hook: pre_receive_hook.write('echo >&2 "we reject this push"; exit 1') - chmod(f"{repo_dir}/.git/hooks/pre-receive", stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) + chmod( + f"{repo_dir}/.git/hooks/pre-receive", + stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR, + ) with GitRepo(self.__mock_repo_api) as testee: testee.clone() diff --git a/tests/test_cliparser.py b/tests/test_cliparser.py index 8732665c..9cee2c0d 100644 --- a/tests/test_cliparser.py +++ b/tests/test_cliparser.py @@ -284,7 +284,8 @@ [--git-provider GIT_PROVIDER] [--git-provider-url GIT_PROVIDER_URL] [--create-pr [CREATE_PR]] [--auto-merge [AUTO_MERGE]] - [--merge-method MERGE_METHOD] [-v [VERBOSE]] + [--merge-method MERGE_METHOD] [--json [JSON]] + [-v [VERBOSE]] gitopscli deploy: error: the following arguments are required: --file, --values, --username, --password, --organisation, --repository-name """ @@ -298,7 +299,8 @@ [--git-provider GIT_PROVIDER] [--git-provider-url GIT_PROVIDER_URL] [--create-pr [CREATE_PR]] [--auto-merge [AUTO_MERGE]] - [--merge-method MERGE_METHOD] [-v [VERBOSE]] + [--merge-method MERGE_METHOD] [--json [JSON]] + [-v [VERBOSE]] optional arguments: -h, --help show this help message and exit @@ -333,6 +335,7 @@ --merge-method MERGE_METHOD Merge Method (e.g., 'squash', 'rebase', 'merge') (default: merge) + --json [JSON] Print a JSON object containing deployment information -v [VERBOSE], --verbose [VERBOSE] Verbose exception logging """