diff options
Diffstat (limited to 'gitlint-core/gitlint/rules.py')
-rw-r--r-- | gitlint-core/gitlint/rules.py | 440 |
1 files changed, 440 insertions, 0 deletions
diff --git a/gitlint-core/gitlint/rules.py b/gitlint-core/gitlint/rules.py new file mode 100644 index 0000000..1c5a618 --- /dev/null +++ b/gitlint-core/gitlint/rules.py @@ -0,0 +1,440 @@ +# pylint: disable=inconsistent-return-statements +import copy +import logging +import re + +from gitlint.options import IntOption, BoolOption, StrOption, ListOption, RegexOption +from gitlint.exception import GitlintError + + +class Rule: + """ Class representing gitlint rules. """ + options_spec = [] + id = None + name = None + target = None + _log = None + + def __init__(self, opts=None): + if not opts: + opts = {} + self.options = {} + for op_spec in self.options_spec: + self.options[op_spec.name] = copy.deepcopy(op_spec) + actual_option = opts.get(op_spec.name) + if actual_option is not None: + self.options[op_spec.name].set(actual_option) + + @property + def log(self): + if not self._log: + self._log = logging.getLogger(__name__) + logging.basicConfig() + return self._log + + def __eq__(self, other): + return self.id == other.id and self.name == other.name and \ + self.options == other.options and self.target == other.target # noqa + + def __str__(self): + return f"{self.id} {self.name}" # pragma: no cover + + +class ConfigurationRule(Rule): + """ Class representing rules that can dynamically change the configuration of gitlint during runtime. """ + pass + + +class CommitRule(Rule): + """ Class representing rules that act on an entire commit at once """ + pass + + +class LineRule(Rule): + """ Class representing rules that act on a line by line basis """ + pass + + +class LineRuleTarget: + """ Base class for LineRule targets. A LineRuleTarget specifies where a given rule will be applied + (e.g. commit message title, commit message body). + Each LineRule MUST have a target specified. """ + pass + + +class CommitMessageTitle(LineRuleTarget): + """ Target class used for rules that apply to a commit message title """ + pass + + +class CommitMessageBody(LineRuleTarget): + """ Target class used for rules that apply to a commit message body """ + pass + + +class RuleViolation: + """ Class representing a violation of a rule. I.e.: When a rule is broken, the rule will instantiate this class + to indicate how and where the rule was broken. """ + + def __init__(self, rule_id, message, content=None, line_nr=None): + self.rule_id = rule_id + self.line_nr = line_nr + self.message = message + self.content = content + + def __eq__(self, other): + equal = self.rule_id == other.rule_id and self.message == other.message + equal = equal and self.content == other.content and self.line_nr == other.line_nr + return equal + + def __str__(self): + return f"{self.line_nr}: {self.rule_id} {self.message}: \"{self.content}\"" + + +class UserRuleError(GitlintError): + """ Error used to indicate that an error occurred while trying to load a user rule """ + pass + + +class MaxLineLength(LineRule): + name = "max-line-length" + id = "R1" + options_spec = [IntOption('line-length', 80, "Max line length")] + violation_message = "Line exceeds max length ({0}>{1})" + + def validate(self, line, _commit): + max_length = self.options['line-length'].value + if len(line) > max_length: + return [RuleViolation(self.id, self.violation_message.format(len(line), max_length), line)] + + +class TrailingWhiteSpace(LineRule): + name = "trailing-whitespace" + id = "R2" + violation_message = "Line has trailing whitespace" + pattern = re.compile(r"\s$", re.UNICODE) + + def validate(self, line, _commit): + if self.pattern.search(line): + return [RuleViolation(self.id, self.violation_message, line)] + + +class HardTab(LineRule): + name = "hard-tab" + id = "R3" + violation_message = "Line contains hard tab characters (\\t)" + + def validate(self, line, _commit): + if "\t" in line: + return [RuleViolation(self.id, self.violation_message, line)] + + +class LineMustNotContainWord(LineRule): + """ Violation if a line contains one of a list of words (NOTE: using a word in the list inside another word is not + a violation, e.g: WIPING is not a violation if 'WIP' is a word that is not allowed.) """ + name = "line-must-not-contain" + id = "R5" + options_spec = [ListOption('words', [], "Comma separated list of words that should not be found")] + violation_message = "Line contains {0}" + + def validate(self, line, _commit): + strings = self.options['words'].value + violations = [] + for string in strings: + regex = re.compile(rf"\b{string.lower()}\b", re.IGNORECASE | re.UNICODE) + match = regex.search(line.lower()) + if match: + violations.append(RuleViolation(self.id, self.violation_message.format(string), line)) + return violations if violations else None + + +class LeadingWhiteSpace(LineRule): + name = "leading-whitespace" + id = "R6" + violation_message = "Line has leading whitespace" + + def validate(self, line, _commit): + pattern = re.compile(r"^\s", re.UNICODE) + if pattern.search(line): + return [RuleViolation(self.id, self.violation_message, line)] + + +class TitleMaxLength(MaxLineLength): + name = "title-max-length" + id = "T1" + target = CommitMessageTitle + options_spec = [IntOption('line-length', 72, "Max line length")] + violation_message = "Title exceeds max length ({0}>{1})" + + +class TitleTrailingWhitespace(TrailingWhiteSpace): + name = "title-trailing-whitespace" + id = "T2" + target = CommitMessageTitle + violation_message = "Title has trailing whitespace" + + +class TitleTrailingPunctuation(LineRule): + name = "title-trailing-punctuation" + id = "T3" + target = CommitMessageTitle + + def validate(self, title, _commit): + punctuation_marks = '?:!.,;' + for punctuation_mark in punctuation_marks: + if title.endswith(punctuation_mark): + return [RuleViolation(self.id, f"Title has trailing punctuation ({punctuation_mark})", title)] + + +class TitleHardTab(HardTab): + name = "title-hard-tab" + id = "T4" + target = CommitMessageTitle + violation_message = "Title contains hard tab characters (\\t)" + + +class TitleMustNotContainWord(LineMustNotContainWord): + name = "title-must-not-contain-word" + id = "T5" + target = CommitMessageTitle + options_spec = [ListOption('words', ["WIP"], "Must not contain word")] + violation_message = "Title contains the word '{0}' (case-insensitive)" + + +class TitleLeadingWhitespace(LeadingWhiteSpace): + name = "title-leading-whitespace" + id = "T6" + target = CommitMessageTitle + violation_message = "Title has leading whitespace" + + +class TitleRegexMatches(LineRule): + name = "title-match-regex" + id = "T7" + target = CommitMessageTitle + options_spec = [RegexOption('regex', None, "Regex the title should match")] + + def validate(self, title, _commit): + # If no regex is specified, immediately return + if not self.options['regex'].value: + return + + if not self.options['regex'].value.search(title): + violation_msg = f"Title does not match regex ({self.options['regex'].value.pattern})" + return [RuleViolation(self.id, violation_msg, title)] + + +class TitleMinLength(LineRule): + name = "title-min-length" + id = "T8" + target = CommitMessageTitle + options_spec = [IntOption('min-length', 5, "Minimum required title length")] + + def validate(self, title, _commit): + min_length = self.options['min-length'].value + actual_length = len(title) + if actual_length < min_length: + violation_message = f"Title is too short ({actual_length}<{min_length})" + return [RuleViolation(self.id, violation_message, title, 1)] + + +class BodyMaxLineLength(MaxLineLength): + name = "body-max-line-length" + id = "B1" + target = CommitMessageBody + + +class BodyTrailingWhitespace(TrailingWhiteSpace): + name = "body-trailing-whitespace" + id = "B2" + target = CommitMessageBody + + +class BodyHardTab(HardTab): + name = "body-hard-tab" + id = "B3" + target = CommitMessageBody + + +class BodyFirstLineEmpty(CommitRule): + name = "body-first-line-empty" + id = "B4" + + def validate(self, commit): + if len(commit.message.body) >= 1: + first_line = commit.message.body[0] + if first_line != "": + return [RuleViolation(self.id, "Second line is not empty", first_line, 2)] + + +class BodyMinLength(CommitRule): + name = "body-min-length" + id = "B5" + options_spec = [IntOption('min-length', 20, "Minimum body length")] + + def validate(self, commit): + min_length = self.options['min-length'].value + body_message_no_newline = "".join([line for line in commit.message.body if line is not None]) + actual_length = len(body_message_no_newline) + if 0 < actual_length < min_length: + violation_message = f"Body message is too short ({actual_length}<{min_length})" + return [RuleViolation(self.id, violation_message, body_message_no_newline, 3)] + + +class BodyMissing(CommitRule): + name = "body-is-missing" + id = "B6" + options_spec = [BoolOption('ignore-merge-commits', True, "Ignore merge commits")] + + def validate(self, commit): + # ignore merges when option tells us to, which may have no body + if self.options['ignore-merge-commits'].value and commit.is_merge_commit: + return + if len(commit.message.body) < 2 or not ''.join(commit.message.body).strip(): + return [RuleViolation(self.id, "Body message is missing", None, 3)] + + +class BodyChangedFileMention(CommitRule): + name = "body-changed-file-mention" + id = "B7" + options_spec = [ListOption('files', [], "Files that need to be mentioned")] + + def validate(self, commit): + violations = [] + for needs_mentioned_file in self.options['files'].value: + # if a file that we need to look out for is actually changed, then check whether it occurs + # in the commit msg body + if needs_mentioned_file in commit.changed_files: + if needs_mentioned_file not in " ".join(commit.message.body): + violation_message = f"Body does not mention changed file '{needs_mentioned_file}'" + violations.append(RuleViolation(self.id, violation_message, None, len(commit.message.body) + 1)) + return violations if violations else None + + +class BodyRegexMatches(CommitRule): + name = "body-match-regex" + id = "B8" + options_spec = [RegexOption('regex', None, "Regex the body should match")] + + def validate(self, commit): + # If no regex is specified, immediately return + if not self.options['regex'].value: + return + + # We intentionally ignore the first line in the body as that's the empty line after the title, + # which most users are not going to expect to be part of the body when matching a regex. + # If this causes contention, we can always introduce an option to change the behavior in a backward- + # compatible way. + body_lines = commit.message.body[1:] if len(commit.message.body) > 1 else [] + + # Similarly, the last line is often empty, this has to do with how git returns commit messages + # User's won't expect this, so prune it off by default + if body_lines and body_lines[-1] == "": + body_lines.pop() + + full_body = "\n".join(body_lines) + + if not self.options['regex'].value.search(full_body): + violation_msg = f"Body does not match regex ({self.options['regex'].value.pattern})" + return [RuleViolation(self.id, violation_msg, None, len(commit.message.body) + 1)] + + +class AuthorValidEmail(CommitRule): + name = "author-valid-email" + id = "M1" + options_spec = [RegexOption('regex', r"[^@ ]+@[^@ ]+\.[^@ ]+", "Regex that author email address should match")] + + def validate(self, commit): + # If no regex is specified, immediately return + if not self.options['regex'].value: + return + + if commit.author_email and not self.options['regex'].value.match(commit.author_email): + return [RuleViolation(self.id, "Author email for commit is invalid", commit.author_email)] + + +class IgnoreByTitle(ConfigurationRule): + name = "ignore-by-title" + id = "I1" + options_spec = [RegexOption('regex', None, "Regex matching the titles of commits this rule should apply to"), + StrOption('ignore', "all", "Comma-separated list of rules to ignore")] + + def apply(self, config, commit): + # If no regex is specified, immediately return + if not self.options['regex'].value: + return + + if self.options['regex'].value.match(commit.message.title): + config.ignore = self.options['ignore'].value + + message = f"Commit title '{commit.message.title}' matches the regex " + \ + f"'{self.options['regex'].value.pattern}', ignoring rules: {self.options['ignore'].value}" + + self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message) + + +class IgnoreByBody(ConfigurationRule): + name = "ignore-by-body" + id = "I2" + options_spec = [RegexOption('regex', None, "Regex matching lines of the body of commits this rule should apply to"), + StrOption('ignore', "all", "Comma-separated list of rules to ignore")] + + def apply(self, config, commit): + # If no regex is specified, immediately return + if not self.options['regex'].value: + return + + for line in commit.message.body: + if self.options['regex'].value.match(line): + config.ignore = self.options['ignore'].value + + message = f"Commit message line '{line}' matches the regex '{self.options['regex'].value.pattern}'," + \ + f" ignoring rules: {self.options['ignore'].value}" + + self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message) + # No need to check other lines if we found a match + return + + +class IgnoreBodyLines(ConfigurationRule): + name = "ignore-body-lines" + id = "I3" + options_spec = [RegexOption('regex', None, "Regex matching lines of the body that should be ignored")] + + def apply(self, _, commit): + # If no regex is specified, immediately return + if not self.options['regex'].value: + return + + new_body = [] + for line in commit.message.body: + if self.options['regex'].value.match(line): + debug_msg = "Ignoring line '%s' because it matches '%s'" + self.log.debug(debug_msg, line, self.options['regex'].value.pattern) + else: + new_body.append(line) + + commit.message.body = new_body + commit.message.full = "\n".join([commit.message.title] + new_body) + + +class IgnoreByAuthorName(ConfigurationRule): + name = "ignore-by-author-name" + id = "I4" + options_spec = [RegexOption('regex', None, "Regex matching the author name of commits this rule should apply to"), + StrOption('ignore', "all", "Comma-separated list of rules to ignore")] + + def apply(self, config, commit): + # If no regex is specified, immediately return + if not self.options['regex'].value: + return + + if self.options['regex'].value.match(commit.author_name): + config.ignore = self.options['ignore'].value + + message = (f"Commit Author Name '{commit.author_name}' matches the regex " + f"'{self.options['regex'].value.pattern}', ignoring rules: {self.options['ignore'].value}") + + self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message) + # No need to check other lines if we found a match + return |