summaryrefslogtreecommitdiffstats
path: root/gitlint/rules.py
blob: ad83204f3e1d20b4f86de1ba63841150fe8058fc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# pylint: disable=inconsistent-return-statements
import copy
import logging
import re

from gitlint.options import IntOption, BoolOption, StrOption, ListOption
from gitlint.utils import sstr

LOG = logging.getLogger(__name__)
logging.basicConfig()


class Rule(object):
    """ Class representing gitlint rules. """
    options_spec = []
    id = None
    name = None
    target = 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)

    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 __ne__(self, other):
        return not self.__eq__(other)  # required for py2

    def __str__(self):
        return sstr(self)  # pragma: no cover

    def __unicode__(self):
        return u"{0} {1}".format(self.id, self.name)  # pragma: no cover

    def __repr__(self):
        return self.__str__()  # 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(object):
    """ 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(object):
    """ 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 __ne__(self, other):
        return not self.__eq__(other)  # required for py2

    def __str__(self):
        return sstr(self)  # pragma: no cover

    def __unicode__(self):
        return u"{0}: {1} {2}: \"{3}\"".format(self.line_nr, self.rule_id, self.message,
                                               self.content)  # pragma: no cover

    def __repr__(self):
        return self.__str__()  # pragma: no cover


class UserRuleError(Exception):
    """ 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"

    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 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 = u"Line contains {0}"

    def validate(self, line, _commit):
        strings = self.options['words'].value
        violations = []
        for string in strings:
            regex = re.compile(r"\b%s\b" % string.lower(), 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, u"Title has trailing punctuation ({0})".format(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 = u"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 = [StrOption('regex', ".*", "Regex the title should match")]

    def validate(self, title, _commit):
        regex = self.options['regex'].value
        pattern = re.compile(regex, re.UNICODE)
        if not pattern.search(title):
            violation_msg = u"Title does not match regex ({0})".format(regex)
            return [RuleViolation(self.id, violation_msg, title)]


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 = "Body message is too short ({0}<{1})".format(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:
            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 = u"Body does not mention changed file '{0}'".format(needs_mentioned_file)
                    violations.append(RuleViolation(self.id, violation_message, None, len(commit.message.body) + 1))
        return violations if violations else None


class AuthorValidEmail(CommitRule):
    name = "author-valid-email"
    id = "M1"
    options_spec = [StrOption('regex', r"[^@ ]+@[^@ ]+\.[^@ ]+", "Regex that author email address should match")]

    def validate(self, commit):
        # Note that unicode is allowed in email addresses
        # See http://stackoverflow.com/questions/3844431
        # /are-email-addresses-allowed-to-contain-non-alphanumeric-characters
        email_regex = re.compile(self.options['regex'].value, re.UNICODE)

        if commit.author_email and not email_regex.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 = [StrOption('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):
        title_regex = re.compile(self.options['regex'].value, re.UNICODE)

        if title_regex.match(commit.message.title):
            config.ignore = self.options['ignore'].value

            message = u"Commit title '{0}' matches the regex '{1}', ignoring rules: {2}"
            message = message.format(commit.message.title, self.options['regex'].value, self.options['ignore'].value)

            LOG.debug("Ignoring commit because of rule '%s': %s", self.id, message)


class IgnoreByBody(ConfigurationRule):
    name = "ignore-by-body"
    id = "I2"
    options_spec = [StrOption('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):
        body_line_regex = re.compile(self.options['regex'].value, re.UNICODE)

        for line in commit.message.body:
            if body_line_regex.match(line):
                config.ignore = self.options['ignore'].value

                message = u"Commit message line '{0}' matches the regex '{1}', ignoring rules: {2}"
                message = message.format(line, self.options['regex'].value, self.options['ignore'].value)

                LOG.debug("Ignoring commit because of rule '%s': %s", self.id, message)
                # No need to check other lines if we found a match
                return