summaryrefslogtreecommitdiff
path: root/lib/gitlab/danger/commit_linter.rb
blob: 2e469359bdc239a68d4db94f948b11b3664a0f51 (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
# frozen_string_literal: true

emoji_checker_path = File.expand_path('emoji_checker', __dir__)
defined?(Rails) ? require_dependency(emoji_checker_path) : require_relative(emoji_checker_path)

module Gitlab
  module Danger
    class CommitLinter
      MIN_SUBJECT_WORDS_COUNT = 3
      MAX_LINE_LENGTH = 72
      MAX_CHANGED_FILES_IN_COMMIT = 3
      MAX_CHANGED_LINES_IN_COMMIT = 30
      SHORT_REFERENCE_REGEX = %r{([\w\-\/]+)?(?<!`)(#|!|&|%)\d+(?<!`)}.freeze
      DEFAULT_SUBJECT_DESCRIPTION = 'commit subject'
      WIP_PREFIX = 'WIP: '
      PROBLEMS = {
        subject_too_short: "The %s must contain at least #{MIN_SUBJECT_WORDS_COUNT} words",
        subject_too_long: "The %s may not be longer than #{MAX_LINE_LENGTH} characters",
        subject_starts_with_lowercase: "The %s must start with a capital letter",
        subject_ends_with_a_period: "The %s must not end with a period",
        separator_missing: "The commit subject and body must be separated by a blank line",
        details_too_many_changes: "Commits that change #{MAX_CHANGED_LINES_IN_COMMIT} or more lines across " \
          "at least #{MAX_CHANGED_FILES_IN_COMMIT} files must describe these changes in the commit body",
        details_line_too_long: "The commit body should not contain more than #{MAX_LINE_LENGTH} characters per line",
        message_contains_text_emoji: "Avoid the use of Markdown Emoji such as `:+1:`. These add limited value " \
          "to the commit message, and are displayed as plain text outside of GitLab",
        message_contains_unicode_emoji: "Avoid the use of Unicode Emoji. These add no value to the commit " \
          "message, and may not be displayed properly everywhere",
        message_contains_short_reference: "Use full URLs instead of short references (`gitlab-org/gitlab#123` or " \
          "`!123`), as short references are displayed as plain text outside of GitLab"
      }.freeze

      attr_reader :commit, :problems

      def initialize(commit)
        @commit = commit
        @problems = {}
        @linted = false
      end

      def fixup?
        commit.message.start_with?('fixup!', 'squash!')
      end

      def suggestion?
        commit.message.start_with?('Apply suggestion to')
      end

      def merge?
        commit.message.start_with?('Merge branch')
      end

      def revert?
        commit.message.start_with?('Revert "')
      end

      def multi_line?
        !details.nil? && !details.empty?
      end

      def failed?
        problems.any?
      end

      def add_problem(problem_key, *args)
        @problems[problem_key] = sprintf(PROBLEMS[problem_key], *args)
      end

      def lint(subject_description = "commit subject")
        return self if @linted

        @linted = true
        lint_subject(subject_description)
        lint_separator
        lint_details
        lint_message

        self
      end

      def lint_subject(subject_description)
        if subject_too_short?
          add_problem(:subject_too_short, subject_description)
        end

        if subject_too_long?
          add_problem(:subject_too_long, subject_description)
        end

        if subject_starts_with_lowercase?
          add_problem(:subject_starts_with_lowercase, subject_description)
        end

        if subject_ends_with_a_period?
          add_problem(:subject_ends_with_a_period, subject_description)
        end

        self
      end

      private

      def lint_separator
        return self unless separator && !separator.empty?

        add_problem(:separator_missing)

        self
      end

      def lint_details
        if !multi_line? && many_changes?
          add_problem(:details_too_many_changes)
        end

        details&.each_line do |line|
          line = line.strip

          next unless line_too_long?(line)

          url_size = line.scan(%r((https?://\S+))).sum { |(url)| url.length }

          # If the line includes a URL, we'll allow it to exceed MAX_LINE_LENGTH characters, but
          # only if the line _without_ the URL does not exceed this limit.
          next unless line_too_long?(line.length - url_size)

          add_problem(:details_line_too_long)
          break
        end

        self
      end

      def lint_message
        if message_contains_text_emoji?
          add_problem(:message_contains_text_emoji)
        end

        if message_contains_unicode_emoji?
          add_problem(:message_contains_unicode_emoji)
        end

        if message_contains_short_reference?
          add_problem(:message_contains_short_reference)
        end

        self
      end

      def files_changed
        commit.diff_parent.stats[:total][:files]
      end

      def lines_changed
        commit.diff_parent.stats[:total][:lines]
      end

      def many_changes?
        files_changed > MAX_CHANGED_FILES_IN_COMMIT && lines_changed > MAX_CHANGED_LINES_IN_COMMIT
      end

      def subject
        message_parts[0].delete_prefix(WIP_PREFIX)
      end

      def separator
        message_parts[1]
      end

      def details
        message_parts[2]&.gsub(/^Signed-off-by.*$/, '')
      end

      def line_too_long?(line)
        case line
        when String
          line.length > MAX_LINE_LENGTH
        when Integer
          line > MAX_LINE_LENGTH
        else
          raise ArgumentError, "The line argument (#{line}) should be a String or an Integer! #{line.class} given."
        end
      end

      def subject_too_short?
        subject.split(' ').length < MIN_SUBJECT_WORDS_COUNT
      end

      def subject_too_long?
        line_too_long?(subject)
      end

      def subject_starts_with_lowercase?
        first_char = subject.sub(/\A(\[.+\]|\w+:)\s/, '')[0]
        first_char_downcased = first_char.downcase
        return true unless ('a'..'z').cover?(first_char_downcased)

        first_char.downcase == first_char
      end

      def subject_ends_with_a_period?
        subject.end_with?('.')
      end

      def message_contains_text_emoji?
        emoji_checker.includes_text_emoji?(commit.message)
      end

      def message_contains_unicode_emoji?
        emoji_checker.includes_unicode_emoji?(commit.message)
      end

      def message_contains_short_reference?
        commit.message.match?(SHORT_REFERENCE_REGEX)
      end

      def emoji_checker
        @emoji_checker ||= Gitlab::Danger::EmojiChecker.new
      end

      def message_parts
        @message_parts ||= commit.message.split("\n", 3)
      end
    end
  end
end