summaryrefslogtreecommitdiff
path: root/tooling/danger/changelog.rb
blob: 6e9c2e5ffdb0b7935d68b49c84d7e829d4abbc87 (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
# frozen_string_literal: true

require 'gitlab/dangerfiles/title_linting'

module Tooling
  module Danger
    module Changelog
      NO_CHANGELOG_LABELS = [
        'tooling',
        'tooling::pipelines',
        'tooling::workflow',
        'ci-build',
        'meta'
      ].freeze
      NO_CHANGELOG_CATEGORIES = %i[docs none].freeze
      CHANGELOG_TRAILER_REGEX = /^Changelog:\s*(?<category>.+)$/.freeze
      CHANGELOG_EE_TRAILER_REGEX = /^EE: true$/.freeze
      CHANGELOG_MODIFIED_URL_TEXT = "**CHANGELOG.md was edited.** Please remove the additions and follow the [changelog guidelines](https://docs.gitlab.com/ee/development/changelog.html).\n\n"
      CHANGELOG_MISSING_URL_TEXT = "**[CHANGELOG missing](https://docs.gitlab.com/ee/development/changelog.html)**:\n\n"

      OPTIONAL_CHANGELOG_MESSAGE = {
        local: "If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message.",
        ci: <<~MSG
        If you want to create a changelog entry for GitLab FOSS, add the `Changelog` trailer to the commit message you want to add to the changelog.

        If you want to create a changelog entry for GitLab EE, also add the trailer `EE: true` to your commit message.

        If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message.
        MSG
      }.freeze
      SEE_DOC = "See the [changelog documentation](https://docs.gitlab.com/ee/development/changelog.html)."

      REQUIRED_CHANGELOG_REASONS = {
        db_changes: 'introduces a database migration',
        feature_flag_removed: 'removes a feature flag'
      }.freeze
      REQUIRED_CHANGELOG_MESSAGE = {
        local: "This merge request requires a changelog entry because it [%<reason>s](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry).",
        ci: <<~MSG
        To create a changelog entry, add the `Changelog` trailer to one of your Git commit messages.

        This merge request requires a changelog entry because it [%<reason>s](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry).
        MSG
      }.freeze

      CATEGORIES = YAML
        .load_file(File.expand_path('../../.gitlab/changelog_config.yml', __dir__))
        .fetch('categories')
        .keys
        .freeze

      class ChangelogCheckResult
        attr_reader :errors, :warnings, :markdowns, :messages

        def initialize(errors: [], warnings: [], markdowns: [], messages: [])
          @errors = errors
          @warnings = warnings
          @markdowns = markdowns
          @messages = messages
        end
        private_class_method :new

        def self.empty
          new
        end

        def self.error(error)
          new(errors: [error])
        end

        def self.warning(warning)
          new(warnings: [warning])
        end

        def error(error)
          errors << error
        end

        def warning(warning)
          warnings << warning
        end

        def markdown(markdown)
          markdowns << markdown
        end

        def message(message)
          messages << message
        end
      end

      # rubocop:disable Style/SignalException
      def check!
        if git.modified_files.include?("CHANGELOG.md")
          fail modified_text
        end

        if present?
          add_danger_messages(check_changelog_path)
        elsif required?
          required_texts.each { |_, text| fail(text) } # rubocop:disable Lint/UnreachableLoop
        elsif optional?
          message optional_text
        end

        check_changelog_commit_categories
      end
      # rubocop:enable Style/SignalException

      # rubocop:disable Style/SignalException
      def add_danger_messages(check_result)
        check_result.errors.each { |error| fail(error) } # rubocop:disable Lint/UnreachableLoop
        check_result.warnings.each { |warning| warn(warning) }
        check_result.markdowns.each { |markdown_hash| markdown(**markdown_hash) }
        check_result.messages.each { |text| message(text) }
      end
      # rubocop:enable Style/SignalException

      def check_changelog_commit_categories
        changelog_commits.each do |commit|
          add_danger_messages(check_changelog_trailer(commit))
        end
      end

      def check_changelog_trailer(commit)
        trailer = commit.message.match(CHANGELOG_TRAILER_REGEX)
        category = trailer[:category]

        return ChangelogCheckResult.empty if CATEGORIES.include?(category)

        ChangelogCheckResult.error("Commit #{commit.sha} uses an invalid changelog category: #{category}")
      end

      def check_changelog_path
        check_result = ChangelogCheckResult.empty
        return check_result unless present?

        ee_changes = project_helper.all_ee_changes.dup

        if ee_changes.any? && !ee_changelog? && !required?
          check_result.warning("This MR changes code in `ee/`, but is missing a Changelog commit. Consider adding the Changelog trailer to at least one commit.")
        end

        if ee_changes.empty? && ee_changelog?
          check_result.warning("This MR has a Changelog commit for EE, but no code changes in `ee/`. Consider removing the use of the `EE: true` trailer from your commits.")
        end

        if ee_changes.any? && ee_changelog? && required_reasons.include?(:db_changes)
          check_result.warning("This MR has a Changelog commit with the `EE: true` trailer, but there are database changes which [requires](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry) the Changelog commiot to not have the `EE: true` trailer. Consider removing the `EE: true` trailer.")
        end

        check_result
      end

      def required_reasons
        [].tap do |reasons|
          reasons << :db_changes if project_helper.changes.added.has_category?(:migration)
          reasons << :feature_flag_removed if project_helper.changes.deleted.has_category?(:feature_flag)
        end
      end

      def required?
        required_reasons.any?
      end

      def optional?
        categories_need_changelog? && mr_without_no_changelog_label?
      end

      def present?
        valid_changelog_commits.any?
      end

      def changelog_commits
        git.commits.select do |commit|
          commit.message.match?(CHANGELOG_TRAILER_REGEX)
        end
      end

      def valid_changelog_commits
        changelog_commits.select do |commit|
          trailer = commit.message.match(CHANGELOG_TRAILER_REGEX)

          CATEGORIES.include?(trailer[:category])
        end
      end

      def ee_changelog?
        changelog_commits.any? do |commit|
          commit.message.match?(CHANGELOG_EE_TRAILER_REGEX)
        end
      end

      def modified_text
        CHANGELOG_MODIFIED_URL_TEXT +
          (helper.ci? ? format(OPTIONAL_CHANGELOG_MESSAGE[:ci]) : OPTIONAL_CHANGELOG_MESSAGE[:local])
      end

      def required_texts
        required_reasons.each_with_object({}) do |required_reason, memo|
          memo[required_reason] =
            CHANGELOG_MISSING_URL_TEXT +
              (helper.ci? ? format(REQUIRED_CHANGELOG_MESSAGE[:ci], reason: REQUIRED_CHANGELOG_REASONS.fetch(required_reason)) : REQUIRED_CHANGELOG_MESSAGE[:local])
        end
      end

      def optional_text
        CHANGELOG_MISSING_URL_TEXT +
          (helper.ci? ? format(OPTIONAL_CHANGELOG_MESSAGE[:ci]) : OPTIONAL_CHANGELOG_MESSAGE[:local])
      end

      private

      def read_file(path)
        File.read(path)
      end

      def categories_need_changelog?
        (project_helper.changes.categories - NO_CHANGELOG_CATEGORIES).any?
      end

      def mr_without_no_changelog_label?
        (helper.mr_labels & NO_CHANGELOG_LABELS).empty?
      end
    end
  end
end