gitlab-dangerfiles 1.1.0 → 2.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.gitlab-ci.yml +36 -1
- data/.yardopts +5 -0
- data/Gemfile +1 -0
- data/LICENSE.txt +1 -1
- data/README.md +57 -6
- data/gitlab-dangerfiles.gemspec +3 -3
- data/lib/danger/plugins/helper.rb +426 -0
- data/lib/danger/{roulette.rb → plugins/roulette.rb} +80 -63
- data/lib/danger/rules/changes_size/Dangerfile +10 -0
- data/lib/danger/rules/commit_messages/Dangerfile +138 -0
- data/lib/gitlab/dangerfiles.rb +82 -2
- data/lib/gitlab/dangerfiles/changes.rb +22 -0
- data/lib/gitlab/dangerfiles/commit_linter.rb +7 -3
- data/lib/gitlab/dangerfiles/config.rb +23 -0
- data/lib/gitlab/dangerfiles/emoji_checker.rb +1 -0
- data/lib/gitlab/dangerfiles/teammate.rb +0 -5
- data/lib/gitlab/dangerfiles/title_linting.rb +2 -0
- data/lib/gitlab/dangerfiles/version.rb +1 -1
- data/lib/gitlab/dangerfiles/weightage.rb +1 -0
- data/lib/gitlab/dangerfiles/weightage/maintainers.rb +1 -0
- data/lib/gitlab/dangerfiles/weightage/reviewers.rb +1 -0
- metadata +12 -8
- data/lib/danger/helper.rb +0 -312
@@ -1,9 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative "
|
4
|
-
require_relative "
|
5
|
-
require_relative "
|
6
|
-
require_relative "../gitlab/dangerfiles/weightage/reviewers"
|
3
|
+
require_relative "../../gitlab/dangerfiles/teammate"
|
4
|
+
require_relative "../../gitlab/dangerfiles/weightage/maintainers"
|
5
|
+
require_relative "../../gitlab/dangerfiles/weightage/reviewers"
|
7
6
|
|
8
7
|
module Danger
|
9
8
|
# Common helper functions for our danger scripts. See Danger::Helper
|
@@ -17,13 +16,21 @@ module Danger
|
|
17
16
|
}.freeze
|
18
17
|
|
19
18
|
Spin = Struct.new(:category, :reviewer, :maintainer, :optional_role, :timezone_experiment)
|
19
|
+
HTTPError = Class.new(StandardError)
|
20
20
|
|
21
|
+
# Finds the +Gitlab::Dangerfiles::Teammate+ object whose username matches the MR author username.
|
22
|
+
#
|
23
|
+
# @return [Gitlab::Dangerfiles::Teammate]
|
21
24
|
def team_mr_author
|
22
|
-
|
25
|
+
company_members.find { |person| person.username == helper.mr_author }
|
23
26
|
end
|
24
27
|
|
25
28
|
# Assigns GitLab team members to be reviewer and maintainer
|
26
|
-
# for
|
29
|
+
# for the given +categories+.
|
30
|
+
#
|
31
|
+
# @param project [String] A project path.
|
32
|
+
# @param categories [Array<Symbol>] An array of categories symbols.
|
33
|
+
# @param timezone_experiment [Boolean] Whether to select reviewers based in timezone or not.
|
27
34
|
#
|
28
35
|
# @return [Array<Spin>]
|
29
36
|
def spin(project, categories = [nil], timezone_experiment: false)
|
@@ -34,6 +41,7 @@ module Danger
|
|
34
41
|
end
|
35
42
|
|
36
43
|
backend_spin = spins.find { |spin| spin.category == :backend }
|
44
|
+
frontend_spin = spins.find { |spin| spin.category == :frontend }
|
37
45
|
|
38
46
|
spins.each do |spin|
|
39
47
|
including_timezone = INCLUDE_TIMEZONE_FOR_CATEGORY.fetch(spin.category, timezone_experiment)
|
@@ -62,81 +70,35 @@ module Danger
|
|
62
70
|
end
|
63
71
|
when :product_intelligence
|
64
72
|
spin.optional_role = :maintainer
|
73
|
+
|
74
|
+
if spin.maintainer.nil?
|
75
|
+
# Fetch an already picked maintainer, or pick one otherwise
|
76
|
+
spin.maintainer = backend_spin&.maintainer || frontend_spin&.maintainer || spin_for_category(project, :backend, timezone_experiment: including_timezone).maintainer
|
77
|
+
end
|
65
78
|
end
|
66
79
|
end
|
67
80
|
|
68
81
|
spins
|
69
82
|
end
|
70
83
|
|
71
|
-
# Looks up the current list of GitLab team members and parses it into a
|
72
|
-
# useful form
|
73
|
-
#
|
74
|
-
# @return [Array<Teammate>]
|
75
|
-
def team
|
76
|
-
@team ||= begin
|
77
|
-
data = helper.http_get_json(ROULETTE_DATA_URL)
|
78
|
-
data.map { |hash| Gitlab::Dangerfiles::Teammate.new(hash) }
|
79
|
-
rescue JSON::ParserError
|
80
|
-
raise "Failed to parse JSON response from #{ROULETTE_DATA_URL}"
|
81
|
-
end
|
82
|
-
end
|
83
|
-
|
84
|
-
# Like +team+, but only returns teammates in the current project, based on
|
85
|
-
# project_name.
|
86
|
-
#
|
87
|
-
# @return [Array<Teammate>]
|
88
|
-
def project_team(project_name)
|
89
|
-
team.select { |member| member.in_project?(project_name) }
|
90
|
-
rescue => err
|
91
|
-
warn("Reviewer roulette failed to load team data: #{err.message}")
|
92
|
-
[]
|
93
|
-
end
|
94
|
-
|
95
|
-
# Known issue: If someone is rejected due to OOO, and then becomes not OOO, the
|
96
|
-
# selection will change on next spin
|
97
|
-
# @param [Array<Teammate>] people
|
98
|
-
def spin_for_person(people, random:, timezone_experiment: false)
|
99
|
-
shuffled_people = people.shuffle(random: random)
|
100
|
-
|
101
|
-
if timezone_experiment
|
102
|
-
shuffled_people.find(&method(:valid_person_with_timezone?))
|
103
|
-
else
|
104
|
-
shuffled_people.find(&method(:valid_person?))
|
105
|
-
end
|
106
|
-
end
|
107
|
-
|
108
84
|
private
|
109
85
|
|
110
|
-
# @param [Teammate] person
|
86
|
+
# @param [Gitlab::Dangerfiles::Teammate] person
|
111
87
|
# @return [Boolean]
|
112
88
|
def valid_person?(person)
|
113
89
|
!mr_author?(person) && person.available
|
114
90
|
end
|
115
91
|
|
116
|
-
# @param [Teammate] person
|
92
|
+
# @param [Gitlab::Dangerfiles::Teammate] person
|
117
93
|
# @return [Boolean]
|
118
94
|
def valid_person_with_timezone?(person)
|
119
95
|
valid_person?(person) && HOURS_WHEN_PERSON_CAN_BE_PICKED.cover?(person.local_hour)
|
120
96
|
end
|
121
97
|
|
122
|
-
# @param [Teammate] person
|
98
|
+
# @param [Gitlab::Dangerfiles::Teammate] person
|
123
99
|
# @return [Boolean]
|
124
100
|
def mr_author?(person)
|
125
|
-
person.username ==
|
126
|
-
end
|
127
|
-
|
128
|
-
def mr_author_username
|
129
|
-
helper.gitlab_helper&.mr_author || `whoami`
|
130
|
-
end
|
131
|
-
|
132
|
-
def mr_source_branch
|
133
|
-
return `git rev-parse --abbrev-ref HEAD` unless helper.gitlab_helper&.mr_json
|
134
|
-
|
135
|
-
helper.gitlab_helper.mr_json["source_branch"]
|
136
|
-
end
|
137
|
-
|
138
|
-
def mr_labels
|
139
|
-
helper.gitlab_helper&.mr_labels || []
|
101
|
+
person.username == helper.mr_author
|
140
102
|
end
|
141
103
|
|
142
104
|
def new_random(seed)
|
@@ -145,7 +107,23 @@ module Danger
|
|
145
107
|
|
146
108
|
def spin_role_for_category(team, role, project, category)
|
147
109
|
team.select do |member|
|
148
|
-
member.public_send("#{role}?", project, category, mr_labels) # rubocop:disable GitlabSecurity/PublicSend
|
110
|
+
member.public_send("#{role}?", project, category, helper.mr_labels) # rubocop:disable GitlabSecurity/PublicSend
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Known issue: If someone is rejected due to OOO, and then becomes not OOO, the
|
115
|
+
# selection will change on next spin.
|
116
|
+
#
|
117
|
+
# @param [Array<Gitlab::Dangerfiles::Teammate>] people
|
118
|
+
#
|
119
|
+
# @return [Gitlab::Dangerfiles::Teammate]
|
120
|
+
def spin_for_person(people, random:, timezone_experiment: false)
|
121
|
+
shuffled_people = people.shuffle(random: random)
|
122
|
+
|
123
|
+
if timezone_experiment
|
124
|
+
shuffled_people.find(&method(:valid_person_with_timezone?))
|
125
|
+
else
|
126
|
+
shuffled_people.find(&method(:valid_person?))
|
149
127
|
end
|
150
128
|
end
|
151
129
|
|
@@ -156,7 +134,7 @@ module Danger
|
|
156
134
|
spin_role_for_category(team, role, project, category)
|
157
135
|
end
|
158
136
|
|
159
|
-
random = new_random(mr_source_branch)
|
137
|
+
random = new_random(helper.mr_source_branch)
|
160
138
|
|
161
139
|
weighted_reviewers = Gitlab::Dangerfiles::Weightage::Reviewers.new(reviewers, traintainers).execute
|
162
140
|
weighted_maintainers = Gitlab::Dangerfiles::Weightage::Maintainers.new(maintainers).execute
|
@@ -166,5 +144,44 @@ module Danger
|
|
166
144
|
|
167
145
|
Spin.new(category, reviewer, maintainer, false, timezone_experiment)
|
168
146
|
end
|
147
|
+
|
148
|
+
# Fetches the given +url+ and parse its response as JSON.
|
149
|
+
#
|
150
|
+
# @param [String] url
|
151
|
+
#
|
152
|
+
# @return [Hash, Array]
|
153
|
+
def http_get_json(url)
|
154
|
+
rsp = Net::HTTP.get_response(URI.parse(url))
|
155
|
+
|
156
|
+
unless rsp.is_a?(Net::HTTPOK)
|
157
|
+
raise HTTPError, "Failed to read #{url}: #{rsp.code} #{rsp.message}"
|
158
|
+
end
|
159
|
+
|
160
|
+
JSON.parse(rsp.body)
|
161
|
+
end
|
162
|
+
|
163
|
+
# Looks up the current list of GitLab team members and parses it into a
|
164
|
+
# useful form.
|
165
|
+
#
|
166
|
+
# @return [Array<Gitlab::Dangerfiles::Teammate>]
|
167
|
+
def company_members
|
168
|
+
@company_members ||= begin
|
169
|
+
data = http_get_json(ROULETTE_DATA_URL)
|
170
|
+
data.map { |hash| Gitlab::Dangerfiles::Teammate.new(hash) }
|
171
|
+
rescue JSON::ParserError
|
172
|
+
raise "Failed to parse JSON response from #{ROULETTE_DATA_URL}"
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# Like +team+, but only returns teammates in the current project, based on
|
177
|
+
# project_name.
|
178
|
+
#
|
179
|
+
# @return [Array<Gitlab::Dangerfiles::Teammate>]
|
180
|
+
def project_team(project_name)
|
181
|
+
company_members.select { |member| member.in_project?(project_name) }
|
182
|
+
rescue => err
|
183
|
+
warn("Reviewer roulette failed to load team data: #{err.message}")
|
184
|
+
[]
|
185
|
+
end
|
169
186
|
end
|
170
187
|
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
thresholds = helper.config.code_size_thresholds
|
4
|
+
lines_changed = git.lines_of_code
|
5
|
+
|
6
|
+
if lines_changed > thresholds[:high]
|
7
|
+
warn "This merge request is definitely too big (#{lines_changed} lines changed), please split it into multiple merge requests."
|
8
|
+
elsif lines_changed > thresholds[:medium]
|
9
|
+
warn "This merge request is quite big (#{lines_changed} lines changed), please consider splitting it into multiple merge requests."
|
10
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../../../gitlab/dangerfiles/commit_linter"
|
4
|
+
require_relative "../../../gitlab/dangerfiles/merge_request_linter"
|
5
|
+
|
6
|
+
COMMIT_MESSAGE_GUIDELINES = "https://docs.gitlab.com/ee/development/contributing/merge_request_workflow.html#commit-messages-guidelines"
|
7
|
+
MORE_INFO = "For more information, take a look at our [Commit message guidelines](#{COMMIT_MESSAGE_GUIDELINES})."
|
8
|
+
THE_DANGER_JOB_TEXT = "the `%<job_name>` job"
|
9
|
+
MAX_COMMITS_COUNT = helper.config.max_commits_count
|
10
|
+
MAX_COMMITS_COUNT_EXCEEDED_MESSAGE = <<~MSG
|
11
|
+
This merge request includes more than %<max_commits_count>d commits. Each commit should meet the following criteria:
|
12
|
+
|
13
|
+
1. Have a well-written commit message.
|
14
|
+
1. Has all tests passing when used on its own (e.g. when using git checkout SHA).
|
15
|
+
1. Can be reverted on its own without also requiring the revert of commit that came before it.
|
16
|
+
1. Is small enough that it can be reviewed in isolation in under 30 minutes or so.
|
17
|
+
|
18
|
+
If this merge request contains commits that do not meet this criteria and/or contains intermediate work, please rebase these commits into a smaller number of commits or split this merge request into multiple smaller merge requests.
|
19
|
+
MSG
|
20
|
+
|
21
|
+
def fail_commit(commit, message, more_info: true)
|
22
|
+
self.fail(build_message(commit, message, more_info: more_info))
|
23
|
+
end
|
24
|
+
|
25
|
+
def warn_commit(commit, message, more_info: true)
|
26
|
+
self.warn(build_message(commit, message, more_info: more_info))
|
27
|
+
end
|
28
|
+
|
29
|
+
def build_message(commit, message, more_info: true)
|
30
|
+
[message].tap do |full_message|
|
31
|
+
full_message << ". #{MORE_INFO}" if more_info
|
32
|
+
full_message.unshift("#{commit.sha}: ") if commit.sha
|
33
|
+
end.join
|
34
|
+
end
|
35
|
+
|
36
|
+
def danger_job_link
|
37
|
+
helper.ci? ? "[#{format(THE_DANGER_JOB_TEXT, job_name: ENV["CI_JOB_NAME"])}](#{ENV['CI_JOB_URL']})" : THE_DANGER_JOB_TEXT
|
38
|
+
end
|
39
|
+
|
40
|
+
# Perform various checks against commits. We're not using
|
41
|
+
# https://github.com/jonallured/danger-commit_lint because its output is not
|
42
|
+
# very helpful, and it doesn't offer the means of ignoring merge commits.
|
43
|
+
def lint_commit(commit)
|
44
|
+
linter = Gitlab::Dangerfiles::CommitLinter.new(commit)
|
45
|
+
|
46
|
+
# For now we'll ignore merge commits, as getting rid of those is a problem
|
47
|
+
# separate from enforcing good commit messages.
|
48
|
+
return linter if linter.merge?
|
49
|
+
|
50
|
+
# We ignore revert commits as they are well structured by Git already
|
51
|
+
return linter if linter.revert?
|
52
|
+
|
53
|
+
# If MR is set to squash, we ignore fixup commits
|
54
|
+
return linter if linter.fixup? && helper.squash_mr?
|
55
|
+
|
56
|
+
if linter.fixup?
|
57
|
+
msg = "Squash or fixup commits must be squashed before merge, or enable squash merge option and re-run #{danger_job_link}."
|
58
|
+
if helper.draft_mr? || helper.squash_mr?
|
59
|
+
warn_commit(commit, msg, more_info: false)
|
60
|
+
else
|
61
|
+
fail_commit(commit, msg, more_info: false)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Makes no sense to process other rules for fixup commits, they trigger just more noise
|
65
|
+
return linter
|
66
|
+
end
|
67
|
+
|
68
|
+
# Fail if a suggestion commit is used and squash is not enabled
|
69
|
+
if linter.suggestion?
|
70
|
+
unless helper.squash_mr?
|
71
|
+
fail_commit(commit, "If you are applying suggestions, enable squash in the merge request and re-run #{danger_job_link}.", more_info: false)
|
72
|
+
end
|
73
|
+
|
74
|
+
return linter
|
75
|
+
end
|
76
|
+
|
77
|
+
linter.lint
|
78
|
+
end
|
79
|
+
|
80
|
+
def lint_mr_title(mr_title)
|
81
|
+
commit = Struct.new(:message, :sha).new(mr_title)
|
82
|
+
|
83
|
+
Gitlab::Dangerfiles::MergeRequestLinter.new(commit).lint
|
84
|
+
end
|
85
|
+
|
86
|
+
def count_non_fixup_commits(commit_linters)
|
87
|
+
commit_linters.count { |commit_linter| !commit_linter.fixup? }
|
88
|
+
end
|
89
|
+
|
90
|
+
def lint_commits(commits)
|
91
|
+
commit_linters = commits.map { |commit| lint_commit(commit) }
|
92
|
+
|
93
|
+
if helper.squash_mr?
|
94
|
+
multi_line_commit_linter = commit_linters.detect { |commit_linter| !commit_linter.merge? && commit_linter.multi_line? }
|
95
|
+
|
96
|
+
if multi_line_commit_linter && multi_line_commit_linter.failed?
|
97
|
+
warn_or_fail_commits(multi_line_commit_linter)
|
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(gitlab.mr_json['title'])
|
101
|
+
if title_linter.failed?
|
102
|
+
warn_or_fail_commits(title_linter)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
else
|
106
|
+
if count_non_fixup_commits(commit_linters) > MAX_COMMITS_COUNT
|
107
|
+
self.warn(format(MAX_COMMITS_COUNT_EXCEEDED_MESSAGE, max_commits_count: MAX_COMMITS_COUNT))
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
failed_commit_linters = commit_linters.select { |commit_linter| commit_linter.failed? }
|
112
|
+
warn_or_fail_commits(failed_commit_linters, default_to_fail: !helper.squash_mr?)
|
113
|
+
end
|
114
|
+
|
115
|
+
def warn_or_fail_commits(failed_linters, default_to_fail: true)
|
116
|
+
level = default_to_fail ? :fail : :warn
|
117
|
+
|
118
|
+
Array(failed_linters).each do |linter|
|
119
|
+
linter.problems.each do |problem_key, problem_desc|
|
120
|
+
case problem_key
|
121
|
+
when :subject_too_short, :subject_above_warning, :details_too_many_changes, :details_line_too_long
|
122
|
+
warn_commit(linter.commit, problem_desc)
|
123
|
+
else
|
124
|
+
self.__send__("#{level}_commit", linter.commit, problem_desc) # rubocop:disable GitlabSecurity/PublicSend
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# As part of https://gitlab.com/groups/gitlab-org/-/epics/4826 we are
|
131
|
+
# vendoring workhorse commits from the stand-alone gitlab-workhorse
|
132
|
+
# repo. There is no point in linting commits that we want to vendor as
|
133
|
+
# is.
|
134
|
+
def workhorse_changes?
|
135
|
+
git.diff.any? { |file| file.path.start_with?('workhorse/') }
|
136
|
+
end
|
137
|
+
|
138
|
+
lint_commits(git.commits) unless workhorse_changes?
|
data/lib/gitlab/dangerfiles.rb
CHANGED
@@ -2,8 +2,88 @@ require "gitlab/dangerfiles/version"
|
|
2
2
|
|
3
3
|
module Gitlab
|
4
4
|
module Dangerfiles
|
5
|
-
|
6
|
-
|
5
|
+
RULES_DIR = File.expand_path("../danger/rules", __dir__)
|
6
|
+
EXISTING_RULES = Dir.glob(File.join(RULES_DIR, "*")).each_with_object([]) do |path, memo|
|
7
|
+
if File.directory?(path)
|
8
|
+
memo << File.basename(path)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
LOCAL_RULES = %w[
|
12
|
+
changes_size
|
13
|
+
commit_messages
|
14
|
+
].freeze
|
15
|
+
CI_ONLY_RULES = %w[
|
16
|
+
].freeze
|
17
|
+
|
18
|
+
# This class provides utility methods to import plugins and dangerfiles easily.
|
19
|
+
class Engine
|
20
|
+
# @param dangerfile [Danger::Dangerfile] A +Danger::Dangerfile+ object.
|
21
|
+
#
|
22
|
+
# @example
|
23
|
+
# # In your main Dangerfile:
|
24
|
+
# dangerfiles = Gitlab::Dangerfiles::Engine.new(self)
|
25
|
+
#
|
26
|
+
# @return [Gitlab::Dangerfiles::Engine]
|
27
|
+
def initialize(dangerfile)
|
28
|
+
@dangerfile = dangerfile
|
29
|
+
end
|
30
|
+
|
31
|
+
# Import all available plugins.
|
32
|
+
#
|
33
|
+
# @example
|
34
|
+
# # In your main Dangerfile:
|
35
|
+
# dangerfiles = Gitlab::Dangerfiles::Engine.new(self)
|
36
|
+
#
|
37
|
+
# # Import all plugins
|
38
|
+
# dangerfiles.import_plugins
|
39
|
+
def import_plugins
|
40
|
+
danger_plugin.import_plugin(File.expand_path("../danger/plugins/*.rb", __dir__))
|
41
|
+
end
|
42
|
+
|
43
|
+
# Import available Dangerfiles.
|
44
|
+
#
|
45
|
+
# @param rules [Symbol, Array<String>] Can be either +:all+ (default) to import all rules,
|
46
|
+
# or an array of rules.
|
47
|
+
# Available rules are: +changes_size+.
|
48
|
+
#
|
49
|
+
# @example
|
50
|
+
# # In your main Dangerfile:
|
51
|
+
# dangerfiles = Gitlab::Dangerfiles::Engine.new(self)
|
52
|
+
#
|
53
|
+
# # Import all rules
|
54
|
+
# dangerfiles.import_dangerfiles
|
55
|
+
#
|
56
|
+
# # Or import only a subset of rules
|
57
|
+
# dangerfiles.import_dangerfiles(rules: %w[changes_size])
|
58
|
+
def import_dangerfiles(rules: :all)
|
59
|
+
filtered_rules(rules).each do |rule|
|
60
|
+
danger_plugin.import_dangerfile(path: File.join(RULES_DIR, rule))
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
attr_reader :dangerfile
|
67
|
+
|
68
|
+
def allowed_rules
|
69
|
+
return LOCAL_RULES unless helper_plugin.respond_to?(:ci?)
|
70
|
+
|
71
|
+
helper_plugin.ci? ? LOCAL_RULES | CI_ONLY_RULES : LOCAL_RULES
|
72
|
+
end
|
73
|
+
|
74
|
+
def filtered_rules(rules)
|
75
|
+
rules = EXISTING_RULES if rules == :all
|
76
|
+
|
77
|
+
Array(rules).map(&:to_s) & EXISTING_RULES & allowed_rules
|
78
|
+
end
|
79
|
+
|
80
|
+
def danger_plugin
|
81
|
+
@danger_plugin ||= dangerfile.plugins[Danger::DangerfileDangerPlugin]
|
82
|
+
end
|
83
|
+
|
84
|
+
def helper_plugin
|
85
|
+
@helper_plugin ||= dangerfile.plugins[Danger::Helper]
|
86
|
+
end
|
7
87
|
end
|
8
88
|
end
|
9
89
|
end
|