diff --git a/.github/workflows/bumpversion.yml b/.github/workflows/bumpversion.yml index f7559b420b..90c8af6373 100644 --- a/.github/workflows/bumpversion.yml +++ b/.github/workflows/bumpversion.yml @@ -32,7 +32,7 @@ jobs: git pull origin master --tags - name: Create bump run: | - cz bump --yes + cz bump --yes --changelog git tag - name: Push changes uses: Woile/github-push-action@master diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d614abaf..b8736827ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,139 +1,504 @@ -# CHANGELOG -## v1.12.0 -### Feature +## Unreleased + +### Refactor + +- **changelog**: rename category to change_type to fit 'keep a changelog' +- **templates**: rename as "keep_a_changelog_template.j2" +- **templates**: remove unneeded __init__ file +- **cli**: reorder commands +- **templates**: move changelog_template from cz to templates +- **tests/utils**: move create_file_and_commit to tests/utils +- **commands/changelog**: remove redundant if statement +- **commands/changelog**: use jinja2 template instead of string concatenation to build changelog + +### Fix + +- **cz/conventional_commits**: fix schema_pattern break due to rebase +- **changelog_template**: fix list format +- **commitizen/cz**: set changelog_map, changelog_pattern to none as default +- **commands/changelog**: remove --skip-merge argument +- **cli**: add changelog arguments + +### Feat + +- **commands/changelog**: make changelog_file an option in config +- **commands/changelog**: exit when there is no commit exists +- **commands/changlog**: add --start-rev argument to `cz changelog` +- **changelog**: generate changelog based on git log +- **commands/changelog**: generate changelog_tree from all past commits +- **cz/conventinal_commits**: add changelog_map, changelog_pattern and implement process_commit +- **cz/base**: add default process_commit for processing commit message +- **changelog**: changelog tree generation from markdown + +## v1.17.0 (2020-03-15) + +### Refactor + +- **tests/bump**: use parameterize to group similliar tests +- **cz/connventional_commit**: use \S to check scope +- **git**: remove unnecessary dot between git range + +### Fix + +- **bump**: fix bump find_increment error + +### Feat + +- **commands/check**: add --rev-range argument for checking commits within some range + +## v1.16.4 (2020-03-03) + +### Fix + +- **commands/init**: fix clean up file when initialize commitizen config + +### Refactor + +- **defaults**: split config files into long term support and deprecated ones + +## v1.16.3 (2020-02-20) + +### Fix + +- replace README.rst with docs/README.md in config files + +### Refactor + +- **docs**: remove README.rst and use docs/README.md + +## v1.16.2 (2020-02-01) + +### Fix + +- **commands/check**: add bump into valid commit message of convention commit pattern + +## v1.16.1 (2020-02-01) + +### Fix + +- **pre-commit**: set pre-commit check stage to commit-msg + +## v1.16.0 (2020-01-21) + +### Refactor + +- **commands/bump**: rename parameter into bump_setting to distinguish bump_setting and argument +- **git**: rename get tag function to distinguish return str and GitTag +- **cmd**: reimplement how cmd is run +- **git**: Use GitCommit, GitTag object to store commit and git information +- **git**: make arguments other then start and end in get_commit keyword arguments +- **git**: Change get_commits into returning commits instead of lines of messsages + +### Feat + +- **git**: get_commits default from first_commit + +## v1.15.1 (2020-01-20) + +## v1.15.0 (2020-01-20) + +### Refactor + +- **tests/commands/bump**: use tmp_dir to replace self implemented tmp dir behavior +- **test_bump_command**: rename camel case variables +- **tests/commands/check**: use pytest fixture tmpdir replace self implemented contextmanager +- **test/commands/other**: replace unit test style mock with mocker fixture +- **tests/commands**: separate command unit tests into modules +- **tests/commands**: make commands related tests a module +- **git**: make find_git_project_root return None if it's not a git project +- **config/base_config**: make set_key not implemented +- **error_codes**: move all the error_codes to a module +- **config**: replace string type path with pathlib.Path + +### Fix + +- **cli**: fix --version not functional +- **git**: remove breakline in the return value of find_git_project_root + +### Feat + +- **config**: look up configuration in git project root +- **git**: add find_git_project_root + +## v1.14.2 (2020-01-14) + +### Fix + +- **github_workflow/pythonpublish**: use peaceiris/actions-gh-pages@v2 to publish docs + +## v1.14.1 (2020-01-11) + +## v1.14.0 (2020-01-06) + +### Refactor + +- **pre-commit-hooks**: add metadata for the check hook + +### Feat + +- **pre-commit-hooks**: add pre-commit hook + +### Fix + +- **cli**: fix the way default handled for name argument +- **cli**: fix name cannot be overwritten through config in newly refactored config design + +## v1.13.1 (2019-12-31) + +### Fix + +- **github_workflow/pythonpackage**: set git config for unit testing +- **scripts/test**: ensure the script fails once the first failure happens + +## v1.13.0 (2019-12-30) + +### Feat + +- add project version to command init + +## v1.12.0 (2019-12-30) + +### Feat - new init command -## v1.11.0 +## v1.10.3 (2019-12-29) -Ignore this version +### Refactor -## v1.10.0 +- **commands/bump**: use "version_files" internally +- **config**: set "files" to alias of "version_files" -### Feature +## v1.10.2 (2019-12-27) -- new argument `--files-only` in bump +### Refactor -## v1.9.2 +- new config system where each config type has its own class +- **config**: add type annotation to config property +- **config**: fix wrongly type annoated functions +- **config/ini_config**: move deprecation warning into class initialization +- **config**: use add_path instead of directly assigning _path +- **all**: replace all the _settings invoke with settings.update +- **cz/customize**: remove unnecessary statement "raise NotImplementedError("Not Implemented yet")" +- **config**: move default settings back to defaults +- **config**: Make config a class and each type of config (e.g., toml, ini) a child class ### Fix -- `--commit-msg-file` is now a required argument +- **config**: handle empty config file +- **config**: fix load global_conf even if it doesn't exist +- **config/ini_config**: replase outdated _parse_ini_settings with _parse_settings -## v1.9.1 +## v1.10.1 (2019-12-10) ### Fix -- exception `AnswerRequiredException` not caught +- **cli**: overwrite "name" only when it's not given +- **config**: fix typo -## v1.9.0 +## v1.10.0 (2019-11-28) -### Feature +### Feat -- new `version` command. `--version` will be deprecated in `2.0.0` -- new `git-cz` entrypoint. After installing `commitizen` you can run `git cz c` (#60) -- new `--dry-run` argument in `commit` (#56) -- new `cz check` command which checks if the message is valid with the rules (#59). Useful for git hooks. -- create a commiting rule directly in the config file (#54) -- support for multi-line body (#6) -- support for jinja templates. Install doign `pip install -U commitizen[jinja2]`. -- support for `.cz.toml`. The confs depending on `ConfigParser` will be deprecated in `2.0.0`. +- support for different commitizens in `cz check` +- **bump**: new argument --files-only +## v1.9.2 (2019-11-23) ### Fix -- tests were fixed -- windows error when removing folders (#67) -- typos in docs +- **commands/check.py**: --commit-msg-file is now a required argument -### Docs -- tutorial for gitlab ci -- tutorial for github actions +## v1.9.1 (2019-11-23) -## v1.8.0 +### Fix -### Feature +- **cz/exceptions**: exception AnswerRequiredException not caught (#89) -- new custom exception for commitizen -- commit is aborted if nothing in staging +## v1.9.0 (2019-11-22) -## v1.7.0 +### Feat -### Feature +- **Commands/check**: enforce the project to always use conventional commits +- **config**: add deprecation warning for loading config from ini files +- **cz/customize**: add jinja support to enhance template flexibility +- **cz/filters**: add required_validator and multiple_line_breaker +- **Commands/commit**: add ´--dry-run´ flag to the Commit command +- **cz/cz_customize**: implement info to support info and info_path +- **cz/cz_customize**: enable bump_pattern bump_map customization +- **cz/cz_customize**: implement customizable cz +- new 'git-cz' entrypoint -- new styles for the prompt -- new configuration option for the prompt styles +### Refactor -## v1.6.0 +- **config**: remove has_pyproject which is no longer used +- **cz/customize**: make jinja2 a custom requirement. if not installed use string.Tempalte instead +- **cz/utils**: rename filters as utils +- **cli**: add back --version and remove subcommand required constraint -### Feature +### Fix -- new retry argument to execute previous commit again +- commit dry-run doesnt require staging to be clean +- correct typo to spell "convention" +- removing folder in windows throwing a PermissionError +- **scripts**: add back the delelte poetry prefix +- **test_cli**: testing the version command -## v1.5.1 +## v1.8.0 (2019-11-12) ### Fix -- issue in poetry add preventing the installation in py36 +- **commands/commit**: catch exception raised by customization cz +- **cli**: handle the exception that command is not given +- **cli**: enforce subcommand as required + +### Refactor + +- **cz/conventional_commit**: make NoSubjectException inherit CzException and add error message +- **command/version**: use out.write instead of out.line +- **command**: make version a command instead of an argument + +### Feat + +- **cz**: add a base exception for cz customization +- **commands/commit**: abort commit if there is nothing to commit +- **git**: add is_staging_clean to check if there is any file in git staging + +## v1.7.0 (2019-11-08) + +### Fix + +- **cz**: fix bug in BaseCommitizen.style +- **cz**: fix merge_style usage error +- **cz**: remove breakpoint + +### Refactor + +- **cz**: change the color of default style + +### Feat + +- **config**: update style instead of overwrite +- **config**: parse style in config +- **commit**: make style configurable for commit command -## v1.5.0 +## v1.6.0 (2019-11-05) -### Feature +### Feat -- it is possible to specify a pattern to be matched in configuration `files` when doing bump. +- **commit**: new retry argument to execute previous commit again -## v1.4.0 +## v1.5.1 (2019-06-04) -### Feature +### Fix + +- #28 allows poetry add on py36 envs + +## v1.5.0 (2019-05-11) -- new argument (--yes) in bump to accept prompt questions +### Feat + +- **bump**: it is now possible to specify a pattern in the files attr to replace the version + +## v1.4.0 (2019-04-26) ### Fix -- error is shown when commiting fails. +- **bump**: handle commit and create tag failure + +### Feat -## v1.3.0 +- added argument yes to bump in order to accept questions -### Feature +## v1.3.0 (2019-04-24) -- bump: new commit message template, useful when having to skip ci. +### Feat -## v1.2.1 +- **bump**: new commit message template + +## v1.2.1 (2019-04-21) ### Fix -- prefixes like docs, build, etc no longer generate a PATCH +- **bump**: prefixes like docs, build, etc no longer generate a PATCH -## v1.2.0 +## v1.2.0 (2019-04-19) -### Feature +### Feat - custom cz plugins now support bumping version -## v1.1.1 +## v1.1.1 (2019-04-18) + +### Refactor + +- changed stdout statements +- **schema**: command logic removed from commitizen base +- **info**: command logic removed from commitizen base +- **example**: command logic removed from commitizen base +- **commit**: moved most of the commit logic to the commit command + +### Fix + +- **bump**: commit message now fits better with semver +- conventional commit 'breaking change' in body instead of title + +## v1.1.0 (2019-04-14) + +### Feat + +- new working bump command +- create version tag +- update given files with new version +- **config**: new set key, used to set version to cfg +- support for pyproject.toml +- first semantic version bump implementaiton ### Fix -- breaking change is now part of the body, instead of being in the subject +- removed all from commit +- fix config file not working + +### Refactor + +- added commands folder, better integration with decli + +## v1.0.0 (2019-03-01) + +### Refactor + +- removed delegator, added decli and many tests + +## 1.0.0b2 (2019-01-18) + +## v1.0.0b1 (2019-01-17) + +### Feat + +- py3 only, tests and conventional commits 1.0 + +## v0.9.11 (2018-12-17) + +### Fix + +- **config**: load config reads in order without failing if there is no commitizen section + +## v0.9.10 (2018-09-22) + +### Fix + +- parse scope (this is my punishment for not having tests) + +## v0.9.9 (2018-09-22) + +### Fix + +- parse scope empty + +## v0.9.8 (2018-09-22) + +### Fix + +- **scope**: parse correctly again + +## v0.9.7 (2018-09-22) + +### Fix + +- **scope**: parse correctly + +## v0.9.6 (2018-09-19) + +### Refactor + +- **conventionalCommit**: moved fitlers to questions instead of message + +### Fix + +- **manifest**: inluded missing files + +## v0.9.5 (2018-08-24) + +### Fix + +- **config**: home path for python versions between 3.0 and 3.5 + +## v0.9.4 (2018-08-02) + +### Feat + +- **cli**: added version + +## v0.9.3 (2018-07-28) + +### Feat + +- **commiter**: conventional commit is a bit more intelligent now + +## v0.9.2 (2017-11-11) + +### Refactor + +- **renamed conventional_changelog to conventional_commits, not backward compatible**: + +## v0.9.1 (2017-11-11) + +### Fix + +- **setup.py**: future is now required for every python version + +## v0.9.0 (2017-11-08) + +### Refactor + +- python 2 support + +## v0.8.6 (2017-11-08) + +## v0.8.5 (2017-11-08) + +## v0.8.4 (2017-11-08) + +## v0.8.3 (2017-11-08) + +## v0.8.2 (2017-10-08) + +## v0.8.1 (2017-10-08) + +## v0.8.0 (2017-10-08) + +### Feat + +- **cz**: jira smart commits + +## v0.7.0 (2017-10-08) + +### Refactor + +- **cli**: renamed all to ls command +- **cz**: renamed angular cz to conventional changelog cz + +## v0.6.0 (2017-10-08) + +### Feat + +- info command for angular -## v1.1.0 +## v0.5.0 (2017-10-07) -### Features +## v0.4.0 (2017-10-07) -- auto bump version based on conventional commits using sem ver -- pyproject support (see [pyproject.toml](./pyproject.toml) for usage) +## v0.3.0 (2017-10-07) -## v1.0.0 +## v0.2.0 (2017-10-07) -### Features +### Feat -- more documentation -- added tests -- support for conventional commits 1.0.0 +- **config**: new loads from ~/.cz and working project .cz .cz.cfg and setup.cfg +- package discovery -### BREAKING CHANGES +### Refactor -- use of questionary to generate the prompt (so we depend on promptkit 2.0) -- python 3 only +- **cz_angular**: improved messages diff --git a/commitizen/changelog.py b/commitizen/changelog.py index b902640897..3990b4cfb5 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -1,33 +1,40 @@ """ # DESIGN -## Parse CHANGELOG.md +## Metadata CHANGELOG.md -1. Get LATEST VERSION from CONFIG -1. Parse the file version to version -2. Build a dict (tree) of that particular version -3. Transform tree into markdown again +1. Identify irrelevant information (possible: changelog title, first paragraph) +2. Identify Unreleased area +3. Identify latest version (to be able to write on top of it) ## Parse git log 1. get commits between versions 2. filter commits with the current cz rules 3. parse commit information -4. generate tree - -Options: -- Generate full or partial changelog +4. yield tree nodes +5. format tree nodes +6. produce full tree +7. generate changelog + +Extra: +- [x] Generate full or partial changelog +- [x] Include in tree from file all the extra comments added manually +- [ ] Add unreleased value +- [ ] hook after message is parsed (add extra information like hyperlinks) +- [ ] hook after changelog is generated (api calls) +- [ ] add support for change_type maps """ +import os import re -from typing import Dict, Generator, Iterable, List +from collections import defaultdict +from typing import Dict, Iterable, List, Optional -MD_VERSION_RE = r"^##\s(?P[a-zA-Z0-9.+]+)\s?\(?(?P[0-9-]+)?\)?" -MD_CATEGORY_RE = r"^###\s(?P[a-zA-Z0-9.+\s]+)" -MD_MESSAGE_RE = r"^-\s(\*{2}(?P[a-zA-Z0-9]+)\*{2}:\s)?(?P.+)" -md_version_c = re.compile(MD_VERSION_RE) -md_category_c = re.compile(MD_CATEGORY_RE) -md_message_c = re.compile(MD_MESSAGE_RE) +import pkg_resources +from jinja2 import Template +from commitizen import defaults +from commitizen.git import GitCommit, GitTag CATEGORIES = [ ("fix", "fix"), @@ -42,92 +49,201 @@ ] -def find_version_blocks(filepath: str) -> Generator: - """ - version block: contains all the information about a version. - - E.g: - ``` - ## 1.2.1 (2019-07-20) - - ## Bug fixes - - - username validation not working - - ## Features - - - new login system - - ``` - """ - with open(filepath, "r") as f: - block: list = [] - for line in f: - line = line.strip("\n") - if not line: - continue - - if line.startswith("## "): - if len(block) > 0: - yield block - block = [line] - else: - block.append(line) - yield block - - -def parse_md_version(md_version: str) -> Dict: - m = md_version_c.match(md_version) - if not m: - return {} - return m.groupdict() - - -def parse_md_category(md_category: str) -> Dict: - m = md_category_c.match(md_category) +def transform_change_type(change_type: str) -> str: + # TODO: Use again to parse, for this we have to wait until the maps get + # defined again. + _change_type_lower = change_type.lower() + for match_value, output in CATEGORIES: + if re.search(match_value, _change_type_lower): + return output + else: + raise ValueError(f"Could not match a change_type with {change_type}") + + +def get_commit_tag(commit: GitCommit, tags: List[GitTag]) -> Optional[GitTag]: + """""" + return next((tag for tag in tags if tag.rev == commit.rev), None) + + +def generate_tree_from_commits( + commits: List[GitCommit], + tags: List[GitTag], + commit_parser: str, + changelog_pattern: str = defaults.bump_pattern, + unreleased_version: Optional[str] = None, +) -> Iterable[Dict]: + pat = re.compile(changelog_pattern) + map_pat = re.compile(commit_parser) + # Check if the latest commit is not tagged + latest_commit = commits[0] + current_tag: Optional[GitTag] = get_commit_tag(latest_commit, tags) + + current_tag_name: str = unreleased_version or "Unreleased" + current_tag_date: str = "" + if current_tag is not None and current_tag.name: + current_tag_name = current_tag.name + current_tag_date = current_tag.date + + changes: Dict = defaultdict(list) + used_tags: List = [current_tag] + for commit in commits: + commit_tag = get_commit_tag(commit, tags) + + if commit_tag is not None and commit_tag not in used_tags: + used_tags.append(commit_tag) + yield { + "version": current_tag_name, + "date": current_tag_date, + "changes": changes, + } + # TODO: Check if tag matches the version pattern, otherwie skip it. + # This in order to prevent tags that are not versions. + current_tag_name = commit_tag.name + current_tag_date = commit_tag.date + changes = defaultdict(list) + + matches = pat.match(commit.message) + if not matches: + continue + + message = map_pat.match(commit.message) + message_body = map_pat.match(commit.body) + if message: + # TODO: add a post hook coming from a rule (CzBase) + parsed_message: Dict = message.groupdict() + # change_type becomes optional by providing None + change_type = parsed_message.pop("change_type", None) + changes[change_type].append(parsed_message) + if message_body: + parsed_message_body: Dict = message_body.groupdict() + change_type = parsed_message_body.pop("change_type", None) + changes[change_type].append(parsed_message_body) + + yield { + "version": current_tag_name, + "date": current_tag_date, + "changes": changes, + } + + +def render_changelog(tree: Iterable) -> str: + template_file = pkg_resources.resource_string( + __name__, "templates/keep_a_changelog_template.j2" + ).decode("utf-8") + jinja_template = Template(template_file, trim_blocks=True) + changelog: str = jinja_template.render(tree=tree) + return changelog + + +def parse_version_from_markdown(value: str) -> Optional[str]: + if not value.startswith("#"): + return None + m = re.search(defaults.version_parser, value) if not m: - return {} - return m.groupdict() + return None + return m.groupdict().get("version") -def parse_md_message(md_message: str) -> Dict: - m = md_message_c.match(md_message) +def parse_title_type_of_line(value: str) -> Optional[str]: + md_title_parser = r"^(?P#+)" + m = re.search(md_title_parser, value) if not m: - return {} - return m.groupdict() - - -def transform_category(category: str) -> str: - _category_lower = category.lower() - for match_value, output in CATEGORIES: - if re.search(match_value, _category_lower): - return output - else: - raise ValueError(f"Could not match a category with {category}") - - -def generate_block_tree(block: List[str]) -> Dict: - tree: Dict = {"commits": []} - category = None - for line in block: - if line.startswith("## "): - category = None - tree = {**tree, **parse_md_version(line)} - elif line.startswith("### "): - result = parse_md_category(line) - if not result: + return None + return m.groupdict().get("title") + + +def get_metadata(filepath: str) -> Dict: + unreleased_start: Optional[int] = None + unreleased_end: Optional[int] = None + unreleased_title: Optional[str] = None + latest_version: Optional[str] = None + latest_version_position: Optional[int] = None + if not os.path.isfile(filepath): + return { + "unreleased_start": None, + "unreleased_end": None, + "latest_version": None, + "latest_version_position": None, + } + + with open(filepath, "r") as changelog_file: + for index, line in enumerate(changelog_file): + line = line.strip().lower() + + unreleased: Optional[str] = None + if "unreleased" in line: + unreleased = parse_title_type_of_line(line) + # Try to find beginning and end lines of the unreleased block + if unreleased: + unreleased_start = index + unreleased_title = unreleased continue - category = transform_category(result.get("category", "")) + elif ( + isinstance(unreleased_title, str) + and parse_title_type_of_line(line) == unreleased_title + ): + unreleased_end = index + + # Try to find the latest release done + version = parse_version_from_markdown(line) + if version: + latest_version = version + latest_version_position = index + break # there's no need for more info + if unreleased_start is not None and unreleased_end is None: + unreleased_end = index + return { + "unreleased_start": unreleased_start, + "unreleased_end": unreleased_end, + "latest_version": latest_version, + "latest_version_position": latest_version_position, + } + + +def incremental_build(new_content: str, lines: List, metadata: Dict) -> List: + """Takes the original lines and updates with new_content. + + The metadata holds information enough to remove the old unreleased and + where to place the new content + + Arguments: + lines -- the lines from the changelog + new_content -- this should be placed somewhere in the lines + metadata -- information about the changelog + + Returns: + List -- updated lines + """ + unreleased_start = metadata.get("unreleased_start") + unreleased_end = metadata.get("unreleased_end") + latest_version_position = metadata.get("latest_version_position") + skip = False + output_lines: List = [] + for index, line in enumerate(lines): + if index == unreleased_start: + skip = True + elif index == unreleased_end: + skip = False + if ( + latest_version_position is None + or isinstance(latest_version_position, int) + and isinstance(unreleased_end, int) + and latest_version_position > unreleased_end + ): + continue + + if skip: + continue - elif line.startswith("- "): - commit = parse_md_message(line) - commit["category"] = category - tree["commits"].append(commit) - else: - print("it's something else: ", line) - return tree + if ( + isinstance(latest_version_position, int) + and index == latest_version_position + ): + output_lines.append(new_content) + output_lines.append("\n") -def generate_full_tree(blocks: Iterable) -> Iterable[Dict]: - for block in blocks: - yield generate_block_tree(block) + output_lines.append(line) + if not isinstance(latest_version_position, int): + output_lines.append(new_content) + return output_lines diff --git a/commitizen/changelog_parser.py b/commitizen/changelog_parser.py new file mode 100644 index 0000000000..c945cbdb4f --- /dev/null +++ b/commitizen/changelog_parser.py @@ -0,0 +1,133 @@ +""" +# DESIGN + +## Parse CHANGELOG.md + +1. Get LATEST VERSION from CONFIG +1. Parse the file version to version +2. Build a dict (tree) of that particular version +3. Transform tree into markdown again +""" +import re +from collections import defaultdict +from typing import Dict, Generator, Iterable, List + +MD_VERSION_RE = r"^##\s(?P<version>[a-zA-Z0-9.+]+)\s?\(?(?P<date>[0-9-]+)?\)?" +MD_CHANGE_TYPE_RE = r"^###\s(?P<change_type>[a-zA-Z0-9.+\s]+)" +MD_MESSAGE_RE = ( + r"^-\s(\*{2}(?P<scope>[a-zA-Z0-9]+)\*{2}:\s)?(?P<message>.+)(?P<breaking>!)?" +) +md_version_c = re.compile(MD_VERSION_RE) +md_change_type_c = re.compile(MD_CHANGE_TYPE_RE) +md_message_c = re.compile(MD_MESSAGE_RE) + + +CATEGORIES = [ + ("fix", "fix"), + ("breaking", "BREAKING CHANGES"), + ("feat", "feat"), + ("refactor", "refactor"), + ("perf", "perf"), + ("test", "test"), + ("build", "build"), + ("ci", "ci"), + ("chore", "chore"), +] + + +def find_version_blocks(filepath: str) -> Generator: + """ + version block: contains all the information about a version. + + E.g: + ``` + ## 1.2.1 (2019-07-20) + + ### Fix + + - username validation not working + + ### Feat + + - new login system + + ``` + """ + with open(filepath, "r") as f: + block: list = [] + for line in f: + line = line.strip("\n") + if not line: + continue + + if line.startswith("## "): + if len(block) > 0: + yield block + block = [line] + else: + block.append(line) + yield block + + +def parse_md_version(md_version: str) -> Dict: + m = md_version_c.match(md_version) + if not m: + return {} + return m.groupdict() + + +def parse_md_change_type(md_change_type: str) -> Dict: + m = md_change_type_c.match(md_change_type) + if not m: + return {} + return m.groupdict() + + +def parse_md_message(md_message: str) -> Dict: + m = md_message_c.match(md_message) + if not m: + return {} + return m.groupdict() + + +def transform_change_type(change_type: str) -> str: + # TODO: Use again to parse, for this we have to wait until the maps get + # defined again. + _change_type_lower = change_type.lower() + for match_value, output in CATEGORIES: + if re.search(match_value, _change_type_lower): + return output + else: + raise ValueError(f"Could not match a change_type with {change_type}") + + +def generate_block_tree(block: List[str]) -> Dict: + # tree: Dict = {"commits": []} + changes: Dict = defaultdict(list) + tree: Dict = {"changes": changes} + + change_type = None + for line in block: + if line.startswith("## "): + # version identified + change_type = None + tree = {**tree, **parse_md_version(line)} + elif line.startswith("### "): + # change_type identified + result = parse_md_change_type(line) + if not result: + continue + change_type = result.get("change_type", "").lower() + + elif line.startswith("- "): + # message identified + commit = parse_md_message(line) + changes[change_type].append(commit) + else: + print("it's something else: ", line) + return tree + + +def generate_full_tree(blocks: Iterable) -> Iterable[Dict]: + for block in blocks: + yield generate_block_tree(block) diff --git a/commitizen/cli.py b/commitizen/cli.py index 9d5130dce9..f5b41a2e2f 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -86,6 +86,12 @@ "action": "store_true", "help": "bump version in the files from the config", }, + { + "name": ["--changelog", "-ch"], + "action": "store_true", + "default": False, + "help": "generate the changelog for the newest version", + }, { "name": "--yes", "action": "store_true", @@ -135,6 +141,22 @@ "name": "--file-name", "help": "file name of changelog (default: 'CHANGELOG.md')", }, + { + "name": "--unreleased-version", + "help": ( + "set the value for the new version (use the tag value), " + "instead of using unreleased" + ), + }, + { + "name": "--incremental", + "action": "store_true", + "default": False, + "help": ( + "generates changelog from last created version, " + "useful if the changelog has been manually modified" + ), + }, { "name": "--start-rev", "default": None, diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 90ec715744..1d0439e4b9 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -4,6 +4,7 @@ from packaging.version import Version from commitizen import bump, factory, git, out +from commitizen.commands.changelog import Changelog from commitizen.config import BaseConfig from commitizen.error_codes import ( COMMIT_FAILED, @@ -29,6 +30,7 @@ def __init__(self, config: BaseConfig, arguments: dict): }, } self.cz = factory.commiter_factory(self.config) + self.changelog = arguments["changelog"] def is_initial_tag(self, current_tag_version: str, is_yes: bool = False) -> bool: """Check if reading the whole git tree up to HEAD is needed.""" @@ -131,6 +133,17 @@ def __call__(self): # noqa: C901 if is_files_only: raise SystemExit() + if self.changelog: + changelog = Changelog( + self.config, + { + "unreleased_version": new_tag_version, + "incremental": True, + "dry_run": dry_run, + }, + ) + changelog() + self.config.set_key("version", new_version.public) c = git.commit(message, args="-a") if c.err: diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index b9bd966d27..12f43f775d 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -1,12 +1,16 @@ -import re -from collections import OrderedDict +import os.path +from difflib import SequenceMatcher +from operator import itemgetter +from typing import Dict, List -import pkg_resources -from jinja2 import Template - -from commitizen import factory, git, out +from commitizen import changelog, factory, git, out from commitizen.config import BaseConfig -from commitizen.error_codes import NO_COMMITS_FOUND, NO_PATTERN_MAP +from commitizen.error_codes import NO_COMMITS_FOUND, NO_PATTERN_MAP, NO_REVISION +from commitizen.git import GitTag + + +def similar(a, b): + return SequenceMatcher(None, a, b).ratio() class Changelog: @@ -16,64 +20,87 @@ def __init__(self, config: BaseConfig, args): self.config: BaseConfig = config self.cz = factory.commiter_factory(self.config) - self.file_name = args["file_name"] or self.config.settings.get("changelog_file") + self.start_rev = args.get("start_rev") + self.file_name = args.get("file_name") or self.config.settings.get( + "changelog_file" + ) + self.incremental = args["incremental"] self.dry_run = args["dry_run"] - self.start_rev = args["start_rev"] + self.unreleased_version = args["unreleased_version"] + + def _find_incremental_rev(self, latest_version: str, tags: List[GitTag]) -> str: + """Try to find the 'start_rev'. + + We use a similarity approach. We know how to parse the version from the markdown + changelog, but not the whole tag, we don't even know how's the tag made. + + This 'smart' function tries to find a similarity between the found version number + and the available tag. + + The SIMILARITY_THRESHOLD is an empirical value, it may have to be adjusted based + on our experience. + """ + SIMILARITY_THRESHOLD = 0.89 + tag_ratio = map( + lambda tag: (SequenceMatcher(None, latest_version, tag.name).ratio(), tag), + tags, + ) + try: + score, tag = max(tag_ratio, key=itemgetter(0)) + except ValueError: + raise SystemExit(NO_REVISION) + if score < SIMILARITY_THRESHOLD: + raise SystemExit(NO_REVISION) + start_rev = tag.name + return start_rev def __call__(self): - changelog_map = self.cz.changelog_map + commit_parser = self.cz.commit_parser changelog_pattern = self.cz.changelog_pattern - if not changelog_map or not changelog_pattern: + start_rev = self.start_rev + unreleased_version = self.unreleased_version + changelog_meta: Dict = {} + + if not changelog_pattern or not commit_parser: out.error( f"'{self.config.settings['name']}' rule does not support changelog" ) raise SystemExit(NO_PATTERN_MAP) - pat = re.compile(changelog_pattern) + tags = git.get_tags() + if not tags: + tags = [] + + if self.incremental: + changelog_meta = changelog.get_metadata(self.file_name) + latest_version = changelog_meta.get("latest_version") + if latest_version: + start_rev = self._find_incremental_rev(latest_version, tags) - commits = git.get_commits(start=self.start_rev) + commits = git.get_commits(start=start_rev) if not commits: out.error("No commits found") raise SystemExit(NO_COMMITS_FOUND) - tag_map = {tag.rev: tag.name for tag in git.get_tags()} + tree = changelog.generate_tree_from_commits( + commits, tags, commit_parser, changelog_pattern, unreleased_version + ) + changelog_out = changelog.render_changelog(tree) - entries = OrderedDict() - # The latest commit is not tagged - latest_commit = commits[0] - if latest_commit.rev not in tag_map: - current_key = "Unreleased" - entries[current_key] = OrderedDict( - {value: [] for value in changelog_map.values()} - ) - else: - current_key = tag_map[latest_commit.rev] - - for commit in commits: - if commit.rev in tag_map: - current_key = tag_map[commit.rev] - entries[current_key] = OrderedDict( - {value: [] for value in changelog_map.values()} - ) - - matches = pat.match(commit.message) - if not matches: - continue - - processed_commit = self.cz.process_commit(commit.message) - for group_name, commit_type in changelog_map.items(): - if matches.group(group_name): - entries[current_key][commit_type].append(processed_commit) - break - - template_file = pkg_resources.resource_string( - __name__, "../templates/keep_a_changelog_template.j2" - ).decode("utf-8") - jinja_template = Template(template_file) - changelog_str = jinja_template.render(entries=entries) if self.dry_run: - out.write(changelog_str) + out.write(changelog_out) raise SystemExit(0) + lines = [] + if self.incremental and os.path.isfile(self.file_name): + with open(self.file_name, "r") as changelog_file: + lines = changelog_file.readlines() + with open(self.file_name, "w") as changelog_file: - changelog_file.write(changelog_str) + if self.incremental: + new_lines = changelog.incremental_build( + changelog_out, lines, changelog_meta + ) + changelog_file.writelines(new_lines) + else: + changelog_file.write(changelog_out) diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index b428d696cd..ac73cc295b 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -32,7 +32,9 @@ def __call__(self): values_to_add["version"] = Version(tag).public values_to_add["tag_format"] = self._ask_tag_format(tag) self._update_config_file(values_to_add) - out.write("The configuration are all set.") + out.write("You can bump the version and create cangelog running:\n") + out.info("cz bump --changelog") + out.success("The configuration are all set.") else: # TODO: handle the case that config file exist but no value out.line(f"Config file {self.config.path} already exists") diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index ff6147d29f..c6e74d95b0 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -9,8 +9,6 @@ class BaseCommitizen(metaclass=ABCMeta): bump_pattern: Optional[str] = None bump_map: Optional[dict] = None - changelog_pattern: Optional[str] = None - changelog_map: Optional[dict] = None default_style_config: List[Tuple[str, str]] = [ ("qmark", "fg:#ff9d00 bold"), ("question", "bold"), @@ -24,6 +22,13 @@ class BaseCommitizen(metaclass=ABCMeta): ("disabled", "fg:#858585 italic"), ] + # The whole subject will be parsed as message by default + # This allows supporting changelog for any rule system. + # It can be modified per rule + commit_parser: Optional[str] = r"(?P<message>.*)" + changelog_pattern: Optional[str] = r".*" + changelog_map: Optional[dict] = None # TODO: Use it + def __init__(self, config: BaseConfig): self.config = config if not self.config.settings.get("style"): diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index 8026532779..b932bd6682 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -1,6 +1,5 @@ import os import re -from collections import OrderedDict from typing import Any, Dict, List from commitizen import defaults @@ -31,10 +30,8 @@ def parse_subject(text): class ConventionalCommitsCz(BaseCommitizen): bump_pattern = defaults.bump_pattern bump_map = defaults.bump_map - changelog_pattern = ( - r"(?P<break>.*\n\nBREAKING CHANGE)|(?P<feat>^feat)|(?P<fix>^fix)" - ) - changelog_map = OrderedDict({"break": "breaking", "feat": "feat", "fix": "fix"}) + commit_parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern def questions(self) -> List[Dict[str, Any]]: questions: List[Dict[str, Any]] = [ @@ -193,4 +190,6 @@ def info(self) -> str: def process_commit(self, commit: str) -> str: pat = re.compile(self.schema_pattern()) m = re.match(pat, commit) + if m is None: + return "" return m.group(3).strip() diff --git a/commitizen/defaults.py b/commitizen/defaults.py index a694a11840..1f4adf52e6 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -32,3 +32,6 @@ ) ) bump_message = "bump: version $current_version → $new_version" + +commit_parser = r"^(?P<change_type>feat|fix|refactor|perf|BREAKING CHANGE)(?:\((?P<scope>[^()\r\n]*)\)|\()?(?P<breaking>!)?:\s(?P<message>.*)?" # noqa +version_parser = r"(?P<version>([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?)" diff --git a/commitizen/error_codes.py b/commitizen/error_codes.py index 20cc54aa9e..9095399c3f 100644 --- a/commitizen/error_codes.py +++ b/commitizen/error_codes.py @@ -22,3 +22,6 @@ # Check NO_COMMIT_MSG = 13 INVALID_COMMIT_MSG = 14 + +# Changelog +NO_REVISION = 16 diff --git a/commitizen/git.py b/commitizen/git.py index 058ba89a00..5c3b5926ee 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -3,12 +3,23 @@ from tempfile import NamedTemporaryFile from typing import List, Optional +from typing_extensions import Protocol + from commitizen import cmd +class GitProtocol(Protocol): + rev: str + name: str + + class GitObject: - def __eq__(self, other): - if not isinstance(other, GitObject): + rev: str + name: str + date: str + + def __eq__(self, other) -> bool: + if not hasattr(other, "rev"): return False return self.rev == other.rev @@ -34,7 +45,7 @@ def __init__(self, name, rev, date): self.date = date.strip() def __repr__(self): - return f"{self.name} ({self.rev})" + return f"GitTag('{self.name}', '{self.rev}', '{self.date}')" def tag(tag: str): diff --git a/commitizen/templates/keep_a_changelog_template.j2 b/commitizen/templates/keep_a_changelog_template.j2 index d3fba14668..be0d0d26e6 100644 --- a/commitizen/templates/keep_a_changelog_template.j2 +++ b/commitizen/templates/keep_a_changelog_template.j2 @@ -1,13 +1,19 @@ -# CHANGELOG - -{% for entry_key, entry_value in entries.items() -%} -## {{entry_key}} -{% for type, commits in entry_value.items() -%} -{%- if commits -%} -### {{type}} -{% for commit in commits-%} -- {{commit}} -{% endfor %} +{% for entry in tree %} + +## {{ entry.version }} {% if entry.date %}({{ entry.date }}){% endif %} + +{% for change_key, changes in entry.changes.items() %} + +{% if change_key %} +### {{ change_key|title }} {% endif %} -{%- endfor %} -{%- endfor %} + +{% for change in changes %} +{% if change.scope %} +- **{{ change.scope }}**: {{ change.message }} +{% elif change.message %} +- {{ change.message }} +{% endif %} +{% endfor %} +{% endfor %} +{% endfor %} diff --git a/docs/bump.md b/docs/bump.md index 393baefd15..d9527135e4 100644 --- a/docs/bump.md +++ b/docs/bump.md @@ -54,7 +54,7 @@ Some examples: ```bash $ cz bump --help -usage: cz bump [-h] [--dry-run] [--files-only] [--yes] +usage: cz bump [-h] [--dry-run] [--files-only] [--changelog] [--yes] [--tag-format TAG_FORMAT] [--bump-message BUMP_MESSAGE] [--prerelease {alpha,beta,rc}] [--increment {MAJOR,MINOR,PATCH}] @@ -63,10 +63,11 @@ optional arguments: -h, --help show this help message and exit --dry-run show output to stdout, no commit, no modified files --files-only bump version in the files from the config + --changelog, -ch generate the changelog for the newest version --yes accept automatically questions done --tag-format TAG_FORMAT - the format used to tag the commit and read it, use it in - existing projects, wrap around simple quotes + the format used to tag the commit and read it, use it + in existing projects, wrap around simple quotes --bump-message BUMP_MESSAGE template used to create the release commmit, useful when working with CI @@ -78,6 +79,14 @@ optional arguments: ## Configuration +### `changelog` + +Generate a **changelog** along with the new version and tag when bumping. + +```bash +cz bump --changelog +``` + ### `tag_format` It is used to read the format from the git tags, and also to generate the tags. diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000000..fab38ea2ad --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,133 @@ +## About + +This command will generate a changelog following the commiting rules established. + +## Usage + +```bash +$ cz changelog --help +usage: cz changelog [-h] [--dry-run] [--file-name FILE_NAME] + [--unreleased-version UNRELEASED_VERSION] [--incremental] + [--start-rev START_REV] + +optional arguments: + -h, --help show this help message and exit + --dry-run show changelog to stdout + --file-name FILE_NAME + file name of changelog (default: 'CHANGELOG.md') + --unreleased-version UNRELEASED_VERSION + set the value for the new version (use the tag value), + instead of using unreleased + --incremental generates changelog from last created version, useful + if the changelog has been manually modified + --start-rev START_REV + start rev of the changelog.If not set, it will + generate changelog from the start +``` + +### Examples + +```bash +cz changelog +``` + +```bash +cz ch +``` + +## Constrains + +changelog generation is constrained only to **markdown** files. + +## Description + +These are the variables used by the changelog generator. + +```md +# <version> (<date>) + +## <change_type> + +- **<scope>**: <message> +``` + +It will create a full block like above per version found in the tags. +And it will create a list of the commits found. +The `change_type` and the `scope` are optional, they don't need to be provided, +but if your regex does they will be rendered. + +The format followed by the changelog is the one from [keep a changelog][keepachangelog] +and the following variables are expected: + +| Variable | Description | Source | +| ------------- | ---------------------------------------------------------------------------------------------- | -------------- | +| `version` | Version number which should follow [semver][semver] | `tags` | +| `date` | Date in which the tag was created | `tags` | +| `change_type` | The group where the commit belongs to, this is optional. Example: fix | `commit regex` | +| `message`\* | Information extracted from the commit message | `commit regex` | +| `scope` | Contextual information. Should be parsed using the regex from the message, it will be **bold** | `commit regex` | +| `breaking` | Wether is a breaking change or not | `commit regex` | + +- **required**: is the only one required to be parsed by the regex + +## Configuration + +### `unreleased_version` + +There is usually an egg and chicken situation when automatically +bumping the version and creating the changelog. +If you bump the version first, you have no changelog, you have to +create it later, and it won't be included in +the release of the created version. + +If you create the changelog before bumping the version, then you +usually don't have the latest tag, and the _Unreleased_ title appears. + +By introducing `unreleased_version` you can prevent this situation. + +Before bumping you can run: + +```bash +cz changelog --unreleased_version="v1.0.0" +``` + +Remember to use the tag instead of the raw version number + +For example if the format of your tag includes a `v` (`v1.0.0`), then you should use that, +if your tag is the same as the raw version, then ignore this. + +Alternatively you can directly bump the version and create the changelog by doing + +```bash +cz bump --changelog +``` + +### `file-name` + +This value can be updated in the `toml` file with the key `changelog_file` under `tools.commitizen` + +Specify the name of the output file, remember that changelog only works with markdown. + +```bash +cz changelog --file-name="CHANGES.md" +``` + +### `incremental` + +Benefits: + +- Build from latest version found in changelog, this is useful if you have a different changelog and want to use commitizen +- Update unreleased area +- Allows users to manually touch the changelog without being rewritten. + +```bash +cz changelog --incremental +``` + +## TODO + +- [ ] support for hooks: this would allow introduction of custom information in the commiter, like a github or jira url. Eventually we could build a `CzConventionalGithub`, which would add links to commits +- [ ] support for map: allow the usage of a `change_type` mapper, to convert from feat to feature for example. + +[keepachangelog]: https://keepachangelog.com/ +[semver]: https://semver.org/ diff --git a/pyproject.toml b/pyproject.toml index 97add2bf9c..11306bb4bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ termcolor = "^1.1" packaging = ">=19,<21" tomlkit = "^0.5.3" jinja2 = "^2.10.3" +typing = "^3.7.4" [tool.poetry.dev-dependencies] ipython = "^7.2" @@ -65,6 +66,7 @@ mypy = "^0.770" mkdocs = "^1.0" mkdocs-material = "^4.1" isort = "^4.3.21" +freezegun = "^0.3.15" [tool.poetry.scripts] cz = "commitizen.cli:main" diff --git a/tests/CHANGELOG_FOR_TEST.md b/tests/CHANGELOG_FOR_TEST.md new file mode 100644 index 0000000000..7605bbed47 --- /dev/null +++ b/tests/CHANGELOG_FOR_TEST.md @@ -0,0 +1,129 @@ + +## v1.2.0 (2019-04-19) + +### Feat + +- custom cz plugins now support bumping version + +## v1.1.1 (2019-04-18) + +### Refactor + +- changed stdout statements +- **schema**: command logic removed from commitizen base +- **info**: command logic removed from commitizen base +- **example**: command logic removed from commitizen base +- **commit**: moved most of the commit logic to the commit command + +### Fix + +- **bump**: commit message now fits better with semver +- conventional commit 'breaking change' in body instead of title + +## v1.1.0 (2019-04-14) + +### Feat + +- new working bump command +- create version tag +- update given files with new version +- **config**: new set key, used to set version to cfg +- support for pyproject.toml +- first semantic version bump implementaiton + +### Fix + +- removed all from commit +- fix config file not working + +### Refactor + +- added commands folder, better integration with decli + +## v1.0.0 (2019-03-01) + +### Refactor + +- removed delegator, added decli and many tests + +### Breaking Change + +- API is stable + +## 1.0.0b2 (2019-01-18) + +## v1.0.0b1 (2019-01-17) + +### Feat + +- py3 only, tests and conventional commits 1.0 + +## v0.9.11 (2018-12-17) + +### Fix + +- **config**: load config reads in order without failing if there is no commitizen section + +## v0.9.10 (2018-09-22) + +### Fix + +- parse scope (this is my punishment for not having tests) + +## v0.9.9 (2018-09-22) + +### Fix + +- parse scope empty + +## v0.9.8 (2018-09-22) + +### Fix + +- **scope**: parse correctly again + +## v0.9.7 (2018-09-22) + +### Fix + +- **scope**: parse correctly + +## v0.9.6 (2018-09-19) + +### Refactor + +- **conventionalCommit**: moved fitlers to questions instead of message + +### Fix + +- **manifest**: inluded missing files + +## v0.9.5 (2018-08-24) + +### Fix + +- **config**: home path for python versions between 3.0 and 3.5 + +## v0.9.4 (2018-08-02) + +### Feat + +- **cli**: added version + +## v0.9.3 (2018-07-28) + +### Feat + +- **commiter**: conventional commit is a bit more intelligent now + +## v0.9.2 (2017-11-11) + +### Refactor + +- renamed conventional_changelog to conventional_commits, not backward compatible + +## v0.9.1 (2017-11-11) + +### Fix + +- **setup.py**: future is now required for every python version diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index a053e6c238..d78d04bee2 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -1,8 +1,10 @@ +import os import sys +from datetime import date import pytest -from commitizen import cli +from commitizen import cli, git from tests.utils import create_file_and_commit @@ -16,53 +18,219 @@ def test_changlog_on_empty_project(mocker): @pytest.mark.usefixtures("tmp_commitizen_project") -def test_changlog_from_start(mocker, capsys): +def test_changlog_from_version_zero_point_two(mocker, capsys): create_file_and_commit("feat: new file") create_file_and_commit("refactor: not in changelog") - create_file_and_commit("Merge into master") - testargs = ["cz", "changelog", "--dry-run"] + # create tag + testargs = ["cz", "bump", "--yes"] mocker.patch.object(sys, "argv", testargs) + cli.main() + capsys.readouterr() + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: after 0.2") + + testargs = ["cz", "changelog", "--start-rev", "0.2.0", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) with pytest.raises(SystemExit): cli.main() out, _ = capsys.readouterr() - assert out == "# CHANGELOG\n\n## Unreleased\n### feat\n- new file\n\n\n" + assert out == "\n## Unreleased \n\n### Feat\n\n- after 0.2\n- after 0.2.0\n\n" @pytest.mark.usefixtures("tmp_commitizen_project") -def test_changlog_from_version_zero_point_two(mocker, capsys): +def test_changlog_with_different_cz(mocker, capsys): + create_file_and_commit("JRA-34 #comment corrected indent issue") + create_file_and_commit("JRA-35 #time 1w 2d 4h 30m Total work logged") + + testargs = ["cz", "-n", "cz_jira", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(SystemExit): + cli.main() + out, _ = capsys.readouterr() + assert ( + out + == "\n## Unreleased \n\n\n- JRA-35 #time 1w 2d 4h 30m Total work logged\n- JRA-34 #comment corrected indent issue\n\n" + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changlog_from_start(mocker, capsys): + changelog_path = os.path.join(os.getcwd(), "CHANGELOG.md") create_file_and_commit("feat: new file") - create_file_and_commit("refactor: not in changelog") + create_file_and_commit("refactor: is in changelog") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + assert ( + out + == "\n## Unreleased \n\n### Refactor\n\n- is in changelog\n\n### Feat\n\n- new file\n" + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changlog_replacing_unreleased_using_incremental(mocker, capsys): + changelog_path = os.path.join(os.getcwd(), "CHANGELOG.md") + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() - # create tag testargs = ["cz", "bump", "--yes"] mocker.patch.object(sys, "argv", testargs) cli.main() - capsys.readouterr() - create_file_and_commit("feat: after 0.2.0") - create_file_and_commit("feat: after 0.2") + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") - testargs = ["cz", "changelog", "--start-rev", "0.2.0", "--dry-run"] + testargs = ["cz", "changelog", "--incremental"] mocker.patch.object(sys, "argv", testargs) - with pytest.raises(SystemExit): - cli.main() + cli.main() - out, _ = capsys.readouterr() + with open(changelog_path, "r") as f: + out = f.read() + + today = date.today().isoformat() assert ( out - == "# CHANGELOG\n\n## Unreleased\n### feat\n- after 0.2\n- after 0.2.0\n\n\n" + == f"\n\n## Unreleased \n\n### Feat\n\n- add more stuff\n\n### Fix\n\n- mama gotta work\n\n## 0.2.0 ({today})\n\n### Fix\n\n- output glitch\n\n### Feat\n\n- add new output\n" ) @pytest.mark.usefixtures("tmp_commitizen_project") -def test_changlog_with_unsupported_cz(mocker, capsys): - testargs = ["cz", "-n", "cz_jira", "changelog", "--dry-run"] +def test_changlog_is_persisted_using_incremental(mocker, capsys): + changelog_path = os.path.join(os.getcwd(), "CHANGELOG.md") + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("Merge into master") + + testargs = ["cz", "bump", "--yes"] mocker.patch.object(sys, "argv", testargs) + cli.main() - with pytest.raises(SystemExit): - cli.main() - out, err = capsys.readouterr() - assert "'cz_jira' rule does not support changelog" in err + testargs = ["cz", "changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "a") as f: + f.write("\nnote: this should be persisted using increment\n") + + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + today = date.today().isoformat() + assert ( + out + == f"\n\n## Unreleased \n\n### Feat\n\n- add more stuff\n\n### Fix\n\n- mama gotta work\n\n## 0.2.0 ({today})\n\n### Fix\n\n- output glitch\n\n### Feat\n\n- add new output\n\nnote: this should be persisted using increment\n" + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changlog_incremental_angular_sample(mocker, capsys): + changelog_path = os.path.join(os.getcwd(), "CHANGELOG.md") + with open(changelog_path, "w") as f: + f.write( + "# [10.0.0-next.3](https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3) (2020-04-22)\n" + "\n" + "### Bug Fixes" + "\n" + "* **common:** format day-periods that cross midnight ([#36611](https://github.com/angular/angular/issues/36611)) ([c6e5fc4](https://github.com/angular/angular/commit/c6e5fc4)), closes [#36566](https://github.com/angular/angular/issues/36566)\n" + ) + create_file_and_commit("irrelevant commit") + git.tag("10.0.0-next.3") + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + assert ( + out + == "\n## Unreleased \n\n### Feat\n\n- add more stuff\n- add new output\n\n### Fix\n\n- mama gotta work\n- output glitch\n\n# [10.0.0-next.3](https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3) (2020-04-22)\n\n### Bug Fixes\n* **common:** format day-periods that cross midnight ([#36611](https://github.com/angular/angular/issues/36611)) ([c6e5fc4](https://github.com/angular/angular/commit/c6e5fc4)), closes [#36566](https://github.com/angular/angular/issues/36566)\n" + ) + + +KEEP_A_CHANGELOG = """# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). +""" + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changlog_incremental_keep_a_changelog_sample(mocker, capsys): + changelog_path = os.path.join(os.getcwd(), "CHANGELOG.md") + with open(changelog_path, "w") as f: + f.write(KEEP_A_CHANGELOG) + create_file_and_commit("irrelevant commit") + git.tag("1.0.0") + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + assert ( + out + == """# Changelog\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n\n## Unreleased \n\n### Feat\n\n- add more stuff\n- add new output\n\n### Fix\n\n- mama gotta work\n- output glitch\n\n## [1.0.0] - 2017-06-20\n### Added\n- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8).\n- Version navigation.\n\n### Changed\n- Start using "changelog" over "change log" since it\'s the common usage.\n\n### Removed\n- Section about "changelog" vs "CHANGELOG".\n\n## [0.3.0] - 2015-12-03\n### Added\n- RU translation from [@aishek](https://github.com/aishek).\n""" + ) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index dd34b9be92..94c3c47017 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,214 +1,703 @@ -import os - import pytest -from commitizen import changelog - -COMMIT_LOG = [ - "bump: version 1.5.0 → 1.5.1", - "", - "Merge pull request #29 from esciara/issue_28", - "fix: #28 allows poetry add on py36 envs", - "fix: #28 allows poetry add on py36 envs", - "", - "Merge pull request #26 from Woile/dependabot/pip/black-tw-19.3b0", - "chore(deps-dev): update black requirement from ^18.3-alpha.0 to ^19.3b0", - "Merge pull request #27 from Woile/dependabot/pip/mypy-tw-0.701", - "chore(deps-dev): update mypy requirement from ^0.700.0 to ^0.701", - "chore(deps-dev): update mypy requirement from ^0.700.0 to ^0.701", - ( - "Updates the requirements on " - "[mypy](https://github.com/python/mypy) to permit the latest version." - ), - "- [Release notes](https://github.com/python/mypy/releases)", - "- [Commits](https://github.com/python/mypy/compare/v0.700...v0.701)", - "", - "Signed-off-by: dependabot[bot] <support@dependabot.com>", - "chore(deps-dev): update black requirement from ^18.3-alpha.0 to ^19.3b0", - ( - "Updates the requirements on [black](https://github.com/ambv/black)" - " to permit the latest version." - ), - "- [Release notes](https://github.com/ambv/black/releases)", - "- [Commits](https://github.com/ambv/black/commits)", - "", - "Signed-off-by: dependabot[bot] <support@dependabot.com>", - "bump: version 1.4.0 → 1.5.0", - "", - "docs: add info about extra pattern in the files when bumping", - "", - ( - "feat(bump): it is now possible to specify a pattern " - "in the files attr to replace the version" - ), - "", +from commitizen import changelog, defaults, git + +COMMITS_DATA = [ + { + "rev": "141ee441c9c9da0809c554103a558eb17c30ed17", + "title": "bump: version 1.1.1 → 1.2.0", + "body": "", + }, + { + "rev": "6c4948501031b7d6405b54b21d3d635827f9421b", + "title": "docs: how to create custom bumps", + "body": "", + }, + { + "rev": "ddd220ad515502200fe2dde443614c1075d26238", + "title": "feat: custom cz plugins now support bumping version", + "body": "", + }, + { + "rev": "ad17acff2e3a2e141cbc3c6efd7705e4e6de9bfc", + "title": "docs: added bump gif", + "body": "", + }, + { + "rev": "56c8a8da84e42b526bcbe130bd194306f7c7e813", + "title": "bump: version 1.1.0 → 1.1.1", + "body": "", + }, + { + "rev": "74c6134b1b2e6bb8b07ed53410faabe99b204f36", + "title": "refactor: changed stdout statements", + "body": "", + }, + { + "rev": "cbc7b5f22c4e74deff4bc92d14e19bd93524711e", + "title": "fix(bump): commit message now fits better with semver", + "body": "", + }, + { + "rev": "1ba46f2a63cb9d6e7472eaece21528c8cd28b118", + "title": "fix: conventional commit 'breaking change' in body instead of title", + "body": "closes #16", + }, + { + "rev": "c35dbffd1bb98bb0b3d1593797e79d1c3366af8f", + "title": "refactor(schema): command logic removed from commitizen base", + "body": "", + }, + { + "rev": "25313397a4ac3dc5b5c986017bee2a614399509d", + "title": "refactor(info): command logic removed from commitizen base", + "body": "", + }, + { + "rev": "d2f13ac41b4e48995b3b619d931c82451886e6ff", + "title": "refactor(example): command logic removed from commitizen base", + "body": "", + }, + { + "rev": "d839e317e5b26671b010584ad8cc6bf362400fa1", + "title": "refactor(commit): moved most of the commit logic to the commit command", + "body": "", + }, + { + "rev": "12d0e65beda969f7983c444ceedc2a01584f4e08", + "title": "docs(README): updated documentation url)", + "body": "", + }, + { + "rev": "fb4c85abe51c228e50773e424cbd885a8b6c610d", + "title": "docs: mkdocs documentation", + "body": "", + }, + { + "rev": "17efb44d2cd16f6621413691a543e467c7d2dda6", + "title": "Bump version 1.0.0 → 1.1.0", + "body": "", + }, + { + "rev": "6012d9eecfce8163d75c8fff179788e9ad5347da", + "title": "test: fixed issues with conf", + "body": "", + }, + { + "rev": "0c7fb0ca0168864dfc55d83c210da57771a18319", + "title": "docs(README): some new information about bump", + "body": "", + }, + { + "rev": "cb1dd2019d522644da5bdc2594dd6dee17122d7f", + "title": "feat: new working bump command", + "body": "", + }, + { + "rev": "9c7450f85df6bf6be508e79abf00855a30c3c73c", + "title": "feat: create version tag", + "body": "", + }, + { + "rev": "9f3af3772baab167e3fd8775d37f041440184251", + "title": "docs: added new changelog", + "body": "", + }, + { + "rev": "b0d6a3defbfde14e676e7eb34946409297d0221b", + "title": "feat: update given files with new version", + "body": "", + }, + { + "rev": "d630d07d912e420f0880551f3ac94e933f9d3beb", + "title": "fix: removed all from commit", + "body": "", + }, + { + "rev": "1792b8980c58787906dbe6836f93f31971b1ec2d", + "title": "feat(config): new set key, used to set version to cfg", + "body": "", + }, + { + "rev": "52def1ea3555185ba4b936b463311949907e31ec", + "title": "feat: support for pyproject.toml", + "body": "", + }, + { + "rev": "3127e05077288a5e2b62893345590bf1096141b7", + "title": "feat: first semantic version bump implementaiton", + "body": "", + }, + { + "rev": "fd480ed90a80a6ffa540549408403d5b60d0e90c", + "title": "fix: fix config file not working", + "body": "", + }, + { + "rev": "e4840a059731c0bf488381ffc77e989e85dd81ad", + "title": "refactor: added commands folder, better integration with decli", + "body": "", + }, + { + "rev": "aa44a92d68014d0da98965c0c2cb8c07957d4362", + "title": "Bump version: 1.0.0b2 → 1.0.0", + "body": "", + }, + { + "rev": "58bb709765380dbd46b74ce6e8978515764eb955", + "title": "docs(README): new badges", + "body": "", + }, + { + "rev": "97afb0bb48e72b6feca793091a8a23c706693257", + "title": "Merge pull request #10 from Woile/feat/decli", + "body": "Feat/decli", + }, + { + "rev": "9cecb9224aa7fa68d4afeac37eba2a25770ef251", + "title": "style: black to files", + "body": "", + }, + { + "rev": "f5781d1a2954d71c14ade2a6a1a95b91310b2577", + "title": "ci: added travis", + "body": "", + }, + { + "rev": "80105fb3c6d45369bc0cbf787bd329fba603864c", + "title": "refactor: removed delegator, added decli and many tests", + "body": "BREAKING CHANGE: API is stable", + }, + { + "rev": "a96008496ffefb6b1dd9b251cb479eac6a0487f7", + "title": "docs: updated test command", + "body": "", + }, + { + "rev": "aab33d13110f26604fb786878856ec0b9e5fc32b", + "title": "Bump version: 1.0.0b1 → 1.0.0b2", + "body": "", + }, + { + "rev": "b73791563d2f218806786090fb49ef70faa51a3a", + "title": "docs(README): updated to reflect current state", + "body": "", + }, + { + "rev": "7aa06a454fb717408b3657faa590731fb4ab3719", + "title": "Merge pull request #9 from Woile/dev", + "body": "feat: py3 only, tests and conventional commits 1.0", + }, + { + "rev": "7c7e96b723c2aaa1aec3a52561f680adf0b60e97", + "title": "Bump version: 0.9.11 → 1.0.0b1", + "body": "", + }, + { + "rev": "ed830019581c83ba633bfd734720e6758eca6061", + "title": "feat: py3 only, tests and conventional commits 1.0", + "body": "more tests\npyproject instead of Pipfile\nquestionary instead of whaaaaat (promptkit 2.0.0 support)", + }, + { + "rev": "c52eca6f74f844ab3ffbde61d98ef96071e132b7", + "title": "Bump version: 0.9.10 → 0.9.11", + "body": "", + }, + { + "rev": "0326652b2657083929507ee66d4d1a0899e861ba", + "title": "fix(config): load config reads in order without failing if there is no commitizen section", + "body": "Closes #8", + }, + { + "rev": "b3f89892222340150e32631ae6b7aab65230036f", + "title": "Bump version: 0.9.9 → 0.9.10", + "body": "", + }, + { + "rev": "5e837bf8ef0735193597372cd2d85e31a8f715b9", + "title": "fix: parse scope (this is my punishment for not having tests)", + "body": "", + }, + { + "rev": "684e0259cc95c7c5e94854608cd3dcebbd53219e", + "title": "Bump version: 0.9.8 → 0.9.9", + "body": "", + }, + { + "rev": "ca38eac6ff09870851b5c76a6ff0a2a8e5ecda15", + "title": "fix: parse scope empty", + "body": "", + }, + { + "rev": "64168f18d4628718c49689ee16430549e96c5d4b", + "title": "Bump version: 0.9.7 → 0.9.8", + "body": "", + }, + { + "rev": "9d4def716ef235a1fa5ae61614366423fbc8256f", + "title": "fix(scope): parse correctly again", + "body": "", + }, + { + "rev": "33b0bf1a0a4dc60aac45ed47476d2e5473add09e", + "title": "Bump version: 0.9.6 → 0.9.7", + "body": "", + }, + { + "rev": "696885e891ec35775daeb5fec3ba2ab92c2629e1", + "title": "fix(scope): parse correctly", + "body": "", + }, + { + "rev": "bef4a86761a3bda309c962bae5d22ce9b57119e4", + "title": "Bump version: 0.9.5 → 0.9.6", + "body": "", + }, + { + "rev": "72472efb80f08ee3fd844660afa012c8cb256e4b", + "title": "refactor(conventionalCommit): moved fitlers to questions instead of message", + "body": "", + }, + { + "rev": "b5561ce0ab3b56bb87712c8f90bcf37cf2474f1b", + "title": "fix(manifest): inluded missing files", + "body": "", + }, + { + "rev": "3e31714dc737029d96898f412e4ecd2be1bcd0ce", + "title": "Bump version: 0.9.4 → 0.9.5", + "body": "", + }, + { + "rev": "9df721e06595fdd216884c36a28770438b4f4a39", + "title": "fix(config): home path for python versions between 3.0 and 3.5", + "body": "", + }, + { + "rev": "0cf6ada372470c8d09e6c9e68ebf94bbd5a1656f", + "title": "Bump version: 0.9.3 → 0.9.4", + "body": "", + }, + { + "rev": "973c6b3e100f6f69a3fe48bd8ee55c135b96c318", + "title": "feat(cli): added version", + "body": "", + }, + { + "rev": "dacc86159b260ee98eb5f57941c99ba731a01399", + "title": "Bump version: 0.9.2 → 0.9.3", + "body": "", + }, + { + "rev": "4368f3c3cbfd4a1ced339212230d854bc5bab496", + "title": "feat(commiter): conventional commit is a bit more intelligent now", + "body": "", + }, + { + "rev": "da94133288727d35dae9b91866a25045038f2d38", + "title": "docs(README): motivation", + "body": "", + }, + { + "rev": "1541f54503d2e1cf39bd777c0ca5ab5eb78772ba", + "title": "Bump version: 0.9.1 → 0.9.2", + "body": "", + }, + { + "rev": "ddc855a637b7879108308b8dbd85a0fd27c7e0e7", + "title": "refactor: renamed conventional_changelog to conventional_commits, not backward compatible", + "body": "", + }, + { + "rev": "46e9032e18a819e466618c7a014bcb0e9981af9e", + "title": "Bump version: 0.9.0 → 0.9.1", + "body": "", + }, + { + "rev": "0fef73cd7dc77a25b82e197e7c1d3144a58c1350", + "title": "fix(setup.py): future is now required for every python version", + "body": "", + }, ] -CHANGELOG_TEMPLATE = """ -## 1.0.0 (2019-07-12) - -### Bug fixes - -- issue in poetry add preventing the installation in py36 -- **users**: lorem ipsum apap - - -### Features - -- it is possible to specify a pattern to be matched in configuration files bump. - -## 0.9 (2019-07-11) - -### Bug fixes - -- holis - -""" - - -@pytest.fixture -def existing_changelog_file(request): - changelog_path = "tests/CHANGELOG.md" - - with open(changelog_path, "w") as f: - f.write(CHANGELOG_TEMPLATE) - - yield changelog_path - - os.remove(changelog_path) - - -def test_read_changelog_blocks(existing_changelog_file): - blocks = changelog.find_version_blocks(existing_changelog_file) - blocks = list(blocks) - amount_of_blocks = len(blocks) - assert amount_of_blocks == 2 - -VERSION_CASES: list = [ - ("## 1.0.0 (2019-07-12)", {"version": "1.0.0", "date": "2019-07-12"}), - ("## 2.3.0a0", {"version": "2.3.0a0", "date": None}), - ("## 0.10.0a0", {"version": "0.10.0a0", "date": None}), - ("## 1.0.0rc0", {"version": "1.0.0rc0", "date": None}), - ("## 1beta", {"version": "1beta", "date": None}), - ( - "## 1.0.0rc1+e20d7b57f3eb (2019-3-24)", - {"version": "1.0.0rc1+e20d7b57f3eb", "date": "2019-3-24"}, - ), - ("### Bug fixes", {}), - ("- issue in poetry add preventing the installation in py36", {}), +TAGS = [ + ("v1.2.0", "141ee441c9c9da0809c554103a558eb17c30ed17", "2019-04-19"), + ("v1.1.1", "56c8a8da84e42b526bcbe130bd194306f7c7e813", "2019-04-18"), + ("v1.1.0", "17efb44d2cd16f6621413691a543e467c7d2dda6", "2019-04-14"), + ("v1.0.0", "aa44a92d68014d0da98965c0c2cb8c07957d4362", "2019-03-01"), + ("1.0.0b2", "aab33d13110f26604fb786878856ec0b9e5fc32b", "2019-01-18"), + ("v1.0.0b1", "7c7e96b723c2aaa1aec3a52561f680adf0b60e97", "2019-01-17"), + ("v0.9.11", "c52eca6f74f844ab3ffbde61d98ef96071e132b7", "2018-12-17"), + ("v0.9.10", "b3f89892222340150e32631ae6b7aab65230036f", "2018-09-22"), + ("v0.9.9", "684e0259cc95c7c5e94854608cd3dcebbd53219e", "2018-09-22"), + ("v0.9.8", "64168f18d4628718c49689ee16430549e96c5d4b", "2018-09-22"), + ("v0.9.7", "33b0bf1a0a4dc60aac45ed47476d2e5473add09e", "2018-09-22"), + ("v0.9.6", "bef4a86761a3bda309c962bae5d22ce9b57119e4", "2018-09-19"), + ("v0.9.5", "3e31714dc737029d96898f412e4ecd2be1bcd0ce", "2018-08-24"), + ("v0.9.4", "0cf6ada372470c8d09e6c9e68ebf94bbd5a1656f", "2018-08-02"), + ("v0.9.3", "dacc86159b260ee98eb5f57941c99ba731a01399", "2018-07-28"), + ("v0.9.2", "1541f54503d2e1cf39bd777c0ca5ab5eb78772ba", "2017-11-11"), + ("v0.9.1", "46e9032e18a819e466618c7a014bcb0e9981af9e", "2017-11-11"), ] -CATEGORIES_CASES: list = [ - ("## 1.0.0 (2019-07-12)", {}), - ("## 2.3.0a0", {}), - ("### Bug fixes", {"category": "Bug fixes"}), - ("### Features", {"category": "Features"}), - ("- issue in poetry add preventing the installation in py36", {}), -] -CATEGORIES_TRANSFORMATIONS: list = [ - ("Bug fixes", "fix"), - ("Features", "feat"), - ("BREAKING CHANGES", "BREAKING CHANGES"), -] - -MESSAGES_CASES: list = [ - ("## 1.0.0 (2019-07-12)", {}), - ("## 2.3.0a0", {}), - ("### Bug fixes", {}), - ( - "- name no longer accept invalid chars", - {"message": "name no longer accept invalid chars", "scope": None}, - ), - ( - "- **users**: lorem ipsum apap", - {"message": "lorem ipsum apap", "scope": "users"}, - ), -] +@pytest.fixture # type: ignore +def gitcommits() -> list: + commits = [ + git.GitCommit(commit["rev"], commit["title"], commit["body"]) + for commit in COMMITS_DATA + ] + return commits -@pytest.mark.parametrize("test_input,expected", VERSION_CASES) -def test_parse_md_version(test_input, expected): - assert changelog.parse_md_version(test_input) == expected +@pytest.fixture # type: ignore +def tags() -> list: + tags = [git.GitTag(*tag) for tag in TAGS] + return tags -@pytest.mark.parametrize("test_input,expected", CATEGORIES_CASES) -def test_parse_md_category(test_input, expected): - assert changelog.parse_md_category(test_input) == expected +@pytest.fixture # type: ignore +def changelog_content() -> str: + changelog_path = "tests/CHANGELOG_FOR_TEST.md" + with open(changelog_path, "r") as f: + return f.read() -@pytest.mark.parametrize("test_input,expected", CATEGORIES_TRANSFORMATIONS) -def test_transform_category(test_input, expected): - assert changelog.transform_category(test_input) == expected +def test_get_commit_tag_is_a_version(gitcommits, tags): + commit = gitcommits[0] + tag = git.GitTag(*TAGS[0]) + current_key = changelog.get_commit_tag(commit, tags) + assert current_key == tag -@pytest.mark.parametrize("test_input,expected", MESSAGES_CASES) -def test_parse_md_message(test_input, expected): - assert changelog.parse_md_message(test_input) == expected +def test_get_commit_tag_is_None(gitcommits, tags): + commit = gitcommits[1] + current_key = changelog.get_commit_tag(commit, tags) + assert current_key is None -def test_transform_category_fail(): - with pytest.raises(ValueError) as excinfo: - changelog.transform_category("Bugs") - assert "Could not match a category" in str(excinfo.value) +def test_generate_tree_from_commits(gitcommits, tags): + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) -def test_generate_block_tree(existing_changelog_file): - blocks = changelog.find_version_blocks(existing_changelog_file) - block = next(blocks) - tree = changelog.generate_block_tree(block) - assert tree == { - "commits": [ - { - "scope": None, - "message": "issue in poetry add preventing the installation in py36", - "category": "fix", + assert tuple(tree) == ( + { + "version": "v1.2.0", + "date": "2019-04-19", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "custom cz plugins now support bumping version", + } + ] }, - {"scope": "users", "message": "lorem ipsum apap", "category": "fix"}, - { - "scope": None, - "message": ( - "it is possible to specify a pattern to be matched " - "in configuration files bump." - ), - "category": "feat", + }, + { + "version": "v1.1.1", + "date": "2019-04-18", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "changed stdout statements", + }, + { + "scope": "schema", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "info", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "example", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "commit", + "breaking": None, + "message": "moved most of the commit logic to the commit command", + }, + ], + "fix": [ + { + "scope": "bump", + "breaking": None, + "message": "commit message now fits better with semver", + }, + { + "scope": None, + "breaking": None, + "message": "conventional commit 'breaking change' in body instead of title", + }, + ], }, - ], - "version": "1.0.0", - "date": "2019-07-12", - } - - -def test_generate_full_tree(existing_changelog_file): - blocks = changelog.find_version_blocks(existing_changelog_file) - tree = list(changelog.generate_full_tree(blocks)) - - assert tree == [ + }, + { + "version": "v1.1.0", + "date": "2019-04-14", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "new working bump command", + }, + {"scope": None, "breaking": None, "message": "create version tag"}, + { + "scope": None, + "breaking": None, + "message": "update given files with new version", + }, + { + "scope": "config", + "breaking": None, + "message": "new set key, used to set version to cfg", + }, + { + "scope": None, + "breaking": None, + "message": "support for pyproject.toml", + }, + { + "scope": None, + "breaking": None, + "message": "first semantic version bump implementaiton", + }, + ], + "fix": [ + { + "scope": None, + "breaking": None, + "message": "removed all from commit", + }, + { + "scope": None, + "breaking": None, + "message": "fix config file not working", + }, + ], + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "added commands folder, better integration with decli", + } + ], + }, + }, + { + "version": "v1.0.0", + "date": "2019-03-01", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "removed delegator, added decli and many tests", + } + ], + "BREAKING CHANGE": [ + {"scope": None, "breaking": None, "message": "API is stable"} + ], + }, + }, + {"version": "1.0.0b2", "date": "2019-01-18", "changes": {}}, + { + "version": "v1.0.0b1", + "date": "2019-01-17", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "py3 only, tests and conventional commits 1.0", + } + ] + }, + }, + { + "version": "v0.9.11", + "date": "2018-12-17", + "changes": { + "fix": [ + { + "scope": "config", + "breaking": None, + "message": "load config reads in order without failing if there is no commitizen section", + } + ] + }, + }, + { + "version": "v0.9.10", + "date": "2018-09-22", + "changes": { + "fix": [ + { + "scope": None, + "breaking": None, + "message": "parse scope (this is my punishment for not having tests)", + } + ] + }, + }, { - "commits": [ - { - "scope": None, - "message": ( - "issue in poetry add preventing the installation in py36" - ), - "category": "fix", - }, - {"scope": "users", "message": "lorem ipsum apap", "category": "fix"}, - { - "scope": None, - "message": ( - "it is possible to specify a pattern to be matched " - "in configuration files bump." - ), - "category": "feat", - }, - ], - "version": "1.0.0", - "date": "2019-07-12", + "version": "v0.9.9", + "date": "2018-09-22", + "changes": { + "fix": [ + {"scope": None, "breaking": None, "message": "parse scope empty"} + ] + }, + }, + { + "version": "v0.9.8", + "date": "2018-09-22", + "changes": { + "fix": [ + { + "scope": "scope", + "breaking": None, + "message": "parse correctly again", + } + ] + }, }, { - "commits": [{"scope": None, "message": "holis", "category": "fix"}], - "version": "0.9", - "date": "2019-07-11", + "version": "v0.9.7", + "date": "2018-09-22", + "changes": { + "fix": [ + {"scope": "scope", "breaking": None, "message": "parse correctly"} + ] + }, + }, + { + "version": "v0.9.6", + "date": "2018-09-19", + "changes": { + "refactor": [ + { + "scope": "conventionalCommit", + "breaking": None, + "message": "moved fitlers to questions instead of message", + } + ], + "fix": [ + { + "scope": "manifest", + "breaking": None, + "message": "inluded missing files", + } + ], + }, }, + { + "version": "v0.9.5", + "date": "2018-08-24", + "changes": { + "fix": [ + { + "scope": "config", + "breaking": None, + "message": "home path for python versions between 3.0 and 3.5", + } + ] + }, + }, + { + "version": "v0.9.4", + "date": "2018-08-02", + "changes": { + "feat": [{"scope": "cli", "breaking": None, "message": "added version"}] + }, + }, + { + "version": "v0.9.3", + "date": "2018-07-28", + "changes": { + "feat": [ + { + "scope": "commiter", + "breaking": None, + "message": "conventional commit is a bit more intelligent now", + } + ] + }, + }, + { + "version": "v0.9.2", + "date": "2017-11-11", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "renamed conventional_changelog to conventional_commits, not backward compatible", + } + ] + }, + }, + { + "version": "v0.9.1", + "date": "2017-11-11", + "changes": { + "fix": [ + { + "scope": "setup.py", + "breaking": None, + "message": "future is now required for every python version", + } + ] + }, + }, + ) + + +def test_render_changelog(gitcommits, tags, changelog_content): + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + result = changelog.render_changelog(tree) + assert result == changelog_content + + +def test_render_changelog_unreleased(gitcommits): + some_commits = gitcommits[:7] + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + some_commits, [], parser, changelog_pattern + ) + result = changelog.render_changelog(tree) + assert "Unreleased" in result + + +def test_render_changelog_tag_and_unreleased(gitcommits, tags): + some_commits = gitcommits[:7] + single_tag = [ + tag for tag in tags if tag.rev == "56c8a8da84e42b526bcbe130bd194306f7c7e813" ] + + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + some_commits, single_tag, parser, changelog_pattern + ) + result = changelog.render_changelog(tree) + + assert "Unreleased" in result + assert "## v1.1.1" in result diff --git a/tests/test_changelog_meta.py b/tests/test_changelog_meta.py new file mode 100644 index 0000000000..505daefb83 --- /dev/null +++ b/tests/test_changelog_meta.py @@ -0,0 +1,165 @@ +import os + +import pytest + +from commitizen import changelog + +CHANGELOG_A = """ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +- Start using "changelog" over "change log" since it's the common usage. + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. +""".strip() + +CHANGELOG_B = """ +## [Unreleased] +- Start using "changelog" over "change log" since it's the common usage. + +## 1.2.0 +""".strip() + +CHANGELOG_C = """ +# Unreleased + +## v1.0.0 +""" + +CHANGELOG_D = """ +## Unreleased +- Start using "changelog" over "change log" since it's the common usage. +""" + + +@pytest.fixture +def changelog_a_file(): + changelog_path = "tests/CHANGELOG.md" + + with open(changelog_path, "w") as f: + f.write(CHANGELOG_A) + + yield changelog_path + + os.remove(changelog_path) + + +@pytest.fixture +def changelog_b_file(): + changelog_path = "tests/CHANGELOG.md" + + with open(changelog_path, "w") as f: + f.write(CHANGELOG_B) + + yield changelog_path + + os.remove(changelog_path) + + +@pytest.fixture +def changelog_c_file(): + changelog_path = "tests/CHANGELOG.md" + + with open(changelog_path, "w") as f: + f.write(CHANGELOG_C) + + yield changelog_path + + os.remove(changelog_path) + + +@pytest.fixture +def changelog_d_file(): + changelog_path = "tests/CHANGELOG.md" + + with open(changelog_path, "w") as f: + f.write(CHANGELOG_D) + + yield changelog_path + + os.remove(changelog_path) + + +VERSIONS_EXAMPLES = [ + ("## [1.0.0] - 2017-06-20", "1.0.0"), + ( + "# [10.0.0-next.3](https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3) (2020-04-22)", + "10.0.0-next.3", + ), + ("### 0.19.1 (Jan 7, 2020)", "0.19.1"), + ("## 1.0.0", "1.0.0"), + ("## v1.0.0", "1.0.0"), + ("## v1.0.0 - (2012-24-32)", "1.0.0"), + ("# version 2020.03.24", "2020.03.24"), + ("## [Unreleased]", None), + ("All notable changes to this project will be documented in this file.", None), + ("# Changelog", None), + ("### Bug Fixes", None), +] + + +@pytest.mark.parametrize("line_from_changelog,output_version", VERSIONS_EXAMPLES) +def test_changelog_detect_version(line_from_changelog, output_version): + version = changelog.parse_version_from_markdown(line_from_changelog) + assert version == output_version + + +TITLES_EXAMPLES = [ + ("## [1.0.0] - 2017-06-20", "##"), + ("## [Unreleased]", "##"), + ("# Unreleased", "#"), +] + + +@pytest.mark.parametrize("line_from_changelog,output_title", TITLES_EXAMPLES) +def test_parse_title_type_of_line(line_from_changelog, output_title): + title = changelog.parse_title_type_of_line(line_from_changelog) + assert title == output_title + + +def test_get_metadata_from_a(changelog_a_file): + meta = changelog.get_metadata(changelog_a_file) + assert meta == { + "latest_version": "1.0.0", + "latest_version_position": 10, + "unreleased_end": 10, + "unreleased_start": 7, + } + + +def test_get_metadata_from_b(changelog_b_file): + meta = changelog.get_metadata(changelog_b_file) + assert meta == { + "latest_version": "1.2.0", + "latest_version_position": 3, + "unreleased_end": 3, + "unreleased_start": 0, + } + + +def test_get_metadata_from_c(changelog_c_file): + meta = changelog.get_metadata(changelog_c_file) + assert meta == { + "latest_version": "1.0.0", + "latest_version_position": 3, + "unreleased_end": 3, + "unreleased_start": 1, + } + + +def test_get_metadata_from_d(changelog_d_file): + meta = changelog.get_metadata(changelog_d_file) + assert meta == { + "latest_version": None, + "latest_version_position": None, + "unreleased_end": 2, + "unreleased_start": 1, + } diff --git a/tests/test_changelog_parser.py b/tests/test_changelog_parser.py new file mode 100644 index 0000000000..f07d8c3f63 --- /dev/null +++ b/tests/test_changelog_parser.py @@ -0,0 +1,194 @@ +import os + +import pytest + +from commitizen import changelog_parser + +CHANGELOG_TEMPLATE = """ +## 1.0.0 (2019-07-12) + +### Fix + +- issue in poetry add preventing the installation in py36 +- **users**: lorem ipsum apap + +### Feat + +- it is possible to specify a pattern to be matched in configuration files bump. + +## 0.9 (2019-07-11) + +### Fix + +- holis + +""" + + +@pytest.fixture # type: ignore +def changelog_content() -> str: + changelog_path = "tests/CHANGELOG_FOR_TEST.md" + with open(changelog_path, "r") as f: + return f.read() + + +@pytest.fixture +def existing_changelog_file(): + changelog_path = "tests/CHANGELOG.md" + + with open(changelog_path, "w") as f: + f.write(CHANGELOG_TEMPLATE) + + yield changelog_path + + os.remove(changelog_path) + + +def test_read_changelog_blocks(existing_changelog_file): + blocks = changelog_parser.find_version_blocks(existing_changelog_file) + blocks = list(blocks) + amount_of_blocks = len(blocks) + assert amount_of_blocks == 2 + + +VERSION_CASES: list = [ + ("## 1.0.0 (2019-07-12)", {"version": "1.0.0", "date": "2019-07-12"}), + ("## 2.3.0a0", {"version": "2.3.0a0", "date": None}), + ("## 0.10.0a0", {"version": "0.10.0a0", "date": None}), + ("## 1.0.0rc0", {"version": "1.0.0rc0", "date": None}), + ("## 1beta", {"version": "1beta", "date": None}), + ( + "## 1.0.0rc1+e20d7b57f3eb (2019-3-24)", + {"version": "1.0.0rc1+e20d7b57f3eb", "date": "2019-3-24"}, + ), + ("### Bug fixes", {}), + ("- issue in poetry add preventing the installation in py36", {}), +] + +CATEGORIES_CASES: list = [ + ("## 1.0.0 (2019-07-12)", {}), + ("## 2.3.0a0", {}), + ("### Bug fixes", {"change_type": "Bug fixes"}), + ("### Features", {"change_type": "Features"}), + ("- issue in poetry add preventing the installation in py36", {}), +] + +CATEGORIES_TRANSFORMATIONS: list = [ + ("Bug fixes", "fix"), + ("Features", "feat"), + ("BREAKING CHANGES", "BREAKING CHANGES"), +] + +MESSAGES_CASES: list = [ + ("## 1.0.0 (2019-07-12)", {}), + ("## 2.3.0a0", {}), + ("### Fix", {}), + ( + "- name no longer accept invalid chars", + { + "message": "name no longer accept invalid chars", + "scope": None, + "breaking": None, + }, + ), + ( + "- **users**: lorem ipsum apap", + {"message": "lorem ipsum apap", "scope": "users", "breaking": None}, + ), +] + + +@pytest.mark.parametrize("test_input,expected", VERSION_CASES) +def test_parse_md_version(test_input, expected): + assert changelog_parser.parse_md_version(test_input) == expected + + +@pytest.mark.parametrize("test_input,expected", CATEGORIES_CASES) +def test_parse_md_change_type(test_input, expected): + assert changelog_parser.parse_md_change_type(test_input) == expected + + +@pytest.mark.parametrize("test_input,expected", CATEGORIES_TRANSFORMATIONS) +def test_transform_change_type(test_input, expected): + assert changelog_parser.transform_change_type(test_input) == expected + + +@pytest.mark.parametrize("test_input,expected", MESSAGES_CASES) +def test_parse_md_message(test_input, expected): + assert changelog_parser.parse_md_message(test_input) == expected + + +def test_transform_change_type_fail(): + with pytest.raises(ValueError) as excinfo: + changelog_parser.transform_change_type("Bugs") + assert "Could not match a change_type" in str(excinfo.value) + + +def test_generate_block_tree(existing_changelog_file): + blocks = changelog_parser.find_version_blocks(existing_changelog_file) + block = next(blocks) + tree = changelog_parser.generate_block_tree(block) + assert tree == { + "changes": { + "fix": [ + { + "scope": None, + "breaking": None, + "message": "issue in poetry add preventing the installation in py36", + }, + {"scope": "users", "breaking": None, "message": "lorem ipsum apap"}, + ], + "feat": [ + { + "scope": None, + "breaking": None, + "message": ( + "it is possible to specify a pattern to be matched " + "in configuration files bump." + ), + } + ], + }, + "version": "1.0.0", + "date": "2019-07-12", + } + + +def test_generate_full_tree(existing_changelog_file): + blocks = changelog_parser.find_version_blocks(existing_changelog_file) + tree = list(changelog_parser.generate_full_tree(blocks)) + + assert tree == [ + { + "changes": { + "fix": [ + { + "scope": None, + "message": "issue in poetry add preventing the installation in py36", + "breaking": None, + }, + { + "scope": "users", + "message": "lorem ipsum apap", + "breaking": None, + }, + ], + "feat": [ + { + "scope": None, + "message": "it is possible to specify a pattern to be matched in configuration files bump.", + "breaking": None, + }, + ], + }, + "version": "1.0.0", + "date": "2019-07-12", + }, + { + "changes": { + "fix": [{"scope": None, "message": "holis", "breaking": None}], + }, + "version": "0.9", + "date": "2019-07-11", + }, + ]