Skip to content

Commit

Permalink
Feature/standalone branch name validation (#4)
Browse files Browse the repository at this point in the history
* Rehome acquire_repo with other repo functions

* Build ALL_NAME_PARTS outside prediction call

* Add standalone branch name validation command

* Bump versions
  • Loading branch information
chris11-taylor-nttd authored May 2, 2024
1 parent 23ac26c commit a32b3e1
Show file tree
Hide file tree
Showing 12 changed files with 198 additions and 23 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "launch-cli"
version = "0.5.0"
version = "0.6.0"
description = "CLI tooling for common Launch functions"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/launch/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from semver import Version

VERSION = "0.5.0"
VERSION = "0.6.0"

SEMANTIC_VERSION = Version.parse(VERSION)
GITHUB_ORG_NAME = "launchbynttdata"
Expand Down
2 changes: 2 additions & 0 deletions src/launch/cli/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ def cli(context: click.core.Context, verbose: bool, version: bool):
from .helm import helm_group
from .service import service_group
from .terragrunt import terragrunt_group
from .validate import validate_group

cli.add_command(get_version)
cli.add_command(github_group)
cli.add_command(terragrunt_group)
cli.add_command(service_group)
cli.add_command(helm_group)
cli.add_command(validate_group)
11 changes: 11 additions & 0 deletions src/launch/cli/validate/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import click

from .commands import branch_name


@click.group(name="validate")
def validate_group():
"""Command family for validation-related tasks."""


validate_group.add_command(branch_name)
50 changes: 50 additions & 0 deletions src/launch/cli/validate/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import logging
import sys
from pathlib import Path

import click

from launch.local_repo.predict import validate_name
from launch.local_repo.repo import acquire_repo


@click.command()
@click.option(
"-b",
"--branch-name",
default="",
help="Provide the exact name of a branch to be validated",
)
def branch_name(branch_name: str):
"""Validates that a branch name will be compatible with our semver handling. If this command is launched from within a Git repository, the current branch will be evaluated. This behavior can be overridden by the --branch-name flag."""

cwd = Path.cwd()
git_path = cwd.joinpath(".git")
if git_path.exists() and git_path.is_dir():
# We're in a Git repo
if not branch_name:
this_repo = acquire_repo(cwd)
try:
branch_name = this_repo.active_branch.name
except TypeError as te:
if "HEAD is a detached symbolic reference" in str(te):
click.secho(
"Current directory contains a git repo that has a detached HEAD. Check out a branch or supply the --branch-name parameter.",
fg="red",
)
sys.exit(-1)
else:
raise
if not branch_name:
click.secho(
"Current directory doesn't contain a git repo, you must provide a branch name with the --branch-name parameter."
)
sys.exit(-2)
try:
if not validate_name(branch_name=branch_name):
raise Exception(f"Branch {branch_name} isn't valid!")
click.secho(f"Branch {branch_name} is valid.", fg="green")
sys.exit(0)
except Exception as e:
click.secho(e, fg="red")
sys.exit(1)
39 changes: 31 additions & 8 deletions src/launch/local_repo/predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
MINOR_NAME_PARTS = ["feature"]
MAJOR_NAME_PARTS = []

ALL_NAME_PARTS = list(
itertools.chain(MAJOR_NAME_PARTS, MINOR_NAME_PARTS, PATCH_NAME_PARTS)
)

BREAKING_CHARS = ["!"]
CAPITALIZE_FIRST_IS_BREAKING = True
DEFAULT_VERSION = Version(major=0, minor=1, patch=0)
Expand All @@ -36,6 +40,29 @@ def latest_tag(tags: list[Version]) -> Version:
return sorted(tags)[-1]


def validate_name(branch_name: str) -> bool:
"""Checks the contents of a branch name against this module's configuration and returns a success/failure boolean with the outcome.
Args:
branch_name (str): Name of the branch to validate.
Returns:
bool: Success indicator. A True value indicates that this branch name conforms to the expected convention, a False value indicates otherwise.
"""
try:
revision_type, _ = split_delimiter(branch_name=branch_name)
except InvalidBranchNameException:
return False

for breaking_char in BREAKING_CHARS:
if breaking_char in revision_type:
revision_type = revision_type.replace(breaking_char, "")

if revision_type.lower().strip() not in map(str.lower, ALL_NAME_PARTS):
return False
return True


def predict_version(
existing_tags: list[Version],
branch_name: str,
Expand All @@ -59,23 +86,19 @@ def predict_version(

revision_type, _ = split_delimiter(branch_name=branch_name)

valid_branch_revision_types = list(
itertools.chain(MAJOR_NAME_PARTS, MINOR_NAME_PARTS, PATCH_NAME_PARTS)
)

logger.debug(f"Evaluating {revision_type=} against {valid_branch_revision_types=}")
logger.debug(f"Evaluating {revision_type=} against {ALL_NAME_PARTS=}")

for breaking_char in breaking_chars:
if breaking_char in revision_type:
logger.debug(
f"Detected {breaking_char=} in branch name, setting {breaking_change=}"
)
breaking_change = True
revision_type = revision_type.strip(breaking_char)
revision_type = revision_type.replace(breaking_char, "")

if revision_type.lower().strip() not in map(str.lower, valid_branch_revision_types):
if revision_type.lower().strip() not in map(str.lower, ALL_NAME_PARTS):
raise InvalidBranchNameException(
f"Branch name {branch_name} is invalid, must case-insensitively match one of {valid_branch_revision_types}"
f"Branch name {branch_name} is invalid, must case-insensitively match one of {ALL_NAME_PARTS}"
)

if capitalize_first_is_breaking:
Expand Down
13 changes: 10 additions & 3 deletions src/launch/local_repo/repo.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import logging
import subprocess
import pathlib

from git import GitCommandError, Repo

from launch import INIT_BRANCH

logger = logging.getLogger(__name__)


def acquire_repo(repo_path: pathlib.Path) -> Repo:
try:
return Repo(path=repo_path)
except Exception as e:
raise RuntimeError(
f"Failed to get a Repo instance from path {repo_path}: {e}"
) from e


def checkout_branch(
repository: Repo, target_branch: str, new_branch: bool = False
) -> None:
Expand Down
11 changes: 2 additions & 9 deletions src/launch/local_repo/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from git.repo import Repo
from semver import Version

from launch.local_repo.repo import acquire_repo

logger = logging.getLogger(__name__)


Expand All @@ -17,15 +19,6 @@ class CommitTagNotSemanticVersionException(Exception):
pass


def acquire_repo(repo_path: pathlib.Path) -> Repo:
try:
return Repo(path=repo_path)
except Exception as e:
raise RuntimeError(
f"Failed to get a Repo instance from path {repo_path}: {e}"
) from e


def read_tags(repo_path: pathlib.Path) -> list[str]:
repo_instance = acquire_repo(repo_path=repo_path)
all_tags = [tag.name for tag in repo_instance.tags]
Expand Down
Empty file.
21 changes: 21 additions & 0 deletions test/unit/cli/validate/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import os

import pytest
from faker import Faker
from git.repo import Repo

fake = Faker()


@pytest.fixture(scope="function")
def working_dir(tmp_path):
old_cwd = os.getcwd()
os.chdir(tmp_path)
yield tmp_path
os.chdir(old_cwd)


@pytest.fixture(scope="function")
def example_repo(working_dir):
repo = Repo.init(path=working_dir)
yield repo
68 changes: 68 additions & 0 deletions test/unit/cli/validate/test_branch_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import pytest

from launch.cli.validate.commands import branch_name

ACCEPTABLE_BRANCH_NAMES = [
"fix/ok",
"fix!/ok",
"patch/ok",
"patch!/ok",
"!patch/ok",
"BUG/ok",
"BUG!/ok",
"!buG/ok",
"feature/ok",
"feature!/ok",
"Feature/ok",
"!Feature/ok",
"feature/ok",
"FEATURE/ok",
]

UNACCEPTABLE_BRANCH_NAMES = [
"main",
"foo/bar",
"foo.bar",
"aab20a1f33aa86ffae87b1786e6736f1c7e10d1d", # pragma: allowlist secret
]


class TestBranchName:
@pytest.mark.parametrize("create_branch_name", ACCEPTABLE_BRANCH_NAMES)
def test_branch_name_valid_in_git_repo(
self, create_branch_name, cli_runner, example_repo
):
example_repo.git.checkout(["-b", create_branch_name])
result = cli_runner.invoke(branch_name, [])
assert not result.exception
assert f"Branch {create_branch_name} is valid." in result.output

@pytest.mark.parametrize("create_branch_name", UNACCEPTABLE_BRANCH_NAMES)
def test_branch_name_valid_in_git_repo(
self, create_branch_name, cli_runner, example_repo
):
example_repo.git.checkout(["-b", create_branch_name])
result = cli_runner.invoke(branch_name, [])
assert result.exception
assert f"Branch {create_branch_name} isn't valid!" in result.output

def test_branch_name_invalid_outside_git_repo(self, cli_runner, working_dir):
result = cli_runner.invoke(branch_name, [])
assert result.exception
assert "Current directory doesn't contain a git repo" in result.output

@pytest.mark.parametrize("use_branch_name", ACCEPTABLE_BRANCH_NAMES)
def test_branch_name_valid_outside_git_repo(
self, use_branch_name, cli_runner, working_dir
):
result = cli_runner.invoke(branch_name, ["--branch-name", use_branch_name])
assert not result.exception
assert f"Branch {use_branch_name} is valid." in result.output

@pytest.mark.parametrize("use_branch_name", UNACCEPTABLE_BRANCH_NAMES)
def test_branch_name_invalid_outside_git_repo(
self, use_branch_name, cli_runner, working_dir
):
result = cli_runner.invoke(branch_name, ["--branch-name", use_branch_name])
assert result.exception
assert f"Branch {use_branch_name} isn't valid!" in result.output
2 changes: 1 addition & 1 deletion test/unit/local_repo/test_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
from semver import Version

from launch.local_repo import tags # Used for mocking only
from launch.local_repo.repo import acquire_repo
from launch.local_repo.tags import (
CommitNotTaggedException,
CommitTagNotSemanticVersionException,
acquire_repo,
create_version_tag,
push_version_tag,
read_semantic_tags,
Expand Down

0 comments on commit a32b3e1

Please sign in to comment.