change(user-output): update user output marking all issues with message

- Dynamic messages in output report
- Color input commit message same as message elements
- Tests updated
This commit is contained in:
Tomas Sebestik
2023-11-08 17:19:42 +01:00
committed by Tomas Sebestik
parent 3523578b39
commit 72404cfabb
11 changed files with 634 additions and 493 deletions

View File

@ -1,4 +1,4 @@
name: Test hook script with Pytest
name: Tests Pytest (multi-os)
on:
push:

View File

@ -1,17 +1,41 @@
## Unreleased
- change(user-output): update user output marking all issues with message - Dynamic messages in output report - Color input commit message same as message elements - Tests updated
- ci: update project settings configuration (pyproject.toml)
- add CHANGELOG.md, commitizen, test packages definitions
- GitHub action - testing on multiple OSes
## v1.2.1 (2023-07-31)
### Fix
- **scope-capitalization**: Update scope regex to be consistent with commitlint in DangerJS (#6)
- fix(scope-capitalization): Update scope regex to be consistent with commitlint in DangerJS (#6)
- docs(README) Update default max summary length
## v1.2.0 (2023-06-29)
- Ignore comment lines from linted commit message (#5)
- * fix: Ignore # lines from linted commit message
* feat: Add hint for preserving commit message to output report
* fix: Allow in scope special characters " _ / . , * -"
- docs: Update hook install process guide (#4)
## v1.1.0 (2023-06-27)
- Update default rules (#3)
- * change(rules): Set maximum summary length to 72 characters
* change(rules): Summary uppercase letter as optional rules
- docs: Update argument usage example in README.md
## v1.0.0 (2023-06-21)
### Feat
- Add linter pre-commit hook logic
- Merge pull request #1 from espressif/add_linter_hook
- feat: Add linter pre-commit hook logic (RDT-471)
- feat: Add linter pre-commit hook logic
- Init

187
README.md
View File

@ -1,57 +1,96 @@
# Conventional Precommit Linter
<div align="center">
<h1>Conventional Precommit Linter</h1>
<img src="docs/conventional-precommit-linter.jpg" width="800">
<br>
<br>
<!-- GitHub Badges -->
<img alt="release" src="https://img.shields.io/github/v/release/espressif/conventional-precommit-linter" />
<img alt="tests" src="https://github.com/espressif/conventional-precommit-linter/actions/workflows/pytest.yml/badge.svg" />
</div>
The Conventional Precommit Linter is a tool designed to ensure commit messages follow the Conventional Commits standard, enhancing the readability and traceability of your project's history.
<hr>
The Conventional Precommit Linter hook is a Python script that checks the format of the commit messages, ensuring they adhere to the Conventional Commits standard with some additional parameters.
It is intended to be used as a pre-commit hook to verify the commit messages before they are committed, improving the overall quality and consistency of your commit history.
## Table of Contents
- [Table of Contents](#table-of-contents)
- [Getting Started](#getting-started)
- [Commit Message Structure](#commit-message-structure)
- [Installation](#installation)
- [Install Commit-msg Hooks](#install-commit-msg-hooks)
- [Configuration](#configuration)
- [Contributing and Development](#contributing-and-development)
- [Credits](#credits)
***
## Getting Started
To use the Conventional Precommit Linter Hook in your project, add the following configuration to your `.pre-commit-config.yaml` file:
### Commit Message Structure
Commit messages are validated against the following format:
```
<type>(<optional-scope>): <summary>
< ... empty line ... >
<optional body lines>
<optional body lines>
<optional body lines>
```
Each component is checked for compliance with the provided or default configuration.
**Example output for failed message:**
<img src="docs/example-output-default-args.png" width="800">
**Example output for failed message (with custom arguments):**
<img src="docs/example-output-custom-args.png" width="800">
### Installation
To integrate the **Conventional Precommit Linter** into your project, add to your `.pre-commit-config.yaml`:
```yaml
- repo: https://github.com/espressif/conventional-precommit-linter
rev: v1.0.0
# FILE: .pre-commit-config.yaml
repos:
- repo: https://github.com/espressif/conventional-precommit-linter
rev: v1.3.0 # The version tag you wish to use
hooks:
- id: conventional-precommit-linter
stages: [commit-msg]
```
### Install commit-msg hooks
**IMPORTANT**: To install `commit-msg` type hooks, execute the command:
### Install Commit-msg Hooks
**IMPORTANT:** `commit-msg` hooks require a specific installation command:
```sh
pre-commit install -t pre-commit -t commit-msg
```
Simple `pre-commit install` will not help here - The `pre-commit install` command in default is responsible ... for installing the pre-commit stage hooks only. This means that it sets up hooks which get triggered before the commit process really starts (that is, before you type a commit message). These hooks are typically used to run checks like linting or unit tests, which need to pass before the commit can be made.
However, the `commit-msg` stage hooks (as the `conventional-precommit-linter` is) are a separate set of hooks that run at a different stage in the commit process. These hooks get triggered after you've typed in your commit message but before the commit is finalized.
Since these two types of hooks run at different stages in the commit process and serve different purposes, the pre-commit framework treats them as distinct. Therefore, you need to use `pre-commit install` to set up the `pre-commit` hooks, and `pre-commit install -t commit-msg` to set up the `commit-msg` hooks.
The `pre-commit install -t pre-commit -t commit-msg` command combines these two commands above.
The developer only needs to execute this command once. Subsequent changes to the `.pre-commit-config.yaml` file do not require re-running it, but it is recommended to run it after each change to this file.
## Parameters
The script supports additional parameters to customize its behavior:
- `--types`: Optional list of commit types to support. If not specified, the default types are `["change", "ci", "docs", "feat", "fix", "refactor", "remove", "revert"]`.
- `--subject-min-length`: Minimum length of the 'Summary' field in commit message. Defaults to `20`.
- `--subject-max-length`: Maximum length of the 'Summary' field in commit message. Defaults to `72`.
- `--body-max-line-length`: Maximum length of a line in the commit message body. Defaults to `100`.
- `--summary-uppercase`: Summary must start with an uppercase letter. If not specified, the default is `false` (uppercase not required).
You can modify these parameters by adding them to the `args` array in the `.pre-commit-config.yaml` file. For example, to change the `--subject-min-length` to `10` and add a new type `fox`, your configuration would look like this:
**Note:** The `pre-commit install` command by default sets up only the `pre-commit` stage hooks. The additional flag `-t commit-msg` is necessary to set up `commit-msg` stage hooks.
For a simplified setup (just with `pre-commit install` without flags), ensure your `.pre-commit-config.yaml` contains the following:
```yaml
# FILE: .pre-commit-config.yaml
---
minimum_pre_commit_version: 3.3.0
default_install_hook_types: [pre-commit,commit-msg]
...
```
After modifying `.pre-commit-config.yaml`, re-run the install command (`pre-commit install`) for changes to take effect.
## Configuration
The linter accepts several configurable parameters to tailor commit message validation:
- `--types`: Define the types of commits allowed (default: [`change`, `ci`, `docs`, `feat`, `fix`, `refactor`, `remove`, `revert`]).
- `--subject-min-length`: Set the minimum length for the summary (default: `20`).
- `--subject-max-length`: Set the maximum length for the summary (default: `72`).
- `--body-max-line-length`: Set the maximum line length for the body (default: `100`).
- `--summary-uppercase`: Enforce the summary to start with an uppercase letter (default: `disabled`).
The **custom configuration** can be specified in `.pre-commit-config.yaml` like this:
```yaml
# FILE: .pre-commit-config.yaml
...
- repo: https://github.com/espressif/conventional-precommit-linter
rev: v1.0.0
rev: v1.3.0 # The version tag you wish to use
hooks:
- id: conventional-precommit-linter
stages: [commit-msg]
@ -60,68 +99,56 @@ You can modify these parameters by adding them to the `args` array in the `.pre-
- --subject-min-length=10
```
## Commit Message Structure
***
The script checks commit messages for the following structure:
## Contributing and Development
```text
<type><(scope/component)>: <Summary>
We welcome contributions! To contribute to this repository, please follow these steps:
<Body>
```
1. **Clone the Project**: Clone the repository to your local machine using:
```sh
git clone https://github.com/espressif/conventional-precommit-linter.git
```
Where:
2. **Set Up Development Environment:**
- `<type>`: a descriptor of the performed change, e.g., `feat`, `fix`, `refactor`, etc. Use one of the specified types (either default or provided using the `--types` parameter).
- Create and activate a virtual environment:
```sh
virtualenv venv -p python3.8 && source ./venv/bin/activate
```
or
```sh
python -m venv venv && source ./venv/bin/activate
```
- `<scope/component>` (optional): the scope or component that the commit pertains to. It should be written in lower case without whitespace, allowed special characters in `scope` are `_` `/` `.` `,` `*` `-` `.`
- Install the project and development dependencies:
```sh
pip install -e '.[dev]'
```
- `<summary>`: a short, concise description of the change. It should not end with a period, and be between `subject_min_length` and `subject_max_length` characters long (as specified by script parameters). If the `--summary-uppercase` flag is used, then the summary must start with a uppercase letter.
3. **Testing Your Changes:**
- `<body>` (optional): a detailed description of the change. Each line should be no longer than `body_max_line_length` characters (as specified by script parameters). There should be one blank line between the summary and the body.
- Create a file named `test_message.txt` in the root of the repository (this file is git-ignored) and place an example commit message in it.
Examples:
- Run the tool to lint the message:
```sh
python -m conventional_precommit_linter.hook test_message.txt
```
```text
fix(freertos): Fix startup timeout issue
This is a detailed description of the commit message body ...
...
ci: added target test job for ESP32-Wifi6
...
```
With the Conventional Precommit Linter hook, your project can maintain clean and understandable commit messages that follow the Conventional Commits standard.
... or with arguments:
```sh
python -m conventional_precommit_linter.hook --subject-min-length 20 --subject-max-length 72 --body-max-line-length 100 test_message.txt
```
## Testing
Our Conventional Precommit Linter hook also includes a test suite to ensure the correct functioning of the script.
To run these tests, you will need to have `pytest` installed. Once `pytest` is installed, you can run the tests by navigating to the root directory of this project and executing:
Before submitting a pull request, ensure your changes pass all the tests. You can run the test suite with the following command:
```sh
pytest
```
... or create a content in file `test_message.txt` and run:
```sh
python -m conventional_precommit_linter.hook --subject-min-length 20 --subject-max-length 72 --body-max-line-length 100 test_message.txt
```
.... or (with default arguments):
```sh
python -m conventional_precommit_linter.hook test_message.txt
```
The test cases include a variety of commit message scenarios and check if the script correctly identifies valid and invalid messages. The test suite ensures the correct identification of message structure, format, length, and content, amongst other parameters.
This way, the integrity of the Conventional Precommit Linter hook is continuously checked. Regularly running these tests after modifications to the script is highly recommended to ensure its consistent performance.
Please also adhere to the Conventional Commits standard when writing your commit messages for this project.
***
## Credit
## Credits
Inspired by project: https://github.com/compilerla/conventional-pre-commit

View File

@ -0,0 +1,25 @@
from colorama import Fore
from colorama import init
from colorama import Style
init(autoreset=True) # Automatically reset the style after each print
def _color_bold_green(text: str) -> str:
return f'{Style.BRIGHT}{Fore.GREEN}{text}'
def _color_purple(text: str) -> str:
return f'{Fore.MAGENTA}{text}'
def _color_orange(text: str) -> str:
return f'{Fore.YELLOW}{text}'
def _color_blue(text: str) -> str:
return f'{Fore.LIGHTBLUE_EX}{text}'
def _color_grey(text: str) -> str:
return f'{Fore.LIGHTBLACK_EX}{text}'

View File

@ -3,18 +3,162 @@ import re
import sys
from typing import List
from typing import Optional
from typing import Tuple
DEFAULT_TYPES = ['change', 'ci', 'docs', 'feat', 'fix', 'refactor', 'remove', 'revert']
from .helpers import _color_blue
from .helpers import _color_bold_green
from .helpers import _color_grey
from .helpers import _color_orange
from .helpers import _color_purple
ERROR_EMPTY_MESSAGE = 'Commit message seems to be empty.'
ERROR_MISSING_COLON = "Missing colon after 'type' or 'scope'. Ensure the commit message has the format '<type><(scope/component)>: <summary>'."
ERROR_TYPE = "Issue with 'type'. Ensure the type is one of [{}]."
ERROR_SCOPE_CAPITALIZATION = "Issue with 'scope'. Ensure the 'scope' is written in lower case without whitespace. Allowed special characters in 'scope' are _ / . , * -"
ERROR_SUMMARY_LENGTH = "Issue with 'summary'. Ensure the summary is between {} and {} characters long."
ERROR_SUMMARY_CAPITALIZATION = "Issue with 'summary'. Ensure the summary starts with an uppercase letter."
ERROR_SUMMARY_PERIOD = "Issue with 'summary'. Ensure the summary does not end with a period."
ERROR_BODY_FORMAT = "Incorrectly formatted 'body'. There should be one blank line between 'summary' and 'body'."
ERROR_BODY_LENGTH = "Issue with 'body' line length. {} line(s) exceeding line length limit."
rules_output_status = {
'empty_message': False,
'error_body_format': False,
'error_body_length': False,
'error_scope_capitalization': False,
'error_scope_format': False,
'error_summary_capitalization': False,
'error_summary_length': False,
'error_summary_period': False,
'error_type': False,
'missing_colon': False,
}
def allowed_types(args: argparse.Namespace) -> str:
default_types = ['change', 'ci', 'docs', 'feat', 'fix', 'refactor', 'remove', 'revert']
# Provided types take precedence over default types
types = args.types[0].split(',') if args.types else default_types
return ', '.join(types)
def read_commit_message(file_path: str) -> str:
with open(file_path, encoding='utf-8') as file:
lines = file.readlines()
lines = [line for line in lines if not line.startswith('#')] # Remove comment lines (starting with '#')
content = ''.join(lines)
if not content.strip():
rules_output_status['empty_message'] = True
return content
def split_message_title(message_title: str) -> Tuple[str, Optional[str], str]:
"""Split 'message title' into 'type/scope' and 'summary'"""
type_and_scope, _, commit_summary = message_title.partition(': ')
commit_type, _, scope_part = type_and_scope.partition('(')
# Check if both opening and closing parentheses are present
if '(' in type_and_scope and ')' not in scope_part:
rules_output_status['error_scope_format'] = True
return commit_type, None, commit_summary # Return None for the scope due to the error
commit_scope: Optional[str] = scope_part.rstrip(')').strip() if scope_part else None
commit_summary = commit_summary.strip()
return commit_type, commit_scope, commit_summary
def check_colon_after_type(message_title: str) -> bool:
"""Check for missing column between type / type(scope) and summary."""
message_parts = message_title.split(': ', 1) # split only on first occurrence
if len(message_parts) != 2:
rules_output_status['missing_colon'] = True
return False
return True
def check_allowed_types(commit_type: str, args: argparse.Namespace) -> None:
"""Check for allowed types."""
types = allowed_types(args)
if commit_type not in types:
rules_output_status['error_type'] = True
def check_scope(commit_scope: str) -> None:
"""Check for scope capitalization and allowed characters"""
regex_scope = r'^[a-z0-9_/.,*-]*$'
if commit_scope and not re.match(regex_scope, commit_scope):
rules_output_status['error_scope_capitalization'] = True
def check_summary_length(commit_summary: str, args: argparse.Namespace) -> None:
"""Check for summary length (between min and max allowed characters)"""
summary_length = len(commit_summary)
if summary_length < args.subject_min_length or summary_length > args.subject_max_length:
rules_output_status['error_summary_length'] = True
def check_summary_lowercase(commit_summary: str) -> None:
"""Check for summary starting with an uppercase letter (rule disabled in default config)"""
if commit_summary[0].islower():
rules_output_status['error_summary_capitalization'] = True
def check_summary_period(commit_summary: str) -> None:
"""Check for summary ending with a period"""
if commit_summary[-1] == '.':
rules_output_status['error_summary_period'] = True
def check_body_empty_lines(message_body: List[str]) -> None:
"""Check for empty line between summary and body"""
if not message_body[0].strip() == '':
rules_output_status['error_body_format'] = True
def check_body_lines_length(message_body: List[str], args: argparse.Namespace) -> None:
"""Check for body lines length (shorter than max allowed characters)"""
if not all(len(line) <= args.body_max_line_length for line in message_body):
rules_output_status['error_body_length'] = True
def _get_icon_for_rule(status: bool) -> str:
"""Return a icon depending on the status of the rule (True = error found, False = success))"""
return '' if status else '✔️ '
def print_report(commit_type: str, commit_scope: Optional[str], commit_summary: str, args) -> None:
# Color the input commit message with matching element colors
commit_message = f'{_color_purple(commit_type)}: { _color_orange( commit_summary)}'
if commit_scope:
commit_message = (
f'{_color_purple(commit_type)}({ _color_blue( commit_scope)}): { _color_orange( commit_summary)}'
)
# Rule messages that are always included
rule_messages = [
f"{_get_icon_for_rule(rules_output_status['error_type'])} {_color_purple('<type>')} is mandatory, use one of the following: [{_color_purple(allowed_types(args))}]",
f"{_get_icon_for_rule(rules_output_status['error_scope_format'])} {_color_blue('(<optional-scope>)')} if used, must be enclosed in parentheses",
f"{_get_icon_for_rule(rules_output_status['error_scope_capitalization'])} {_color_blue('(<optional-scope>)')} if used, must be written in lower case without whitespace",
f"{_get_icon_for_rule(rules_output_status['error_summary_period'])} {_color_orange('<summary>')} must not end with a period",
f"{_get_icon_for_rule(rules_output_status['error_summary_length'])} {_color_orange('<summary>')} must be between {args.subject_min_length} and {args.subject_max_length} characters long",
f"{_get_icon_for_rule(rules_output_status['error_body_length'])} {_color_grey('<body>')} lines must be no longer than {args.body_max_line_length} characters",
f"{_get_icon_for_rule(rules_output_status['error_body_format'])} {_color_grey('<body>')} must be separated from the 'summary' by a blank line",
]
# Dynamically add the additional rules set by arguments
if args.summary_uppercase:
rule_messages.append(
f"{_get_icon_for_rule(rules_output_status['error_summary_capitalization'])} {_color_orange('<summary>')} must start with an uppercase letter"
)
# Combine the rule messages into the final report block
message_rules_block = ' ' + '\n '.join(rule_messages)
full_guide_message = f"""\n❌ INVALID COMMIT MESSAGE: {commit_message}
_______________________________________________________________
Commit message structure: {_color_purple('<type>')}{_color_blue("(<optional-scope>)")}: {_color_orange('<summary>')}
<... empty line ...>
{_color_grey('<optional body lines>')}
{_color_grey('<optional body lines>')}
_______________________________________________________________
Commit message rules:
{message_rules_block}
"""
print(full_guide_message)
print(
f'👉 To preserve and correct a commit message, run: {_color_bold_green("git commit --edit --file=.git/COMMIT_EDITMSG")}'
)
def parse_args(argv: List[str]) -> argparse.Namespace:
@ -22,157 +166,59 @@ def parse_args(argv: List[str]) -> argparse.Namespace:
prog='conventional-pre-commit', description='Check a git commit message for Conventional Commits formatting.'
)
parser.add_argument('--types', type=str, nargs='*', help='Optional list of types to support')
parser.add_argument(
'--subject-min-length', type=int, default=20, help="Minimum length of the 'Summary' field in commit message"
)
parser.add_argument(
'--subject-max-length', type=int, default=72, help="Maximum length of the 'Summary' field in commit message"
)
parser.add_argument(
'--body-max-line-length', type=int, default=100, help='Maximum length of the line in message body'
)
parser.add_argument('--subject-min-length', type=int, default=20, help="Minimum length of the 'Summary'")
parser.add_argument('--subject-max-length', type=int, default=72, help="Maximum length of the 'Summary'")
parser.add_argument('--body-max-line-length', type=int, default=100, help='Maximum length of the body line')
parser.add_argument('--summary-uppercase', action='store_true', help='Summary must start with an uppercase letter')
parser.add_argument('input', type=str, help='A file containing a git commit message')
return parser.parse_args(argv)
def get_types(args: argparse.Namespace) -> str:
# Provided types take precedence over default types
types = args.types[0].split(',') if args.types else DEFAULT_TYPES
return ', '.join(types)
def raise_error(message: str, error: str, types: str, args: argparse.Namespace) -> None:
full_error_msg = f'❌ Invalid commit message: "{message}"\n{error}'
guide_good_message = f"""
commit message structure: <type><(scope/component)>: <Summary>
commit message rules:
- use one of the following types: [{types}]
- 'scope/component' is optional, but if used, must be written in lower case without whitespace
- 'summary' must not end with a period
- 'summary' must be between {args.subject_min_length} and {args.subject_max_length} characters long
- 'body' is optional, but if used, lines must be no longer than {args.body_max_line_length} characters
- 'body' is optional, but if used, must be separated from the 'summary' by a blank line
Example of a good commit message (with scope and body):
fix(freertos): Fix startup timeout issue
This is a text of commit message body ...
...
Example of a good commit message (without scope and body):
ci: added target test job for ESP32-Wifi6
...
"""
print(f'{full_error_msg}{guide_good_message}')
print(
'\n\033[93m 👉 To preserve and correct a commit message, run\033[92m git commit --edit --file=.git/COMMIT_EDITMSG \033[0m'
)
raise SystemExit(1)
def read_commit_message(file_path: str) -> str:
try:
with open(file_path, encoding='utf-8') as file:
lines = file.readlines()
# Remove lines starting with '#'
lines = [line for line in lines if not line.startswith('#')]
content = ''.join(lines)
if not content.strip():
print(f'{ERROR_EMPTY_MESSAGE}')
raise SystemExit(1)
return content
except UnicodeDecodeError as exc:
print('❌ Problem with reading the commit message. Possible encoding issue.')
raise SystemExit(1) from exc
def parse_commit_message(args: argparse.Namespace, input_commit_message: str) -> None:
# Split the 'commit message' into the 'message title' (first line) and 'message body' (the rest)
message_title, *message_body = input_commit_message.strip().split('\n', 1)
# First split 'message title' into potential 'type/scope' and 'summary'
message_parts = message_title.split(': ', 1) # using 1 as second argument to split only on first occurrence
if len(message_parts) != 2:
types = get_types(args)
raise_error(message_title, ERROR_MISSING_COLON, types, args)
# Check if a 'scope' is provided in the potential 'type/scope' part
type_scope_parts = message_parts[0].split('(', 1) # using 1 as second argument to split only on first occurrence
if len(type_scope_parts) == 1:
# no 'scope' provided
commit_type = type_scope_parts[0]
commit_scope = None
else:
# 'scope' provided
commit_type = type_scope_parts[0]
commit_scope = type_scope_parts[1].rstrip(')')
commit_summary = message_parts[1]
# Check for invalid commit 'type'
types = get_types(args)
if commit_type not in types.split(', '):
error = ERROR_TYPE.format(types)
raise_error(message_title, error, types, args)
# If 'scope' is provided, check for valid 'scope'
regex_scope = r'^[a-z0-9_/.,*-]*$'
if commit_scope and not re.match(regex_scope, commit_scope):
raise_error(message_title, ERROR_SCOPE_CAPITALIZATION, types, args)
# Check for valid length of 'summary'
summary_length = len(commit_summary)
if summary_length < args.subject_min_length or summary_length > args.subject_max_length:
error = ERROR_SUMMARY_LENGTH.format(args.subject_min_length, args.subject_max_length)
raise_error(message_title, error, types, args)
# Check if the 'summary' starts with a lowercase letter
if args.summary_uppercase and commit_summary[0].islower():
raise_error(message_title, ERROR_SUMMARY_CAPITALIZATION, types, args)
# Check if the 'summary' ends with a period (full stop)
if commit_summary[-1] == '.':
raise_error(message_title, ERROR_SUMMARY_PERIOD, types, args)
# Skip the rest if there is no message 'body' (as `body` is optional)
if not message_body:
return
# Parse commit message 'body' (when it exists)
if re.match(r'\n', message_body[0]):
body = message_body[0][1:] # Remove the first blank line
# Check if each line of the 'body' is no longer than 100 characters
lines = body.split('\n')
invalid_lines = [line for line in lines if len(line) > args.body_max_line_length]
num_invalid_lines = len(invalid_lines)
if num_invalid_lines:
error = ERROR_BODY_LENGTH.format(num_invalid_lines)
for line in invalid_lines:
print(f" Line: '{line[:30]}...' (length: {len(line)})")
raise_error(message_title, error, types, args)
else:
raise_error(message_title, ERROR_BODY_FORMAT, types, args)
def main(argv: Optional[List[str]] = None) -> None:
def main(argv: Optional[List[str]] = None) -> int:
argv = argv or sys.argv[1:]
args = parse_args(argv)
# Read commit message
# Parse the commit message in to parts
input_commit_message = read_commit_message(args.input)
# Parse the commit message
parse_commit_message(args, input_commit_message)
if not input_commit_message.strip():
print('❌ Commit message seems to be empty.')
return 1
message_lines = input_commit_message.strip().split('\n') # Split the commit message into lines
message_title = message_lines[0] # The summary is the first line
message_body = message_lines[1:] # The body is everything after the summary, if it exists
commit_type, commit_scope, commit_summary = split_message_title(message_title)
if not check_colon_after_type(message_title):
print(f'❌ Missing colon after {_color_purple("<type>")} or {_color_blue("(<optional-scope>)")}.')
print(
f'\nEnsure the commit message has the format \"{_color_purple("<type>")}{_color_blue("(<optional-scope>)")}: {_color_orange("<summary>")}\"'
)
return 1
# Commit message title (first line) checks
check_allowed_types(commit_type, args)
if commit_scope:
check_scope(commit_scope)
check_summary_length(commit_summary, args)
check_summary_period(commit_summary)
if args.summary_uppercase:
check_summary_lowercase(commit_summary)
# Commit message body checks
if message_body:
check_body_empty_lines(message_body)
check_body_lines_length(message_body, args)
# Create report if issues found
if any(value for value in rules_output_status.values()):
print_report(commit_type, commit_scope, commit_summary, args)
return 1
# No output and exit RC 0 if no issues found
return 0
if __name__ == '__main__':
main()
raise SystemExit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -3,7 +3,7 @@
{ name = "Tomas Sebestik (Espressif Systems)", email = "tomas.sebestik@espressif.com" },
]
classifiers = ["Programming Language :: Python :: 3 :: Only"]
dependencies = []
dependencies = ["colorama==0.4.6"]
description = "A pre-commit hook that checks commit messages for Conventional Commits formatting."
dynamic = ["version"]
keywords = ["conventional-commits", "git", "pre-commit"]
@ -87,7 +87,7 @@
[tool.pytest.ini_options]
addopts = "-s --log-cli-level DEBUG --cov=conventional_precommit_linter --cov-report=term"
addopts = "-ra -v -p no:print --cov=conventional_precommit_linter --cov-report=term"
python_classes = ["Test*"]
python_files = ["test_*.py"]
python_functions = ["test_*"]

View File

@ -2,198 +2,219 @@ import tempfile
import pytest
from conventional_precommit_linter.hook import ERROR_BODY_FORMAT
from conventional_precommit_linter.hook import ERROR_BODY_LENGTH
from conventional_precommit_linter.hook import ERROR_EMPTY_MESSAGE
from conventional_precommit_linter.hook import ERROR_MISSING_COLON
from conventional_precommit_linter.hook import ERROR_SCOPE_CAPITALIZATION
from conventional_precommit_linter.hook import ERROR_SUMMARY_CAPITALIZATION
from conventional_precommit_linter.hook import ERROR_SUMMARY_LENGTH
from conventional_precommit_linter.hook import ERROR_SUMMARY_PERIOD
from conventional_precommit_linter.hook import ERROR_TYPE
from conventional_precommit_linter.hook import main
from conventional_precommit_linter.hook import rules_output_status
# Input arguments
# Default values for the commit message format
TYPES = 'change, ci, docs, feat, fix, refactor, remove, revert, fox'
SUBJECT_MIN_LENGTH = 21
SUBJECT_MAX_LENGTH = 53
BODY_MAX_LINE_LENGTH = 101
BODY_MAX_LINE_LENGTH = 107
SUMMARY_UPPERCASE = True
# Default dictionary with all values set to False
default_rules_output_status = {
'empty_message': False,
'error_body_format': False,
'error_body_length': False,
'error_scope_capitalization': False,
'error_scope_format': False,
'error_summary_capitalization': False,
'error_summary_length': False,
'error_summary_period': False,
'error_type': False,
'missing_colon': False,
}
# Dynamic test naming based on the commit message
def commit_message_id(commit_message): # pylint: disable=redefined-outer-name
return commit_message[0] # Use the first line of the commit message as the test ID
# Fixture for messages
@pytest.fixture(
params=[
(
# Expected PASS: Message with scope and body
'feat(bootloader): This is commit message with scope and body\n\nThis is a text of body',
True,
None,
{},
),
(
# Expected PASS: Message with scope, without body
'change(wifi): This is commit message with scope without body',
True,
None,
{},
),
(
# Expected PASS: Message with scope (with hyphen in scope), without body
'change(esp-rom): This is commit message with hyphen in scope',
True,
None,
{},
),
(
# Expected PASS: Message with scope (with asterisk in scope), without body
'change(examples*storage): This is commit message with asterisk in scope',
True,
None,
{},
),
(
# Expected PASS: Message with scope (with comma in scope), without body
'change(examples,storage): This is commit message with comma in scope',
True,
None,
{},
),
(
# Expected PASS: Message with scope (with slash in scope), without body
'change(examples/storage): This is commit message with slash in scope',
True,
None,
{},
),
(
# Expected PASS: Message without scope, with body
'change: This is commit message without scope with body\n\nThis is a text of body\n# Please enter the commit message for your changes. Lines starting\n# with \'#\' will be ignored, and an empty message aborts the commit.\n#', # noqa: E501
True,
None,
'change: This is commit message without scope with body\n\nThis is a text of body\n# Please enter the commit message for your changes. Lines starting\n# with \'#\' will be ignored, and an empty message aborts the commit.\n#',
{},
),
(
# Expected PASS: Message without scope, without body
'change: This is commit message without scope and body',
True,
None,
{},
),
(
# Expected PASS: Test of additional types
'fox(esp32): Testing additional types\n\nThis is a text of body',
True,
None,
{},
),
(
# Expected PASS: 'body' line longer (custom arg 107 chars)
'fix(bt): Update database schemas\n\nUpdating the database schema to include fields and user profile preferences, cleaning up unnecessary calls',
{},
),
(
# Expected FAIL: missing colon between 'type' (and 'scope') and 'summary'
'change this is commit message without body',
False,
ERROR_MISSING_COLON,
),
(
# Expected FAIL: 'summary' too short
'fix: Fix bug',
False,
ERROR_SUMMARY_LENGTH.format(SUBJECT_MIN_LENGTH, SUBJECT_MAX_LENGTH),
),
(
# Expected FAIL: 'summary' too long
'change(rom): Refactor authentication flow for enhanced security measures',
False,
ERROR_SUMMARY_LENGTH.format(SUBJECT_MIN_LENGTH, SUBJECT_MAX_LENGTH),
),
(
# Expected FAIL: 'summary' ends with period
'change(rom): Fixed the another bug.',
False,
ERROR_SUMMARY_PERIOD,
),
(
# Expected FAIL: 'summary' starts with lowercase
'change(rom): this message starts with lowercase',
False,
ERROR_SUMMARY_CAPITALIZATION,
),
(
# Expected FAIL: uppercase in 'scope'
'change(Bt): Added new feature with change\n\nThis feature adds functionality',
False,
ERROR_SCOPE_CAPITALIZATION,
),
(
# Expected FAIL: uppercase in 'scope'
'fix(dangerGH): Update token permissions - allow Danger to add comments to PR',
False,
ERROR_SCOPE_CAPITALIZATION,
),
(
# Expected FAIL: not allowed 'type' with scope and body
'delete(bt): Added new feature with change\n\nThis feature adds functionality',
False,
# Adjusted expected error message
ERROR_TYPE.format(TYPES),
),
(
# Expected FAIL: not allowed 'type' without scope and without body
'wip: Added new feature with change',
False,
# Adjusted expected error message
ERROR_TYPE.format(TYPES),
),
(
# Expected FAIL: not allowed 'type' (type starts with uppercase)
'Fix(bt): Added new feature with change\n\nThis feature adds functionality',
False,
ERROR_TYPE.format(TYPES),
),
(
# Expected FAIL: missing blank line between 'summary' and 'body'
'change: Added new feature with change\nThis feature adds functionality',
False,
ERROR_BODY_FORMAT,
),
(
# Expected FAIL: 'body' line too long
'fix(bt): Update database schemas\n\nUpdating the database schema to include new fields and user profile preferences, cleaning up unnecessary calls', # noqa: E501
False,
ERROR_BODY_LENGTH.format(1), # 1 here means found one line that is too long
{'missing_colon': True},
),
(
# Expected FAIL: empty commit message
' \n\n \n',
False,
ERROR_EMPTY_MESSAGE,
{'empty_message': True},
),
]
(
# Expected FAIL: 'summary' too short
'fix: Fix bug',
{'error_summary_length': True},
),
(
# Expected FAIL: 'summary' too long
'change(rom): Refactor authentication flow for enhanced security measures',
{'error_summary_length': True},
),
(
# Expected FAIL: 'summary' ends with period
'change(rom): Fixed the another bug.',
{'error_summary_period': True},
),
(
# Expected FAIL: 'summary' starts with lowercase
'change(rom): this message starts with lowercase',
{'error_summary_capitalization': True},
),
(
# Expected FAIL: uppercase in 'scope', with body
'change(Bt): Added new feature with change\n\nThis feature adds functionality',
{'error_scope_capitalization': True},
),
(
# Expected FAIL: uppercase in 'scope', no body
'fix(dangerGH): Update token permissions - allow Danger to add comments to PR',
{'error_scope_capitalization': True, 'error_summary_length': True},
),
(
# Expected FAIL: not allowed 'type' with scope and body
'delete(bt): Added new feature with change\n\nThis feature adds functionality',
{'error_type': True},
),
(
# Expected FAIL: not allowed 'type' without scope and without body
'delete: Added new feature with change',
{'error_type': True},
),
(
# Expected FAIL: not allowed 'type' without scope and without body
'wip: Added new feature with change',
{'error_type': True},
),
(
# Expected FAIL: not allowed 'type' (type starts with uppercase)
'Fix(bt): Added new feature with change\n\nThis feature adds functionality',
{'error_type': True},
),
(
# Expected FAIL: missing blank line between 'summary' and 'body'
'change: Added new feature with change\nThis feature adds functionality',
{'error_body_format': True},
),
(
# Expected FAIL: 'body' line too long
'fix(bt): Update database schemas\n\nUpdating the database schema to include new fields and user profile preferences, cleaning up unnecessary calls',
{'error_body_length': True},
),
(
# Expected FAIL: 'scope' missing parenthese
'fix(bt: Update database schemas\n\nUpdating the database schema to include new fields.',
{'error_scope_format': True},
),
(
# Expected FAIL: allowed special 'type', uppercase in 'scope' required, 'summary' too long, 'summary' ends with period
'fox(BT): update database schemas. Updating the database schema to include new fields and user profile preferences, cleaning up unnecessary calls.',
{
'error_summary_capitalization': True,
'error_scope_capitalization': True,
'error_summary_length': True,
'error_summary_period': True,
},
),
],
# Use the commit message to generate IDs for each test case
ids=commit_message_id,
)
def message(request):
return request.param
def commit_message(request):
# Combine the default dictionary with the test-specific dictionary
combined_output = {**default_rules_output_status, **request.param[1]}
return request.param[0], combined_output
def test_commit_message(message, capsys):
# Unpack the message, the expectation, and the expected error message
message_text, should_pass, expected_error = message
def test_commit_message_with_args(commit_message): # pylint: disable=redefined-outer-name
message_text, expected_output = commit_message
# Convert the constants into a list of command-line arguments
# Reset rules_output_status to its default state before each test case
rules_output_status.clear()
rules_output_status.update(default_rules_output_status)
# Create a temporary file to mock a commit message file input
with tempfile.NamedTemporaryFile(mode='w', delete=False) as temp:
temp.write(message_text)
temp_file_name = temp.name
# Construct the argument list for main with the new constants
argv = [
'--types',
TYPES.replace(', ', ','),
TYPES,
'--subject-min-length',
str(SUBJECT_MIN_LENGTH),
'--subject-max-length',
str(SUBJECT_MAX_LENGTH),
'--body-max-line-length',
str(BODY_MAX_LINE_LENGTH),
'--summary-uppercase',
'--summary-uppercase' if SUMMARY_UPPERCASE else '--no-summary-uppercase',
temp_file_name,
]
# Create a temporary file and write the commit message to it
with tempfile.NamedTemporaryFile(delete=False) as temp:
temp.write(message_text.encode())
temp_file_name = temp.name
# Add the path of the temp file to the arguments
argv.append(temp_file_name)
# If the message is expected to be invalid, check that it raises SystemExit and that the error message matches
if not should_pass:
with pytest.raises(SystemExit):
main(argv)
captured = capsys.readouterr()
assert expected_error in captured.out
else:
# Run the main function of your script with the temporary file and arguments
try:
main(argv)
finally:
# Clean up the temporary file after the test
temp.close()
# Retrieve the actual rules_output_status after running the main function
actual_output = rules_output_status
# Assert that the actual rules_output_status matches the expected output
assert actual_output == expected_output, f'Failed on commit message: {message_text}'

View File

@ -2,189 +2,187 @@ import tempfile
import pytest
from conventional_precommit_linter.hook import ERROR_BODY_FORMAT
from conventional_precommit_linter.hook import ERROR_BODY_LENGTH
from conventional_precommit_linter.hook import ERROR_EMPTY_MESSAGE
from conventional_precommit_linter.hook import ERROR_MISSING_COLON
from conventional_precommit_linter.hook import ERROR_SCOPE_CAPITALIZATION
from conventional_precommit_linter.hook import ERROR_SUMMARY_LENGTH
from conventional_precommit_linter.hook import ERROR_SUMMARY_PERIOD
from conventional_precommit_linter.hook import ERROR_TYPE
from conventional_precommit_linter.hook import main
from conventional_precommit_linter.hook import rules_output_status
# DEFAULT input arguments
# Default values for the commit message format
TYPES = 'change, ci, docs, feat, fix, refactor, remove, revert'
SUBJECT_MIN_LENGTH = 20
SUBJECT_MAX_LENGTH = 72
BODY_MAX_LINE_LENGTH = 100
# Default dictionary with all values set to False
default_rules_output_status = {
'empty_message': False,
'error_body_format': False,
'error_body_length': False,
'error_scope_capitalization': False,
'error_scope_format': False,
'error_summary_capitalization': False,
'error_summary_length': False,
'error_summary_period': False,
'error_type': False,
'missing_colon': False,
}
# Dynamic test naming based on the commit message
def commit_message_id(commit_message): # pylint: disable=redefined-outer-name
return commit_message[0] # Use the first line of the commit message as the test ID
# Fixture for messages
@pytest.fixture(
params=[
(
# Expected PASS: Message with scope and body
'feat(bootloader): This is commit message with scope and body\n\nThis is a text of body',
True,
None,
{},
),
(
# Expected PASS: Message with scope, without body
'change(wifi): This is commit message with scope without body',
True,
None,
{},
),
(
# Expected PASS: Message with scope (with hyphen in scope), without body
'change(esp-rom): This is commit message with hyphen in scope',
True,
None,
{},
),
(
# Expected PASS: Message with scope (with asterisk in scope), without body
'change(examples*storage): This is commit message with asterisk in scope',
True,
None,
{},
),
(
# Expected PASS: Message with scope (with comma in scope), without body
'change(examples,storage): This is commit message with comma in scope',
True,
None,
{},
),
(
# Expected PASS: Message with scope (with slash in scope), without body
'change(examples/storage): This is commit message with slash in scope',
True,
None,
{},
),
(
# Expected PASS: Message without scope, with body
'change: This is commit message without scope with body\n\nThis is a text of body\n# Please enter the commit message for your changes. Lines starting\n# with \'#\' will be ignored, and an empty message aborts the commit.\n#', # noqa: E501
True,
None,
'change: This is commit message without scope with body\n\nThis is a text of body\n# Please enter the commit message for your changes. Lines starting\n# with \'#\' will be ignored, and an empty message aborts the commit.\n#',
{},
),
(
# Expected PASS: Message without scope, without body
'change: This is commit message without scope and body',
True,
None,
{},
),
(
# Expected PASS: 'summary' starts with lowercase
'change(rom): this message starts with lowercase',
True,
None,
{},
),
(
# Expected FAIL: missing colon between 'type' (and 'scope') and 'summary'
'change this is commit message without body',
False,
ERROR_MISSING_COLON,
),
(
# Expected FAIL: 'summary' too short
'fix: Fix bug',
False,
ERROR_SUMMARY_LENGTH.format(SUBJECT_MIN_LENGTH, SUBJECT_MAX_LENGTH),
),
(
# Expected FAIL: 'summary' too long
'change(rom): Refactor authentication flow for enhanced security measures and improved user experience', # noqa: E501
False,
ERROR_SUMMARY_LENGTH.format(SUBJECT_MIN_LENGTH, SUBJECT_MAX_LENGTH),
),
(
# Expected FAIL: 'summary' ends with period
'change(rom): Fixed the another bug.',
False,
ERROR_SUMMARY_PERIOD,
),
(
# Expected FAIL: uppercase in 'scope'
'change(Bt): Added new feature with change\n\nThis feature adds functionality',
False,
ERROR_SCOPE_CAPITALIZATION,
),
(
# Expected FAIL: uppercase in 'scope'
'fix(dangerGH): Update token permissions - allow Danger to add comments to PR',
False,
ERROR_SCOPE_CAPITALIZATION,
),
(
# Expected FAIL: not allowed 'type' with scope and body
'delete(bt): Added new feature with change\n\nThis feature adds functionality',
False,
# Adjusted expected error message
ERROR_TYPE.format(TYPES),
),
(
# Expected FAIL: not allowed 'type' without scope and without body
'wip: Added new feature with change',
False,
# Adjusted expected error message
ERROR_TYPE.format(TYPES),
),
(
# Expected FAIL: not allowed 'type' (type starts with uppercase)
'Fix(bt): Added new feature with change\n\nThis feature adds functionality',
False,
ERROR_TYPE.format(TYPES),
),
(
# Expected FAIL: missing blank line between 'summary' and 'body'
'change: Added new feature with change\nThis feature adds functionality',
False,
ERROR_BODY_FORMAT,
),
(
# Expected FAIL: 'body' line too long
'fix(bt): Update database schemas\n\nUpdating the database schema to include new fields and user profile preferences, cleaning up unnecessary calls', # noqa: E501
False,
ERROR_BODY_LENGTH.format(1), # 1 here means found one line that is too long
{'missing_colon': True},
),
(
# Expected FAIL: empty commit message
' \n\n \n',
False,
ERROR_EMPTY_MESSAGE,
{'empty_message': True},
),
]
(
# Expected FAIL: 'summary' too short
'fix: Fix bug',
{'error_summary_length': True},
),
(
# Expected FAIL: 'summary' too long
'change(rom): Refactor authentication flow for enhanced security measures and improved user experience',
{'error_summary_length': True},
),
(
# Expected FAIL: 'summary' ends with period
'change(rom): Fixed the another bug.',
{'error_summary_period': True},
),
(
# Expected FAIL: uppercase in 'scope', with body
'change(Bt): Added new feature with change\n\nThis feature adds functionality',
{'error_scope_capitalization': True},
),
(
# Expected FAIL: uppercase in 'scope', no body
'fix(dangerGH): Update token permissions - allow Danger to add comments to PR',
{'error_scope_capitalization': True},
),
(
# Expected FAIL: not allowed 'type' with scope and body
'delete(bt): Added new feature with change\n\nThis feature adds functionality',
{'error_type': True},
),
(
# Expected FAIL: not allowed 'type' without scope and without body
'fox: Added new feature with change',
{'error_type': True},
),
(
# Expected FAIL: not allowed 'type' (type starts with uppercase)
'Fix(bt): Added new feature with change\n\nThis feature adds functionality',
{'error_type': True},
),
(
# Expected FAIL: missing blank line between 'summary' and 'body'
'change: Added new feature with change\nThis feature adds functionality',
{'error_body_format': True},
),
(
# Expected FAIL: 'body' line too long
'fix(bt): Update database schemas\n\nUpdating the database schema to include new fields and user profile preferences, cleaning up unnecessary calls',
{'error_body_length': True},
),
(
# Expected FAIL: 'scope' missing parenthese
'fix(bt: Update database schemas\n\nUpdating the database schema to include new fields.',
{'error_scope_format': True},
),
(
# Expected FAIL: wrong 'type', uppercase in 'scope', 'summary' too long, 'summary' ends with period
'fox(BT): Update database schemas. Updating the database schema to include new fields and user profile preferences, cleaning up unnecessary calls.',
{
'error_scope_capitalization': True,
'error_summary_length': True,
'error_summary_period': True,
'error_type': True,
},
),
],
# Use the commit message to generate IDs for each test case
ids=commit_message_id,
)
def message(request):
return request.param
def commit_message(request):
# Combine the default dictionary with the test-specific dictionary
combined_output = {**default_rules_output_status, **request.param[1]}
return request.param[0], combined_output
def test_commit_message(message, capsys):
# Unpack the message, the expectation, and the expected error message
message_text, should_pass, expected_error = message
def test_commit_message(commit_message): # pylint: disable=redefined-outer-name
message_text, expected_output = commit_message
# Convert the constants into a list of command-line arguments
argv = [
'--types',
TYPES.replace(', ', ','),
'--subject-min-length',
str(SUBJECT_MIN_LENGTH),
'--subject-max-length',
str(SUBJECT_MAX_LENGTH),
'--body-max-line-length',
str(BODY_MAX_LINE_LENGTH),
]
# Reset rules_output_status to its default state before each test case
rules_output_status.clear()
rules_output_status.update(default_rules_output_status)
# Create a temporary file and write the commit message to it
with tempfile.NamedTemporaryFile(delete=False) as temp:
temp.write(message_text.encode())
# Create a temporary file to mock a commit message file input
with tempfile.NamedTemporaryFile(mode='w', delete=False) as temp:
temp.write(message_text)
temp_file_name = temp.name
# Add the path of the temp file to the arguments
argv.append(temp_file_name)
# Run the main function of your script with the temporary file
try:
main([temp_file_name]) # Pass the file name as a positional argument
finally:
temp.close() # Clean up the temporary file after the test
# If the message is expected to be invalid, check that it raises SystemExit and that the error message matches
if not should_pass:
with pytest.raises(SystemExit):
main(argv)
captured = capsys.readouterr()
assert expected_error in captured.out
else:
main(argv)
# Retrieve the actual rules_output_status after running the main function
actual_output = rules_output_status
# Assert that the actual rules_output_status matches the expected output
assert actual_output == expected_output, f'Failed on commit message: {message_text}'