gitlab-dangerfiles 4.6.0 → 4.8.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
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")
294
- categories << :ux
295
- else
296
- begin
138
+ if labels.include?("Community contribution") ||
297
139
  # 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
140
+ # designer for the team.
141
+ Gitlab::Dangerfiles::Teammate.has_member_for_the_group?(
142
+ :ux, project: config_project_name.downcase, labels: labels)
143
+ categories << :ux
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
@@ -96,11 +96,6 @@ def lint_commits(commits)
96
96
  if multi_line_commit_linter && multi_line_commit_linter.failed?
97
97
  warn_or_fail_commits(multi_line_commit_linter)
98
98
  commit_linters.delete(multi_line_commit_linter) # Don't show an error (here) and a warning (below)
99
- elsif helper.ci? # We don't have access to the MR title locally
100
- title_linter = lint_mr_title(helper.mr_title)
101
- if title_linter.failed?
102
- warn_or_fail_commits(title_linter)
103
- end
104
99
  end
105
100
  else
106
101
  if count_non_fixup_commits(commit_linters) > MAX_COMMITS_COUNT
@@ -121,7 +116,7 @@ def warn_or_fail_commits(failed_linters, default_to_fail: true)
121
116
  when :subject_too_short, :subject_above_warning, :details_too_many_changes, :details_line_too_long
122
117
  warn_commit(linter.commit, problem_desc)
123
118
  else
124
- self.__send__("#{level}_commit", linter.commit, problem_desc)
119
+ self.__send__(:"#{level}_commit", linter.commit, problem_desc)
125
120
  end
126
121
  end
127
122
  end
@@ -16,13 +16,15 @@ To spread load more evenly across eligible reviewers, Danger has picked a candid
16
16
  review slot. Feel free to
17
17
  [override these selections](https://about.gitlab.com/handbook/engineering/projects/##{PROJECT_NAME})
18
18
  if you think someone else would be better-suited
19
- or use the [GitLab Review Workload Dashboard](https://gitlab-org.gitlab.io/gitlab-roulette/) to find other available reviewers.
19
+ or use the [GitLab Review Workload Dashboard](https://gitlab-org.gitlab.io/gitlab-roulette/?currentProject=#{PROJECT_NAME})
20
+ to find other available reviewers.
20
21
 
21
22
  To read more on how to use the reviewer roulette, please take a look at the
22
23
  [Engineering workflow](https://about.gitlab.com/handbook/engineering/workflow/#basics)
23
24
  and [code review guidelines](https://docs.gitlab.com/ee/development/code_review.html).
24
25
  Please consider assigning a reviewer or maintainer who is a
25
- [domain expert](https://about.gitlab.com/handbook/engineering/projects/#gitlab-development-kit) in the area of the merge request.
26
+ [domain expert](https://about.gitlab.com/handbook/engineering/projects/##{PROJECT_NAME})
27
+ in the area of the merge request.
26
28
 
27
29
  Once you've decided who will review this merge request, mention them as you
28
30
  normally would! Danger does not automatically notify them for you.
@@ -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
@@ -16,7 +16,7 @@ module Gitlab
16
16
  subject_too_long: "The %s may not be longer than #{MAX_LINE_LENGTH} characters",
17
17
  subject_starts_with_a_space: "The %s must not start with a space",
18
18
  subject_starts_with_lowercase: "The %s must start with a capital letter",
19
- subject_ends_with_a_period: "The %s must not end with a period",
19
+ subject_ends_with_a_period: "The %s must not end with a period"
20
20
  }
21
21
  end
22
22
 
@@ -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
@@ -26,7 +26,7 @@ module Gitlab
26
26
  message_contains_unicode_emoji: "Avoid the use of Unicode Emoji. These add no value to the commit " \
27
27
  "message, and may not be displayed properly everywhere",
28
28
  message_contains_short_reference: "Use full URLs instead of short references (`gitlab-org/gitlab#123` or " \
29
- "`!123`), as short references are displayed as plain text outside of GitLab",
29
+ "`!123`), as short references are displayed as plain text outside of GitLab"
30
30
  }
31
31
  )
32
32
  end
@@ -148,7 +148,7 @@ module Gitlab
148
148
 
149
149
  def message_contains_short_reference?
150
150
  match_data = commit.message.match(SHORT_REFERENCE_REGEX) ||
151
- commit.message.match(MS_SHORT_REFERENCE_REGEX)
151
+ commit.message.match(MS_SHORT_REFERENCE_REGEX)
152
152
 
153
153
  return false unless match_data
154
154
 
@@ -27,7 +27,7 @@ module Gitlab
27
27
 
28
28
  def initialize
29
29
  names = JSON.parse(File.read(DIGESTS)).keys +
30
- JSON.parse(File.read(ALIASES)).keys
30
+ JSON.parse(File.read(ALIASES)).keys
31
31
 
32
32
  @emoji = names.map { |name| ":#{name}:" }
33
33
  end