summaryrefslogtreecommitdiff
path: root/tooling/danger/stable_branch.rb
blob: 9b4671460960d6d8001ef5b41843872d369dbea7 (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
# frozen_string_literal: true

module Tooling
  module Danger
    module StableBranch
      VersionApiError = Class.new(StandardError)

      STABLE_BRANCH_REGEX = %r{\A(?<version>\d+-\d+)-stable-ee\z}.freeze
      FAILING_PACKAGE_AND_TEST_STATUSES = %w[manual canceled].freeze

      # rubocop:disable Lint/MixedRegexpCaptureTypes
      VERSION_REGEX = %r{
        \A(?<major>\d+)
        \.(?<minor>\d+)
        (\.(?<patch>\d+))?
        (-(?<rc>rc(?<rc_number>\d*)))?
        (-\h+\.\h+)?
        (-ee|\.ee\.\d+)?\z
      }x.freeze
      # rubocop:enable Lint/MixedRegexpCaptureTypes

      MAINTENANCE_POLICY_URL = 'https://docs.gitlab.com/ee/policy/maintenance.html'

      MAINTENANCE_POLICY_MESSAGE = <<~MSG
      See the [release and maintenance policy](#{MAINTENANCE_POLICY_URL}) for more information.
      MSG

      FEATURE_ERROR_MESSAGE = <<~MSG
      This MR includes the `type::feature` label. Features do not qualify for patch releases. #{MAINTENANCE_POLICY_MESSAGE}
      MSG

      BUG_ERROR_MESSAGE = <<~MSG
      This branch is meant for backporting bug fixes. If this MR qualifies please add the `type::bug` label. #{MAINTENANCE_POLICY_MESSAGE}
      MSG

      VERSION_WARNING_MESSAGE = <<~MSG
      Backporting to older releases requires an [exception request process](https://docs.gitlab.com/ee/policy/maintenance.html#backporting-to-older-releases)
      MSG

      FAILED_VERSION_REQUEST_MESSAGE = <<~MSG
      There was a problem checking if this is a qualified version for backporting. Re-running this job may fix the problem.
      MSG

      PIPELINE_EXPEDITE_ERROR_MESSAGE = <<~MSG
      ~"pipeline:expedite" is not allowed on stable branches because it causes the `e2e:package-and-test` job to be skipped.
      MSG

      NEEDS_PACKAGE_AND_TEST_MESSAGE = <<~MSG
      The `e2e:package-and-test` job is not present, has been canceled, or needs to be automatically triggered.
      Please ensure the job is present in the latest pipeline, if necessary, retry the `danger-review` job.
      Read the "QA e2e:package-and-test" section for more details.
      MSG

      WARN_PACKAGE_AND_TEST_MESSAGE = <<~MSG
      **The `e2e:package-and-test` job needs to succeed or have approval from a Software Engineer in Test.**
      Read the "QA e2e:package-and-test" section for more details.
      MSG

      # rubocop:disable Style/SignalException
      def check!
        return unless valid_stable_branch?

        fail FEATURE_ERROR_MESSAGE if has_feature_label?
        fail BUG_ERROR_MESSAGE unless bug_fixes_only?

        warn VERSION_WARNING_MESSAGE unless targeting_patchable_version?

        return if has_flaky_failure_label? || has_only_documentation_changes?

        fail PIPELINE_EXPEDITE_ERROR_MESSAGE if has_pipeline_expedite_label?

        status = package_and_test_bridge_and_pipeline_status

        if status.nil? || FAILING_PACKAGE_AND_TEST_STATUSES.include?(status) # rubocop:disable Style/GuardClause
          fail NEEDS_PACKAGE_AND_TEST_MESSAGE
        else
          warn WARN_PACKAGE_AND_TEST_MESSAGE unless status == 'success'
        end
      end
      # rubocop:enable Style/SignalException

      def encourage_package_and_qa_execution?
        valid_stable_branch? &&
          !has_only_documentation_changes? &&
          !has_flaky_failure_label?
      end

      private

      def valid_stable_branch?
        !!stable_target_branch && !helper.security_mr?
      end

      def package_and_test_bridge_and_pipeline_status
        mr_head_pipeline_id = gitlab.mr_json.dig('head_pipeline', 'id')
        return unless mr_head_pipeline_id

        bridge = package_and_test_bridge(mr_head_pipeline_id)

        return unless bridge

        if bridge['status'] == 'created'
          bridge['status']
        else
          bridge.fetch('downstream_pipeline')&.fetch('status')
        end
      end

      def package_and_test_bridge(mr_head_pipeline_id)
        gitlab
          .api
          .pipeline_bridges(helper.mr_target_project_id, mr_head_pipeline_id)
          &.find { |bridge| bridge['name'] == 'e2e:package-and-test' }
      end

      def stable_target_branch
        helper.mr_target_branch.match(STABLE_BRANCH_REGEX)
      end

      def has_feature_label?
        helper.mr_has_labels?('type::feature')
      end

      def has_bug_label?
        helper.mr_has_labels?('type::bug')
      end

      def has_pipeline_expedite_label?
        helper.mr_has_labels?('pipeline:expedite')
      end

      def has_flaky_failure_label?
        helper.mr_has_labels?('failure::flaky-test')
      end

      def bug_fixes_only?
        has_bug_label? || has_only_documentation_changes?
      end

      def has_only_documentation_changes?
        categories_changed = helper.changes_by_category.keys
        return false unless categories_changed.size == 1
        return true if categories_changed.first == :docs

        false
      end

      def targeting_patchable_version?
        raise VersionApiError if last_three_minor_versions.empty?

        last_three_minor_versions.include?(targeted_version)
      rescue VersionApiError
        warn FAILED_VERSION_REQUEST_MESSAGE
        true
      end

      def last_three_minor_versions
        return [] unless versions

        current_version = versions.first.match(VERSION_REGEX)
        version_1 = previous_minor_version(current_version)
        version_2 = previous_minor_version(version_1)

        [
          version_to_minor_string(current_version),
          version_to_minor_string(version_1),
          version_to_minor_string(version_2)
        ]
      end

      def targeted_version
        stable_target_branch[1].tr('-', '.')
      end

      def versions(page = 1)
        version_api_endpoint = "https://version.gitlab.com/api/v1/versions?per_page=50&page=#{page}"
        response = HTTParty.get(version_api_endpoint) # rubocop:disable Gitlab/HTTParty

        raise VersionApiError unless response.success?

        version_list = response.parsed_response.map { |v| v['version'] } # rubocop:disable Rails/Pluck

        version_list.sort_by { |v| Gem::Version.new(v) }.reverse
      end

      def previous_minor_version(version)
        previous_minor = version[:minor].to_i - 1

        return "#{version[:major]}.#{previous_minor}".match(VERSION_REGEX) if previous_minor >= 0

        fetch_last_minor_version_for_major(version[:major].to_i - 1)
      end

      def fetch_last_minor_version_for_major(major)
        page = 1
        last_minor_version = nil

        while last_minor_version.nil?
          last_minor_version = versions(page).find do |version|
            version.split('.').first.to_i == major
          end

          break if page > 10

          page += 1
        end

        raise VersionApiError if last_minor_version.nil?

        last_minor_version.match(VERSION_REGEX)
      end

      def version_to_minor_string(version)
        "#{version[:major]}.#{version[:minor]}"
      end
    end
  end
end