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.
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "helper"
4
- require_relative "../gitlab/dangerfiles/teammate"
5
- require_relative "../gitlab/dangerfiles/weightage/maintainers"
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
- team.find { |person| person.username == mr_author_username }
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 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.
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 == mr_author_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?
@@ -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/*.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