diff --git a/README.md b/README.md index 3c37c2e..7fb66f2 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,11 @@ jobs: #### GitHub Action Inputs -| # | Name | Type | Default | Description | -| --- | ----------------- | ------- | ------- | --------------------------------------------------------------------- | -| 1 | **fail_on_error** | Boolean | true | Determines whether the GitHub Action should fail if commitlint fails. | -| 2 | **verbose** | Boolean | false | Verbose output. | +| # | Name | Type | Default | Description | +| --- | ----------------- | ------- | ---------------------- | --------------------------------------------------------------------- | +| 1 | **fail_on_error** | Boolean | `true` | Determines whether the GitHub Action should fail if commitlint fails. | +| 2 | **verbose** | Boolean | `false` | Verbose output. | +| 3 | **token** | String | `secrets.GITHUB_TOKEN` | Github Token for fetching commits using Github API. | #### GitHub Action Outputs diff --git a/action.yml b/action.yml index a84f8e6..c5195e6 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,6 @@ 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. @@ -9,6 +10,11 @@ inputs: description: Verbose output. default: 'false' required: false + token: + description: Token for fetching commits using Github API. + default: ${{ github.token }} + required: false + outputs: status: description: Status @@ -16,9 +22,11 @@ outputs: exit_code: description: Exit Code value: ${{ steps.commitlint.outputs.exit_code }} + branding: color: 'red' icon: 'git-commit' + runs: using: 'composite' steps: @@ -27,41 +35,14 @@ runs: with: python-version: '3.8' - - name: Install Commitlint - run: python -m pip install --disable-pip-version-check -e ${{ github.action_path }} - shell: bash - - # checkout to the source code - # for push event - - name: Get pushed commit count - if: github.event_name == 'push' - id: push_commit_count - run: | - echo "count=$(echo '${{ toJson(github.event.commits) }}' | jq '. | length')" \ - >> $GITHUB_OUTPUT - shell: bash - - - name: Checkout to pushed commits - if: github.event_name == 'push' - uses: actions/checkout@v4.1.7 - with: - ref: ${{ github.sha }} - fetch-depth: ${{ steps.push_commit_count.outputs.count }} - - # for pull_request event - - name: Checkout to PR source branch - if: github.event_name == 'pull_request' - uses: actions/checkout@v4.1.7 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: ${{ github.event.pull_request.commits }} - - # checking the commits (for both push and pull_request) - - name: Check the commits + - name: Commitlint Action id: commitlint run: | - python ${{ github.action_path }}/github_actions/run.py + python -m pip install --quiet --disable-pip-version-check -e ${GITHUB_ACTION_PATH} + python ${{ github.action_path }}/github_actions shell: bash env: + # NOTE: Remove once https://github.com/actions/runner/issues/665 is fixed. + INPUT_TOKEN: ${{ inputs.token }} INPUT_FAIL_ON_ERROR: ${{ inputs.fail_on_error }} INPUT_VERBOSE: ${{ inputs.verbose }} diff --git a/github_actions/__main__.py b/github_actions/__main__.py new file mode 100644 index 0000000..1e06182 --- /dev/null +++ b/github_actions/__main__.py @@ -0,0 +1,5 @@ +"""Main entry point for the GitHub Actions workflow.""" + +from action.run import run_action + +run_action() diff --git a/github_actions/action/__init__.py b/github_actions/action/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/github_actions/event.py b/github_actions/action/event.py similarity index 63% rename from github_actions/event.py rename to github_actions/action/event.py index 61a5c54..fa6a2bc 100644 --- a/github_actions/event.py +++ b/github_actions/action/event.py @@ -1,5 +1,5 @@ """ -This module defines the `GithubEvent` class for handling GitHub event details. +This module defines the `GitHubEvent` class for handling GitHub event details. Note: This module relies on the presence of specific environment variables @@ -12,7 +12,7 @@ # pylint: disable=R0902; Too many instance attributes -class GithubEvent: +class GitHubEvent: """Class representing GitHub events. This class provides methods for loading and accessing various details of @@ -24,6 +24,7 @@ class GithubEvent: ref (str): The Git reference (branch or tag) for the event. workflow (str): The name of the GitHub workflow. action (str): The action that triggered the event. + repository (str): The GitHub repository name. actor (str): The GitHub username of the user or app that triggered the event. job (str): The name of the job associated with the event. run_attempt (str): The current attempt number for the job run. @@ -34,12 +35,11 @@ class GithubEvent: payload (dict): The GitHub event payload. Raises: - EnvironmentError: If the required environment variable 'GITHUB_EVENT_PATH' - is not found. + EnvironmentError: If GitHub env are not set properly. Example: ```python - github_event = GithubEvent() + github_event = GitHubEvent() print(github_event.event_name) print(github_event.sha) print(github_event.payload) @@ -47,7 +47,7 @@ class GithubEvent: """ def __init__(self) -> None: - """Initialize a new instance of the GithubEvent class.""" + """Initialize a new instance of the GitHubEvent class.""" self.__load_details() def __load_details(self) -> None: @@ -58,30 +58,31 @@ def __load_details(self) -> None: environment variables set by GitHub Actions and loading the event payload from a file. """ - self.event_name = os.environ.get("GITHUB_EVENT_NAME") - self.sha = os.environ.get("GITHUB_SHA") - self.ref = os.environ.get("GITHUB_REF") - self.workflow = os.environ.get("GITHUB_WORKFLOW") - self.action = os.environ.get("GITHUB_ACTION") - self.actor = os.environ.get("GITHUB_ACTOR") - self.job = os.environ.get("GITHUB_JOB") - self.run_attempt = os.environ.get("GITHUB_RUN_ATTEMPT") - self.run_number = os.environ.get("GITHUB_RUN_NUMBER") - self.run_id = os.environ.get("GITHUB_RUN_ID") - - if "GITHUB_EVENT_PATH" not in os.environ: - raise EnvironmentError("GITHUB_EVENT_PATH not found on the environment.") - - self.event_path = os.environ["GITHUB_EVENT_PATH"] - with open(self.event_path, encoding="utf-8") as file: - self.payload = json.load(file) + try: + self.event_name = os.environ["GITHUB_EVENT_NAME"] + self.sha = os.environ["GITHUB_SHA"] + self.ref = os.environ["GITHUB_REF"] + self.workflow = os.environ["GITHUB_WORKFLOW"] + self.action = os.environ["GITHUB_ACTION"] + self.actor = os.environ["GITHUB_ACTOR"] + self.repository = os.environ["GITHUB_REPOSITORY"] + self.job = os.environ["GITHUB_JOB"] + self.run_attempt = os.environ["GITHUB_RUN_ATTEMPT"] + self.run_number = os.environ["GITHUB_RUN_NUMBER"] + self.run_id = os.environ["GITHUB_RUN_ID"] + + self.event_path = os.environ["GITHUB_EVENT_PATH"] + with open(self.event_path, encoding="utf-8") as file: + self.payload: Dict[str, Any] = json.load(file) + except KeyError as ex: + raise EnvironmentError("GitHub env not found.") from ex def to_dict(self) -> Dict[str, Any]: """ - Convert the GithubEvent instance to a dictionary. + Convert the GitHubEvent instance to a dictionary. Returns: - dict: A dictionary containing the attributes of the GithubEvent instance. + dict: A dictionary containing the attributes of the GitHubEvent instance. """ return { attr: getattr(self, attr) diff --git a/github_actions/action/run.py b/github_actions/action/run.py new file mode 100644 index 0000000..e01b44f --- /dev/null +++ b/github_actions/action/run.py @@ -0,0 +1,212 @@ +""" +This module runs the actions based on GitHub events, specifically for push, +pull_request and pull_request_target events. +""" + +import os +import subprocess +import sys +from math import ceil +from typing import Iterable, List, Optional, Tuple, cast + +from .event import GitHubEvent +from .utils import ( + get_boolean_input, + get_input, + request_github_api, + write_line_to_file, + write_output, +) + +# Events +EVENT_PUSH = "push" +EVENT_PULL_REQUEST = "pull_request" +EVENT_PULL_REQUEST_TARGET = "pull_request_target" + +# Inputs +INPUT_TOKEN = "token" +INPUT_FAIL_ON_ERROR = "fail_on_error" +INPUT_VERBOSE = "verbose" + +# Status +STATUS_SUCCESS = "success" +STATUS_FAILURE = "failure" + +MAX_PR_COMMITS = 250 + + +def get_push_commit_messages(event: GitHubEvent) -> Iterable[str]: + """ + Return push commits. + + Args: + event (GitHubEvent): An instance of the GitHubEvent class representing + the GitHub event. + + Returns: + List[str]: List of github commits. + """ + return (commit_data["message"] for commit_data in event.payload["commits"]) + + +def get_pr_commit_messages(event: GitHubEvent) -> Iterable[str]: + """ + Return PR commits. + + Args: + event (GitHubEvent): An instance of the GitHubEvent class representing + the GitHub event. + + Returns: + List[str]: List of github commits. + """ + token = get_input(INPUT_TOKEN) + repo = event.repository + pr_number: int = event.payload["number"] + total_commits: int = event.payload["pull_request"]["commits"] + + if total_commits > MAX_PR_COMMITS: + sys.exit( + "::error:: GitHub API doesn't support PRs with more than " + f"{MAX_PR_COMMITS} commits.\n" + "Please refer to " + "https://docs.github.com/en/rest/pulls/pulls" + "?apiVersion=2022-11-28#list-commits-on-a-pull-request" + ) + + # pagination + per_page = 50 + total_page = ceil(total_commits / per_page) + + commits: List[str] = [] + for page in range(1, total_page + 1): + status, data = request_github_api( + method="GET", + url=f"/repos/{repo}/pulls/{pr_number}/commits", + token=token, + params={"per_page": per_page, "page": page}, + ) + + if status != 200: + sys.exit(f"::error::Github API failed with status code {status}") + + commits.extend(commit_data["commit"]["message"] for commit_data in data) + + return commits + + +def run_commitlint(commit_message: str) -> Tuple[bool, Optional[str]]: + """ + Run the commitlint for the given commit message. + + Args: + commit_message (str): A commit message to check with commitlint. + + Returns: + Tuple[bool, Optional[str]]: A tuple with the success status as the first + element and error message as the second element. + """ + + try: + commands = ["commitlint", commit_message, "--hide-input"] + + verbose = get_boolean_input(INPUT_VERBOSE) + if verbose: + commands.append("--verbose") + + output = subprocess.check_output(commands, text=True, stderr=subprocess.PIPE) + if output: + sys.stdout.write(f"{output}") + + return True, None + except subprocess.CalledProcessError as error: + if error.stdout: + sys.stdout.write(f"{error.stdout}") + + return False, str(error.stderr) + + +def check_commit_messages(commit_messages: Iterable[str]) -> None: + """ + Check the commit messages and create outputs for GitHub Actions. + + Args: + commit_messages (Iterable[str]): List of commit messages to check. + + Raises: + SystemExit: If any of the commit messages is invalid. + """ + failed_commits_count = 0 + + for commit_message in commit_messages: + commit_message_header = commit_message.split("\n")[0] + sys.stdout.write(f"\n⧗ {commit_message_header}\n") + + success, error = run_commitlint(commit_message) + if success: + continue + + error = ( + cast(str, error) + .replace("%", "%25") + .replace("\r", "%0D") + .replace("\n", "%0A") + ) + sys.stdout.write(f"::error title={commit_message_header}::{error}") + failed_commits_count += 1 + + # GitHub step summary path + github_step_summary = os.environ["GITHUB_STEP_SUMMARY"] + + if failed_commits_count == 0: + # success + write_line_to_file(github_step_summary, "commitlint: All commits passed!") + write_output("status", STATUS_SUCCESS) + write_output("exit_code", 0) + return + + # failure + write_line_to_file( + github_step_summary, f"commitlint: {failed_commits_count} commit(s) failed!" + ) + write_output("status", STATUS_FAILURE) + write_output("exit_code", 1) + fail_on_error = get_boolean_input(INPUT_FAIL_ON_ERROR) + if fail_on_error: + sys.exit(1) + + +def _handle_pr_event(event: GitHubEvent) -> None: + """ + Handle pull_request GitHub event. + + Args: + event (GitHubEvent): An instance of the GitHubEvent class representing + the GitHub event. + """ + commit_messages = get_pr_commit_messages(event) + check_commit_messages(commit_messages) + + +def _handle_push_event(event: GitHubEvent) -> None: + """ + Handle push GitHub event. + + Args: + event (GitHubEvent): An instance of the GitHubEvent class representing + the GitHub event. + """ + commit_messages = get_push_commit_messages(event) + check_commit_messages(commit_messages) + + +def run_action() -> None: + """Run commitlint action""" + event = GitHubEvent() + + if event.event_name == EVENT_PUSH: + _handle_push_event(event) + elif event.event_name in (EVENT_PULL_REQUEST, EVENT_PULL_REQUEST_TARGET): + _handle_pr_event(event) + else: + sys.stdout.write(f"Skipping for event {event.event_name}\n") diff --git a/github_actions/action/utils.py b/github_actions/action/utils.py new file mode 100644 index 0000000..5ad58e5 --- /dev/null +++ b/github_actions/action/utils.py @@ -0,0 +1,123 @@ +"""Utility functions for GitHub Actions""" + +import http.client +import json +import os +import urllib.parse +from typing import Any, Dict, Optional, Tuple, Union + + +def get_input(key: str) -> str: + """ + Read the GitHub action input. + + Args: + key (str): Input key. + + Returns: + str: The value of the input. + """ + key = key.upper() + return os.environ[f"INPUT_{key}"] + + +def get_boolean_input(key: str) -> bool: + """ + Parse the input environment key of boolean type in the YAML 1.2 + "core schema" specification. + Support boolean input list: + `true | True | TRUE | false | False | FALSE`. + ref: https://yaml.org/spec/1.2/spec.html#id2804923 + + Args: + key (str): Input key. + + Returns: + bool: The parsed boolean value. + + Raises: + TypeError: If the environment variable's value does not meet the + YAML 1.2 "core schema" specification for booleans. + """ + val = get_input(key) + + if val.upper() == "TRUE": + return True + + if val.upper() == "FALSE": + return False + + raise TypeError( + """ + Input does not meet YAML 1.2 "Core Schema" specification.\n' + Support boolean input list: + `true | True | TRUE | false | False | FALSE`. + """ + ) + + +def write_line_to_file(filepath: str, line: str) -> None: + """ + Write line to a specified filepath. + + Args: + filepath (str): The path of the file. + line (str): The Line to write in the file. + """ + with open(file=filepath, mode="a", encoding="utf-8") as output_file: + output_file.write(f"{line}\n") + + +def write_output(name: str, value: Union[str, int]) -> None: + """ + Write an output to the GitHub Actions environment. + + Args: + name (str): The name of the output variable. + value (Union[str, int]): The value to be assigned to the output variable. + """ + output_filepath = os.environ["GITHUB_OUTPUT"] + write_line_to_file(output_filepath, f"{name}={value}") + + +def request_github_api( + method: str, + url: str, + token: str, + body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, +) -> Tuple[int, Any]: + """ + Sends a request to the GitHub API. + + Args: + method (str): The HTTP request method, e.g., "GET" or "POST". + url (str): The endpoint URL for the GitHub API. + token (str): The GitHub API token for authentication. + body (Optional[Dict[str, Any]]): The request body as a dictionary. + params (Optional[Dict[str, str]]): The query parameters as a dictionary. + + Returns: + Tuple[int, Any]: A tuple with the status as the first element and the response + data as the second element. + + """ + if params: + url += "?" + urllib.parse.urlencode(params) + + conn = http.client.HTTPSConnection(host="api.github.com") + conn.request( + method=method, + url=url, + body=json.dumps(body) if body else None, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "commitlint", + }, + ) + res = conn.getresponse() + json_data = res.read().decode("utf-8") + data = json.loads(json_data) + + return res.status, data diff --git a/github_actions/run.py b/github_actions/run.py deleted file mode 100644 index 24ee644..0000000 --- a/github_actions/run.py +++ /dev/null @@ -1,187 +0,0 @@ -""" -This script contains actions to be taken based on GitHub events, -specifically for push and pull_request events. -""" - -import os -import subprocess -import sys -from typing import Optional, Union - -from event import GithubEvent - -# Events -EVENT_PUSH = "push" -EVENT_PULL_REQUEST = "pull_request" -EVENT_PULL_REQUEST_TARGET = "pull_request_target" - -# Inputs -INPUT_FAIL_ON_ERROR = "INPUT_FAIL_ON_ERROR" -INPUT_VERBOSE = "INPUT_VERBOSE" - -# Status -STATUS_SUCCESS = "success" -STATUS_FAILURE = "failure" - - -def _handle_pr_event(event: GithubEvent) -> None: - """ - Handle pull_request GitHub event. - - Args: - event (GithubEvent): An instance of the GithubEvent class representing - the GitHub event. - - Raises: - EnvironmentError: If the base SHA and head SHA cannot be retrieved from - the event payload. - """ - try: - to_commit = event.payload["pull_request"]["head"]["sha"] - - # getting from_commit using the total commits count - _total_commits = event.payload["pull_request"]["commits"] - from_commit = f"{to_commit}~{_total_commits-1}" - _check_commits(from_commit, to_commit) - except KeyError: - raise EnvironmentError("Unable to retrieve Base SHA and Head SHA") from None - - -def _handle_push_event(event: GithubEvent) -> None: - """ - Handle push GitHub event. - - Args: - event (GithubEvent): An instance of the GithubEvent class representing - the GitHub event. - - Raises: - EnvironmentError: If the from hash and to hash cannot be retrieved from - the event payload. - """ - try: - commits = event.payload["commits"] - from_commit = commits[0]["id"] - to_commit = commits[-1]["id"] - _check_commits(from_commit, to_commit) - except KeyError: - raise EnvironmentError("Unable to retrieve From hash and To hash") from None - - -def _write_output(name: str, value: Union[str, int]) -> None: - """ - Writes an output to the GitHub Actions environment. - - Args: - name (str): The name of the output variable. - value: The value to be assigned to the output variable. - - Raises: - OSError: If there is an issue opening or writing to the output file. - """ - output_file_path = os.environ.get("GITHUB_OUTPUT", "") - with open(file=output_file_path, mode="a", encoding="utf-8") as output_file: - output_file.write(f"{name}={value}\n") - - -def _get_input(key: str) -> Optional[str]: - """ - Reads the github action input - - Args: - key (str): The environment variable to parse - - Returns: - str or None: The value of the input or None if it is not set - """ - return os.environ.get(key) - - -def _parse_boolean_input(val: Optional[str]) -> bool: - """ - Parses the input environment key of boolean type in the YAML 1.2 - "core schema" specification. - Support boolean input list: - `true | True | TRUE | false | False | FALSE` . - ref: https://yaml.org/spec/1.2/spec.html#id2804923 - - Args: - key (str, optional): The name of the environment variable to parse. - - Returns: - bool: The parsed boolean value. - - Raises: - TypeError: If the environment variable's value does not meet the - YAML 1.2 "core schema" specification for booleans. - """ - - if val in {"true", "True", "TRUE"}: - return True - if val in {"false", "False", "FALSE"}: - return False - raise TypeError( - """ - Input does not meet YAML 1.2 "Core Schema" specification.\n' - Support boolean input list: - `true | True | TRUE | false | False | FALSE - """ - ) - - -def _check_commits(from_hash: str, to_hash: str) -> None: - """Check commits using commitlint. - - Args: - from_hash (str): The hash of the starting commit. - to_hash (str): The hash of the ending commit. - """ - sys.stdout.write(f"Commit from {from_hash} to {to_hash}\n") - try: - commands = [ - "commitlint", - "--from-hash", - from_hash, - "--to-hash", - to_hash, - ] - - verbose = _parse_boolean_input(_get_input(INPUT_VERBOSE)) - if verbose: - commands.append("--verbose") - - output = subprocess.check_output( - commands, - text=True, - ).strip() - sys.stdout.write(f"{output}\n") - - _write_output("status", STATUS_SUCCESS) - _write_output("exit_code", 0) - - except subprocess.CalledProcessError as error: - sys.stderr.write("::error::Commit validation failed!\n") - _write_output("status", STATUS_FAILURE) - _write_output("exit_code", error.returncode) - val = _get_input(INPUT_FAIL_ON_ERROR) - fail_on_error = _parse_boolean_input(val) - if fail_on_error: - sys.exit(1) - - -def main() -> None: - """Main entry point for the GitHub Actions workflow.""" - event = GithubEvent() - - if event.event_name == EVENT_PUSH: - _handle_push_event(event) - elif event.event_name in {EVENT_PULL_REQUEST, EVENT_PULL_REQUEST_TARGET}: - _handle_pr_event(event) - elif event.event_name is None: - sys.stdout.write("No any events, skipping\n") - else: - sys.stdout.write(f"Skipping for event {event.event_name}\n") - - -if __name__ == "__main__": - main() diff --git a/src/commitlint/cli.py b/src/commitlint/cli.py index 52172aa..f34e6c8 100644 --- a/src/commitlint/cli.py +++ b/src/commitlint/cli.py @@ -66,6 +66,14 @@ def get_args() -> argparse.Namespace: action="store_true", help="Skip the detailed error message check", ) + # --hide-input: specifically created for Github Actions + # and is ignored from documentation. + parser.add_argument( + "--hide-input", + action="store_true", + help="Hide input from stdout", + default=False, + ) output_group = parser.add_mutually_exclusive_group(required=False) # --quiet option is optional @@ -93,7 +101,10 @@ def get_args() -> argparse.Namespace: def _show_errors( - commit_message: str, errors: List[str], skip_detail: bool = False + commit_message: str, + errors: List[str], + skip_detail: bool = False, + hide_input: bool = False, ) -> None: """ Display a formatted error message for a list of errors. @@ -102,12 +113,14 @@ def _show_errors( 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. """ error_count = len(errors) commit_message = remove_diff_from_commit_message(commit_message) - console.error(f"⧗ Input:\n{commit_message}\n") + if not hide_input: + console.error(f"⧗ Input:\n{commit_message}\n") if skip_detail: console.error(VALIDATION_FAILED) @@ -140,7 +153,10 @@ def _get_commit_message_from_file(filepath: str) -> str: def _handle_commit_message( - commit_message: str, skip_detail: bool, strip_comments: bool = False + commit_message: str, + skip_detail: bool, + hide_input: bool, + strip_comments: bool = False, ) -> None: """ Handles a single commit message, checks its validity, and prints the result. @@ -148,6 +164,7 @@ def _handle_commit_message( 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). @@ -160,12 +177,12 @@ def _handle_commit_message( console.success(VALIDATION_SUCCESSFUL) return - _show_errors(commit_message, errors, skip_detail) + _show_errors(commit_message, errors, skip_detail, hide_input) sys.exit(1) def _handle_multiple_commit_messages( - commit_messages: List[str], skip_detail: bool + commit_messages: List[str], skip_detail: bool, hide_input: bool ) -> None: """ Handles multiple commit messages, checks their validity, and prints the result. @@ -173,6 +190,8 @@ def _handle_multiple_commit_messages( 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. + Raises: SystemExit: If any of the commit messages is invalid. """ @@ -185,7 +204,7 @@ def _handle_multiple_commit_messages( continue has_error = True - _show_errors(commit_message, errors, skip_detail) + _show_errors(commit_message, errors, skip_detail, hide_input) console.error("") if has_error: @@ -207,27 +226,36 @@ def main() -> None: console.verbose("starting commitlint") try: if args.file: - console.verbose("checking commit from file") + console.verbose("commit message source: file") commit_message = _get_commit_message_from_file(args.file) _handle_commit_message( - commit_message, skip_detail=args.skip_detail, strip_comments=True + commit_message, + skip_detail=args.skip_detail, + hide_input=args.hide_input, + strip_comments=True, ) elif args.hash: - console.verbose("checking commit from 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) + _handle_commit_message( + commit_message, skip_detail=args.skip_detail, hide_input=args.hide_input + ) elif args.from_hash: - console.verbose("checking commit from hash range") + console.verbose("commit message source: hash range") commit_messages = get_commit_messages_of_hash_range( args.from_hash, args.to_hash ) _handle_multiple_commit_messages( - commit_messages, skip_detail=args.skip_detail + commit_messages, + skip_detail=args.skip_detail, + hide_input=args.hide_input, ) else: - console.verbose("checking commit message") + console.verbose("commit message source: direct message") commit_message = args.commit_message.strip() - _handle_commit_message(commit_message, skip_detail=args.skip_detail) + _handle_commit_message( + commit_message, skip_detail=args.skip_detail, hide_input=args.hide_input + ) except CommitlintException as ex: console.error(f"{ex}") sys.exit(1) diff --git a/tests/test_cli.py b/tests/test_cli.py index a047cb1..4098b0b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,7 @@ # type: ignore # pylint: disable=all -from unittest.mock import MagicMock, call, mock_open, patch +from unittest.mock import Mock, call, mock_open, patch import pytest @@ -15,12 +15,30 @@ ) +class ArgsMock(Mock): + """ + Args Mock, used for mocking CLI arguments. + Main purpose: returns `None` instead of `Mock` if attribute is not assigned. + + ``` + arg = ArgsMock(value1=10) + arg.value1 # 10 + arg.value2 # None + ``` + """ + + def __getattr__(self, name): + if name in self.__dict__: + return self.__dict__[name] + return None + + class TestCLIGetArgs: # get_args @patch( "argparse.ArgumentParser.parse_args", - return_value=MagicMock( + return_value=ArgsMock( commit_message="commit message", file=None, hash=None, @@ -38,7 +56,7 @@ def test__get_args__with_commit_message(self, *_): @patch( "argparse.ArgumentParser.parse_args", - return_value=MagicMock(file="path/to/file.txt"), + return_value=ArgsMock(file="path/to/file.txt"), ) def test__get_args__with_file(self, *_): args = get_args() @@ -46,7 +64,7 @@ def test__get_args__with_file(self, *_): @patch( "argparse.ArgumentParser.parse_args", - return_value=MagicMock(hash="commit_hash", file=None), + return_value=ArgsMock(hash="commit_hash", file=None), ) def test__get_args__with_hash(self, *_): args = get_args() @@ -55,7 +73,7 @@ def test__get_args__with_hash(self, *_): @patch( "argparse.ArgumentParser.parse_args", - return_value=MagicMock(from_hash="from_commit_hash", file=None, hash=None), + return_value=ArgsMock(from_hash="from_commit_hash", file=None, hash=None), ) def test__get_args__with_from_hash(self, *_): args = get_args() @@ -65,7 +83,7 @@ def test__get_args__with_from_hash(self, *_): @patch( "argparse.ArgumentParser.parse_args", - return_value=MagicMock( + return_value=ArgsMock( from_hash="from_commit_hash", to_hash="to_commit_hash", file=None, hash=None ), ) @@ -78,12 +96,20 @@ def test__get_args__with_to_hash(self, *_): @patch( "argparse.ArgumentParser.parse_args", - return_value=MagicMock(skip_detail=True), + return_value=ArgsMock(skip_detail=True), ) def test__get_args__with_skip_detail(self, *_): args = get_args() assert args.skip_detail is True + @patch( + "argparse.ArgumentParser.parse_args", + return_value=ArgsMock(hide_input=True), + ) + def test__get_args__with_hide_input(self, *_): + args = get_args() + assert args.hide_input is True + @patch("commitlint.console.success") @patch("commitlint.console.error") @@ -92,15 +118,7 @@ class TestCLIMain: @patch( "commitlint.cli.get_args", - return_value=MagicMock( - commit_message="feat: valid commit message", - file=None, - hash=None, - from_hash=None, - skip_detail=False, - quiet=False, - verbose=False, - ), + return_value=ArgsMock(commit_message="feat: valid commit message"), ) def test__main__valid_commit_message( self, _mock_get_args, _mock_output_error, mock_output_success @@ -110,14 +128,8 @@ def test__main__valid_commit_message( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - commit_message="feat: valid commit message", - file=None, - hash=None, - from_hash=None, - skip_detail=True, - quiet=False, - verbose=False, + return_value=ArgsMock( + commit_message="feat: valid commit message", skip_detail=True ), ) def test__main__valid_commit_message_using_skip_detail( @@ -128,15 +140,7 @@ def test__main__valid_commit_message_using_skip_detail( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - commit_message="Invalid commit message", - file=None, - hash=None, - from_hash=None, - skip_detail=False, - quiet=False, - verbose=False, - ), + return_value=ArgsMock(commit_message="Invalid commit message"), ) def test__main__invalid_commit_message( self, _mock_get_args, mock_output_error, _mock_output_success @@ -153,14 +157,8 @@ def test__main__invalid_commit_message( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - commit_message="Invalid commit message", - file=None, - hash=None, - from_hash=None, - skip_detail=True, - quiet=False, - verbose=False, + return_value=ArgsMock( + commit_message="Invalid commit message", skip_detail=True ), ) def test__main__invalid_commit_message_using_skip_detail( @@ -176,11 +174,27 @@ def test__main__invalid_commit_message_using_skip_detail( ] ) + @patch( + "commitlint.cli.get_args", + return_value=ArgsMock(commit_message="Invalid commit message", hide_input=True), + ) + def test__main__invalid_commit_message_with_hide_input_True( + self, _mock_get_args, mock_output_error, _mock_output_success + ): + with pytest.raises(SystemExit): + main() + mock_output_error.assert_has_calls( + [ + call("✖ Found 1 error(s)."), + call(f"- {INCORRECT_FORMAT_ERROR}"), + ] + ) + # main: file @patch( "commitlint.cli.get_args", - return_value=MagicMock(file="path/to/file.txt", skip_detail=False, quiet=False), + return_value=ArgsMock(file="path/to/file.txt"), ) @patch("builtins.open", mock_open(read_data="feat: valid commit message")) def test__main__valid_commit_message_with_file( @@ -191,7 +205,7 @@ def test__main__valid_commit_message_with_file( @patch( "commitlint.cli.get_args", - return_value=MagicMock(file="path/to/file.txt", skip_detail=False, quiet=False), + return_value=ArgsMock(file="path/to/file.txt"), ) @patch( "builtins.open", @@ -205,7 +219,7 @@ def test__main__valid_commit_message_and_comments_with_file( @patch( "commitlint.cli.get_args", - return_value=MagicMock(file="path/to/file.txt", skip_detail=False, quiet=False), + return_value=ArgsMock(file="path/to/file.txt"), ) @patch("builtins.open", mock_open(read_data="Invalid commit message 2")) def test__main__invalid_commit_message_with_file( @@ -226,9 +240,7 @@ def test__main__invalid_commit_message_with_file( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - file=None, hash="commit_hash", skip_detail=False, quiet=False - ), + return_value=ArgsMock(hash="commit_hash"), ) @patch("commitlint.cli.get_commit_message_of_hash") def test__main__valid_commit_message_with_hash( @@ -244,9 +256,7 @@ def test__main__valid_commit_message_with_hash( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - file=None, hash="commit_hash", skip_detail=False, quiet=False - ), + return_value=ArgsMock(hash="commit_hash"), ) @patch("commitlint.cli.get_commit_message_of_hash") def test__main__invalid_commit_message_with_hash( @@ -273,15 +283,7 @@ def test__main__invalid_commit_message_with_hash( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - file=None, - hash=None, - from_hash="start_commit_hash", - to_hash="end_commit_hash", - skip_detail=False, - quiet=False, - verbose=False, - ), + return_value=ArgsMock(from_hash="start_commit_hash", to_hash="end_commit_hash"), ) @patch("commitlint.cli.get_commit_messages_of_hash_range") def test__main__valid_commit_message_with_hash_range( @@ -300,14 +302,8 @@ def test__main__valid_commit_message_with_hash_range( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - file=None, - hash=None, - from_hash="invalid_start_hash", - to_hash="end_commit_hash", - skip_detail=False, - quiet=False, - verbose=False, + return_value=ArgsMock( + from_hash="invalid_start_hash", to_hash="end_commit_hash" ), ) @patch("commitlint.cli.get_commit_messages_of_hash_range") @@ -330,9 +326,7 @@ def test__main__invalid_commit_message_with_hash_range( @patch( "argparse.ArgumentParser.parse_args", - return_value=MagicMock( - commit_message="feat: commit message", file=None, hash=None, from_hash=None - ), + return_value=ArgsMock(commit_message="feat: commit message"), ) @patch( "commitlint.cli.lint_commit_message", @@ -355,15 +349,7 @@ def test__main__handle_exceptions( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - commit_message="feat: test commit", - file=None, - hash=None, - from_hash=None, - skip_detail=False, - quiet=True, - verbose=False, - ), + return_value=ArgsMock(commit_message="feat: test commit", quiet=True), ) def test__main__sets_config_for_quiet( self, @@ -378,15 +364,7 @@ def test__main__sets_config_for_quiet( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - commit_message="feat: test commit", - file=None, - hash=None, - from_hash=None, - skip_detail=False, - quiet=False, - verbose=True, - ), + return_value=ArgsMock(commit_message="feat: test commit", verbose=True), ) def test__main__sets_config_for_verbose( self, @@ -399,9 +377,7 @@ def test__main__sets_config_for_verbose( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - file="path/to/non_existent_file.txt", skip_detail=False, quiet=False - ), + 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 @@ -419,15 +395,7 @@ class TestCLIMainQuiet: @patch( "commitlint.cli.get_args", - return_value=MagicMock( - commit_message="Invalid commit message", - file=None, - hash=None, - from_hash=None, - skip_detail=False, - quiet=True, - verbose=False, - ), + return_value=ArgsMock(commit_message="Invalid commit message", quiet=True), ) @patch("sys.stdout.write") @patch("sys.stderr.write") @@ -442,15 +410,7 @@ def test__main__quiet_option_with_invalid_commit_message( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - commit_message="feat: valid commit message", - file=None, - hash=None, - from_hash=None, - skip_detail=False, - quiet=True, - verbose=False, - ), + return_value=ArgsMock(commit_message="feat: valid commit message", quiet=True), ) @patch("sys.stdout.write") @patch("sys.stderr.write") @@ -463,14 +423,8 @@ def test__main__quiet_option_with_valid_commit_message( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - file=None, - hash=None, - from_hash="start_commit_hash", - to_hash="end_commit_hash", - skip_detail=False, - quiet=True, - verbose=False, + return_value=ArgsMock( + from_hash="start_commit_hash", to_hash="end_commit_hash", quiet=True ), ) @patch("commitlint.cli.get_commit_messages_of_hash_range") @@ -487,14 +441,8 @@ def test__valid_commit_message_with_hash_range_in_quiet( @patch( "commitlint.cli.get_args", - return_value=MagicMock( - file=None, - hash=None, - from_hash="start_commit_hash", - to_hash="end_commit_hash", - skip_detail=False, - quiet=True, - verbose=False, + return_value=ArgsMock( + from_hash="start_commit_hash", to_hash="end_commit_hash", quiet=True ), ) @patch("commitlint.cli.get_commit_messages_of_hash_range")