gitlab-dangerfiles 1.0.0 → 2.1.1

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