gitlab-dangerfiles 1.1.0 → 2.1.2

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