gitlab-dangerfiles 4.5.1 → 4.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,34 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../gitlab/dangerfiles/approval"
4
+ require_relative "../../gitlab/dangerfiles/spinner"
3
5
  require_relative "../../gitlab/dangerfiles/teammate"
4
- require_relative "../../gitlab/dangerfiles/weightage/maintainers"
5
- require_relative "../../gitlab/dangerfiles/weightage/reviewers"
6
6
 
7
7
  module Danger
8
8
  # Common helper functions for our danger scripts. See Danger::Helper
9
9
  # for more details
10
10
  class Roulette < Danger::Plugin
11
- ROULETTE_DATA_URL = "https://gitlab-org.gitlab.io/gitlab-roulette/roulette.json"
12
11
  HOURS_WHEN_PERSON_CAN_BE_PICKED = (6..14).freeze
13
-
14
- Spin = Struct.new(:category, :reviewer, :maintainer, :optional_role)
15
12
  HTTPError = Class.new(StandardError)
16
13
 
17
- Approval = Struct.new(:category, :spin) do
18
- def self.from_approval_rule(rule, maintainer)
19
- category =
20
- if rule["section"] == "codeowners"
21
- "`#{rule["name"]}`"
22
- else
23
- rule["section"]
24
- end.to_sym
25
-
26
- spin = Spin.new(category, nil, maintainer, :reviewer)
27
-
28
- new(category, spin)
29
- end
30
- end
31
-
32
14
  def prepare_categories(changes_keys)
33
15
  categories = Set.new(changes_keys)
34
16
 
@@ -53,7 +35,7 @@ module Danger
53
35
  #
54
36
  # @return [Gitlab::Dangerfiles::Teammate]
55
37
  def team_mr_author
56
- @team_mr_author ||= find_member(helper.mr_author)
38
+ @team_mr_author ||= Gitlab::Dangerfiles::Teammate.find_member(helper.mr_author)
57
39
  end
58
40
 
59
41
  # Assigns GitLab team members to be reviewer and maintainer
@@ -63,70 +45,22 @@ module Danger
63
45
  # @param categories [Array<Symbol>] An array of categories symbols.
64
46
  #
65
47
  # @return [Array<Spin>]
66
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
67
48
  def spin(project = nil, categories = [:none], ux_fallback_wider_community_reviewer: teammate_pedroms)
49
+ # TODO: Deprecate the project argument. It prevents us from
50
+ # memorizing Spinner and can cause unexpected results if it's
51
+ # passing a different project than the merge request project.
68
52
  project = (project || config_project_name).downcase
69
53
  categories = categories.map { |category| category&.downcase || :none }
70
- categories.reject! { |category| import_and_integrate_reject_category?(category, project) }
71
-
72
- spins = categories.sort_by(&:to_s).map do |category|
73
- spin_for_category(project, category)
74
- end
75
-
76
- backend_spin = spins.find { |spin| spin.category == :backend }
77
- frontend_spin = spins.find { |spin| spin.category == :frontend }
78
-
79
- spins.each do |spin|
80
- case spin.category
81
- when :qa
82
- # MR includes QA changes, but also other changes, and author isn't an SET
83
- if categories.size > 1 &&
84
- !(team_mr_author && team_mr_author.capabilities(project).any? { |capability| capability.end_with?("qa") })
85
- spin.optional_role = :maintainer
86
- end
87
- when :test
88
- spin.optional_role = :maintainer
89
-
90
- if spin.reviewer.nil?
91
- # Fetch an already picked backend reviewer, or pick one otherwise
92
- spin.reviewer = backend_spin&.reviewer || spin_for_category(project, :backend).reviewer
93
- end
94
- when :tooling, :engineering_productivity # Deprecated as of 2.3.0 in favor of tooling
95
- if spin.maintainer.nil?
96
- # Fetch an already picked backend maintainer, or pick one otherwise
97
- spin.maintainer = backend_spin&.maintainer || spin_for_category(project, :backend).maintainer
98
- end
99
- when :ci_template
100
- if spin.maintainer.nil?
101
- # Fetch an already picked backend maintainer, or pick one otherwise
102
- spin.maintainer = backend_spin&.maintainer || spin_for_category(project, :backend).maintainer
103
- end
104
- when :analytics_instrumentation
105
- spin.optional_role = :maintainer
106
-
107
- if spin.maintainer.nil?
108
- # Fetch an already picked maintainer, or pick one otherwise
109
- spin.maintainer = backend_spin&.maintainer || frontend_spin&.maintainer || spin_for_category(project, :backend).maintainer
110
- end
111
- when :import_integrate_be, :import_integrate_fe
112
- spin.optional_role = :maintainer
113
- when :ux
114
- spin.optional_role = :maintainer
115
54
 
116
- # We want at least a UX reviewer who can review any wider community
117
- # contribution even without a team designer. We assign this to Pedro.
118
- spin.reviewer = ux_fallback_wider_community_reviewer if
119
- labels.include?("Community contribution") &&
120
- spin.reviewer.nil? &&
121
- spin.maintainer.nil?
122
- end
123
- end
124
-
125
- spins
55
+ Gitlab::Dangerfiles::Spinner.new(
56
+ project: project,
57
+ author: helper.mr_author, team_author: team_mr_author,
58
+ labels: labels, categories: categories, random: random,
59
+ ux_fallback_wider_community_reviewer:
60
+ ux_fallback_wider_community_reviewer)
61
+ .spin
126
62
  end
127
63
 
128
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
129
-
130
64
  def codeowners_approvals
131
65
  approval_rules = helper.mr_approval_state["rules"]
132
66
 
@@ -134,23 +68,31 @@ module Danger
134
68
 
135
69
  required_approval_rules = unique_approval_rules(approval_rules)
136
70
  required_approval_rules.filter_map do |rule|
137
- spin_for_approval_rule?(rule) &&
138
- Approval.from_approval_rule(rule, spin_for_approver(rule))
71
+ if spin_for_approval_rule?(rule)
72
+ approver = Gitlab::Dangerfiles::Spinner.new(
73
+ project: config_project_name.downcase,
74
+ author: helper.mr_author, team_author: team_mr_author,
75
+ random: random
76
+ ).spin_for_approver(rule)
77
+
78
+ Gitlab::Dangerfiles::Approval.from_approval_rule(rule, approver)
79
+ end
139
80
  end
140
81
  end
141
82
 
83
+ alias_method :required_approvals, :codeowners_approvals
84
+
85
+ # For backward compatibility
142
86
  def warnings
143
- @warnings ||= []
87
+ Gitlab::Dangerfiles::Teammate.warnings
144
88
  end
145
89
 
90
+ private
91
+
146
92
  def teammate_pedroms
147
- @teammate_pedroms ||= find_member("pedroms")
93
+ @teammate_pedroms ||= Gitlab::Dangerfiles::Teammate.find_member("pedroms")
148
94
  end
149
95
 
150
- alias_method :required_approvals, :codeowners_approvals
151
-
152
- private
153
-
154
96
  def spin_for_approval_rule?(rule)
155
97
  rule["rule_type"] == "code_owner" &&
156
98
  should_include_codeowners_rule?(rule) &&
@@ -188,179 +130,17 @@ module Danger
188
130
  end
189
131
  end
190
132
 
191
- # @param [Gitlab::Dangerfiles::Teammate] person
192
- # @return [Boolean]
193
- def valid_person?(person)
194
- !mr_author?(person) && person.available
195
- end
196
-
197
- # @param [Gitlab::Dangerfiles::Teammate] person
198
- # @return [Boolean]
199
- def mr_author?(person)
200
- person.username == helper.mr_author
201
- end
202
-
203
- # @param [String] category name
204
- # @return [Boolean]
205
- def import_and_integrate_reject_category?(category, project)
206
- # Reject Import and Integrate categories if the MR author has reviewing abilities for the category.
207
- team_mr_author&.import_integrate_be?(project, category, labels) ||
208
- team_mr_author&.import_integrate_fe?(project, category, labels)
209
- end
210
-
211
133
  def random
212
134
  @random ||= Random.new(Digest::MD5.hexdigest(helper.mr_source_branch).to_i(16))
213
135
  end
214
136
 
215
- def spin_role_for_category(team, role, project, category)
216
- team.select do |member|
217
- member.public_send("#{role}?", project, category, labels)
218
- end
219
- end
220
-
221
- # Known issue: If someone is rejected due to OOO, and then becomes not OOO, the
222
- # selection will change on next spin.
223
- #
224
- # @param [Array<Gitlab::Dangerfiles::Teammate>] people
225
- #
226
- # @return [Gitlab::Dangerfiles::Teammate]
227
- def spin_for_person(people)
228
- shuffled_people = people.shuffle(random: random)
229
-
230
- shuffled_people.find(&method(:valid_person?))
231
- end
232
-
233
- # Spin a reviewer for a particular approval rule
234
- #
235
- # @param [Hash] rule of approval
236
- #
237
- # @return [Gitlab::Dangerfiles::Teammate]
238
- def spin_for_approver(rule)
239
- approvers = rule["eligible_approvers"].filter_map do |approver|
240
- find_member(approver["username"], project: config_project_name.downcase)
241
- end
242
-
243
- spin_for_person(approvers) || spin_for_approver_fallback(rule)
244
- end
245
-
246
- # It can be possible that we don't have a valid reviewer for approval.
247
- # In this case, we sample again without considering:
248
- #
249
- # * If they're available
250
- # * If they're an actual reviewer from roulette data
251
- #
252
- # We do this because we strictly require an approval from the approvers.
253
- #
254
- # @param [Hash] rule of approval
255
- #
256
- # @return [Gitlab::Dangerfiles::Teammate]
257
- def spin_for_approver_fallback(rule)
258
- fallback_approvers = rule["eligible_approvers"].map do |approver|
259
- find_member(approver["username"]) ||
260
- Gitlab::Dangerfiles::Teammate.new(approver)
261
- end
262
-
263
- # Intentionally not using `spin_for_person` to skip `valid_person?`.
264
- # This should strictly return someone so we don't filter anything,
265
- # and it's a fallback mechanism which should not happen often that
266
- # deserves a complex algorithm.
267
- fallback_approvers.sample(random: random)
268
- end
269
-
270
- def spin_for_category(project, category)
271
- team = project_team(project)
272
- reviewers, traintainers, maintainers =
273
- %i[reviewer traintainer maintainer].map do |role|
274
- spin_role_for_category(team, role, project, category)
275
- end
276
-
277
- weighted_reviewers = Gitlab::Dangerfiles::Weightage::Reviewers.new(reviewers, traintainers).execute
278
- weighted_maintainers = Gitlab::Dangerfiles::Weightage::Maintainers.new(maintainers).execute
279
-
280
- reviewer = spin_for_person(weighted_reviewers)
281
- maintainer = spin_for_person(weighted_maintainers)
282
-
283
- # allow projects with small number of reviewers to take from maintainers if possible
284
- if reviewer.nil? && weighted_maintainers.uniq.size > 1
285
- weighted_maintainers.delete(maintainer)
286
- reviewer = spin_for_person(weighted_maintainers)
287
- end
288
-
289
- Spin.new(category, reviewer, maintainer, false)
290
- end
291
-
292
137
  def prepare_ux_category!(categories)
293
- if labels.include?("Community contribution")
138
+ if labels.include?("Community contribution") ||
139
+ # We only want to spin a reviewer for merge requests which has a
140
+ # designer for the team.
141
+ Gitlab::Dangerfiles::Teammate.has_member_for_the_group?(
142
+ :ux, project: config_project_name.downcase, labels: labels)
294
143
  categories << :ux
295
- else
296
- begin
297
- # We only want to spin a reviewer for merge requests which has a
298
- # designer for the team. There's no easy way to tell this, so we
299
- # pretend this is a community contribution, in which case we only
300
- # pick the team designer. If there's no one got picked, it means
301
- # there's no designer for this team.
302
- labels << "Community contribution"
303
-
304
- # Don't use a fallback reviewer so when a group doesn't have
305
- # available reviewers, it'll not give us any reviewers.
306
- ux_spin = spin(nil, [:ux], ux_fallback_wider_community_reviewer: nil).first
307
-
308
- categories << :ux if ux_spin.reviewer || ux_spin.maintainer
309
- ensure
310
- # Make sure we delete the label afterward
311
- labels.delete("Community contribution")
312
- end
313
- end
314
- end
315
-
316
- # Fetches the given +url+ and parse its response as JSON.
317
- #
318
- # @param [String] url
319
- #
320
- # @return [Hash, Array, NilClass]
321
- def http_get_json(url)
322
- rsp = Net::HTTP.get_response(URI.parse(url))
323
-
324
- if rsp.is_a?(Net::HTTPRedirection)
325
- uri = URI.parse(rsp.header["location"])
326
-
327
- uri.query = nil if uri
328
-
329
- warnings << "Redirection detected: #{uri}."
330
- return nil
331
- end
332
-
333
- unless rsp.is_a?(Net::HTTPOK)
334
- message = rsp.message[0, 30]
335
- warnings << "HTTPError: Failed to read #{url}: #{rsp.code} #{message}."
336
- return nil
337
- end
338
-
339
- JSON.parse(rsp.body)
340
- end
341
-
342
- # Looks up the current list of GitLab team members and parses it into a
343
- # useful form.
344
- #
345
- # @return [Array<Gitlab::Dangerfiles::Teammate>]
346
- def company_members
347
- @company_members ||= begin
348
- data = http_get_json(ROULETTE_DATA_URL) || []
349
- data.map { |hash| Gitlab::Dangerfiles::Teammate.new(hash) }
350
- rescue JSON::ParserError
351
- warnings << "Failed to parse JSON response from #{ROULETTE_DATA_URL}"
352
- []
353
- end
354
- end
355
-
356
- def find_member(username, project: nil)
357
- company_members.find do |member|
358
- member.username == username &&
359
- if project
360
- member.in_project?(project)
361
- else
362
- true
363
- end
364
144
  end
365
145
  end
366
146
 
@@ -377,18 +157,5 @@ module Danger
377
157
  def labels
378
158
  @labels ||= helper.mr_labels
379
159
  end
380
-
381
- # Like +team+, but only returns teammates in the current project, based on
382
- # project_name.
383
- #
384
- # @return [Array<Gitlab::Dangerfiles::Teammate>]
385
- def project_team(project_name)
386
- company_members.select do |member|
387
- member.in_project?(project_name)
388
- end
389
- rescue => err
390
- warn("Reviewer roulette failed to load team data: #{err.message}")
391
- []
392
- end
393
160
  end
394
161
  end
@@ -121,7 +121,7 @@ def warn_or_fail_commits(failed_linters, default_to_fail: true)
121
121
  when :subject_too_short, :subject_above_warning, :details_too_many_changes, :details_line_too_long
122
122
  warn_commit(linter.commit, problem_desc)
123
123
  else
124
- self.__send__("#{level}_commit", linter.commit, problem_desc)
124
+ self.__send__(:"#{level}_commit", linter.commit, problem_desc)
125
125
  end
126
126
  end
127
127
  end
@@ -9,7 +9,7 @@ def post_labels(labels)
9
9
  helper.mr_iid,
10
10
  add_labels: labels.join(","))
11
11
  rescue Gitlab::Error::Forbidden
12
- warn("This Merge Request needs to be labelled with #{helper.labels_list(labels)}. Please request a reviewer or maintainer to add them.")
12
+ warn("Labels missing: please ask a reviewer or maintainer to add #{helper.labels_list(labels)} to this merge request.")
13
13
  end
14
14
 
15
15
  post_labels(helper.labels_to_add) if helper.labels_to_add.any?
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spin"
4
+
5
+ module Gitlab
6
+ module Dangerfiles
7
+ Approval = Struct.new(:category, :spin) do
8
+ def self.from_approval_rule(rule, maintainer)
9
+ category =
10
+ if rule["section"] == "codeowners"
11
+ "`#{rule['name']}`"
12
+ else
13
+ rule["section"]
14
+ end.to_sym
15
+
16
+ spin = Spin.new(category, nil, maintainer, :reviewer)
17
+
18
+ new(category, spin)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module Dangerfiles
5
+ CapabilityStruct = Struct.new(:category, :project, :kind, :labels, keyword_init: true)
6
+
7
+ class Capability < CapabilityStruct
8
+ def self.for(category, **arguments)
9
+ (category_to_class[category] || self)
10
+ .new(category: category, **arguments)
11
+ end
12
+
13
+ def self.category_to_class
14
+ @category_to_class ||= {
15
+ none: None,
16
+ test: Test,
17
+ tooling: Tooling,
18
+ import_integrate_be: ImportIntegrateBE,
19
+ import_integrate_fe: ImportIntegrateFE,
20
+ ux: UX,
21
+ }.freeze
22
+ end
23
+ private_class_method :category_to_class
24
+
25
+ def has_capability?(teammate)
26
+ teammate.capabilities(project).include?(capability)
27
+ end
28
+
29
+ private
30
+
31
+ def capability
32
+ @capability ||= "#{kind} #{category}"
33
+ end
34
+
35
+ class None < Capability
36
+ def capability
37
+ @capability ||= kind.to_s
38
+ end
39
+ end
40
+
41
+ class Test < Capability
42
+ def has_capability?(teammate)
43
+ return false if kind != :reviewer
44
+
45
+ area = teammate.role[/Software Engineer in Test(?:.*?, (\w+))/, 1]
46
+
47
+ !!area && labels.any?("devops::#{area.downcase}")
48
+ end
49
+ end
50
+
51
+ class Tooling < Capability
52
+ def has_capability?(teammate)
53
+ if super
54
+ true
55
+ elsif %i[trainee_maintainer maintainer].include?(kind)
56
+ false
57
+ else # fallback to backend reviewer
58
+ teammate.capabilities(project).include?("#{kind} backend")
59
+ end
60
+ end
61
+ end
62
+
63
+ class ImportIntegrateBE < Capability
64
+ def has_capability?(teammate)
65
+ kind == :reviewer &&
66
+ teammate.role.match?(/Backend Engineer.+Manage:Import and Integrate/)
67
+ end
68
+ end
69
+
70
+ class ImportIntegrateFE < Capability
71
+ def has_capability?(teammate)
72
+ kind == :reviewer &&
73
+ teammate.role.match?(/Frontend Engineer.+Manage:Import and Integrate/)
74
+ end
75
+ end
76
+
77
+ class UX < Capability
78
+ def has_capability?(teammate)
79
+ super && teammate.member_of_the_group?(labels)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end