commit_linter.rb 6.7 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
# 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
      WARN_SUBJECT_LENGTH = 50
      URL_LIMIT_SUBJECT = "https://chris.beams.io/posts/git-commit/#limit-50"
      MAX_CHANGED_FILES_IN_COMMIT = 3
      MAX_CHANGED_LINES_IN_COMMIT = 30
      SHORT_REFERENCE_REGEX = %r{([\w\-\/]+)?(#|!|&|%)\d+\b}.freeze
      DEFAULT_SUBJECT_DESCRIPTION = 'commit subject'
17
      WIP_PREFIX = 'WIP: '
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
      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_above_warning: "The %s length is acceptable, but please try to [reduce it to #{WARN_SUBJECT_LENGTH} characters](#{URL_LIMIT_SUBJECT}).",
        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)
        elsif subject_above_warning?
          add_problem(:subject_above_warning, 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 } # rubocop:disable CodeReuse/ActiveRecord

          # 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
168
        message_parts[0].delete_prefix(WIP_PREFIX)
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
      end

      def separator
        message_parts[1]
      end

      def details
        message_parts[2]
      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_above_warning?
        subject.length > WARN_SUBJECT_LENGTH
      end

      def subject_starts_with_lowercase?
        first_char = subject[0]

        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