gitlab-dangerfiles 3.13.0 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -1
  3. data/.gitlab/CODEOWNERS +1 -1
  4. data/.gitlab/merge_request_templates/Release.md +1 -1
  5. data/.gitlab-ci.yml +21 -42
  6. data/.rubocop.yml +6 -16
  7. data/.rubocop_todo.yml +5 -8
  8. data/Gemfile +0 -16
  9. data/Gemfile.lock +234 -0
  10. data/README.md +96 -9
  11. data/gitlab-dangerfiles.gemspec +13 -5
  12. data/lefthook.yml +0 -5
  13. data/lib/danger/plugins/changelog.rb +9 -7
  14. data/lib/danger/plugins/duo_code.rb +12 -0
  15. data/lib/danger/plugins/internal/helper.rb +34 -10
  16. data/lib/danger/plugins/roulette.rb +95 -256
  17. data/lib/danger/rules/commit_messages/Dangerfile +2 -7
  18. data/lib/danger/rules/duo_code_review/Dangerfile +10 -0
  19. data/lib/danger/rules/simple_roulette/Dangerfile +17 -10
  20. data/lib/danger/rules/type_label/Dangerfile +0 -8
  21. data/lib/danger/rules/z_add_labels/Dangerfile +3 -3
  22. data/lib/gitlab/dangerfiles/approval.rb +22 -0
  23. data/lib/gitlab/dangerfiles/base_linter.rb +1 -1
  24. data/lib/gitlab/dangerfiles/capability.rb +82 -0
  25. data/lib/gitlab/dangerfiles/commit_linter.rb +2 -2
  26. data/lib/gitlab/dangerfiles/config.rb +29 -3
  27. data/lib/gitlab/dangerfiles/emoji_checker.rb +1 -1
  28. data/lib/gitlab/dangerfiles/spec_helper.rb +231 -1
  29. data/lib/gitlab/dangerfiles/spin.rb +15 -0
  30. data/lib/gitlab/dangerfiles/spinner.rb +191 -0
  31. data/lib/gitlab/dangerfiles/teammate.rb +94 -20
  32. data/lib/gitlab/dangerfiles/type_label_guesser.rb +2 -2
  33. data/lib/gitlab/dangerfiles/version.rb +1 -1
  34. data/lib/gitlab/dangerfiles.rb +0 -1
  35. metadata +100 -25
  36. data/lib/danger/rules/subtype_label/Dangerfile +0 -14
  37. data/lib/gitlab/dangerfiles/category.rb +0 -111
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- TYPE_LABEL_MISSING_MESSAGE = "Please add a [merge request type](https://about.gitlab.com/handbook/engineering/metrics/#work-type-classification) to this merge request."
4
-
5
3
  require_relative "../../../gitlab/dangerfiles/type_label_guesser"
6
4
 
7
5
  return if helper.has_scoped_label_with_scope?("type")
@@ -10,9 +8,3 @@ if respond_to?(:changelog)
10
8
  type_label_guesser = Gitlab::Dangerfiles::TypeLabelGuesser.new
11
9
  helper.labels_to_add.concat(type_label_guesser.labels_from_changelog_categories(changelog.categories))
12
10
  end
13
-
14
- if ENV['DANGER_ERROR_WHEN_TYPE_LABEL_IS_MISSING'] == 'true'
15
- fail TYPE_LABEL_MISSING_MESSAGE
16
- else
17
- warn TYPE_LABEL_MISSING_MESSAGE
18
- end
@@ -6,10 +6,10 @@ return unless helper.ci?
6
6
 
7
7
  def post_labels(labels)
8
8
  gitlab.api.update_merge_request(helper.mr_target_project_id,
9
- helper.mr_iid,
10
- add_labels: labels.join(","))
9
+ helper.mr_iid,
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
@@ -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,82 @@
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 && teammate.role.match?(/Backend Engineer.+:Import and Integrate/)
66
+ end
67
+ end
68
+
69
+ class ImportIntegrateFE < Capability
70
+ def has_capability?(teammate)
71
+ kind == :reviewer && teammate.role.match?(/Frontend Engineer.+:Import and Integrate/)
72
+ end
73
+ end
74
+
75
+ class UX < Capability
76
+ def has_capability?(teammate)
77
+ super && teammate.member_of_the_group?(labels)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ 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
 
@@ -21,31 +21,54 @@ module Gitlab
21
21
  # match changed lines in files that match +filename_regex+. Used in `helper.changes_by_category`, `helper.changes`, and `helper.categories_for_file`.
22
22
  attr_accessor :files_to_category
23
23
 
24
+ # @!attribute custom_labels_for_categories
25
+ # @return [{String => String}] A hash of the form +{ category_name => label }+.
26
+ # Used in `helper.custom_labels_for_categories`.
27
+ attr_accessor :custom_labels_for_categories
28
+
29
+ # @!attribute disabled_roulette_categories
30
+ # @return [Array] indicating which categories would be disabled for the simple roulette. Default to `[]` (all categories are enabled)
31
+ attr_accessor :disabled_roulette_categories
32
+
33
+ # Rule: changes_size
24
34
  # @!attribute code_size_thresholds
25
35
  # @return [{ high: Integer, medium: Integer }] a hash of the form +{ high: 42, medium: 12 }+ where +:high+ is the lines changed threshold which triggers an error, and +:medium+ is the lines changed threshold which triggers a warning. Also, see +DEFAULT_CHANGES_SIZE_THRESHOLDS+ for the format of the hash.
26
36
  attr_accessor :code_size_thresholds
27
37
 
38
+ # Rule: commit_messages
28
39
  # @!attribute max_commits_count
29
40
  # @return [Integer] the maximum number of allowed non-squashed/non-fixup commits for a given MR. A warning is triggered if the MR has more commits.
30
41
  attr_accessor :max_commits_count
31
42
 
32
- # @!attribute disabled_roulette_categories
33
- # @return [Array] indicating which categories would be disabled for the simple roulette. Default to `[]` (all categories are enabled)
34
- attr_accessor :disabled_roulette_categories
43
+ # Rule: duo_code_review
44
+ # @!attribute duo_code_review
45
+ # @return [Symbol] whether a review from GitLab Duo Code is `:mandatory` or `:optional`. Default to `:optional`.
46
+ attr_accessor :duo_code_review
35
47
 
48
+ # Rule: simple_roulette
36
49
  # @!attribute included_optional_codeowners_sections_for_roulette
37
50
  # @return [Array] indicating which optional codeowners sections should be included in roulette. Default to `[]`.
38
51
  attr_accessor :included_optional_codeowners_sections_for_roulette
39
52
 
53
+ # Rule: simple_roulette
40
54
  # @!attribute excluded_required_codeowners_sections_for_roulette
41
55
  # @return [Array] indicating which required codeowners sections should be excluded from roulette. Default to `[]`.
42
56
  attr_accessor :excluded_required_codeowners_sections_for_roulette
43
57
 
58
+ # @!attribute auto_assign_for_roulette_roles
59
+ # @return [Array<Symbol>] which roles to auto assign as reviewers, given roulette recommendations (:reviewer, :maintainer, or both). If empty, auto-assignment is disabled. Default to `[]`.
60
+ attr_accessor :auto_assign_for_roulette_roles
61
+
62
+ # @!attribute auto_assign_for_roulette_labels
63
+ # @return [Array<String>] MR labels that allow auto reviewer assignment. If empty, applies to all MRs, provided :auto_assign_for_roulette_roles is not empty. Default to `[]`.
64
+ attr_accessor :auto_assign_for_roulette_labels
65
+
44
66
  DEFAULT_CHANGES_SIZE_THRESHOLDS = { high: 2_000, medium: 500 }.freeze
45
67
  DEFAULT_COMMIT_MESSAGES_MAX_COMMITS_COUNT = 10
46
68
 
47
69
  def initialize
48
70
  @files_to_category = {}
71
+ @custom_labels_for_categories = {}
49
72
  @project_root = nil
50
73
  @project_name = ENV["CI_PROJECT_NAME"]
51
74
  @ci_only_rules = []
@@ -54,6 +77,9 @@ module Gitlab
54
77
  @disabled_roulette_categories = []
55
78
  @included_optional_codeowners_sections_for_roulette = []
56
79
  @excluded_required_codeowners_sections_for_roulette = []
80
+ @auto_assign_for_roulette_roles = []
81
+ @auto_assign_for_roulette_labels = []
82
+ @duo_code_review = :optional
57
83
  end
58
84
  end
59
85
  end
@@ -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
@@ -17,7 +17,7 @@ module DangerSpecHelper
17
17
  "GITLAB_CI" => "true",
18
18
  "DANGER_GITLAB_HOST" => "gitlab.example.com",
19
19
  "CI_MERGE_REQUEST_IID" => 28_493,
20
- "DANGER_GITLAB_API_TOKEN" => "123sbdq54erfsd3422gdfio",
20
+ "DANGER_GITLAB_API_TOKEN" => "123sbdq54erfsd3422gdfio"
21
21
  }
22
22
  end
23
23
 
@@ -45,6 +45,19 @@ module DangerSpecHelper
45
45
  end
46
46
  end
47
47
 
48
+ RSpec::Matchers.define :match_teammates do |expected|
49
+ match do |actual|
50
+ expected.each do |expected_person|
51
+ matched_person = actual.find { |actual_person| actual_person.name == expected_person.username }
52
+
53
+ matched_person &&
54
+ matched_person.name == expected_person.name &&
55
+ matched_person.role == expected_person.role &&
56
+ matched_person.projects == expected_person.projects
57
+ end
58
+ end
59
+ end
60
+
48
61
  RSpec.shared_context "with dangerfile" do
49
62
  let(:dangerfile) { DangerSpecHelper.testing_dangerfile }
50
63
  let(:added_files) { %w[added-from-git] }
@@ -68,3 +81,220 @@ RSpec.shared_context "with dangerfile" do
68
81
  allow(dangerfile.helper).to receive(:changes).and_return(changes) if dangerfile.respond_to?(:helper)
69
82
  end
70
83
  end
84
+
85
+ RSpec.shared_context "with teammates" do
86
+ let(:backend_available) { true }
87
+ let(:backend_tz_offset_hours) { 2.0 }
88
+ let(:backend_maintainer_project) { { "gitlab" => "maintainer backend" } }
89
+ let(:backend_maintainer) do
90
+ Gitlab::Dangerfiles::Teammate.new(
91
+ "username" => "backend-maintainer",
92
+ "name" => "Backend maintainer",
93
+ "role" => "Backend engineer",
94
+ "projects" => backend_maintainer_project,
95
+ "available" => backend_available,
96
+ "tz_offset_hours" => backend_tz_offset_hours
97
+ )
98
+ end
99
+
100
+ let(:another_backend_maintainer) do
101
+ Gitlab::Dangerfiles::Teammate.new(
102
+ "username" => "another-backend-maintainer",
103
+ "name" => "Another Backend Maintainer",
104
+ "role" => "Backend engineer",
105
+ "projects" => backend_maintainer_project,
106
+ "available" => backend_available,
107
+ "tz_offset_hours" => backend_tz_offset_hours
108
+ )
109
+ end
110
+
111
+ let(:backend_reviewer_available) { true }
112
+ let(:backend_reviewer) do
113
+ Gitlab::Dangerfiles::Teammate.new(
114
+ "username" => "backend-reviewer",
115
+ "name" => "Backend reviewer",
116
+ "role" => "Backend engineer",
117
+ "projects" => { "gitlab" => "reviewer backend" },
118
+ "available" => backend_reviewer_available,
119
+ "tz_offset_hours" => 1.0
120
+ )
121
+ end
122
+
123
+ let(:frontend_reviewer) do
124
+ Gitlab::Dangerfiles::Teammate.new(
125
+ "username" => "frontend-reviewer",
126
+ "name" => "Frontend reviewer",
127
+ "role" => "Frontend engineer",
128
+ "projects" => { "gitlab" => "reviewer frontend" },
129
+ "available" => true,
130
+ "tz_offset_hours" => 2.0
131
+ )
132
+ end
133
+
134
+ let(:frontend_maintainer) do
135
+ Gitlab::Dangerfiles::Teammate.new(
136
+ "username" => "frontend-maintainer",
137
+ "name" => "Frontend maintainer",
138
+ "role" => "Frontend engineer",
139
+ "projects" => { "gitlab" => "maintainer frontend" },
140
+ "available" => true,
141
+ "tz_offset_hours" => 2.0
142
+ )
143
+ end
144
+
145
+ let(:ux_reviewer) do
146
+ Gitlab::Dangerfiles::Teammate.new(
147
+ "username" => "ux-reviewer",
148
+ "name" => "UX reviewer",
149
+ "role" => "Product Designer",
150
+ "projects" => { "gitlab" => "reviewer ux" },
151
+ "specialty" => "Create: Source Code",
152
+ "available" => true,
153
+ "tz_offset_hours" => 2.0
154
+ )
155
+ end
156
+
157
+ let(:software_engineer_in_test) do
158
+ Gitlab::Dangerfiles::Teammate.new(
159
+ "username" => "software-engineer-in-test",
160
+ "name" => "Software Engineer in Test",
161
+ "role" => "Software Engineer in Test, Create:Source Code",
162
+ "projects" => { "gitlab" => "maintainer qa", "gitlab-qa" => "maintainer" },
163
+ "available" => true,
164
+ "tz_offset_hours" => 2.0
165
+ )
166
+ end
167
+
168
+ let(:software_engineer_in_import_integrate_fe) do
169
+ Gitlab::Dangerfiles::Teammate.new(
170
+ "username" => "software-engineer-in-import-and-integrate-fe",
171
+ "name" => "Frontend Engineer in Import and Integrate",
172
+ "role" => "AnyRole Frontend Engineer, AnyStage:Import and Integrate",
173
+ "projects" => { "gitlab" => "reviewer frontend" },
174
+ "available" => true,
175
+ "tz_offset_hours" => 2.0
176
+ )
177
+ end
178
+
179
+ let(:software_engineer_in_import_integrate_be) do
180
+ Gitlab::Dangerfiles::Teammate.new(
181
+ "username" => "software-engineer-in-import-and-integrate-be",
182
+ "name" => "Backend Engineer in Import and Integrate",
183
+ "role" => "AnyRole Backend Engineer, AnyStage:Import and Integrate",
184
+ "projects" => { "gitlab" => "reviewer backend" },
185
+ "available" => true,
186
+ "tz_offset_hours" => 2.0
187
+ )
188
+ end
189
+
190
+ let(:tooling_reviewer) do
191
+ Gitlab::Dangerfiles::Teammate.new(
192
+ "username" => "eng-prod-reviewer",
193
+ "name" => "EP engineer",
194
+ "role" => "Engineering Productivity",
195
+ "projects" => { "gitlab" => "reviewer tooling" },
196
+ "available" => true,
197
+ "tz_offset_hours" => 2.0
198
+ )
199
+ end
200
+
201
+ let(:ci_template_reviewer) do
202
+ Gitlab::Dangerfiles::Teammate.new(
203
+ "username" => "ci-template-maintainer",
204
+ "name" => "CI Template engineer",
205
+ "role" => '~"ci::templates"',
206
+ "projects" => { "gitlab" => "reviewer ci_template" },
207
+ "available" => true,
208
+ "tz_offset_hours" => 2.0
209
+ )
210
+ end
211
+
212
+ let(:analytics_instrumentation_reviewer) do
213
+ Gitlab::Dangerfiles::Teammate.new(
214
+ "username" => "analytics-instrumentation-reviewer",
215
+ "name" => "PI engineer",
216
+ "role" => "Backend Engineer, Analytics: Analytics Instrumentation",
217
+ "projects" => { "gitlab" => "reviewer analytics_instrumentation" },
218
+ "available" => true,
219
+ "tz_offset_hours" => 2.0
220
+ )
221
+ end
222
+
223
+ let(:import_and_integrate_backend_reviewer) do
224
+ Gitlab::Dangerfiles::Teammate.new(
225
+ "username" => "import-and-integrate-backend-reviewer",
226
+ "name" => "Import and Integrate BE engineer",
227
+ "role" => "AnyRole Backend Engineer, AnyStage:Import and Integrate",
228
+ "projects" => { "gitlab" => "reviewer backend" },
229
+ "available" => backend_reviewer_available,
230
+ "tz_offset_hours" => 2.0
231
+ )
232
+ end
233
+
234
+ let(:import_and_integrate_frontend_reviewer) do
235
+ Gitlab::Dangerfiles::Teammate.new(
236
+ "username" => "import-and-integrate-frontend-reviewer",
237
+ "name" => "Import and Integrate FE engineer",
238
+ "role" => "AnyRole Frontend Engineer, AnyStage:Import and Integrate",
239
+ "projects" => { "gitlab" => "reviewer frontend" },
240
+ "available" => true,
241
+ "tz_offset_hours" => 2.0
242
+ )
243
+ end
244
+
245
+ let(:workhorse_reviewer) do
246
+ Gitlab::Dangerfiles::Teammate.new(
247
+ "username" => "workhorse-reviewer",
248
+ "name" => "Workhorse reviewer",
249
+ "role" => "Backend engineer",
250
+ "projects" => { "gitlab-workhorse" => "reviewer" },
251
+ "available" => true,
252
+ "tz_offset_hours" => 2.0
253
+ )
254
+ end
255
+
256
+ let(:workhorse_maintainer) do
257
+ Gitlab::Dangerfiles::Teammate.new(
258
+ "username" => "workhorse-maintainer",
259
+ "name" => "Workhorse maintainer",
260
+ "role" => "Backend engineer",
261
+ "projects" => { "gitlab-workhorse" => "maintainer" },
262
+ "available" => true,
263
+ "tz_offset_hours" => 2.0
264
+ )
265
+ end
266
+
267
+ let(:teammates) do
268
+ [
269
+ backend_maintainer.to_h,
270
+ backend_reviewer.to_h,
271
+ frontend_maintainer.to_h,
272
+ frontend_reviewer.to_h,
273
+ ux_reviewer.to_h,
274
+ software_engineer_in_test.to_h,
275
+ tooling_reviewer.to_h,
276
+ ci_template_reviewer.to_h,
277
+ workhorse_reviewer.to_h,
278
+ workhorse_maintainer.to_h,
279
+ analytics_instrumentation_reviewer.to_h,
280
+ import_and_integrate_backend_reviewer.to_h,
281
+ import_and_integrate_frontend_reviewer.to_h,
282
+ software_engineer_in_import_integrate_fe.to_h,
283
+ software_engineer_in_import_integrate_be.to_h
284
+ ]
285
+ end
286
+
287
+ let(:teammate_json) { teammates.to_json }
288
+ let(:ux_fallback_reviewer) { instance_double(Gitlab::Dangerfiles::Teammate) }
289
+ let(:company_members) { Gitlab::Dangerfiles::Teammate.fetch_company_members }
290
+
291
+ before do
292
+ WebMock
293
+ .stub_request(:get, Gitlab::Dangerfiles::Teammate::ROULETTE_DATA_URL)
294
+ .to_return(body: teammate_json)
295
+
296
+ # This avoid changing the internal state of the class
297
+ allow(Gitlab::Dangerfiles::Teammate).to receive(:warnings).and_return([])
298
+ allow(Gitlab::Dangerfiles::Teammate).to receive(:company_members).and_return(company_members)
299
+ end
300
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module Dangerfiles
5
+ Spin = Struct.new(:category, :reviewer, :maintainer, :optional_role) do
6
+ def no_reviewer?
7
+ reviewer.nil?
8
+ end
9
+
10
+ def no_maintainer?
11
+ maintainer.nil?
12
+ end
13
+ end
14
+ end
15
+ end