gitlab-dangerfiles 1.1.1 → 2.1.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -16,23 +16,32 @@ module Danger
16
16
  }.freeze
17
17
 
18
18
  Spin = Struct.new(:category, :reviewer, :maintainer, :optional_role, :timezone_experiment)
19
+ HTTPError = Class.new(StandardError)
19
20
 
21
+ # Finds the +Gitlab::Dangerfiles::Teammate+ object whose username matches the MR author username.
22
+ #
23
+ # @return [Gitlab::Dangerfiles::Teammate]
20
24
  def team_mr_author
21
- team.find { |person| person.username == helper.mr_author }
25
+ company_members.find { |person| person.username == helper.mr_author }
22
26
  end
23
27
 
24
28
  # Assigns GitLab team members to be reviewer and maintainer
25
- # for each change category that a Merge Request contains.
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.
26
34
  #
27
35
  # @return [Array<Spin>]
28
36
  def spin(project, categories = [nil], timezone_experiment: false)
29
- spins = categories.sort.map do |category|
37
+ spins = categories.sort_by(&:to_s).map do |category|
30
38
  including_timezone = INCLUDE_TIMEZONE_FOR_CATEGORY.fetch(category, timezone_experiment)
31
39
 
32
40
  spin_for_category(project, category, timezone_experiment: including_timezone)
33
41
  end
34
42
 
35
43
  backend_spin = spins.find { |spin| spin.category == :backend }
44
+ frontend_spin = spins.find { |spin| spin.category == :frontend }
36
45
 
37
46
  spins.each do |spin|
38
47
  including_timezone = INCLUDE_TIMEZONE_FOR_CATEGORY.fetch(spin.category, timezone_experiment)
@@ -61,64 +70,32 @@ module Danger
61
70
  end
62
71
  when :product_intelligence
63
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
64
78
  end
65
79
  end
66
80
 
67
81
  spins
68
82
  end
69
83
 
70
- # Looks up the current list of GitLab team members and parses it into a
71
- # useful form
72
- #
73
- # @return [Array<Teammate>]
74
- def team
75
- @team ||= begin
76
- data = helper.http_get_json(ROULETTE_DATA_URL)
77
- data.map { |hash| Gitlab::Dangerfiles::Teammate.new(hash) }
78
- rescue JSON::ParserError
79
- raise "Failed to parse JSON response from #{ROULETTE_DATA_URL}"
80
- end
81
- end
82
-
83
- # Like +team+, but only returns teammates in the current project, based on
84
- # project_name.
85
- #
86
- # @return [Array<Teammate>]
87
- def project_team(project_name)
88
- team.select { |member| member.in_project?(project_name) }
89
- rescue => err
90
- warn("Reviewer roulette failed to load team data: #{err.message}")
91
- []
92
- end
93
-
94
- # Known issue: If someone is rejected due to OOO, and then becomes not OOO, the
95
- # selection will change on next spin
96
- # @param [Array<Teammate>] people
97
- def spin_for_person(people, random:, timezone_experiment: false)
98
- shuffled_people = people.shuffle(random: random)
99
-
100
- if timezone_experiment
101
- shuffled_people.find(&method(:valid_person_with_timezone?))
102
- else
103
- shuffled_people.find(&method(:valid_person?))
104
- end
105
- end
106
-
107
84
  private
108
85
 
109
- # @param [Teammate] person
86
+ # @param [Gitlab::Dangerfiles::Teammate] person
110
87
  # @return [Boolean]
111
88
  def valid_person?(person)
112
89
  !mr_author?(person) && person.available
113
90
  end
114
91
 
115
- # @param [Teammate] person
92
+ # @param [Gitlab::Dangerfiles::Teammate] person
116
93
  # @return [Boolean]
117
94
  def valid_person_with_timezone?(person)
118
95
  valid_person?(person) && HOURS_WHEN_PERSON_CAN_BE_PICKED.cover?(person.local_hour)
119
96
  end
120
97
 
121
- # @param [Teammate] person
98
+ # @param [Gitlab::Dangerfiles::Teammate] person
122
99
  # @return [Boolean]
123
100
  def mr_author?(person)
124
101
  person.username == helper.mr_author
@@ -134,6 +111,22 @@ module Danger
134
111
  end
135
112
  end
136
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?))
127
+ end
128
+ end
129
+
137
130
  def spin_for_category(project, category, timezone_experiment: false)
138
131
  team = project_team(project)
139
132
  reviewers, traintainers, maintainers =
@@ -151,5 +144,44 @@ module Danger
151
144
 
152
145
  Spin.new(category, reviewer, maintainer, false, timezone_experiment)
153
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
154
186
  end
155
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?
@@ -2,8 +2,88 @@ require "gitlab/dangerfiles/version"
2
2
 
3
3
  module Gitlab
4
4
  module Dangerfiles
5
- def self.import_plugins(danger)
6
- danger.import_plugin(File.expand_path("../danger/plugins/*.rb", __dir__))
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
@@ -4,41 +4,63 @@ require_relative "title_linting"
4
4
 
5
5
  module Gitlab
6
6
  module Dangerfiles
7
+ # @!attribute file
8
+ # @return [String] the file name that's changed.
9
+ # @!attribute change_type
10
+ # @return [Symbol] the type of change (+:added+, +:modified+, +:deleted+, +:renamed_before+, +:renamed_after+).
11
+ # @!attribute category
12
+ # @return [Symbol] the category of the change.
13
+ # This is defined by consumers of the gem through +helper.changes_by_category+ or +helper.changes+.
7
14
  Change = Struct.new(:file, :change_type, :category)
8
15
 
9
16
  class Changes < ::SimpleDelegator
17
+ # Return an +Gitlab::Dangerfiles::Changes+ object with only the changes for the added files.
18
+ #
19
+ # @return [Gitlab::Dangerfiles::Changes]
10
20
  def added
11
21
  select_by_change_type(:added)
12
22
  end
13
23
 
24
+ # @return [Gitlab::Dangerfiles::Changes] the changes for the modified files.
14
25
  def modified
15
26
  select_by_change_type(:modified)
16
27
  end
17
28
 
29
+ # @return [Gitlab::Dangerfiles::Changes] the changes for the deleted files.
18
30
  def deleted
19
31
  select_by_change_type(:deleted)
20
32
  end
21
33
 
34
+ # @return [Gitlab::Dangerfiles::Changes] the changes for the renamed files (before the rename).
22
35
  def renamed_before
23
36
  select_by_change_type(:renamed_before)
24
37
  end
25
38
 
39
+ # @return [Gitlab::Dangerfiles::Changes] the changes for the renamed files (after the rename).
26
40
  def renamed_after
27
41
  select_by_change_type(:renamed_after)
28
42
  end
29
43
 
44
+ # @param category [Symbol] A category of change.
45
+ #
46
+ # @return [Boolean] whether there are any change for the given +category+.
30
47
  def has_category?(category)
31
48
  any? { |change| change.category == category }
32
49
  end
33
50
 
51
+ # @param category [Symbol] a category of change.
52
+ #
53
+ # @return [Gitlab::Dangerfiles::Changes] changes for the given +category+.
34
54
  def by_category(category)
35
55
  Changes.new(select { |change| change.category == category })
36
56
  end
37
57
 
58
+ # @return [Array<Symbol>] an array of the unique categories of changes.
38
59
  def categories
39
60
  map(&:category).uniq
40
61
  end
41
62
 
63
+ # @return [Array<String>] an array of the changed files.
42
64
  def files
43
65
  map(&:file)
44
66
  end