1# SPDX-License-Identifier: Apache-2.0
2
3"""
4The classes below are examples of user-defined CommitRules. Commit rules are gitlint rules that
5act on the entire commit at once. Once the rules are discovered, gitlint will automatically take care of applying them
6to the entire commit. This happens exactly once per commit.
7
8A CommitRule contrasts with a LineRule (see examples/my_line_rules.py) in that a commit rule is only applied once on
9an entire commit. This allows commit rules to implement more complex checks that span multiple lines and/or checks
10that should only be done once per gitlint run.
11
12While every LineRule can be implemented as a CommitRule, it's usually easier and more concise to go with a LineRule if
13that fits your needs.
14"""
15
16from gitlint.rules import CommitRule, RuleViolation, CommitMessageTitle, LineRule, CommitMessageBody
17from gitlint.options import IntOption, StrOption
18import re
19
20class BodyMinLineCount(CommitRule):
21    # A rule MUST have a human friendly name
22    name = "body-min-line-count"
23
24    # A rule MUST have an *unique* id, we recommend starting with UC (for User-defined Commit-rule).
25    id = "UC6"
26
27    # A rule MAY have an options_spec if its behavior should be configurable.
28    options_spec = [IntOption('min-line-count', 1, "Minimum body line count excluding Signed-off-by")]
29
30    def validate(self, commit):
31        filtered = [x for x in commit.message.body if not x.lower().startswith("signed-off-by") and x != '']
32        line_count = len(filtered)
33        min_line_count = self.options['min-line-count'].value
34        if line_count < min_line_count:
35            message = "Commit message body is empty, should at least have {} line(s).".format(min_line_count)
36            return [RuleViolation(self.id, message, line_nr=1)]
37
38class BodyMaxLineCount(CommitRule):
39    # A rule MUST have a human friendly name
40    name = "body-max-line-count"
41
42    # A rule MUST have an *unique* id, we recommend starting with UC (for User-defined Commit-rule).
43    id = "UC1"
44
45    # A rule MAY have an options_spec if its behavior should be configurable.
46    options_spec = [IntOption('max-line-count', 200, "Maximum body line count")]
47
48    def validate(self, commit):
49        line_count = len(commit.message.body)
50        max_line_count = self.options['max-line-count'].value
51        if line_count > max_line_count:
52            message = "Commit message body contains too many lines ({0} > {1})".format(line_count, max_line_count)
53            return [RuleViolation(self.id, message, line_nr=1)]
54
55class SignedOffBy(CommitRule):
56    """ This rule will enforce that each commit contains a "Signed-off-by" line.
57    We keep things simple here and just check whether the commit body contains a line that starts with "Signed-off-by".
58    """
59
60    # A rule MUST have a human friendly name
61    name = "body-requires-signed-off-by"
62
63    # A rule MUST have an *unique* id, we recommend starting with UC (for User-defined Commit-rule).
64    id = "UC2"
65
66    def validate(self, commit):
67        flags = re.UNICODE
68        flags |= re.IGNORECASE
69        for line in commit.message.body:
70            if line.lower().startswith("signed-off-by"):
71                if not re.search(r"(^)Signed-off-by: ([-'\w.]+) ([-'\w.]+) (.*)", line, flags=flags):
72                    return [RuleViolation(self.id, "Signed-off-by: must have a full name", line_nr=1)]
73                else:
74                    return
75        return [RuleViolation(self.id, "Commit message does not contain a 'Signed-off-by:' line", line_nr=1)]
76
77class TitleMaxLengthRevert(LineRule):
78    name = "title-max-length-no-revert"
79    id = "UC5"
80    target = CommitMessageTitle
81    options_spec = [IntOption('line-length', 75, "Max line length")]
82    violation_message = "Commit title exceeds max length ({0}>{1})"
83
84    def validate(self, line, _commit):
85        max_length = self.options['line-length'].value
86        if len(line) > max_length and not line.startswith("Revert"):
87            return [RuleViolation(self.id, self.violation_message.format(len(line), max_length), line)]
88
89class TitleStartsWithSubsystem(LineRule):
90    name = "title-starts-with-subsystem"
91    id = "UC3"
92    target = CommitMessageTitle
93    options_spec = [StrOption('regex', ".*", "Regex the title should match")]
94
95    def validate(self, title, _commit):
96        regex = self.options['regex'].value
97        pattern = re.compile(regex, re.UNICODE)
98        violation_message = "Commit title does not follow [subsystem]: [subject] (and should not start with literal subsys:)"
99        if not pattern.search(title):
100            return [RuleViolation(self.id, violation_message, title)]
101
102class MaxLineLengthExceptions(LineRule):
103    name = "max-line-length-with-exceptions"
104    id = "UC4"
105    target = CommitMessageBody
106    options_spec = [IntOption('line-length', 75, "Max line length")]
107    violation_message = "Commit message body line exceeds max length ({0}>{1})"
108
109    def validate(self, line, _commit):
110        max_length = self.options['line-length'].value
111        urls = re.findall(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', line)
112        if line.lower().startswith('signed-off-by') or line.lower().startswith('co-authored-by'):
113            return
114
115        if urls:
116            return
117
118        if len(line) > max_length:
119            return [RuleViolation(self.id, self.violation_message.format(len(line), max_length), line)]
120
121class BodyContainsBlockedTags(LineRule):
122    name = "body-contains-blocked-tags"
123    id = "UC7"
124    target = CommitMessageBody
125    tags = ["Change-Id"]
126
127    def validate(self, line, _commit):
128        flags = re.IGNORECASE
129        for tag in self.tags:
130            if re.search(rf"^\s*{tag}:", line, flags=flags):
131                return [RuleViolation(self.id, f"Commit message contains a blocked tag: {tag}")]
132        return
133