diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9af88cb..68c9d7a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,7 +2,7 @@ name: Github CI on: push: - branches: ['main'] + branches: ["main"] pull_request: jobs: @@ -11,13 +11,13 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.10'] + python-version: ["3.10"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -38,7 +38,7 @@ jobs: poetry run pytest --cov=src/ --cov-report=xml --no-cov-on-fail - name: Send coverage to CodeCov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false diff --git a/.github/workflows/commitlint.yaml b/.github/workflows/commitlint.yml similarity index 81% rename from .github/workflows/commitlint.yaml rename to .github/workflows/commitlint.yml index 29964d0..5c5fa1a 100644 --- a/.github/workflows/commitlint.yaml +++ b/.github/workflows/commitlint.yml @@ -2,7 +2,7 @@ name: Commitlint on: push: - branches: ['main'] + branches: ["main"] pull_request: jobs: @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest name: Commitlint steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Run commitlint # uses: opensource-nepal/commitlint@v1 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index d3eecf8..1cb7d0d 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,6 +1,6 @@ on: push: - branches: ['main'] + branches: ["main"] name: release-please @@ -8,6 +8,8 @@ jobs: release-please: runs-on: ubuntu-latest permissions: + # This job has the highest privileges, so always pin actions to a specific commit hash. + # Ensure the referenced commit hash is verified and free from known vulnerabilities. id-token: write # for PYPI release contents: write pull-requests: write @@ -15,9 +17,9 @@ jobs: steps: - name: Release id: release - uses: googleapis/release-please-action@7987652d64b4581673a76e33ad5e98e3dd56832f # v4.1.3 + uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 if: ${{ steps.release.outputs.release_created }} - name: tag major and minor versions @@ -32,10 +34,10 @@ jobs: git push origin v${{ steps.release.outputs.major }} -f - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 if: ${{ steps.release.outputs.release_created }} with: - python-version: '3.x' + python-version: "3.10" - name: Install dependencies if: ${{ steps.release.outputs.release_created }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49bc845..9589da9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,6 +95,17 @@ We welcome and appreciate pull requests from the community. To contribute: - Participate in the code review process and address any feedback promptly. +## Release + +The release process, changelog, and versioning are managed by +[release-please](https://github.com/googleapis/release-please). Versions are automatically +determined based on Conventional Commit types, and the changelog is generated from commit +messages. + +The [release-please-action](https://github.com/googleapis/release-please-action) creates +a release PR ([example PR](https://github.com/opensource-nepal/commitlint/pull/62)) for +bug fixes and features. A new release is published only after the release PR is merged. + ## License By contributing to this project, you agree that your contributions will be licensed under the **GPL-3.0 License**. diff --git a/README.md b/README.md index 99cd9c7..000bc37 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ steps: ... ``` -If you don't have any workflows, create a new GitHub workflow file, e.g., `.github/workflows/commitlint.yaml`: +If you don't have any workflows, create a new GitHub workflow file, e.g., `.github/workflows/commitlint.yml`: ```yaml name: Conventional Commitlint @@ -70,11 +70,12 @@ Github API failed with status code 403. Response: {'message': 'Resource not acce #### GitHub Action Inputs -| # | Name | Type | Default | Description | -| --- | ----------------- | ------- | ---------------------- | --------------------------------------------------------------------- | -| 1 | **fail_on_error** | Boolean | `true` | Whether the GitHub Action should fail if commitlint detects an issue. | -| 2 | **verbose** | Boolean | `false` | Enables verbose output. | -| 3 | **token** | String | `secrets.GITHUB_TOKEN` | GitHub Token for fetching commits using the GitHub API. | +| # | Name | Type | Default | Description | +| --- | --------------------- | ------- | ---------------------- | --------------------------------------------------------------------------------------------- | +| 1 | **fail_on_error** | Boolean | `true` | Whether the GitHub Action should fail if commitlint detects an issue. | +| 2 | **verbose** | Boolean | `false` | Enables verbose output. | +| 3 | **max_header_length** | Number | | Optional. Maximum header length to check. If not specified, the header length is not checked. | +| 4 | **token** | String | `secrets.GITHUB_TOKEN` | GitHub Token for fetching commits using the GitHub API. | #### GitHub Action Outputs diff --git a/action.yml b/action.yml index e397c54..e87d409 100644 --- a/action.yml +++ b/action.yml @@ -1,14 +1,17 @@ -name: 'Conventional Commitlint' -description: 'A GitHub Action to check conventional commit message' +name: "Conventional Commitlint" +description: "A GitHub Action to check conventional commit message" inputs: fail_on_error: description: Whether to fail the workflow if commit messages don't follow conventions. - default: 'true' + default: "true" required: false verbose: description: Verbose output. - default: 'false' + default: "false" + required: false + max_header_length: + description: "Maximum header length to check. If not specified, the header length is not checked." required: false token: description: Token for fetching commits using Github API. @@ -24,16 +27,18 @@ outputs: value: ${{ steps.commitlint.outputs.exit_code }} branding: - color: 'red' - icon: 'git-commit' + color: "red" + icon: "git-commit" runs: - using: 'composite' + using: "composite" steps: - name: Install Python - uses: actions/setup-python@v5.1.0 + # Use a specific version for action dependencies + # A commitlint action version should use fixed dependency versions (not mutable versions) + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: '3.10' + python-version: "3.10" - name: Commitlint Action id: commitlint @@ -46,3 +51,4 @@ runs: INPUT_TOKEN: ${{ inputs.token }} INPUT_FAIL_ON_ERROR: ${{ inputs.fail_on_error }} INPUT_VERBOSE: ${{ inputs.verbose }} + INPUT_MAX_HEADER_LENGTH: ${{ inputs.max_header_length }} diff --git a/docs/cli.md b/docs/cli.md index d9b8f32..6ac889c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -9,26 +9,29 @@ pip install commitlint ## Usage ``` -commitlint [-h] [-V] [--file FILE] [--hash HASH] [--from-hash FROM_HASH] [--to-hash TO_HASH] [--skip-detail] [--hide-input] +commitlint [-h] [-V] [--file FILE] [--hash HASH] [--from-hash FROM_HASH] [--to-hash TO_HASH] + [--skip-detail] [--hide-input] [-q | -v] + [--max-header-length MAX_HEADER_LENGTH] [commit_message] Check if a commit message follows the Conventional Commits format. Positional arguments: - commit_message The commit message to be checked. + commit_message The commit message to be checked. Options: - -h, --help Show this help message and exit. - -V, --version Show the program's version number and exit. - --file FILE Path to a file containing the commit message. - --hash HASH Commit hash. - --from-hash FROM_HASH Commit hash to start checking from. - --to-hash TO_HASH Commit hash to check up to. - --skip-detail Skip detailed error messages. - --hide-input Hide input from stdout. - -q, --quiet Suppress stdout and stderr. - -v, --verbose Enable verbose output. + -h, --help Show this help message and exit. + --file FILE Path to a file containing the commit message. + -V, --version Show the program's version number and exit. + --hash HASH Commit hash. + --from-hash FROM_HASH Commit hash to start checking from. + --to-hash TO_HASH Commit hash to check up to. + --skip-detail Skip detailed error messages. + --hide-input Hide input from stdout. + -q, --quiet Suppress stdout and stderr. + -v, --verbose Enable verbose output. + --max-header-length LENGTH Maximum header length to check. ``` ## Examples @@ -79,6 +82,12 @@ Run `commitlint` in verbose mode: $ commitlint --verbose "chore: my commit message" ``` +Run `commitlint` with maximum header length check: + +```shell +$ commitlint --max-header-length 72 "chore: my commit message" +``` + Check the version: ```shell diff --git a/github_actions/action/run.py b/github_actions/action/run.py index 13f4810..f24519f 100644 --- a/github_actions/action/run.py +++ b/github_actions/action/run.py @@ -12,6 +12,7 @@ from .utils import ( get_boolean_input, get_input, + get_int_input, request_github_api, write_line_to_file, write_output, @@ -26,6 +27,7 @@ INPUT_TOKEN = "token" INPUT_FAIL_ON_ERROR = "fail_on_error" INPUT_VERBOSE = "verbose" +INPUT_MAX_HEADER_LENGTH = "max_header_length" # Status STATUS_SUCCESS = "success" @@ -115,6 +117,10 @@ def run_commitlint(commit_message: str) -> Tuple[bool, Optional[str]]: if verbose: commands.append("--verbose") + max_header_length = get_int_input(INPUT_MAX_HEADER_LENGTH) + if max_header_length is not None: + commands.extend(["--max-header-length", str(max_header_length)]) + output = subprocess.check_output(commands, text=True, stderr=subprocess.PIPE) if output: sys.stdout.write(f"{output}") diff --git a/github_actions/action/utils.py b/github_actions/action/utils.py index 5ad58e5..7284263 100644 --- a/github_actions/action/utils.py +++ b/github_actions/action/utils.py @@ -56,6 +56,28 @@ def get_boolean_input(key: str) -> bool: ) +def get_int_input(key: str) -> int | None: + """ + Read the GitHub action integer input. + + Args: + key: Input key. + + Returns: + The integer value of the input. If not integer, returns None + """ + val = get_input(key) + + if val == "": + # GitHub Action passes empty data as a empty string ("") + return None + + try: + return int(val) + except ValueError: + raise ValueError(f"Input '{key}' must be a valid integer.") from None + + def write_line_to_file(filepath: str, line: str) -> None: """ Write line to a specified filepath. diff --git a/src/commitlint/app_params.py b/src/commitlint/app_params.py new file mode 100644 index 0000000..04db7f1 --- /dev/null +++ b/src/commitlint/app_params.py @@ -0,0 +1,25 @@ +"""Module for AppParams""" + +from dataclasses import dataclass + + +@dataclass +class AppParams: + """ + Represents runtime parameters that control linting behavior and output handling. + + These parameters are typically derived from CLI arguments and define how + commit messages are validated and displayed. + """ + + # Skips the detailed error check (fails immediately without detail error message). + skip_detail: bool = False + + # Hide input from stdout/stderr. Specially used by Github Actions. + hide_input: bool = False + + # Maximum header length to check. If not specified, the header length is not checked. + max_header_length: int | None = None + + # Remove comments from the commit message. + strip_comments: bool = False diff --git a/src/commitlint/cli.py b/src/commitlint/cli.py index f34e6c8..8aef2ad 100644 --- a/src/commitlint/cli.py +++ b/src/commitlint/cli.py @@ -20,6 +20,7 @@ from . import console from .__version__ import __version__ +from .app_params import AppParams from .config import config from .exceptions import CommitlintException from .git_helpers import get_commit_message_of_hash, get_commit_messages_of_hash_range @@ -28,6 +29,30 @@ from .messages import VALIDATION_FAILED, VALIDATION_SUCCESSFUL +def positive_int_type(value: str) -> int: + """ + Parse a positive integer (> 0). + + Args: + value: Input value. + + Raises: + argparse.ArgumentTypeError: If value is not a positive integer. + + Returns: + Parsed positive integer. + """ + try: + ivalue = int(value) + except (ValueError, TypeError): + raise argparse.ArgumentTypeError(f"{value} is not a valid integer") from None + + if ivalue <= 0: + raise argparse.ArgumentTypeError("Value must be a positive integer (> 0)") + + return ivalue + + def get_args() -> argparse.Namespace: """ Parse CLI arguments for checking if a commit message. @@ -94,6 +119,16 @@ def get_args() -> argparse.Namespace: default=False, ) + # --max-header-length : enables header length check (optional) + parser.add_argument( + "--max-header-length", + type=positive_int_type, + help=( + "Maximum header length to check. If not specified, the header length is not checked." + ), + default=None, + ) + # parsing args args = parser.parse_args() @@ -103,8 +138,7 @@ def get_args() -> argparse.Namespace: def _show_errors( commit_message: str, errors: List[str], - skip_detail: bool = False, - hide_input: bool = False, + params: AppParams, ) -> None: """ Display a formatted error message for a list of errors. @@ -112,17 +146,17 @@ def _show_errors( Args: commit_message (str): The commit message to display. errors (List[str]): A list of error messages to be displayed. - skip_detail (bool): Whether to skip the detailed error message. - hide_input (bool): Hide input from stdout/stderr. + params (AppParams): Application parameters for configuring + validation and output. """ error_count = len(errors) commit_message = remove_diff_from_commit_message(commit_message) - if not hide_input: + if not params.hide_input: console.error(f"⧗ Input:\n{commit_message}\n") - if skip_detail: + if params.skip_detail: console.error(VALIDATION_FAILED) return @@ -154,43 +188,40 @@ def _get_commit_message_from_file(filepath: str) -> str: def _handle_commit_message( commit_message: str, - skip_detail: bool, - hide_input: bool, - strip_comments: bool = False, + params: AppParams, ) -> None: """ Handles a single commit message, checks its validity, and prints the result. Args: commit_message (str): The commit message to be handled. - skip_detail (bool): Whether to skip the detailed error linting. - hide_input (bool): Hide input from stdout/stderr. - strip_comments (bool, optional): Whether to remove comments from the - commit message (default is False). + params (AppParams): Application parameters for configuring + validation and output. Raises: SystemExit: If the commit message is invalid. """ - success, errors = lint_commit_message(commit_message, skip_detail, strip_comments) + success, errors = lint_commit_message(commit_message, params) if success: console.success(VALIDATION_SUCCESSFUL) return - _show_errors(commit_message, errors, skip_detail, hide_input) + _show_errors(commit_message, errors, params) sys.exit(1) def _handle_multiple_commit_messages( - commit_messages: List[str], skip_detail: bool, hide_input: bool + commit_messages: List[str], + params: AppParams, ) -> None: """ Handles multiple commit messages, checks their validity, and prints the result. Args: commit_messages (List[str]): List of commit messages to be handled. - skip_detail (bool): Whether to skip the detailed error linting. - hide_input (bool): Hide input from stdout/stderr. + params (AppParams): Application parameters for configuring + validation and output. Raises: SystemExit: If any of the commit messages is invalid. @@ -198,13 +229,13 @@ def _handle_multiple_commit_messages( has_error = False for commit_message in commit_messages: - success, errors = lint_commit_message(commit_message, skip_detail) + success, errors = lint_commit_message(commit_message, params) if success: console.verbose("lint success") continue has_error = True - _show_errors(commit_message, errors, skip_detail, hide_input) + _show_errors(commit_message, errors, params) console.error("") if has_error: @@ -230,15 +261,23 @@ def main() -> None: commit_message = _get_commit_message_from_file(args.file) _handle_commit_message( commit_message, - skip_detail=args.skip_detail, - hide_input=args.hide_input, - strip_comments=True, + AppParams( + skip_detail=args.skip_detail, + hide_input=args.hide_input, + max_header_length=args.max_header_length, + strip_comments=True, + ), ) elif args.hash: console.verbose("commit message source: hash") commit_message = get_commit_message_of_hash(args.hash) _handle_commit_message( - commit_message, skip_detail=args.skip_detail, hide_input=args.hide_input + commit_message, + AppParams( + skip_detail=args.skip_detail, + hide_input=args.hide_input, + max_header_length=args.max_header_length, + ), ) elif args.from_hash: console.verbose("commit message source: hash range") @@ -247,14 +286,22 @@ def main() -> None: ) _handle_multiple_commit_messages( commit_messages, - skip_detail=args.skip_detail, - hide_input=args.hide_input, + AppParams( + skip_detail=args.skip_detail, + hide_input=args.hide_input, + max_header_length=args.max_header_length, + ), ) else: console.verbose("commit message source: direct message") commit_message = args.commit_message.strip() _handle_commit_message( - commit_message, skip_detail=args.skip_detail, hide_input=args.hide_input + commit_message, + AppParams( + skip_detail=args.skip_detail, + hide_input=args.hide_input, + max_header_length=args.max_header_length, + ), ) except CommitlintException as ex: console.error(f"{ex}") diff --git a/src/commitlint/constants.py b/src/commitlint/constants.py index 6e90fdc..45341e1 100644 --- a/src/commitlint/constants.py +++ b/src/commitlint/constants.py @@ -1,7 +1,5 @@ """This module defines constants used throughout the application.""" -COMMIT_HEADER_MAX_LENGTH = 72 - COMMIT_TYPES = ( "build", "ci", diff --git a/src/commitlint/linter/_linter.py b/src/commitlint/linter/_linter.py index a2a3455..f6c2222 100644 --- a/src/commitlint/linter/_linter.py +++ b/src/commitlint/linter/_linter.py @@ -6,6 +6,7 @@ from typing import List, Tuple from .. import console +from ..app_params import AppParams from .utils import is_ignored, remove_comments from .validators import ( HeaderLengthValidator, @@ -16,17 +17,15 @@ def lint_commit_message( - commit_message: str, skip_detail: bool = False, strip_comments: bool = False + commit_message: str, params: AppParams ) -> Tuple[bool, List[str]]: """ Lints a commit message. Args: commit_message (str): The commit message to be linted. - skip_detail (bool, optional): Whether to skip the detailed error linting - (default is False). - strip_comments (bool, optional): Whether to remove comments from the - commit message (default is False). + params (AppParams): Application parameters for configuring + validation and output. Returns: Tuple[bool, List[str]]: Returns success as a first element and list of errors @@ -37,7 +36,7 @@ def lint_commit_message( # perform processing and pre checks # removing unnecessary commit comments - if strip_comments: + if params.strip_comments: console.verbose("removing comments from the commit message") commit_message = remove_comments(commit_message) @@ -48,15 +47,18 @@ def lint_commit_message( return True, [] # for skip_detail check - if skip_detail: + if params.skip_detail: console.verbose("running simple validators for linting") return run_validators( commit_message, + params, validator_classes=[HeaderLengthValidator, SimplePatternValidator], fail_fast=True, ) console.verbose("running detailed validators for linting") return run_validators( - commit_message, validator_classes=[HeaderLengthValidator, PatternValidator] + commit_message, + params, + validator_classes=[HeaderLengthValidator, PatternValidator], ) diff --git a/src/commitlint/linter/validators.py b/src/commitlint/linter/validators.py index b7240f4..e99c491 100644 --- a/src/commitlint/linter/validators.py +++ b/src/commitlint/linter/validators.py @@ -9,7 +9,8 @@ from typing import List, Tuple, Type, Union from .. import console -from ..constants import COMMIT_HEADER_MAX_LENGTH, COMMIT_TYPES +from ..app_params import AppParams +from ..constants import COMMIT_TYPES from ..messages import ( COMMIT_TYPE_INVALID_ERROR, COMMIT_TYPE_MISSING_ERROR, @@ -30,8 +31,9 @@ class CommitValidator(ABC): """Abstract Base validator for commit message.""" - def __init__(self, commit_message: str) -> None: + def __init__(self, commit_message: str, params: AppParams) -> None: self._commit_message = commit_message + self.params = params self._errors: List[str] = [] # start validation @@ -70,9 +72,15 @@ def validate(self) -> None: Returns: None """ + max_header_length = self.params.max_header_length + + if max_header_length is None: + # skip header length check + return + header = self.commit_message.split("\n")[0] - if len(header) > COMMIT_HEADER_MAX_LENGTH: - self.add_error(HEADER_LENGTH_ERROR) + if len(header) > max_header_length: + self.add_error(HEADER_LENGTH_ERROR % max_header_length) class SimplePatternValidator(CommitValidator): @@ -134,7 +142,7 @@ def validate(self) -> None: self.re_match = pattern_match - validators = [ + validate_fns = [ self.validate_commit_type, self.validate_commit_type_no_space_after, self.validate_scope, @@ -145,8 +153,8 @@ def validate(self) -> None: self.validate_description_no_full_stop_at_end, ] - for validator in validators: - error = validator() + for validate_fn in validate_fns: + error = validate_fn() if error: self.add_error(error) @@ -287,6 +295,7 @@ def validate_description_no_full_stop_at_end( def run_validators( commit_message: str, + params: AppParams, validator_classes: List[Type[CommitValidator]], fail_fast: bool = False, ) -> Tuple[bool, List[str]]: @@ -294,6 +303,8 @@ def run_validators( Args: commit_message (str): The commit message to validate. + params (AppParams): Application parameters for configuring + validation and output. validator_classes (List[Type[CommitValidator]]): List of validator classes to run. fail_fast (bool, optional): Return early if one validator fails. Defaults to @@ -309,7 +320,7 @@ def run_validators( for validator_class in validator_classes: console.verbose(f"running validator {validator_class.__name__}") - validator = validator_class(commit_message) + validator = validator_class(commit_message, params) if not validator.is_valid(): console.verbose(f"{validator_class.__name__}: validation failed") if fail_fast: diff --git a/src/commitlint/messages.py b/src/commitlint/messages.py index c7cb4f2..6934d69 100644 --- a/src/commitlint/messages.py +++ b/src/commitlint/messages.py @@ -2,7 +2,7 @@ This module provides constant messages used in the application for various scenarios. """ -from .constants import COMMIT_HEADER_MAX_LENGTH, COMMIT_TYPES +from .constants import COMMIT_TYPES VALIDATION_SUCCESSFUL = "Commit validation: successful!" VALIDATION_FAILED = "Commit validation: failed!" @@ -10,9 +10,7 @@ INCORRECT_FORMAT_ERROR = ( "Commit message does not follow the Conventional Commits format." ) -HEADER_LENGTH_ERROR = ( - f"Header length cannot exceed {COMMIT_HEADER_MAX_LENGTH} characters." -) +HEADER_LENGTH_ERROR = "Header length cannot exceed %s characters." COMMIT_TYPE_MISSING_ERROR = "Type is missing." COMMIT_TYPE_INVALID_ERROR = ( f"Invalid type '%s'. Type must be one of: {', '.join(COMMIT_TYPES)}." diff --git a/tests/fixtures/actions_env.py b/tests/fixtures/actions_env.py index a9bccc2..d28a494 100644 --- a/tests/fixtures/actions_env.py +++ b/tests/fixtures/actions_env.py @@ -24,3 +24,4 @@ def set_github_env_vars(): os.environ["INPUT_TOKEN"] = "token" os.environ["INPUT_VERBOSE"] = "false" os.environ["INPUT_FAIL_ON_ERROR"] = "true" + os.environ["INPUT_MAX_HEADER_LENGTH"] = "" diff --git a/tests/fixtures/linter.py b/tests/fixtures/linter.py index b800585..6464d0e 100644 --- a/tests/fixtures/linter.py +++ b/tests/fixtures/linter.py @@ -2,7 +2,6 @@ # pylint: disable=all from typing import List, Tuple -from commitlint.constants import COMMIT_HEADER_MAX_LENGTH from commitlint.messages import ( COMMIT_TYPE_INVALID_ERROR, COMMIT_TYPE_MISSING_ERROR, @@ -11,7 +10,6 @@ DESCRIPTION_MISSING_ERROR, DESCRIPTION_MULTIPLE_SPACE_START_ERROR, DESCRIPTION_NO_LEADING_SPACE_ERROR, - HEADER_LENGTH_ERROR, INCORRECT_FORMAT_ERROR, SCOPE_EMPTY_ERROR, SCOPE_WHITESPACE_ERROR, @@ -36,11 +34,6 @@ # success ("feat: add new feature", True, []), ("feat: add new feature\n\nthis is body", True, []), - ( - "feat: add new feature\n\nthis is body" + "a" * COMMIT_HEADER_MAX_LENGTH, - True, - [], - ), ("feat: add new feature\n\nthis is body\n\ntest", True, []), ("feat: add new feature\n", True, []), ("build(deps-dev): bump @babel/traverse from 7.22.17 to 7.24.0", True, []), @@ -64,14 +57,6 @@ ("initial Commit", True, []), # incorrect format check ("feat add new feature", False, [INCORRECT_FORMAT_ERROR]), - # header length check - ("feat: " + "a" * (COMMIT_HEADER_MAX_LENGTH - 1), False, [HEADER_LENGTH_ERROR]), - ("feat: " + "a" * (COMMIT_HEADER_MAX_LENGTH - 1), False, [HEADER_LENGTH_ERROR]), - ( - "Test " + "a" * (COMMIT_HEADER_MAX_LENGTH + 1), - False, - [HEADER_LENGTH_ERROR, INCORRECT_FORMAT_ERROR], - ), # commit type check (": add new feature", False, [COMMIT_TYPE_MISSING_ERROR]), ("(invalid): add new feature", False, [COMMIT_TYPE_MISSING_ERROR]), diff --git a/tests/test_cli.py b/tests/test_cli.py index 17cc7b1..58c2599 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,7 @@ INCORRECT_FORMAT_ERROR, VALIDATION_FAILED, VALIDATION_SUCCESSFUL, + HEADER_LENGTH_ERROR, ) @@ -104,6 +105,28 @@ def test__get_args___fails_with_quiet_and_verbose(self, *_): get_args() assert ex.value.code == 2 + @patch("sys.argv", ["prog", "--max-header-length", "72", "commit_msg"]) + def test__get_args__with_max_header_length(self, *_): + args = get_args() + assert args.max_header_length == 72 + + @patch("sys.argv", ["prog", "--max-header-length", "0", "commit_msg"]) + def test__get_args__with_max_header_length_non_positive_int(self, *_): + with pytest.raises(SystemExit) as ex: + get_args() + assert ex.value.code == 2 + + @patch("sys.argv", ["prog", "--max-header-length", "string", "commit_msg"]) + def test__get_args__with_max_header_length_string(self, *_): + with pytest.raises(SystemExit) as ex: + get_args() + assert ex.value.code == 2 + + @patch("sys.argv", ["prog", "commit_msg"]) + def test__get_args__max_header_length_not_set(self, *_): + args = get_args() + assert args.max_header_length is None + @patch("commitlint.console.success") @patch("commitlint.console.error") @@ -230,6 +253,20 @@ def test__main__invalid_commit_message_with_file( ] ) + @patch( + "commitlint.cli.get_args", + return_value=ArgsMock(file="path/to/non_existent_file.txt"), + ) + def test__main__with_missing_file( + self, _mock_get_args, _mock_output_error, mock_output_success + ): + mock_open().side_effect = FileNotFoundError( + 2, "No such file or directory", "path/to/non_existent_file.txt" + ) + + with pytest.raises(SystemExit): + main() + # main: hash @patch( @@ -369,19 +406,38 @@ def test__main__sets_config_for_verbose( main() assert config.verbose is True + # main : max-header-length + @patch( "commitlint.cli.get_args", - return_value=ArgsMock(file="path/to/non_existent_file.txt"), + return_value=ArgsMock( + commit_message="feat: valid commit message " + "a" * 10000, + ), ) - def test__main__with_missing_file( + def test__main__skips_header_length_check_if_max_header_length_not_set( self, _mock_get_args, _mock_output_error, mock_output_success ): - mock_open().side_effect = FileNotFoundError( - 2, "No such file or directory", "path/to/non_existent_file.txt" - ) + main() + mock_output_success.assert_called_with(f"{VALIDATION_SUCCESSFUL}") + @patch( + "commitlint.cli.get_args", + return_value=ArgsMock( + commit_message="feat: commit message", max_header_length=10 + ), + ) + def test__main__checks_header_length_if_max_header_length_is_passed( + self, _mock_get_args, mock_output_error, _mock_output_success + ): with pytest.raises(SystemExit): main() + mock_output_error.assert_has_calls( + [ + call("⧗ Input:\nfeat: commit message\n"), + call("✖ Found 1 error(s)."), + call(f"- {HEADER_LENGTH_ERROR % 10}"), + ] + ) class TestCLIMainQuiet: diff --git a/tests/test_github_actions/test_run/test_get_pr_commit_messages.py b/tests/test_github_actions/test_run/test_get_pr_commit_messages.py index 8d44462..4ae84cd 100644 --- a/tests/test_github_actions/test_run/test_get_pr_commit_messages.py +++ b/tests/test_github_actions/test_run/test_get_pr_commit_messages.py @@ -37,9 +37,10 @@ def test__get_pr_commit_messages__single_page( result = get_pr_commit_messages(event) assert result == ["feat: commit message"] + repo = os.environ["GITHUB_REPOSITORY"] mock_request_github_api.assert_called_once_with( method="GET", - url="/repos/opensource-nepal/commitlint/pulls/10/commits", + url=f"/repos/{repo}/pulls/10/commits", token="token", params={"per_page": PER_PAGE_COMMITS, "page": 1}, ) @@ -69,16 +70,17 @@ def test__get_pr_commit_messages__multiple_page( assert result == ["feat: commit message1", "feat: commit message2"] assert mock_request_github_api.call_count == 2 + repo = os.environ["GITHUB_REPOSITORY"] mock_request_github_api.assert_any_call( method="GET", - url="/repos/opensource-nepal/commitlint/pulls/10/commits", + url=f"/repos/{repo}/pulls/10/commits", token="token", params={"per_page": PER_PAGE_COMMITS, "page": 1}, ) mock_request_github_api.assert_any_call( method="GET", - url="/repos/opensource-nepal/commitlint/pulls/10/commits", + url=f"/repos/{repo}/pulls/10/commits", token="token", params={"per_page": PER_PAGE_COMMITS, "page": 2}, ) diff --git a/tests/test_github_actions/test_run/test_run_commitlint.py b/tests/test_github_actions/test_run/test_run_commitlint.py index bfb2c12..3b05693 100644 --- a/tests/test_github_actions/test_run/test_run_commitlint.py +++ b/tests/test_github_actions/test_run/test_run_commitlint.py @@ -56,3 +56,21 @@ def test__run_commitlint__verbose(mock_check_output): text=True, stderr=subprocess.PIPE, ) + + +@patch( + "subprocess.check_output", + return_value="feat: valid commit message", +) +@patch.dict(os.environ, {**os.environ, "INPUT_MAX_HEADER_LENGTH": "72"}) +def test__run_commitlint__max_header_length(mock_check_output): + commit_message = "feat: add new feature" + + result = run_commitlint(commit_message) + + assert result == (True, None) + mock_check_output.assert_called_once_with( + ["commitlint", commit_message, "--hide-input", "--max-header-length", "72"], + text=True, + stderr=subprocess.PIPE, + ) diff --git a/tests/test_github_actions/test_utils/test_get_int_input.py b/tests/test_github_actions/test_utils/test_get_int_input.py new file mode 100644 index 0000000..6bda282 --- /dev/null +++ b/tests/test_github_actions/test_utils/test_get_int_input.py @@ -0,0 +1,30 @@ +# type: ignore +# pylint: disable=all +import os +import pytest +from unittest.mock import patch + +from github_actions.action.utils import get_int_input + + +def test__get_input__variable_set(): + with patch.dict(os.environ, {"INPUT_TEST": "1"}): + assert get_int_input("test") == 1 + + +def test__get_input__returns_none_if_empty(): + # GitHub Action passes empty data as a empty string ("") + with patch.dict(os.environ, {"INPUT_TEST": ""}): + assert get_int_input("test") is None + + +def test__get_input__raises_exception_if_float(): + with patch.dict(os.environ, {"INPUT_TEST": "2.5"}): + with pytest.raises(ValueError): + get_int_input("test") + + +def test__get_input__raises_exception_if_str(): + with patch.dict(os.environ, {"INPUT_TEST": "hello"}): + with pytest.raises(ValueError): + get_int_input("test") diff --git a/tests/test_linter/test__linter.py b/tests/test_linter/test__linter.py index e994f64..34d6911 100644 --- a/tests/test_linter/test__linter.py +++ b/tests/test_linter/test__linter.py @@ -5,7 +5,7 @@ import pytest -from commitlint.constants import COMMIT_HEADER_MAX_LENGTH +from commitlint.app_params import AppParams from commitlint.linter import lint_commit_message from commitlint.messages import HEADER_LENGTH_ERROR, INCORRECT_FORMAT_ERROR @@ -19,20 +19,22 @@ def fixture_data(request): def test_lint_commit_message(fixture_data): commit_message, expected_success, expected_errors = fixture_data - success, errors = lint_commit_message(commit_message, skip_detail=False) + success, errors = lint_commit_message(commit_message, AppParams(skip_detail=False)) assert success == expected_success assert errors == expected_errors def test__lint_commit_message__skip_detail(fixture_data): commit_message, expected_success, _ = fixture_data - success, _ = lint_commit_message(commit_message, skip_detail=True) + success, _ = lint_commit_message(commit_message, AppParams(skip_detail=False)) assert success == expected_success def test__lint_commit_message__remove_comments_if_strip_comments_is_True(): commit_message = "feat(scope): add new feature\n#this is a comment" - success, errors = lint_commit_message(commit_message, strip_comments=True) + success, errors = lint_commit_message( + commit_message, AppParams(strip_comments=True) + ) assert success is True assert errors == [] @@ -43,19 +45,39 @@ def test__lint_commit_message__calls_remove_comments_if_strip_comments_is_True( ): commit_message = "feat(scope): add new feature" mock_remove_comments.return_value = commit_message - lint_commit_message(commit_message, strip_comments=True) + lint_commit_message(commit_message, AppParams(strip_comments=True)) mock_remove_comments.assert_called_once() +def test__lint_commit_message__checks_max_header_length(): + max_length = 10 + commit_message = "a" * (max_length + 1) + success, errors = lint_commit_message( + commit_message, AppParams(max_header_length=max_length) + ) + assert success is False + assert (HEADER_LENGTH_ERROR % max_length) in errors + + +def test__lint_commit_message__skips_max_header_length_check(): + max_length = 1000 + commit_message = "feat: a" * (max_length + 1) + success, _ = lint_commit_message(commit_message, AppParams()) + assert success is True + + def test__lint_commit_message__skip_detail_returns_header_length_error_message(): - commit_message = "Test " + "a" * (COMMIT_HEADER_MAX_LENGTH + 1) - success, errors = lint_commit_message(commit_message, skip_detail=True) + max_length = 10 + commit_message = "a" * (max_length + 1) + success, errors = lint_commit_message( + commit_message, AppParams(skip_detail=True, max_header_length=max_length) + ) assert success is False - assert errors == [HEADER_LENGTH_ERROR] + assert errors == [HEADER_LENGTH_ERROR % max_length] def test__lint_commit_message__skip_detail_returns_invalid_format_error_message(): commit_message = "Test invalid commit message" - success, errors = lint_commit_message(commit_message, skip_detail=True) + success, errors = lint_commit_message(commit_message, AppParams(skip_detail=True)) assert success is False assert errors == [INCORRECT_FORMAT_ERROR]