gitlab-dangerfiles 1.1.1 → 2.1.3

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.
@@ -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