summaryrefslogtreecommitdiff
path: root/buildscripts/validate_commit_message.py
diff options
context:
space:
mode:
Diffstat (limited to 'buildscripts/validate_commit_message.py')
-rwxr-xr-xbuildscripts/validate_commit_message.py421
1 files changed, 29 insertions, 392 deletions
diff --git a/buildscripts/validate_commit_message.py b/buildscripts/validate_commit_message.py
index 37973faa584..03283b9d940 100755
--- a/buildscripts/validate_commit_message.py
+++ b/buildscripts/validate_commit_message.py
@@ -28,398 +28,36 @@
#
"""Validate that the commit message is ok."""
import argparse
-import collections
-import logging
+import os
import re
import subprocess
import sys
-from contextlib import contextmanager
-from enum import IntEnum
-from http import HTTPStatus
-from http.client import HTTPConnection # py3
-from typing import Dict, List, Type, Tuple, Optional, Match, Any
-
-import requests.exceptions
-from jira import JIRA, Issue
-from jira.exceptions import JIRAError
VALID_PATTERNS = [
- # NOTE: re.VERBOSE is for visibility / debugging. As such significant white space must be
- # escaped (e.g ' ' to \s).
- re.compile(
- r'''
- ^
- ((?P<revert>Revert)\s*[\"\']?)? # Revert (optional)
- ((?P<ticket>(?:EVG|SERVER|WT)-[0-9]+)\s*) # ticket identifier
- #(?P<body>(?:(?!\(cherry\spicked\sfrom).)*)? # To also capture the body
- ((?:(?!\(cherry\spicked\sfrom).)*)? # negative lookahead backport
- (?P<backport>\(cherry\spicked\sfrom.*)? # back port (optional)
- ''', re.MULTILINE | re.DOTALL | re.VERBOSE),
- re.compile(r'(?P<lint>^Fix lint$)'), # Allow "Fix lint" as the sole commit summary
- re.compile(r'(?P<imported>^Import (wiredtiger|tools): .*)'), # These are public tickets
+ re.compile(r"^Fix lint$"), # Allow "Fix lint" as the sole commit summary
+ re.compile(r'^(Revert ["\']?)?(EVG|SERVER|WT)-[0-9]+'), # These are public tickets
+ re.compile(r'^Import (wiredtiger|tools):'), # These are public tickets
]
-"""valid public patterns."""
-
-PRIVATE_PATTERNS = [re.compile(r'^(?P<ticket>[A-Z]+-[0-9]+)')]
-"""private patterns."""
-
-INVALID_JIRA_STATUS = ('closed', )
-"""List of lower cased invalid jira status strings."""
-
-GIT_SHOW_COMMAND = ['git', 'show', '-1', '-s', '--format=%s']
-"""git command line to get the last commit message."""
-
-DEFAULT_JIRA = 'https://jira.mongodb.org'
-LOGGER = logging.getLogger(__name__)
-
-
-class Status(IntEnum):
- """Status enumeration values."""
-
- OK = 0
- ERROR = 1
- WARNING = 2
-
-
-class Violation(collections.namedtuple('Fault', ['status', 'message'])):
- """validation issue holder."""
-
- def __str__(self):
- return str(self.message)
-
-
-def get_full_message(message: List[str]) -> str:
- """
- Convert the message to a single string or get the last git commit message.
-
- If the input list is empty then the last git commit message is used.
-
- :param message: A list of the message components.
- :return: The message.
- """
- LOGGER.info('get commit message')
- if not message:
- LOGGER.info('Validating last git commit message')
- result = subprocess.check_output(GIT_SHOW_COMMAND)
- message = result.decode('utf-8')
- else:
- message = " ".join(message)
- LOGGER.info('Validating commit message \'%s\'', message)
- return message
-
-
-def find_ticket(message: str) -> Dict:
- """
- Find ticket data in message.
-
- :param message: The commit message.
- :return: A dict of the commit message components (may be empty).
- """
- ticket = find_matching_pattern(message, VALID_PATTERNS)
- if ticket:
- ticket['public'] = True
- else:
- ticket = find_matching_pattern(message, PRIVATE_PATTERNS)
- if ticket:
- ticket['public'] = False
- return ticket
-
-
-def find_matching_pattern(message: str, patterns: List[Match]) -> Dict:
- """
- Find the first matching pattern.
-
- :param message: The commit message.
- :param patterns: A list of regular expressions.
- :return: A dict of the commit message components (may be empty).
- """
- for valid_pattern in patterns:
- matching_pattern = valid_pattern.match(message)
- # pattern matches and there is a ticket
- if matching_pattern:
- return matching_pattern.groupdict()
- return {}
-
-
-def validate_message(message: str, author: str,
- jira: Optional[Type[JIRA]]) -> Tuple[Dict, List[Violation]]:
- """
- Validate the commit message.
-
- :param message: The commit message.
- :param author: The author.
- :param jira: The jira connection.
- :return: The ticket dict and violations.
- """
- LOGGER.info('validating message')
- if not message.strip():
- ticket = {}
- violations = [Violation(Status.ERROR, 'found empty commit message')]
- else:
- ticket = find_ticket(message)
- violations = validate_ticket(ticket, author, jira)
- return ticket, violations
-
-
-def validate_ticket(ticket: Dict, author: str, jira: Optional[Type[JIRA]]) -> List[Violation]:
- """
- Validate the ticket and commit message.
-
- :param ticket: The extract ticket information.
- :param author: The author.
- :param jira: The jira connection.
- :return: The violations.
- """
- violations = []
-
- if not ticket:
- violations.append(Violation(Status.WARNING, 'found a commit without a ticket'))
- elif ticket['public']:
- violations = validate_public_ticket(ticket, author, jira)
- else:
- tid = ticket['ticket']
- violations.append(Violation(Status.ERROR, f'private project: {tid}'))
-
- return violations
-
-
-def validate_status(issue: Type[Issue]) -> Optional[Violation]:
- """
- Validate that the issue status is allowed.
-
- :param issue: The jira issue.
- :return: A violation (if applicable).
- """
- status = str(issue.fields.status).lower()
- if status in INVALID_JIRA_STATUS:
- return Violation(Status.ERROR, f'status cannot be {status}')
- return None
-
+PRIVATE_PATTERNS = [re.compile(r"^[A-Z]+-[0-9]+")]
-def validate_author(issue: Type[Issue], ticket: Dict, author: str) -> Violation:
- """
- Validate that the issue author is correct.
+STATUS_OK = 0
+STATUS_ERROR = 1
- :param issue: The jira issue.
- :param ticket: The ticket data.
- :param author: The expected author.
- :return: A violation (if applicable).
- """
- assignee = issue.fields.assignee
- if assignee.name != author:
- details = (f'assignee is not author \'{assignee.name}\'('
- f' \'{assignee.displayName}\') != \'{author}\'')
- if not ticket.get('backport', False):
- return Violation(Status.WARNING, details)
- else:
- LOGGER.debug('%s but this is a backport', details)
- return None
+GIT_SHOW_COMMAND = ["git", "show", "-1", "-s", "--format=%s"]
-def validate_public_ticket(ticket: Dict, author: str, jira: Type[JIRA],
- verbose: bool = False) -> List[Violation]:
- """
- Validate the status of a public ticket.
-
- :param ticket: The extract ticket information.
- :param author: The author.
- :param jira: The jira connection.
- :param verbose: A flag to enable / disable verbose output.
- :return: The violations.
- """
- violations = []
- ticket_id = ticket['ticket']
- try:
- if jira is not None:
- with silence(verbose):
- issue = jira.issue(ticket_id)
- if issue:
- violation = validate_status(issue)
- if violation:
- violations.append(violation)
-
- violation = validate_author(issue, ticket, author)
- if violation:
- violations.append(violation)
-
- else:
- LOGGER.debug('unable to fully validate issue \'%s\'', ticket_id)
- violations.append(
- Violation(Status.WARNING, f'{ticket_id}: unable to validate with jira'))
- except requests.exceptions.ConnectionError:
- LOGGER.debug('%s: unexpected connection exception', exc_info=True)
- violations.append(
- Violation(Status.WARNING, f'{ticket_id}: unexpected connection exception'))
- except JIRAError as ex:
- LOGGER.debug('unexpected jira exception', exc_info=True)
- if ex.status_code == HTTPStatus.NOT_FOUND:
- violation = Violation(Status.ERROR, f'{ticket_id}: not found')
- elif ex.status_code == HTTPStatus.UNAUTHORIZED:
- violation = Violation(Status.ERROR, f'{ticket_id}: private (unauthorized)')
- else:
- violation = Violation(Status.WARNING,
- f'{ticket_id}: unexpected jira error {ex.status_code}')
- violations.append(violation)
- except ValueError:
- LOGGER.debug('unexpected exception', exc_info=True)
- violations.append(Violation(Status.WARNING, f'{ticket_id}: unexpected exception'))
-
- return violations
-
-
-def handle_violations(ticket: Dict, message: str, violations: List[Violation],
- warning_as_errors: bool) -> Type[Status]:
- """
- Handle any validation issues found.
-
- :param ticket: The extract ticket information.
- :param message: The commit message.
- :param violations: The validation violations.
- :param warning_as_errors: If True then treat all violations as errors.
- :return: The Status.ERROR if no errors or warning_as_errors.
- """
- LOGGER.info('handle validation issues')
- if warning_as_errors:
- errors = violations
- warnings = []
- else:
- errors = [validation for validation in violations if validation.status == Status.ERROR]
- warnings = [validation for validation in violations if validation.status == Status.WARNING]
-
- if errors:
- LOGGER.error("%s\n\t%s", ticket['ticket'] if ticket and 'ticket' in ticket else message,
- "\n\t".join(error.message for error in errors))
-
- if warnings:
- LOGGER.warning("%s\n\t%s", ticket['ticket'] if ticket and 'ticket' in ticket else message,
- "\n\t".join(warning.message for warning in warnings))
-
- return Status.ERROR if errors else Status.OK
-
-
-def jira_client(jira_server: str, verbose: bool = False) -> Optional[Type[JIRA]]:
- """
- Connect to jira.
-
- Create a connection to jira_server and validate that SERVER-1 is accessible.
- :param jira_server: The jira server endpoint to connect and validate.
- :param verbose: A flag controlling th verbosity of the checks. The requests and jira package
- are very verbose by default.
- :return: The jira client instance if all went well.
- """
- try:
- # requests and JIRA can be very verbose.
- LOGGER.info('connecting to %s', jira_server)
- with silence(verbose):
- jira = JIRA(jira_server, logging=verbose)
- # check the status of a known / existing ticket. A JIRAError with a status code of
- # 404 Not Found maybe returned or a 401 unauthorized.
- jira.issue("SERVER-1")
- return jira
- except (requests.exceptions.ConnectionError, JIRAError, ValueError) as ex:
- # These are recoverable / ignorable exceptions. We print exception the full stack trace
- # when debugging / verbose output is requested.
- # ConnectionErrors relate to networking.
- # JIRAError, ValueError refer to invalid / unexpected responses.
- class_name = _get_class_name(ex)
- if isinstance(ex, requests.exceptions.ConnectionError):
- details = f'{class_name}: unable to connect to {jira_server}'
- elif isinstance(ex, JIRAError):
- details = f'{class_name}: unable to access {jira_server}, status: {ex.status_code}'
- elif isinstance(ex, ValueError):
- details = f'{class_name}: communication error with {jira_server}'
- LOGGER.debug(details, exc_info=True)
- except Exception as ex: # pylint: disable=broad-except
- # recoverable / ignorable exceptions but unknown so print trace.
- LOGGER.debug('%s unknown error: %s', _get_class_name(ex), jira_server, exc_info=True)
- return None
-
-
-def configure_logging(level: int, formatter: str = '%(levelname)s: %(message)s'):
- """
- Configure logging.
-
- :param level: The log level verbosity. 0 logs warnings and above. 1 enabled info and above, all
- other values are DEBUG and above.
- :param formatter: The log formatter.
- """
- # level 0 is the default level (warning or greater).
- logging.basicConfig(format=formatter)
- root_logger = logging.getLogger()
- debuglevel = 0
- if level == 0:
- level = logging.WARNING
- elif level == 1:
- level = logging.INFO
- elif level >= 2:
- debuglevel = 1
- level = logging.DEBUG
-
- root_logger.setLevel(level)
- HTTPConnection.debuglevel = debuglevel
-
-
-def _get_class_name(obj: Any) -> str:
- """Get the class name without package."""
- return type(obj).__name__
-
-
-@contextmanager
-def silence(disable: bool = False):
- """
- Silence logging within this scope.
-
- :param disable: A flag to programmatically enable / disable the silence functionality. Useful
- for debugging.
- """
- logger = logging.getLogger()
- old = logger.disabled
- try:
- if not disable:
- logger.disabled = True
- yield
- finally:
- logger.disabled = old
-
-
-def parse_args(argv: List[str]) -> Type[argparse.ArgumentParser]:
- """
- Parse the command line args.
-
- :param argv: The command line arguments.
- :return: The parsed arguments.
- """
+def main(argv=None):
+ """Execute Main function to validate commit messages."""
parser = argparse.ArgumentParser(
usage="Validate the commit message. "
"It validates the latest message when no arguments are provided.")
parser.add_argument(
- "-a",
- '--author',
- dest="author",
- nargs='?',
- const=1,
- type=str,
- help="Your jira username of the author. This value must match the JIRA assignee.",
- required=True,
- )
- parser.add_argument(
- "-j",
- dest="jira_server",
- nargs='?',
- const=1,
- type=str,
- help="The jira server location. Defaults to '" + DEFAULT_JIRA + "'",
- default=DEFAULT_JIRA,
- )
- parser.add_argument(
- "-W",
+ "-i",
action="store_true",
- dest="warning_as_errors",
- help="treat warnings as errors.",
+ dest="ignore_warnings",
+ help="Ignore all warnings.",
default=False,
)
- parser.add_argument("-v", "--verbosity", action="count", default=0,
- help="increase output verbosity")
parser.add_argument(
"message",
metavar="commit message",
@@ -427,25 +65,24 @@ def parse_args(argv: List[str]) -> Type[argparse.ArgumentParser]:
help="The commit message to validate",
)
args = parser.parse_args(argv)
- return args
-
-
-def main(argv: List = None) -> Type[Status]:
- """
- Execute main function to validate commit messages.
-
- :param argv: The command line arguments.
- :return: Status.OK if the commit message validation passed other wise Status.ERROR.
- """
-
- args = parse_args(argv)
- configure_logging(level=args.verbosity)
- jira = jira_client(args.jira_server)
- message = get_full_message(args.message)
- ticket, violations = validate_message(message, args.author, jira)
+ if not args.message:
+ print('Validating last git commit message')
+ result = subprocess.check_output(GIT_SHOW_COMMAND)
+ message = result.decode('utf-8')
+ else:
+ message = " ".join(args.message)
- return handle_violations(ticket, message, violations, args.warning_as_errors)
+ if any(valid_pattern.match(message) for valid_pattern in VALID_PATTERNS):
+ status = STATUS_OK
+ elif any(private_pattern.match(message) for private_pattern in PRIVATE_PATTERNS):
+ print("ERROR: found a reference to a private project\n{message}".format(message=message))
+ status = STATUS_ERROR
+ else:
+ print("{message_type}: found a commit without a ticket\n{message}".format(
+ message_type="WARNING" if args.ignore_warnings else "ERROR", message=message))
+ status = STATUS_OK if args.ignore_warnings else STATUS_ERROR
+ return status
if __name__ == "__main__":