From d11c800abbe0a50ab098dfc00e6784fc710d0367 Mon Sep 17 00:00:00 2001 From: Yurii Puchkov Date: Sat, 23 Dec 2023 15:53:45 -0700 Subject: [PATCH] feat: prepared tool code --- .dockerignore | 3 + .flake8rc | 7 + .github/workflows/build.yaml | 56 ++++ .gitignore | 5 + Dockerfile | 32 +++ README.md | 157 +++++++++++ pytest.ini | 2 + requirements-dev.txt | 7 + requirements.txt | 1 + scripts/git/hooks/commit-msg | 16 ++ scripts/install.sh | 18 ++ src/__init__.py | 0 src/changelogs_mngr.py | 63 +++++ src/git.py | 341 +++++++++++++++++++++++ src/pygitver | 7 + src/pygitver.py | 162 +++++++++++ src/templates/changelog-common.tmpl | 59 ++++ src/templates/changelog.tmpl | 56 ++++ tests/__init__.py | 0 tests/data/changelogs/broken-json.json | 1 + tests/data/changelogs/service-1.json | 29 ++ tests/data/changelogs/service-2.json | 29 ++ tests/data/joined_changelog.json | 63 +++++ tests/test_changelogs_mngr.py | 58 ++++ tests/test_git.py | 361 +++++++++++++++++++++++++ tox.ini | 86 ++++++ 26 files changed, 1619 insertions(+) create mode 100644 .dockerignore create mode 100644 .flake8rc create mode 100644 .github/workflows/build.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 scripts/git/hooks/commit-msg create mode 100755 scripts/install.sh create mode 100644 src/__init__.py create mode 100644 src/changelogs_mngr.py create mode 100644 src/git.py create mode 100755 src/pygitver create mode 100644 src/pygitver.py create mode 100644 src/templates/changelog-common.tmpl create mode 100644 src/templates/changelog.tmpl create mode 100644 tests/__init__.py create mode 100644 tests/data/changelogs/broken-json.json create mode 100644 tests/data/changelogs/service-1.json create mode 100644 tests/data/changelogs/service-2.json create mode 100644 tests/data/joined_changelog.json create mode 100644 tests/test_changelogs_mngr.py create mode 100644 tests/test_git.py create mode 100644 tox.ini diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ac4c720 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.pytest_cache/* +venv +.tox diff --git a/.flake8rc b/.flake8rc new file mode 100644 index 0000000..d59033b --- /dev/null +++ b/.flake8rc @@ -0,0 +1,7 @@ +[flake8] +ignore = E501 +max-line-length = 120 +exclude = + .git, + .tox, + venv \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..e0566a0 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,56 @@ +name: Docker Build Image & Push (CICDv2) +permissions: write-all +'on': + push: + branches: + - main + - feat/* + - feature/* + - fix/* + - bugfix/* + pull_request: + branches: + - main +env: + IMAGE_NAME: 'pygitver' + REGISTRY_NAME: pygitver + +jobs: + unittests: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Unit Tests + shell: bash + run: | + set -x + pip install -U tox + pip install tox + tox + build: + runs-on: ubuntu-latest + needs: unittests + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v3 + + - name: Docker Build + shell: bash + run: | + set -x + docker build -t ${{ env.IMAGE_NAME }} . + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ env.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Docker Push + shell: bash + run: | + set -x + echo "TODO" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9bb0372 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +.tox +.coverage +*/__pychache__/* +venv diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6d6d43d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 +FROM python:3.12-alpine as build + +ENV SEMVER_HELPER_TEMPLATE_CHANGELOG="/pygitver/templates/changelog.tmpl" +ENV SEMVER_HELPER_ROOT="/pygitver" + +# Make sure we use the virtualenv: +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Install pip requirements +COPY ./requirements.txt /pygitver/requirements.txt +RUN pip install -U pip \ + && pip install -r /pygitver/requirements.txt --no-cache-dir + +FROM python:3.12-alpine + +# Install dependencies +RUN apk add --no-cache git openssh \ + && ln -s /pygitver/pygitver /usr/local/bin/pygitver + +# Copy application code and +COPY ./src /pygitver +COPY ./scripts /pygitver/scripts +COPY --from=build /opt/venv /opt/venv + +WORKDIR /app + +# Make sure we use the virtualenv: +ENV PATH="/opt/venv/bin:$PATH" + +ENTRYPOINT ["python", "/pygitver/pygitver.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7220d6b --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# Semver-Helper + +Features: +* Conventional Commit linter +* Generate CHANGELOG +* Bump version based on CHANGELOG + +## Conventional Commits Rules +Tool supports simplified Conventional Commits which are described in this section. + +The commit message should be structured as follows: +```shell +[optional scope]: +``` + +The commit contains the following structural elements, to communicate intent to the consumers of your library: +* `fix:` a commit of the type fix patches a bug in your codebase (this correlates with `PATCH` in Semantic Versioning). +* `feat:` a commit of the type feat introduces a new feature to the codebase (this correlates with `MINOR` in Semantic Versioning). +* `BREAKING CHANGE:` a commit that has a footer `BREAKING CHANGE:`, or appends a `!` after the type/scope, introduces a breaking API change (correlating with `MAJOR` in Semantic Versioning). A BREAKING CHANGE can be part of commits of any type. +* Other allowed prefixes: `build:`, `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:`. These correlate with `PATCH` in Semantic Versioning. + +### Conventional Commits Examples: + +Commit without scope without breaking change +``` +fix: crash on wrong input data +``` + + +Commit message with ! to draw attention to breaking change +``` +feat!: send an email to the customer when a product is shipped +``` + + +Commit message with scope and ! to draw attention to breaking change +``` +feat(api)!: send an email to the customer when a product is shipped +``` + + +Commit message with scope +``` +feat(lang): add Polish language +``` + + +## Users (Developers) Section + + +### Install Semver-Helper +Run in the `git` root folder of the target repository on localhost. +```shell +docker run --rm -v $(pwd):/app -w /app --entrypoint '' panpuchkov/pygitver /pygitver/scripts/install.sh +``` + +* It doesn't matter what the current branch is. +* You should install it in every repository that needs conventional commit messages. + +### Update Semver-Helper + +Run in terminal in any folder: + +```shell +docker pull panpuchkov/pygitver +``` + +### Usage + +You don't need to use it directly, it will be used automatically on each git commit. + +_Example of a commit message that is **NOT VALID** for Conventional Commits:_ +```shell +$ git commit -am "test" +ERROR: Commit does not fit Conventional Commits requirements +``` + +_Example of a commit message that is **VALID** for Conventional Commits:_ +```shell +$ git commit -am "feat: test" +[feature/test2 af1a5c4] feat: test + 1 file changed, 1 deletion(-) +``` + +_Note: repeat this procedure for each repository_ + +## DevOps and Tool Developers Section + +### Usage + +_**Note:** The tool is running in the docker container and mounting your repository to it. +It means that you have to run it just from the root directory of the repository, +from the directory with `.git` folder._ + +```shell +docker run --rm -v $(pwd):/app -w /app panpuchkov/pygitver --help +``` + +#### Examples + +Check if the git commit message is valid for Conventional Commits: +```shell +$ docker run --rm -v $(pwd):/app -w /app panpuchkov/pygitver --check-commit-message "feat: conventional commit message" +$ echo $? # get exit code +0 +$ docker run --rm -v $(pwd):/app -w /app panpuchkov/pygitver --check-commit-message "non-conventional commit message" +ERROR: Commit does not fit Conventional Commits requirements +$ echo $? # get exit code +1 +``` + +Get current version (last git tag): +```shell +$ docker run --rm -v $(pwd):/app -w /app panpuchkov/pygitver --curr-ver +v0.0.3 +``` + +Get next version (bump last git tag): +```shell +$ docker run --rm -v $(pwd):/app -w /app panpuchkov/pygitver --next-ver +v1.0.0 +``` + +#### Custom CHANGELOG Templates + +* Take as an example: `./src/templates/changelog.tmpl` +* Place somewhere in your project custom template +* Send environment variable `SEMVER_HELPER_TEMPLATE_CHANGELOG` to docker + on run with full template path in Docker (usually `/app/...`) + +### Development + +#### Build Docker +```shell +docker build -t pygitver . +``` + +#### Install to Localhost +```shell +pip install -r requirements-dev.txt +``` + +#### Test on Localhost + +##### Run all checks +```shell +tox +``` + +##### A single file of the test run +```shell + tox -e coverage -- ./tests/test_git.py -vv +``` +or +```shell + coverage run -m pytest -- ./tests/test_git.py +``` diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d2eda2f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = ./src \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..abc5b38 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,7 @@ +Jinja2 + +tox +pytest +black +mypy +docformatter diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..769ef83 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Jinja2==3.1.2 diff --git a/scripts/git/hooks/commit-msg b/scripts/git/hooks/commit-msg new file mode 100644 index 0000000..51be78a --- /dev/null +++ b/scripts/git/hooks/commit-msg @@ -0,0 +1,16 @@ +#!/bin/sh + +# pygitver - commit-msg git hook +COMMIT_MSG=$1 +COMMIT_LINT_DOCKER="panpuchkov/pygitver" +docker run --rm -v $(pwd):/app -w /app ${COMMIT_LINT_DOCKER} --check-commit-message "$(cat \"${COMMIT_MSG}\")" + +COMMIT_LINT_CHECK="$?" +if [[ 0 -ne "${COMMIT_LINT_CHECK}" ]]; then + exit ${COMMIT_LINT_CHECK}; +fi + +# update docker image in background (optional, uncomment next line if you want auto update) +# docker pull ${COMMIT_LINT_DOCKER} >> /dev/null & + +# pygitver - commit-msg git hook ^^^ diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..ea14e4b --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +SEMVER_ROOT="/pygitver" +GIT_HOOK_COMMIT_MSG_FILE_DST=".git/hooks/commit-msg" +GIT_HOOK_COMMIT_MSG_FILE_SRC="${SEMVER_ROOT}/scripts/git/hooks/commit-msg" + +echo "Installing git hook."; +if [ ! -r ".git" ]; then + echo "Directory '.git' not found. Run in the root directory of the repository." +fi + +if [ ! -r "${GIT_HOOK_COMMIT_MSG_FILE_DST}" ]; then + cp "${GIT_HOOK_COMMIT_MSG_FILE_SRC}" ${GIT_HOOK_COMMIT_MSG_FILE_DST} + chmod +x ${GIT_HOOK_COMMIT_MSG_FILE_DST} + echo "Done." +else + echo "Git hook commit-msg is already exists, please check if it is correct." +fi diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/changelogs_mngr.py b/src/changelogs_mngr.py new file mode 100644 index 0000000..d482d4c --- /dev/null +++ b/src/changelogs_mngr.py @@ -0,0 +1,63 @@ +import os +import json +from git import Git, GitError + +from jinja2 import Environment, FileSystemLoader, TemplateNotFound + + +class ChangelogsMngrError(Exception): + pass + + +class ChangelogsMngr: + def __init__(self, changelogs_version: str = "0.0.1") -> None: + self._changelogs_version = changelogs_version + self._init_changelog() + + def _init_changelog(self): + self._changelogs: dict = {"version": self._changelogs_version, "services": {}} + self._bump_version_rules: dict = { + "major": False, + "minor": False, + "patch": False, + } + + def _update_bump_version_rules(self, service_name: str) -> None: + for key in self._bump_version_rules.keys(): + self._bump_version_rules[key] |= self._changelogs["services"][service_name][ + "bump_rules" + ][key] + + def read_files(self, path: str, file_ext: str = "json"): + self._init_changelog() + for file_name in sorted(os.listdir(path)): + try: + with open(os.path.join(path, file_name)) as fp: + if file_ext and file_name.endswith(f".{file_ext}"): + file_name = file_name[: -(len(file_ext) + 1)] + self._changelogs["services"][file_name] = json.load(fp) + self._update_bump_version_rules(file_name) + except json.JSONDecodeError: + # nothing to do, just skip invalid file + pass + try: + self._changelogs["version"] = Git.bump_version( + self._changelogs["version"], + self._bump_version_rules, + ) + except GitError: # pragma: no cover + self._changelogs["version"] = None + return self._changelogs + + def changelog_generate( + self, template_name: str = "templates/changelog-common.tmpl" + ) -> str: + try: + env = Environment(loader=FileSystemLoader(os.path.dirname(template_name))) + template = env.get_template(os.path.basename(template_name)) + output = template.render(**self._changelogs) + except TemplateNotFound: + raise ChangelogsMngrError( + f"ERROR: Template '{template_name}' was not found." + ) + return output diff --git a/src/git.py b/src/git.py new file mode 100644 index 0000000..9c420a6 --- /dev/null +++ b/src/git.py @@ -0,0 +1,341 @@ +import json +import os +import re +import subprocess +from jinja2 import Environment, FileSystemLoader, TemplateNotFound + + +class GitError(Exception): + pass + + +RE_CONVENTIONAL_COMMIT = ( + r"^(" + r"(?:" + r"(?:(?:fix)|(?:feat)|(?:build)|(?:chore)|(?:ci)|(?:docs)|(?:style)|(?:refactor)|(?:perf)|(?:test)|(?:deprecated)" + r"|(?:BREAKING CHANGE))" + r"[\s\t]*(?:\([^\:)]+\))?[\s\t]*!?:[\s\t]*" + r")" + r"|(?:Merge branch )|(?:Merge pull request )" + r")" +) + + +class Git: + __version__ = "0.1.2" + + @staticmethod + def _cmd(command: str) -> str: + """ + Run shell command, used for running git commands. + + :param command: string with shell command + :return: string with the raw output of the shell stdout + """ + subprocess_res = subprocess.run( + list(filter(lambda x: x, command.split(" "))), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + output = subprocess_res.stdout.decode("utf-8") + if 0 != subprocess_res.returncode: + raise GitError( + json.dumps({"return_code": subprocess_res.returncode, "result": output}) + ) # pragma: no cover + return output + + @classmethod + def _commit_msg_normalize(cls, commit: str) -> str: + """ + Normalize commit message, removes pygitver prefix from git commit, + example: "fix: commit message" -> "commit message". + + :param commit: string with the commit message + :return: string with the normalized commit message + """ + return ( + re.sub(RE_CONVENTIONAL_COMMIT, "", commit, flags=re.IGNORECASE) + .rstrip() + .lstrip() + ) + + @staticmethod + def _append_commit_to_section( + section: list, _commit_normalized: str, _unique: bool + ) -> None: + """ + Append commit to the required section. + + :param section: target section where to add a commit message + (normalized or as is) + :param _commit_normalized: Normalize commit message if True + :param _unique: do not add duplicates if is True + """ + if _unique: + if _commit_normalized not in section: + section.append(_commit_normalized) + else: + section.append(_commit_normalized) + + @classmethod + def _changelog_group_sort( + cls, git_log: str, commit_wo_prefix: bool, unique: bool + ) -> dict: + """ + Sort change log by groups (features, bugfixes, deprecations, docs, + others). + + :param git_log: multiline string with commits (one line per + commit) + :param unique: do not show duplicates commit if True + :param commit_wo_prefix: remove commit pygitver prefix if is True + :return: dict with commits sorted by groups and 'bump_rules', + example: { "bump_rules": {"major": False, "minor": True, + "patch": True}, "changelog": { 'features': [ 'feat(api)!: + new api' ], 'bugfixes': [ 'fix: test fix 1', 'fix(api)!: + test fix 2' ], 'deprecations': [], 'others': [], 'docs': [], + 'non_conventional_commit': [] } } + """ + res: dict = { + "features": [], + "bugfixes": [], + "deprecations": [], + "others": [], + "docs": [], + "non_conventional_commit": [], + } + bump_rules: dict = {"major": False, "minor": False, "patch": False} + for commit in git_log.rstrip().split("\n"): + commit = commit.rstrip() + commit_normalized = ( + cls._commit_msg_normalize(commit) if commit_wo_prefix else commit + ) + if bool( + re.search( + r"(^.*!:.+)|(^breaking change)|(^deprecated.*)|(.*:.*breaking change:.*)", + commit, + re.IGNORECASE, + ) + ): + bump_rules["major"] = True + + if bool(re.search("^deprecated.*:", commit, re.IGNORECASE)): + cls._append_commit_to_section( + res["deprecations"], commit_normalized, unique + ) + elif bool(re.search("^feat.*:", commit, re.IGNORECASE)): + cls._append_commit_to_section( + res["features"], commit_normalized, unique + ) + bump_rules["minor"] = True + elif bool(re.search("^fix.*:", commit, re.IGNORECASE)): + cls._append_commit_to_section( + res["bugfixes"], commit_normalized, unique + ) + bump_rules["patch"] = True + elif bool(re.search("^docs.*:", commit, re.IGNORECASE)): + cls._append_commit_to_section(res["docs"], commit_normalized, unique) + bump_rules["patch"] = True + elif not bool(re.search(RE_CONVENTIONAL_COMMIT, commit, re.IGNORECASE)): + cls._append_commit_to_section( + res["non_conventional_commit"], commit_normalized, unique + ) + bump_rules["patch"] = True + else: + cls._append_commit_to_section(res["others"], commit_normalized, unique) + bump_rules["patch"] = True + return {"bump_rules": bump_rules, "changelog": res} + + @staticmethod + def _version_prefix(version: str) -> str: + """ + Get version prefix if exists, example version "v1.0.0", the prefix is + "v". + + :param version: string with version + :return: string with prefix, empty string if no prefix + """ + search_res = re.search(r"^[^0-9]*", version) + version_prefix = search_res.group() if search_res and search_res else "" + return version_prefix + + @classmethod + def tags(cls, update_from_remote: bool = False): + """ + Get git tags sorted in back order. + + :param update_from_remote: run git fetch from remote before + getting tags + :return: string with git tags + """ + if update_from_remote: # pragma: no cover + cls._cmd("git fetch --all --tags") + branch = cls._cmd("git branch --show-current").strip("\r").strip("\n") + res = list( + filter( + None, + cls._cmd(f"git tag -l --sort=-v:refname --merged {branch}").split("\n"), + ) + ) + return res + + @classmethod + def check_commit_message(cls, commit: str) -> bool: + """ + Check if the git commit message is valid for Conventional Commits. + + :param commit: git commit message + :return: True if message is valid for Conventional Commits + """ + return bool( + re.match(f"{RE_CONVENTIONAL_COMMIT}([^\\s\\t]+)", commit, re.IGNORECASE) + ) + + @classmethod + def changelog(cls, start: str = "", end: str = "") -> str: + """ + Get raw change log from the 'start' to the 'end' steps. + + :param start: from git tag + :param end: to git tag of HEAD by default + :return: string with raw git log commit messages + """ + if not start: + start = cls._cmd("git log --pretty=format:%H --reverse -n 1") + if not end: + end = "HEAD" + git_commits_range = ( + f"{start}...{end} " if "" != cls.version_current() else "" + ) # pragma: no cover + return cls._cmd( + f"git log --pretty=format:%s {git_commits_range}--no-merges" + ) # pragma: no cover + + @classmethod + def changelog_group( + cls, + start: str = "", + end: str = "HEAD", + commit_wo_prefix: bool = True, + unique: bool = False, + ) -> dict: + """ + Get raw change log from the 'start' to the 'end' steps. + + :param start: from git tag + :param end: to git tag of HEAD by default + :param commit_wo_prefix: remove commit pygitver prefixes if True + :param unique: do not show duplicates commit if True + :return: dict{"return_code": code, "result": {"fix": [], "feat": + [], "other": []}} + """ + git_log = cls.changelog(start=start, end=end) + git_log_sorted = cls._changelog_group_sort( + git_log, commit_wo_prefix, unique=unique + ) + ver = ( + cls.bump_current_version(git_log_sorted["bump_rules"]) + if end == "HEAD" + else end + ) + return {"version": ver, **git_log_sorted} + + @staticmethod + def changelog_generate(changelog_group: dict, template_name: str = "") -> str: + """ + Generate full changelog. + + :param changelog_group: dictionary with changelog (result of the + function 'changelog_group') + :param template_name: file name with changelog template in + Jinja2 format + :return: string with formatted changelog + """ + if not template_name: + template_name = os.getenv( + "PYGITVER_TEMPLATE_CHANGELOG", "templates/changelog.tmpl" + ) + try: + env = Environment(loader=FileSystemLoader(os.path.dirname(template_name))) + template = env.get_template(os.path.basename(template_name)) + output = template.render( + {"version": changelog_group["version"], **changelog_group["changelog"]} + ) + except TemplateNotFound: + output = f"ERROR: Template '{template_name}' was not found." + return output + + @classmethod + def git_version(cls) -> str: + """ + Get installed git version. + + :return: string with git version + """ + res = cls._cmd("git --version") + res = res.replace("\n", "").split(" ")[-1] + return res + + @classmethod + def version_current(cls) -> str: + """ + Get current (latest) git tag. + + :return: string with git tag or empty string if does not exist + """ + for _tag in cls.tags(): + if cls.version_validate(_tag): + return _tag + return "0.0.0" + + @staticmethod + def version_validate(version: str) -> bool: + """ + Validate version string. + + :param version: string with version + :return: True if version has correct format (example: + 'prefix1.2.3'), otherwise False + """ + return bool(re.match(r"^[a-z\-_]*\d+\.\d+\.\d+(?:-[a-zA-Z\d.]+)?$", version)) + + @classmethod + def bump_current_version(cls, bump_rules: dict) -> str: + """ + Get current version and bump is base on 'bump_rules' dict. + + :param bump_rules: bump version rules, example: {"major": False, + "minor": True, "patch": False} version rules will be applied + to the oldest (left to right) version rule with 'True' + :return: string with the bumped current version + """ + return cls.bump_version(cls.version_current(), bump_rules) + + @classmethod + def bump_version(cls, version: str, bump_rules: dict) -> str: + """ + Get current version and bump is base on 'bump_rules' dict. + + :param version: set new version if defined, otherwise take + current + :param bump_rules: bump version rules, example: {"major": False, + "minor": True, "patch": False} version rules will be applied + to the oldest (left to right) version rule with 'True' + :return: string with the bumped current version + """ + version_prefix = cls._version_prefix(version) + version = version.removeprefix(version_prefix) + + version_items = version.split(".") if version != "" else ["0", "0", "0"] + if bump_rules["major"] is True: + version_items[0] = str(int(version_items[0]) + 1) + version_items[1] = "0" + version_items[2] = "0" + elif bump_rules["minor"] is True: + version_items[1] = str(int(version_items[1]) + 1) + version_items[2] = "0" + elif bump_rules["patch"] is True: + # this case is covered by 'test_bump_version_auto', but not recognised by linter + version_items[2] = str(int(version_items[2]) + 1) + version = ".".join(version_items) + return version_prefix + (version if version != "0.0.0" else "0.0.1") diff --git a/src/pygitver b/src/pygitver new file mode 100755 index 0000000..9dd1def --- /dev/null +++ b/src/pygitver @@ -0,0 +1,7 @@ +#/bin/sh + +# removes issue with git dubious ownership in a docker container +git config --global --add safe.directory $(pwd) + +# run pygitver with parameters +python /pygitver/pygitver.py $@ diff --git a/src/pygitver.py b/src/pygitver.py new file mode 100644 index 0000000..4188a29 --- /dev/null +++ b/src/pygitver.py @@ -0,0 +1,162 @@ +import argparse + +from git import Git, GitError +from changelogs_mngr import ChangelogsMngr, ChangelogsMngrError +import json + + +def main(): + parser = argparse.ArgumentParser( + description=f"pygitver tool, ver: {Git.__version__}" + ) + parser.add_argument( + "-v", + "--version", + action="version", + version="%(prog)s " + Git.__version__, + help="show tool version", + ) + + parser.add_argument( + "-cv", + "--curr-ver", + action="store_true", + help="get current version (last git tag)", + required=False, + ) + parser.add_argument( + "-nv", + "--next-ver", + action="store_true", + help="get next version (bump last git tag)", + required=False, + ) + parser.add_argument( + "-t", "--tags", action="store_true", help="git git tags", required=False + ) + parser.add_argument( + "-ccm", + "--check-commit-message", + action="store", + help="check if the git commit message is valid for Conventional Commits", + required=False, + ) + + # Changelog + subparsers = parser.add_subparsers( + title="Get changelog", help="Get changelog in TEXT or JSON format" + ) + changelog = subparsers.add_parser("changelog") + changelog.add_argument( + "-s", + "--start", + type=str, + default="", + help="Starting from tag, default='last pygitver tag'", + ) + changelog.add_argument( + "-e", "--end", type=str, default="HEAD", help="Ending with tag, default=HEAD" + ) + changelog.add_argument( + "-f", + "--format", + type=str, + default="text", + help="Change log format (text, json), default=text", + ) + # Changelog ^^^ + + # Changelogs + changelogs = subparsers.add_parser("changelogs") + changelogs.add_argument( + "-d", + "--dir", + type=str, + required=True, + help="Directory with microservices' changelog files", + ) + changelogs.add_argument( + "-clsv", + "--changelogs-version", + type=str, + default="0.0.1", + required=False, + help="Directory with microservices' changelog files", + ) + changelogs.add_argument( + "-f", + "--format", + type=str, + default="text", + help="Change log format (text, json), default=text", + ) + changelogs.add_argument( + "-t", + "--template", + type=str, + default="templates/changelog-common.tmpl", + help="Template for the CHANGELOG in Jinja2 format", + ) + # Changelogs ^^^ + + args = parser.parse_args() + + try: + if args.tags: + for tag in Git.tags(): + print(tag) + elif args.curr_ver: + print(Git.version_current()) + elif args.next_ver: + curr_ver = Git.version_current() + changelog_group = Git.changelog_group(start=curr_ver) + print(changelog_group["version"]) + elif "dir" in args: + join_changelogs = ChangelogsMngr(changelogs_version=args.changelogs_version) + output = join_changelogs.read_files(path=args.dir, file_ext="json") + if args.format == "text": + try: + print( + join_changelogs.changelog_generate(template_name=args.template) + ) + except ChangelogsMngrError as err: + print(err) + exit(1) + elif args.format == "json": + print(json.dumps(output)) + else: + print("ERROR: unknown output format") + exit(1) + elif "format" in args: + changelog_group = Git.changelog_group( + start=args.start if args.start else Git.version_current(), + end=args.end, + unique=True, + ) + if args.format == "text": + print(Git.changelog_generate(changelog_group)) + elif args.format == "json": + print(json.dumps(changelog_group)) + else: + print("ERROR: unknown output format") + exit(1) + + except GitError as err: + git_error = json.loads(str(err)) + print(git_error["result"]) + exit(git_error["return_code"]) + + if args.check_commit_message: + res = Git.check_commit_message(args.check_commit_message) + if not res: + print("ERROR: Commit does not fit Conventional Commits requirements") + print( + "More about Conventional Commits: " + "https://www.conventionalcommits.org/en/v1.0.0/" + ) + exit(1) + exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/templates/changelog-common.tmpl b/src/templates/changelog-common.tmpl new file mode 100644 index 0000000..029b60e --- /dev/null +++ b/src/templates/changelog-common.tmpl @@ -0,0 +1,59 @@ +# Change Log + +## Version: {{ version }} + +{% if date %}:Released: {{ date }}{% endif %} +{% if maintainer %}:Maintainer: {{ maintainer }}{% endif %} + + +## Services + +{% for key, value in services.items() %} + +### {{ key | capitalize }} + +Version: {{ value.version }} + +{% if value.changelog.features %} +#### Features + +{% for item in value.changelog.features %} +* {{ item | capitalize }} +{% endfor %} +{% endif %} + + +{% if value.changelog.bugfixes %} +#### Bug Fixes + +{% for item in value.changelog.bugfixes %} +* {{ item | capitalize }} +{% endfor %} +{% endif %} + + +{% if value.changelog.deprecations %} +#### Deprecations + +{% for item in value.changelog.deprecations %} +* {{ item | capitalize }} +{% endfor %} +{% endif %} + +{% if value.changelog.docs %} +#### Improved Documentation + +{% for item in value.changelog.docs %} +* {{ item | capitalize }} +{% endfor %} +{% endif %} + +{% if value.changelog.others %} +#### Trivial/Internal Changes + +{% for item in value.changelog.others %} +* {{ item | capitalize }} +{% endfor %} +{% endif %} + +{% endfor %} diff --git a/src/templates/changelog.tmpl b/src/templates/changelog.tmpl new file mode 100644 index 0000000..e505ca6 --- /dev/null +++ b/src/templates/changelog.tmpl @@ -0,0 +1,56 @@ +########## +Change Log +########## + +Version {{ version }} +============= + +{% if date %}:Released: {{ date }}{% endif %} +{% if maintainer %}:Maintainer: {{ maintainer }}{% endif %} + +{% if features %} +Features +-------- + +{% for item in features %} +* {{ item | capitalize }} +{% endfor %} +{% endif %} + + +{% if bugfixes %} +Bug Fixes +--------- + +{% for item in bugfixes %} +* {{ item | capitalize }} +{% endfor %} +{% endif %} + + +{% if deprecations %} +Deprecations +------------ + +{% for item in deprecations %} +* {{ item | capitalize }} +{% endfor %} +{% endif %} + +{% if docs %} +Improved Documentation +---------------------- + +{% for item in docs %} +* {{ item | capitalize }} +{% endfor %} +{% endif %} + +{% if others %} +Trivial/Internal Changes +------------------------ + +{% for item in others %} +* {{ item | capitalize }} +{% endfor %} +{% endif %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/changelogs/broken-json.json b/tests/data/changelogs/broken-json.json new file mode 100644 index 0000000..a118be4 --- /dev/null +++ b/tests/data/changelogs/broken-json.json @@ -0,0 +1 @@ +{"data": 'some broken json file",} \ No newline at end of file diff --git a/tests/data/changelogs/service-1.json b/tests/data/changelogs/service-1.json new file mode 100644 index 0000000..cfd678a --- /dev/null +++ b/tests/data/changelogs/service-1.json @@ -0,0 +1,29 @@ +{ + "version": "1.2.0", + "bump_rules": { + "major": false, + "minor": true, + "patch": true + }, + "changelog": { + "features": [ + "feat(api): new api 1.1", + "feat(api): update api 1.2" + ], + "bugfixes": [ + "fix: test fix 1" + ], + "deprecations": [ + "feat(api): deprecated 1" + ], + "others": [ + "refactor: code refactoring 1" + ], + "docs": [ + "docs: update doc 1" + ], + "non_conventional_commit": [ + "some non-conventional commit 1" + ] + } +} \ No newline at end of file diff --git a/tests/data/changelogs/service-2.json b/tests/data/changelogs/service-2.json new file mode 100644 index 0000000..8bc0562 --- /dev/null +++ b/tests/data/changelogs/service-2.json @@ -0,0 +1,29 @@ +{ + "version": "2.0.0", + "bump_rules": { + "major": true, + "minor": true, + "patch": true + }, + "changelog": { + "features": [ + "feat(api)!: new api 2" + ], + "bugfixes": [ + "fix: test fix 2.1", + "fix(api)!: test fix 2.2" + ], + "deprecations": [ + "feat(api): deprecated 2" + ], + "others": [ + "refactor: code refactoring 2" + ], + "docs": [ + "docs: update doc 2" + ], + "non_conventional_commit": [ + "some non-conventional commit 2" + ] + } +} \ No newline at end of file diff --git a/tests/data/joined_changelog.json b/tests/data/joined_changelog.json new file mode 100644 index 0000000..645a49f --- /dev/null +++ b/tests/data/joined_changelog.json @@ -0,0 +1,63 @@ +{ + "version": "1.0.0", + "services": { + "service-1": { + "version": "1.2.0", + "bump_rules": { + "major": false, + "minor": true, + "patch": true + }, + "changelog": { + "features": [ + "feat(api): new api 1.1", + "feat(api): update api 1.2" + ], + "bugfixes": [ + "fix: test fix 1" + ], + "deprecations": [ + "feat(api): deprecated 1" + ], + "others": [ + "refactor: code refactoring 1" + ], + "docs": [ + "docs: update doc 1" + ], + "non_conventional_commit": [ + "some non-conventional commit 1" + ] + } + }, + "service-2": { + "version": "2.0.0", + "bump_rules": { + "major": true, + "minor": true, + "patch": true + }, + "changelog": { + "features": [ + "feat(api)!: new api 2" + ], + "bugfixes": [ + "fix: test fix 2.1", + "fix(api)!: test fix 2.2" + ], + "deprecations": [ + "feat(api): deprecated 2" + ], + "others": [ + "refactor: code refactoring 2" + ], + "docs": [ + "docs: update doc 2" + ], + "non_conventional_commit": [ + "some non-conventional commit 2" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/test_changelogs_mngr.py b/tests/test_changelogs_mngr.py new file mode 100644 index 0000000..b7ad8e6 --- /dev/null +++ b/tests/test_changelogs_mngr.py @@ -0,0 +1,58 @@ +import json +import unittest +from src.changelogs_mngr import ChangelogsMngr, Git, ChangelogsMngrError +from unittest import mock + + +class TestChangelogsMngr(unittest.TestCase): + + @mock.patch.object(Git, "bump_current_version") + def test_join_changelogs(self, monkeypatch): + monkeypatch.return_value = "1.0.0" + res = ChangelogsMngr().read_files("./tests/data/changelogs/", "json") + with open("tests/data/joined_changelog.json", "r") as fp: + expected = json.load(fp) + assert res == expected + + def test_join_changelogs_template(self): + join_changelogs = ChangelogsMngr("2.1.2") + join_changelogs.read_files("./tests/data/changelogs/", "json") + res = join_changelogs.changelog_generate(template_name="src/templates/changelog-common.tmpl") + expected = "# Change Log\n\n" \ + "## Version: 3.0.0\n\n\n\n\n\n" \ + "## Services\n\n\n\n" \ + "### Service-1\n\n" \ + "Version: 1.2.0\n\n\n" \ + "#### Features\n\n\n* Feat(api): new api 1.1\n\n* Feat(api): update api 1.2\n\n\n\n\n\n" \ + "#### Bug Fixes\n\n\n* Fix: test fix 1\n\n\n\n\n\n" \ + "#### Deprecations\n\n\n* Feat(api): deprecated 1\n\n\n\n\n" \ + "#### Improved Documentation\n\n\n* Docs: update doc 1\n\n\n\n\n" \ + "#### Trivial/Internal Changes\n\n\n* Refactor: code refactoring 1\n\n\n\n\n\n" \ + "### Service-2\n\n" \ + "Version: 2.0.0\n\n\n" \ + "#### Features\n\n\n* Feat(api)!: new api 2\n\n\n\n\n\n" \ + "#### Bug Fixes\n\n\n* Fix: test fix 2.1\n\n* Fix(api)!: test fix 2.2\n\n\n\n\n\n" \ + "#### Deprecations\n\n\n* Feat(api): deprecated 2\n\n\n\n\n" \ + "#### Improved Documentation\n\n\n* Docs: update doc 2\n\n\n\n\n" \ + "#### Trivial/Internal Changes\n\n\n* Refactor: code refactoring 2\n\n\n\n" + l_res = res.split("\n") + l_expected = expected.split("\n") + for pos, _ in enumerate(l_res): + self.assertEqual(l_expected[pos], l_res[pos]) + + def test_init_changelog(self): + chl_mngr = ChangelogsMngr() + chl_mngr._changelogs = {"version": "0.0.0", "services": {"test": None}} + chl_mngr._bump_version_rules = {"major": True, "minor": True, "patch": True} + chl_mngr._init_changelog() + self.assertEqual(chl_mngr._changelogs, {"version": "0.0.1", "services": {}}) + self.assertEqual(chl_mngr._bump_version_rules, {"major": False, "minor": False, "patch": False}) + + def test_changelog_generate(self): + template_name = "no-template.tmpl" + chl_mngr = ChangelogsMngr() + + with self.assertRaises(ChangelogsMngrError) as context: + chl_mngr.changelog_generate(template_name=template_name) + self.assertEqual(f"ERROR: Template '{template_name}' was not found.", + str(context.exception)) diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..6dadb41 --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,361 @@ +from src.git import Git + +GIT_LOG_OUTPUT_MOCK = "fix: test fix 1\n" \ + "feat(api)!: new api\n" \ + "fix(api)!: test fix 2\n" \ + "docs: update doc\n" \ + "deprecated: deprecated api\n" \ + "some non-conventional commit\n" \ + "refactor: code refactoring" + + +def test_version(monkeypatch): + monkeypatch.setattr(Git, "_cmd", value=lambda *args, **kwargs: "git version 2.40.0") + assert Git.git_version() >= "2.22.0" + + +def test_tags(): + # Note: git repo should have a tag with version v0.0.1. This check is not mocked. + assert "v0.0.1" in Git.tags() + + +def test_version_current(monkeypatch): + monkeypatch.setattr(Git, "tags", lambda: []) + assert "0.0.0" == Git.version_current() + + monkeypatch.setattr(Git, "tags", lambda: ["0.0.2", "0.0.1"]) + assert "0.0.2" == Git.version_current() + + monkeypatch.setattr(Git, "tags", lambda: ["1.0.error"]) + assert "0.0.0" == Git.version_current() + + monkeypatch.setattr(Git, "tags", lambda: ["1.0.error", "0.1.2"]) + assert "0.1.2" == Git.version_current() + + monkeypatch.setattr(Git, "tags", lambda: ["R23.03-rc.1", "0.0.1", "0.0.0"]) + assert "0.0.1" == Git.version_current() + + monkeypatch.setattr(Git, "tags", lambda: ["R23.08.10-rc.1", "0.0.1", "0.0.0"]) + assert "0.0.1" == Git.version_current() + + monkeypatch.setattr(Git, "tags", lambda: ["v23.08.10-rc.1", "0.0.1", "0.0.0"]) + assert "v23.08.10-rc.1" == Git.version_current() + + +def test_changelog_group(monkeypatch): + monkeypatch.setattr(Git, "changelog", value=lambda *args, **kwargs: GIT_LOG_OUTPUT_MOCK) + monkeypatch.setattr(Git, "version_current", value=lambda: "1.2.3") + + # Test normalized commits + res = Git.changelog_group(unique=True) + expected_normalized = { + 'version': '2.0.0', + 'bump_rules': { + 'major': True, + 'minor': True, + 'patch': True + }, + 'changelog': { + 'features': [ + 'new api' + ], + 'bugfixes': [ + 'test fix 1', + 'test fix 2' + ], + 'deprecations': [ + 'deprecated api' + ], + 'others': [ + 'code refactoring' + ], + 'docs': [ + 'update doc' + ], + 'non_conventional_commit': [ + 'some non-conventional commit' + ] + } + } + assert res == expected_normalized + + # Test duplicates + monkeypatch.setattr(Git, + "changelog", + value=lambda *args, **kwargs: f"{GIT_LOG_OUTPUT_MOCK}\n{GIT_LOG_OUTPUT_MOCK}") + monkeypatch.setattr(Git, "version_current", value=lambda: "1.2.0") + res = Git.changelog_group(commit_wo_prefix=True, unique=True) + assert res == expected_normalized + + monkeypatch.setattr(Git, + "changelog", + value=lambda *args, **kwargs: f"feat(api)!: new api\n{GIT_LOG_OUTPUT_MOCK}") + monkeypatch.setattr(Git, "version_current", value=lambda: "1.2.0") + res = Git.changelog_group(commit_wo_prefix=True, unique=True) + assert res == expected_normalized + + # Test non-normalized commits + monkeypatch.setattr(Git, "changelog", value=lambda *args, **kwargs: GIT_LOG_OUTPUT_MOCK) + res = Git.changelog_group(commit_wo_prefix=False) + assert res == { + 'version': '2.0.0', + 'bump_rules': { + 'major': True, + 'minor': True, + 'patch': True + }, + 'changelog': { + 'features': [ + 'feat(api)!: new api' + ], + 'bugfixes': [ + 'fix: test fix 1', + 'fix(api)!: test fix 2' + ], + 'deprecations': [ + 'deprecated: deprecated api' + ], + 'others': [ + 'refactor: code refactoring' + ], + 'docs': [ + 'docs: update doc' + ], + 'non_conventional_commit': [ + 'some non-conventional commit' + ] + } + } + + monkeypatch.setattr(Git, "changelog", value=lambda *args, **kwargs: GIT_LOG_OUTPUT_MOCK) + monkeypatch.setattr(Git, "version_current", value=lambda: "1.2.0") + res = Git.changelog_group(commit_wo_prefix=False, + end="1.0.0" + ) + assert res["version"] == '1.0.0' + + +def test_changelog_generate(monkeypatch): + monkeypatch.setattr(Git, "changelog", value=lambda *args, **kwargs: GIT_LOG_OUTPUT_MOCK) + monkeypatch.setattr(Git, "git_version", value=lambda *args, **kwargs: "git version v1.0.0") + changelog = Git.changelog_generate(changelog_group=Git.changelog_group(), + template_name="src/templates/changelog.tmpl") + assert changelog == "##########\nChange Log\n##########\n\nVersion v1.0.0\n=============\n\n\n\n\n\n" \ + "Features\n--------\n\n\n* New api\n\n\n\n\n\n" \ + "Bug Fixes\n---------\n\n\n* Test fix 1\n\n* Test fix 2\n\n\n\n\n\n" \ + "Deprecations\n------------\n\n\n* Deprecated api\n\n\n\n\n" \ + "Improved Documentation\n----------------------\n\n\n* Update doc\n\n\n\n\n" \ + "Trivial/Internal Changes\n------------------------\n\n\n* Code refactoring\n\n" + changelog = Git.changelog_generate(changelog_group=Git.changelog_group(), + template_name="src/templates/no-template.tmpl") + assert changelog.startswith("ERROR: Template ") is True + + +def test_bump_version_auto(monkeypatch): + bump_rules = {"major": True, "minor": False, "patch": False} + ver = Git.bump_version("", bump_rules) + assert "1.0.0" == ver + + bump_rules = {"major": True, "minor": False, "patch": False} + ver = Git.bump_version("1.2.0", bump_rules) + assert "2.0.0" == ver + + +def test_bump_current_version_auto(monkeypatch): + monkeypatch.setattr(Git, "version_current", value=lambda: "") + + bump_rules = {"major": True, "minor": False, "patch": False} + ver = Git.bump_current_version(bump_rules) + assert "1.0.0" == ver + + monkeypatch.setattr(Git, "version_current", value=lambda: "1.2.0") + + bump_rules = {"major": True, "minor": False, "patch": False} + ver = Git.bump_current_version(bump_rules) + assert "2.0.0" == ver + + bump_rules = {"major": True, "minor": True, "patch": False} + ver = Git.bump_current_version(bump_rules) + assert "2.0.0" == ver + + bump_rules = {"major": True, "minor": True, "patch": True} + ver = Git.bump_current_version(bump_rules) + assert "2.0.0" == ver + + ver = Git.bump_current_version({"major": False, "minor": True, "patch": False}) + assert "1.3.0" == ver + + ver = Git.bump_current_version({"major": False, "minor": True, "patch": True}) + assert "1.3.0" == ver + + ver = Git.bump_current_version({"major": False, "minor": False, "patch": True}) + assert "1.2.1" == ver + + +def test_version_prefix(monkeypatch): + assert "" == Git._version_prefix("1.2.3") + assert "v" == Git._version_prefix("v1.2.3") + assert "v-" == Git._version_prefix("v-1.2.3") + assert "v-v" == Git._version_prefix("v-v1.2.3") + assert "v " == Git._version_prefix("v 1.2.3") + + +def test_version_validate(): + assert Git.version_validate("0.1.3") is True + assert Git.version_validate("v0.1.3") is True + assert Git.version_validate("0.1.3-rc1") is True + assert Git.version_validate("v0.1.3-rc1") is True + assert Git.version_validate("10.21.33") is True + assert Git.version_validate("v10.21.33") is True + assert Git.version_validate("10.21.33-rc1") is True + assert Git.version_validate("v10.21.33-rc1") is True + assert Git.version_validate("10.21.33") is True + assert Git.version_validate("v10.21.33") is True + assert Git.version_validate("10.21.33-rc1") is True + assert Git.version_validate("v10.21.33-rc1.2-") is False + + +def test_check_commit_message(): + assert Git.check_commit_message("fix: test commit msg") is True + assert Git.check_commit_message("fix(api): test commit msg") is True + assert Git.check_commit_message("fix!: test commit msg") is True + assert Git.check_commit_message("fix(api)!: test commit msg") is True + assert Git.check_commit_message("feat: test commit msg") is True + assert Git.check_commit_message("feat(api): test commit msg") is True + assert Git.check_commit_message("feat!: test commit msg") is True + assert Git.check_commit_message("feat(api)!: test commit msg") is True + assert Git.check_commit_message("build: test commit msg") is True + assert Git.check_commit_message("chore: test commit msg") is True + assert Git.check_commit_message("ci: test commit msg") is True + assert Git.check_commit_message("docs: test commit msg") is True + assert Git.check_commit_message("style: test commit msg") is True + assert Git.check_commit_message("refactor: test commit msg") is True + assert Git.check_commit_message("perf: test commit msg") is True + assert Git.check_commit_message("test: test commit msg") is True + assert Git.check_commit_message("test:test commit msg") is True + assert Git.check_commit_message("BREAKING CHANGE: test commit msg") is True + assert Git.check_commit_message("Breaking change: test commit msg") is True + assert Git.check_commit_message("deprecated: test commit msg") is True + assert Git.check_commit_message("deprecated(api): test commit msg") is True + assert Git.check_commit_message("test commit msg") is False + assert Git.check_commit_message("test:") is False + assert Git.check_commit_message("test: ") is False + assert Git.check_commit_message("feat: test, BREAKING CHANGE: test commit msg") is True + assert Git.check_commit_message("feat: test, \nBREAKING CHANGE: test commit msg") is True + assert Git.check_commit_message("feat: test, Breaking change: test commit msg") is True + assert Git.check_commit_message("feat: test, \nBreaking change: test commit msg") is True + assert Git.check_commit_message("Merge branch 'test/test-rebase-2' into test/test-rebase-1") is True + assert Git.check_commit_message("Merge branch-'test/test-rebase-2' into test/test-rebase-1") is False + assert Git.check_commit_message(" Merge branch 'test/test-rebase-2' into test/test-rebase-1") is False + assert Git.check_commit_message("Merge pull request 'test/test-rebase-2' into test/test-rebase-1") is True + assert Git.check_commit_message("Merge pull request-'test/test-rebase-2' into test/test-rebase-1") is False + assert Git.check_commit_message(" Merge pull request'test/test-rebase-2' into test/test-rebase-1") is False + + +def test_commit_msg_normalize(): + assert Git._commit_msg_normalize("fix: test ") == "test" + assert Git._commit_msg_normalize("fix: test") == "test" + assert Git._commit_msg_normalize("fix:test") == "test" + assert Git._commit_msg_normalize("test") == "test" + assert Git._commit_msg_normalize(" test ") == "test" + + +def test_changelog_group_sort(): + expected = { + 'features': [ + 'feat(api)!: new api' + ], + 'bugfixes': [ + 'fix: test fix 1', + 'fix(api)!: test fix 2' + ], + 'deprecations': [], + 'others': [], + 'docs': [], + 'non_conventional_commit': [] + } + res = Git._changelog_group_sort(git_log="feat(api)!: new api\n" + "fix: test fix 1\n" + "fix(api)!: test fix 2", + commit_wo_prefix=False, + unique=True + ) + assert expected == res["changelog"] + + expected = { + 'features': [ + 'new api' + ], + 'bugfixes': [ + 'test fix 1', + 'test fix 2' + ], + 'deprecations': [], + 'others': [], + 'docs': [], + 'non_conventional_commit': [] + } + res = Git._changelog_group_sort(git_log="feat(api)!: new api\n" + "fix: test fix 1\n" + "fix(api)!: test fix 2", + commit_wo_prefix=True, + unique=True + ) + assert expected == res["changelog"] + + +def test_changelog_group_bump_version(): + # Patch + res = Git._changelog_group_sort(git_log="chore: test update 1\n", + commit_wo_prefix=False, + unique=True + ) + expected_bump_rules = {'major': False, 'minor': False, 'patch': True} + assert expected_bump_rules == res["bump_rules"] + + res = Git._changelog_group_sort(git_log="fix: test update 1\n", + commit_wo_prefix=False, + unique=True + ) + expected_bump_rules = {'major': False, 'minor': False, 'patch': True} + assert expected_bump_rules == res["bump_rules"] + + res = Git._changelog_group_sort(git_log="docs: remove deprecated docs 1\n", + commit_wo_prefix=False, + unique=True + ) + expected_bump_rules = {'major': False, 'minor': False, 'patch': True} + assert expected_bump_rules == res["bump_rules"] + + res = Git._changelog_group_sort(git_log="non conventional commit\n", + commit_wo_prefix=False, + unique=True + ) + expected_bump_rules = {'major': False, 'minor': False, 'patch': True} + assert expected_bump_rules == res["bump_rules"] + + # Minor + res = Git._changelog_group_sort(git_log="feat(api): update response\n" + "fix: test fix 1\n", + commit_wo_prefix=False, + unique=True + ) + expected_bump_rules = {'major': False, 'minor': True, 'patch': True} + assert expected_bump_rules == res["bump_rules"] + + # Major + res = Git._changelog_group_sort(git_log="feat(api)!: new api", commit_wo_prefix=False, unique=True) + assert res["bump_rules"]["major"] + + res = Git._changelog_group_sort(git_log="fix(api)!: new api", commit_wo_prefix=False, unique=True) + assert res["bump_rules"]["major"] + + res = Git._changelog_group_sort(git_log="breaking change: api", commit_wo_prefix=False, unique=True) + assert res["bump_rules"]["major"] + + res = Git._changelog_group_sort(git_log="deprecated: api", commit_wo_prefix=False, unique=True) + assert res["bump_rules"]["major"] + + res = Git._changelog_group_sort(git_log="fix: api, breaking change: remove api endpoint", commit_wo_prefix=False, + unique=True) + assert res["bump_rules"]["major"] diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..feee084 --- /dev/null +++ b/tox.ini @@ -0,0 +1,86 @@ +[tox] +envlist = + checks + py3{7,8,9,10,11,12} +isolated_build = True +skip_missing_interpreters = True + +[gh-actions] +python = + # setuptools >=62 needs Python >=3.7 + 3.7: py37,check + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311 + 3.12: py312 + + +[testenv] +description = Run test suite for {basepython} +skip_install = true +commands = pytest {posargs:} +deps = + pytest + pytest-cov + setuptools>=62.0 + setuptools-scm + Jinja2 +setenv = + PIP_DISABLE_PIP_VERSION_CHECK = 1 + + +[testenv:black] +description = Check for formatting changes +basepython = python3 +skip_install = true +deps = black +commands = black --check -v {posargs:./src} + + +[testenv:flake8] +description = Check code style +basepython = python3 +deps = flake8 +commands = flake8 --config .flake8rc {posargs:} + + +[testenv:mypy] +description = Check code style +basepython = python3 +deps = mypy +commands = mypy {posargs:--ignore-missing-imports --check-untyped-defs src} + + +[testenv:docstrings] +description = Check for PEP257 compatible docstrings +basepython = python3 +deps = docformatter +commands = docformatter --check --diff {posargs:--pre-summary-newline -r src} + +[testenv:coverage] +description = Test coverage +basepython = python3 +deps = + -rrequirements.txt + pytest + pytest-cov +commands = + coverage run -m pytest + coverage report -m + +[testenv:checks] +description = Run code style checks +basepython = python3 +deps = + {[testenv:black]deps} + {[testenv:flake8]deps} + {[testenv:mypy]deps} + {[testenv:docstrings]deps} + {[testenv:coverage]deps} +commands = + {[testenv:black]commands} + {[testenv:flake8]commands} + {[testenv:mypy]commands} + {[testenv:docstrings]commands} + {[testenv:coverage]commands}