gitlab-dangerfiles 1.0.0 → 2.1.1

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)
@@ -60,81 +68,37 @@ module Danger
60
68
  # Fetch an already picked backend maintainer, or pick one otherwise
61
69
  spin.maintainer = backend_spin&.maintainer || spin_for_category(project, :backend, timezone_experiment: including_timezone).maintainer
62
70
  end
71
+ when :product_intelligence
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
63
78
  end
64
79
  end
65
80
 
66
81
  spins
67
82
  end
68
83
 
69
- # Looks up the current list of GitLab team members and parses it into a
70
- # useful form
71
- #
72
- # @return [Array<Teammate>]
73
- def team
74
- @team ||= begin
75
- data = helper.http_get_json(ROULETTE_DATA_URL)
76
- data.map { |hash| Gitlab::Dangerfiles::Teammate.new(hash) }
77
- rescue JSON::ParserError
78
- raise "Failed to parse JSON response from #{ROULETTE_DATA_URL}"
79
- end
80
- end
81
-
82
- # Like +team+, but only returns teammates in the current project, based on
83
- # project_name.
84
- #
85
- # @return [Array<Teammate>]
86
- def project_team(project_name)
87
- team.select { |member| member.in_project?(project_name) }
88
- rescue => err
89
- warn("Reviewer roulette failed to load team data: #{err.message}")
90
- []
91
- end
92
-
93
- # Known issue: If someone is rejected due to OOO, and then becomes not OOO, the
94
- # selection will change on next spin
95
- # @param [Array<Teammate>] people
96
- def spin_for_person(people, random:, timezone_experiment: false)
97
- shuffled_people = people.shuffle(random: random)
98
-
99
- if timezone_experiment
100
- shuffled_people.find(&method(:valid_person_with_timezone?))
101
- else
102
- shuffled_people.find(&method(:valid_person?))
103
- end
104
- end
105
-
106
84
  private
107
85
 
108
- # @param [Teammate] person
86
+ # @param [Gitlab::Dangerfiles::Teammate] person
109
87
  # @return [Boolean]
110
88
  def valid_person?(person)
111
89
  !mr_author?(person) && person.available
112
90
  end
113
91
 
114
- # @param [Teammate] person
92
+ # @param [Gitlab::Dangerfiles::Teammate] person
115
93
  # @return [Boolean]
116
94
  def valid_person_with_timezone?(person)
117
95
  valid_person?(person) && HOURS_WHEN_PERSON_CAN_BE_PICKED.cover?(person.local_hour)
118
96
  end
119
97
 
120
- # @param [Teammate] person
98
+ # @param [Gitlab::Dangerfiles::Teammate] person
121
99
  # @return [Boolean]
122
100
  def mr_author?(person)
123
- person.username == mr_author_username
124
- end
125
-
126
- def mr_author_username
127
- helper.gitlab_helper&.mr_author || `whoami`
128
- end
129
-
130
- def mr_source_branch
131
- return `git rev-parse --abbrev-ref HEAD` unless helper.gitlab_helper&.mr_json
132
-
133
- helper.gitlab_helper.mr_json["source_branch"]
134
- end
135
-
136
- def mr_labels
137
- helper.gitlab_helper&.mr_labels || []
101
+ person.username == helper.mr_author
138
102
  end
139
103
 
140
104
  def new_random(seed)
@@ -143,7 +107,23 @@ module Danger
143
107
 
144
108
  def spin_role_for_category(team, role, project, category)
145
109
  team.select do |member|
146
- 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?))
147
127
  end
148
128
  end
149
129
 
@@ -154,7 +134,7 @@ module Danger
154
134
  spin_role_for_category(team, role, project, category)
155
135
  end
156
136
 
157
- random = new_random(mr_source_branch)
137
+ random = new_random(helper.mr_source_branch)
158
138
 
159
139
  weighted_reviewers = Gitlab::Dangerfiles::Weightage::Reviewers.new(reviewers, traintainers).execute
160
140
  weighted_maintainers = Gitlab::Dangerfiles::Weightage::Maintainers.new(maintainers).execute
@@ -164,5 +144,44 @@ module Danger
164
144
 
165
145
  Spin.new(category, reviewer, maintainer, false, timezone_experiment)
166
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
167
186
  end
168
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,139 @@
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_EXCEEDED_MESSAGE = <<~MSG
10
+ This merge request includes more than %<max_commits_count>d commits. Each commit should meet the following criteria:
11
+
12
+ 1. Have a well-written commit message.
13
+ 1. Has all tests passing when used on its own (e.g. when using git checkout SHA).
14
+ 1. Can be reverted on its own without also requiring the revert of commit that came before it.
15
+ 1. Is small enough that it can be reviewed in isolation in under 30 minutes or so.
16
+
17
+ 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.
18
+ MSG
19
+
20
+ max_commits_count = helper.config.max_commits_count
21
+
22
+ def fail_commit(commit, message, more_info: true)
23
+ self.fail(build_message(commit, message, more_info: more_info))
24
+ end
25
+
26
+ def warn_commit(commit, message, more_info: true)
27
+ self.warn(build_message(commit, message, more_info: more_info))
28
+ end
29
+
30
+ def build_message(commit, message, more_info: true)
31
+ [message].tap do |full_message|
32
+ full_message << ". #{MORE_INFO}" if more_info
33
+ full_message.unshift("#{commit.sha}: ") if commit.sha
34
+ end.join
35
+ end
36
+
37
+ def danger_job_link
38
+ helper.ci? ? "[#{format(THE_DANGER_JOB_TEXT, job_name: ENV["CI_JOB_NAME"])}](#{ENV['CI_JOB_URL']})" : THE_DANGER_JOB_TEXT
39
+ end
40
+
41
+ # Perform various checks against commits. We're not using
42
+ # https://github.com/jonallured/danger-commit_lint because its output is not
43
+ # very helpful, and it doesn't offer the means of ignoring merge commits.
44
+ def lint_commit(commit)
45
+ linter = Gitlab::Dangerfiles::CommitLinter.new(commit)
46
+
47
+ # For now we'll ignore merge commits, as getting rid of those is a problem
48
+ # separate from enforcing good commit messages.
49
+ return linter if linter.merge?
50
+
51
+ # We ignore revert commits as they are well structured by Git already
52
+ return linter if linter.revert?
53
+
54
+ # If MR is set to squash, we ignore fixup commits
55
+ return linter if linter.fixup? && helper.squash_mr?
56
+
57
+ if linter.fixup?
58
+ msg = "Squash or fixup commits must be squashed before merge, or enable squash merge option and re-run #{danger_job_link}."
59
+ if helper.draft_mr? || helper.squash_mr?
60
+ warn_commit(commit, msg, more_info: false)
61
+ else
62
+ fail_commit(commit, msg, more_info: false)
63
+ end
64
+
65
+ # Makes no sense to process other rules for fixup commits, they trigger just more noise
66
+ return linter
67
+ end
68
+
69
+ # Fail if a suggestion commit is used and squash is not enabled
70
+ if linter.suggestion?
71
+ unless helper.squash_mr?
72
+ fail_commit(commit, "If you are applying suggestions, enable squash in the merge request and re-run #{danger_job_link}.", more_info: false)
73
+ end
74
+
75
+ return linter
76
+ end
77
+
78
+ linter.lint
79
+ end
80
+
81
+ def lint_mr_title(mr_title)
82
+ commit = Struct.new(:message, :sha).new(mr_title)
83
+
84
+ Gitlab::Dangerfiles::MergeRequestLinter.new(commit).lint
85
+ end
86
+
87
+ def count_non_fixup_commits(commit_linters)
88
+ commit_linters.count { |commit_linter| !commit_linter.fixup? }
89
+ end
90
+
91
+ def lint_commits(commits)
92
+ commit_linters = commits.map { |commit| lint_commit(commit) }
93
+
94
+ if helper.squash_mr?
95
+ multi_line_commit_linter = commit_linters.detect { |commit_linter| !commit_linter.merge? && commit_linter.multi_line? }
96
+
97
+ if multi_line_commit_linter && multi_line_commit_linter.failed?
98
+ warn_or_fail_commits(multi_line_commit_linter)
99
+ commit_linters.delete(multi_line_commit_linter) # Don't show an error (here) and a warning (below)
100
+ elsif helper.ci? # We don't have access to the MR title locally
101
+ title_linter = lint_mr_title(gitlab.mr_json['title'])
102
+ if title_linter.failed?
103
+ warn_or_fail_commits(title_linter)
104
+ end
105
+ end
106
+ else
107
+ if count_non_fixup_commits(commit_linters) > max_commits_count
108
+ self.warn(format(MAX_COMMITS_COUNT_EXCEEDED_MESSAGE, max_commits_count: max_commits_count))
109
+ end
110
+ end
111
+
112
+ failed_commit_linters = commit_linters.select { |commit_linter| commit_linter.failed? }
113
+ warn_or_fail_commits(failed_commit_linters, default_to_fail: !helper.squash_mr?)
114
+ end
115
+
116
+ def warn_or_fail_commits(failed_linters, default_to_fail: true)
117
+ level = default_to_fail ? :fail : :warn
118
+
119
+ Array(failed_linters).each do |linter|
120
+ linter.problems.each do |problem_key, problem_desc|
121
+ case problem_key
122
+ when :subject_too_short, :subject_above_warning, :details_too_many_changes, :details_line_too_long
123
+ warn_commit(linter.commit, problem_desc)
124
+ else
125
+ self.__send__("#{level}_commit", linter.commit, problem_desc) # rubocop:disable GitlabSecurity/PublicSend
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ # As part of https://gitlab.com/groups/gitlab-org/-/epics/4826 we are
132
+ # vendoring workhorse commits from the stand-alone gitlab-workhorse
133
+ # repo. There is no point in linting commits that we want to vendor as
134
+ # is.
135
+ def workhorse_changes?
136
+ git.diff.any? { |file| file.path.start_with?('workhorse/') }
137
+ end
138
+
139
+ 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