Skip to content
Open
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
15 changes: 10 additions & 5 deletions docs/commands/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ backend:
value: foo # <- and this one in a list, selected via sibling value 'TEST'
```

With the following command GitOps CLI will update all values on the default branch.
With the following command GitOps CLI will update all values on the default branch. Use `--branch` to commit on an existing branch, or to create that branch if it does not exist yet.

```bash
gitopscli deploy \
Expand Down Expand Up @@ -99,6 +99,8 @@ This will end up in one single commit with your specified commit-message.

In some cases you might want to create a pull request for your updates. You can achieve this by adding `--create-pr` to the command. The pull request can be left open or merged directly with `--auto-merge`.

By default GitOps CLI creates a random branch for the pull request (e.g. `gitopscli-deploy-b973b5bb`). Use `--branch` to specify that branch name instead: an existing remote branch is checked out, otherwise a new branch is created. `--branch` also works without `--create-pr`.

```bash
gitopscli deploy \
--git-provider-url https://bitbucket.baloise.dev \
Expand All @@ -111,6 +113,7 @@ gitopscli deploy \
--file "example/values.yaml" \
--values "{frontend.tag: 1.1.0, backend.tag: 1.1.0, 'backend.env[?name==''TEST''].value': bar}" \
--create-pr \
--branch "deploy/myapp" \
--auto-merge
```

Expand All @@ -123,9 +126,9 @@ gitopscli deploy \
```
usage: gitopscli deploy [-h] --file FILE --values VALUES
[--single-commit [SINGLE_COMMIT]]
[--commit-message COMMIT_MESSAGE] --username USERNAME
--password PASSWORD [--git-user GIT_USER]
[--git-email GIT_EMAIL]
[--commit-message COMMIT_MESSAGE] [--branch BRANCH]
--username USERNAME --password PASSWORD
[--git-user GIT_USER] [--git-email GIT_EMAIL]
[--git-author-name GIT_AUTHOR_NAME]
[--git-author-email GIT_AUTHOR_EMAIL]
--organisation ORGANISATION --repository-name
Expand All @@ -145,6 +148,8 @@ options:
Create only single commit for all updates
--commit-message COMMIT_MESSAGE
Specify exact commit message of deployment commit
--branch BRANCH Specify the branch where the changes should be
committed to. Creates a new branch if it doesn't exist yet.
--username USERNAME Git username (alternative: GITOPSCLI_USERNAME env
variable)
--password PASSWORD Git password or token (alternative: GITOPSCLI_PASSWORD
Expand All @@ -165,7 +170,7 @@ options:
--git-provider-url GIT_PROVIDER_URL
Git provider base API URL (e.g. https://bitbucket.example.tld)
--create-pr [CREATE_PR]
Creates a Pull Request
Creates a Pull Request from a random new branch. Use --branch to use a specific branch name instead.
--auto-merge [AUTO_MERGE]
Automatically merge the created PR (only valid with --create-pr)
--merge-method MERGE_METHOD
Expand Down
9 changes: 9 additions & 0 deletions gitopscli/cliparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ def __create_deploy_parser() -> ArgumentParser:
type=str,
default=None,
)
parser.add_argument(
"--branch",
help=(
"Specify the branch where the changes should be committed to. "
"If omitted with --create-pr, a random branch is created."
),
type=str,
default=None,
)
__add_git_credentials_args(parser)
__add_git_commit_user_args(parser)
__add_git_org_and_repo_args(parser)
Expand Down
11 changes: 7 additions & 4 deletions gitopscli/commands/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class Args(GitApiConfig):
pr_labels: list[str] | None
merge_parameters: Any | None
merge_method: Literal["squash", "rebase", "merge"] = "merge"
branch: str | None = None

def __init__(self, args: DeployCommand.Args) -> None:
self.__args = args
Expand All @@ -46,11 +47,13 @@ def __init__(self, args: DeployCommand.Args) -> None:
def execute(self) -> None:
git_repo_api = self.__create_git_repo_api()
with GitRepo(git_repo_api) as git_repo:
git_repo.clone()

if self.__args.create_pr:
pr_branch = f"gitopscli-deploy-{str(uuid.uuid4())[:8]}"
git_repo.new_branch(pr_branch)
pr_branch = self.__args.branch or f"gitopscli-deploy-{str(uuid.uuid4())[:8]}"
git_repo.clone(pr_branch, create=True)
elif self.__args.branch:
git_repo.clone(self.__args.branch, create=True)
else:
git_repo.clone()

updated_values = self.__update_values(git_repo)
if not updated_values:
Expand Down
48 changes: 37 additions & 11 deletions gitopscli/git_api/git_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from types import TracebackType
from typing import Literal

from git import GitCommandError, GitError, Repo
from git import Git, GitCommandError, GitError, Repo
from typing_extensions import Self # noqa: UP035

from gitopscli.gitops_exception import GitOpsException
Expand Down Expand Up @@ -41,13 +41,18 @@ def get_full_file_path(self, relative_path: str) -> str:
def get_clone_url(self) -> str:
return self.__api.get_clone_url()

def clone(self, branch: str | None = None) -> None:
def clone(self, branch: str | None = None, create: bool = False) -> None:
self.__delete_tmp_dir()
self.__tmp_dir = create_tmp_dir()
git_options = ["--depth=1"]
url = self.get_clone_url()
if branch:
logging.info("Cloning repository: %s (branch: %s)", url, branch)

clone_branch = branch
if create and branch and not self.__remote_branch_exists(branch, remote=url):
clone_branch = None # clone default branch; new branch created after

if clone_branch:
logging.info("Cloning repository: %s (branch: %s)", url, clone_branch)
else:
logging.info("Cloning repository: %s", url)
username = self.__api.get_username()
Expand All @@ -56,19 +61,22 @@ def clone(self, branch: str | None = None) -> None:
if username is not None and password is not None:
credentials_file = self.__create_credentials_file(username, password)
git_options.append(f"--config credential.helper={credentials_file}")
if branch:
git_options.append(f"--branch {branch}")
if clone_branch:
git_options.append(f"--branch {clone_branch}")
self.__repo = Repo.clone_from(
url=url,
to_path=f"{self.__tmp_dir}/repo",
multi_options=git_options,
allow_unsafe_options=True,
)
except GitError as ex:
if branch:
raise GitOpsException(f"Error cloning branch '{branch}' of '{url}'") from ex
if clone_branch:
raise GitOpsException(f"Error cloning branch '{clone_branch}' of '{url}'") from ex
raise GitOpsException(f"Error cloning '{url}'") from ex

if create and branch and clone_branch is None:
self.new_branch(branch)

def new_branch(self, branch: str) -> None:
logging.info("Creating new branch: %s", branch)
repo = self.__get_repo()
Expand Down Expand Up @@ -131,9 +139,27 @@ def get_author_from_last_commit(self) -> str:
last_commit = repo.head.commit
return str(repo.git.show("-s", "--format=%an <%ae>", last_commit.hexsha))

def __remote_branch_exists(self, branch: str) -> bool:
repo = self.__get_repo()
result = repo.git.ls_remote("--heads", "origin", f"refs/heads/{branch}")
def __remote_branch_exists(self, branch: str, remote: str = "origin") -> bool:
if remote == "origin":
repo = self.__get_repo()
result = repo.git.ls_remote("--heads", remote, f"refs/heads/{branch}")
else:
username = self.__api.get_username()
password = self.__api.get_password()
try:
g = Git()
if username is not None and password is not None:
if not self.__tmp_dir:
self.__tmp_dir = create_tmp_dir()
credentials_file = self.__create_credentials_file(username, password)
result = g.execute([
"git", "-c", f"credential.helper={credentials_file}",
"ls-remote", "--heads", remote, f"refs/heads/{branch}",
])
else:
result = g.ls_remote("--heads", remote, f"refs/heads/{branch}")
except GitError as ex:
raise GitOpsException(f"Error checking remote branch '{branch}' at '{remote}'.") from ex
if isinstance(result, str):
return result.strip() != ""
return bool(result)
Expand Down
97 changes: 90 additions & 7 deletions tests/commands/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ def setUp(self):
self.git_repo_mock.__enter__.return_value = self.git_repo_mock
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.example_commit_hash = "5f3a443e7ecb3723c1a71b9744e2993c0b6dfc00"
self.git_repo_mock.commit.return_value = self.example_commit_hash
self.git_repo_mock.pull_rebase.return_value = None
Expand Down Expand Up @@ -137,9 +136,8 @@ def test_create_pr_single_value_change_happy_flow_with_output(self, mock_print):
assert self.mock_manager.method_calls == [
call.GitRepoApiFactory.create(args, "ORGA", "REPO"),
call.GitRepo(self.git_repo_api_mock),
call.GitRepo.clone(),
call.uuid.uuid4(),
call.GitRepo.new_branch("gitopscli-deploy-b973b5bb"),
call.GitRepo.clone("gitopscli-deploy-b973b5bb", create=True),
call.GitRepo.get_full_file_path("test/file.yml"),
call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.c", "foo"),
call.logging.info("Updated yaml property %s to %s", "a.b.c", "foo"),
Expand Down Expand Up @@ -192,9 +190,8 @@ def test_create_pr_multiple_value_changes_happy_flow_with_output(self, mock_prin
assert self.mock_manager.method_calls == [
call.GitRepoApiFactory.create(args, "ORGA", "REPO"),
call.GitRepo(self.git_repo_api_mock),
call.GitRepo.clone(),
call.uuid.uuid4(),
call.GitRepo.new_branch("gitopscli-deploy-b973b5bb"),
call.GitRepo.clone("gitopscli-deploy-b973b5bb", create=True),
call.GitRepo.get_full_file_path("test/file.yml"),
call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.c", "foo"),
call.logging.info("Updated yaml property %s to %s", "a.b.c", "foo"),
Expand Down Expand Up @@ -253,9 +250,8 @@ def test_create_pr_and_merge_happy_flow(self, mock_print):
assert self.mock_manager.method_calls == [
call.GitRepoApiFactory.create(args, "ORGA", "REPO"),
call.GitRepo(self.git_repo_api_mock),
call.GitRepo.clone(),
call.uuid.uuid4(),
call.GitRepo.new_branch("gitopscli-deploy-b973b5bb"),
call.GitRepo.clone("gitopscli-deploy-b973b5bb", create=True),
call.GitRepo.get_full_file_path("test/file.yml"),
call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.c", "foo"),
call.logging.info("Updated yaml property %s to %s", "a.b.c", "foo"),
Expand All @@ -277,6 +273,93 @@ def test_create_pr_and_merge_happy_flow(self, mock_print):
no_output = ""
self.assertMultiLineEqual(mock_print.getvalue(), no_output)

@mock.patch("sys.stdout", new_callable=StringIO)
def test_create_pr_with_custom_branch(self, mock_print):
args = DeployCommand.Args(
file="test/file.yml",
values={"a.b.c": "foo"},
username="USERNAME",
password="PASSWORD",
git_user="GIT_USER",
git_email="GIT_EMAIL",
git_author_name=None,
git_author_email=None,
create_pr=True,
auto_merge=False,
single_commit=False,
organisation="ORGA",
repository_name="REPO",
git_provider=GitProvider.GITHUB,
git_provider_url=None,
commit_message=None,
json=False,
pr_labels=None,
merge_parameters=None,
branch="my-custom-branch",
)
DeployCommand(args).execute()

assert self.mock_manager.method_calls == [
call.GitRepoApiFactory.create(args, "ORGA", "REPO"),
call.GitRepo(self.git_repo_api_mock),
call.GitRepo.clone("my-custom-branch", create=True),
call.GitRepo.get_full_file_path("test/file.yml"),
call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.c", "foo"),
call.logging.info("Updated yaml property %s to %s", "a.b.c", "foo"),
call.GitRepo.commit("GIT_USER", "GIT_EMAIL", None, None, "changed 'a.b.c' to 'foo' in test/file.yml"),
call.GitRepo.pull_rebase(),
call.GitRepo.push(),
call.GitRepoApi.create_pull_request_to_default_branch(
"my-custom-branch",
"Updated value in test/file.yml",
"Updated 1 value in `test/file.yml`:\n```yaml\na.b.c: foo\n```\n",
),
]

no_output = ""
self.assertMultiLineEqual(mock_print.getvalue(), no_output)

@mock.patch("sys.stdout", new_callable=StringIO)
def test_custom_branch_without_create_pr(self, mock_print):
args = DeployCommand.Args(
file="test/file.yml",
values={"a.b.c": "foo"},
username="USERNAME",
password="PASSWORD",
git_user="GIT_USER",
git_email="GIT_EMAIL",
git_author_name=None,
git_author_email=None,
create_pr=False,
auto_merge=False,
single_commit=False,
organisation="ORGA",
repository_name="REPO",
git_provider=GitProvider.GITHUB,
git_provider_url=None,
commit_message=None,
json=False,
pr_labels=None,
merge_parameters=None,
branch="my-custom-branch",
)
DeployCommand(args).execute()

assert self.mock_manager.method_calls == [
call.GitRepoApiFactory.create(args, "ORGA", "REPO"),
call.GitRepo(self.git_repo_api_mock),
call.GitRepo.clone("my-custom-branch", create=True),
call.GitRepo.get_full_file_path("test/file.yml"),
call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.c", "foo"),
call.logging.info("Updated yaml property %s to %s", "a.b.c", "foo"),
call.GitRepo.commit("GIT_USER", "GIT_EMAIL", None, None, "changed 'a.b.c' to 'foo' in test/file.yml"),
call.GitRepo.pull_rebase(),
call.GitRepo.push(),
]

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(
Expand Down
Loading
Loading