gitlab-dangerfiles 0.8.1 → 2.0.0

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,16 +16,24 @@ 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
- def spin(project, categories, timezone_experiment: false)
36
+ def spin(project, categories = [nil], timezone_experiment: false)
30
37
  spins = categories.sort.map do |category|
31
38
  including_timezone = INCLUDE_TIMEZONE_FOR_CATEGORY.fetch(category, timezone_experiment)
32
39
 
@@ -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
@@ -2,8 +2,87 @@ 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
+ ].freeze
14
+ CI_ONLY_RULES = %w[
15
+ ].freeze
16
+
17
+ # This class provides utility methods to import plugins and dangerfiles easily.
18
+ class Engine
19
+ # @param dangerfile [Danger::Dangerfile] A +Danger::Dangerfile+ object.
20
+ #
21
+ # @example
22
+ # # In your main Dangerfile:
23
+ # dangerfiles = Gitlab::Dangerfiles::Engine.new(self)
24
+ #
25
+ # @return [Gitlab::Dangerfiles::Engine]
26
+ def initialize(dangerfile)
27
+ @dangerfile = dangerfile
28
+ end
29
+
30
+ # Import all available plugins.
31
+ #
32
+ # @example
33
+ # # In your main Dangerfile:
34
+ # dangerfiles = Gitlab::Dangerfiles::Engine.new(self)
35
+ #
36
+ # # Import all plugins
37
+ # dangerfiles.import_plugins
38
+ def import_plugins
39
+ danger_plugin.import_plugin(File.expand_path("../danger/plugins/*.rb", __dir__))
40
+ end
41
+
42
+ # Import available Dangerfiles.
43
+ #
44
+ # @param rules [Symbol, Array<String>] Can be either +:all+ (default) to import all rules,
45
+ # or an array of rules.
46
+ # Available rules are: +changes_size+.
47
+ #
48
+ # @example
49
+ # # In your main Dangerfile:
50
+ # dangerfiles = Gitlab::Dangerfiles::Engine.new(self)
51
+ #
52
+ # # Import all rules
53
+ # dangerfiles.import_dangerfiles
54
+ #
55
+ # # Or import only a subset of rules
56
+ # dangerfiles.import_dangerfiles(rules: %w[changes_size])
57
+ def import_dangerfiles(rules: :all)
58
+ filtered_rules(rules).each do |rule|
59
+ danger_plugin.import_dangerfile(path: File.join(RULES_DIR, rule))
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :dangerfile
66
+
67
+ def allowed_rules
68
+ return LOCAL_RULES unless helper_plugin.respond_to?(:ci?)
69
+
70
+ helper_plugin.ci? ? LOCAL_RULES | CI_ONLY_RULES : LOCAL_RULES
71
+ end
72
+
73
+ def filtered_rules(rules)
74
+ rules = EXISTING_RULES if rules == :all
75
+
76
+ Array(rules).map(&:to_s) & EXISTING_RULES & allowed_rules
77
+ end
78
+
79
+ def danger_plugin
80
+ @danger_plugin ||= dangerfile.plugins[Danger::DangerfileDangerPlugin]
81
+ end
82
+
83
+ def helper_plugin
84
+ @helper_plugin ||= dangerfile.plugins[Danger::Helper]
85
+ end
7
86
  end
8
87
  end
9
88
  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
@@ -15,7 +15,7 @@ module Gitlab
15
15
  {
16
16
  separator_missing: "The commit subject and body must be separated by a blank line",
17
17
  details_too_many_changes: "Commits that change #{MAX_CHANGED_LINES_IN_COMMIT} or more lines across " \
18
- "at least #{MAX_CHANGED_FILES_IN_COMMIT} files must describe these changes in the commit body",
18
+ "at least #{MAX_CHANGED_FILES_IN_COMMIT} files should describe these changes in the commit body",
19
19
  details_line_too_long: "The commit body should not contain more than #{MAX_LINE_LENGTH} characters per line",
20
20
  message_contains_text_emoji: "Avoid the use of Markdown Emoji such as `:+1:`. These add limited value " \
21
21
  "to the commit message, and are displayed as plain text outside of GitLab",
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module Dangerfiles
5
+ class Config
6
+ # @!attribute code_size_thresholds
7
+ # @return [{ high: Integer, medium: Integer }] a hash of the form +{ high: 42, medium: 12 }+ where +:high+ is the lines changed threshold which triggers an error, and +:medium+ is the lines changed threshold which triggers a warning. Also, see +DEFAULT_CHANGES_SIZE_THRESHOLDS+ for the format of the hash.
8
+ attr_accessor :code_size_thresholds
9
+
10
+ DEFAULT_CHANGES_SIZE_THRESHOLDS = { high: 2_000, medium: 500 }.freeze
11
+
12
+ def initialize
13
+ @code_size_thresholds = DEFAULT_CHANGES_SIZE_THRESHOLDS
14
+ end
15
+ end
16
+ end
17
+ end
@@ -4,6 +4,7 @@ require "json"
4
4
 
5
5
  module Gitlab
6
6
  module Dangerfiles
7
+ # @api private
7
8
  class EmojiChecker
8
9
  DIGESTS = File.expand_path("../../../fixtures/emojis/digests.json", __dir__)
9
10
  ALIASES = File.expand_path("../../../fixtures/emojis/aliases.json", __dir__)
@@ -45,17 +45,63 @@ end
45
45
 
46
46
  RSpec.shared_context "with dangerfile" do
47
47
  let(:dangerfile) { DangerSpecHelper.testing_dangerfile }
48
- let(:added_files) { %w[added1] }
49
- let(:modified_files) { %w[modified1] }
50
- let(:deleted_files) { %w[deleted1] }
51
- let(:renamed_before_file) { "renamed_before" }
52
- let(:renamed_after_file) { "renamed_after" }
48
+ let(:added_files) { %w[added-from-git] }
49
+ let(:modified_files) { %w[modified-from-git] }
50
+ let(:deleted_files) { %w[deleted-from-git] }
51
+ let(:renamed_before_file) { "renamed_before-from-git" }
52
+ let(:renamed_after_file) { "renamed_after-from-git" }
53
53
  let(:renamed_files) { [{ before: renamed_before_file, after: renamed_after_file }] }
54
54
  let(:change_class) { Gitlab::Dangerfiles::Change }
55
55
  let(:changes_class) { Gitlab::Dangerfiles::Changes }
56
56
  let(:changes) { changes_class.new([]) }
57
57
  let(:mr_title) { "Fake Title" }
58
58
  let(:mr_labels) { [] }
59
+ let(:mr_changes_from_api) do
60
+ {
61
+ "changes" => [
62
+ {
63
+ "old_path" => "added-from-api",
64
+ "new_path" => "added-from-api",
65
+ "a_mode" => "100644",
66
+ "b_mode" => "100644",
67
+ "new_file" => true,
68
+ "renamed_file" => false,
69
+ "deleted_file" => false,
70
+ "diff" => "@@ -49,6 +49,14 @@\n- vendor/ruby/\n policy: pull\n \n+.danger-review-cache:\n",
71
+ },
72
+ {
73
+ "old_path" => "modified-from-api",
74
+ "new_path" => "modified-from-api",
75
+ "a_mode" => "100644",
76
+ "b_mode" => "100644",
77
+ "new_file" => false,
78
+ "renamed_file" => false,
79
+ "deleted_file" => false,
80
+ "diff" => "@@ -49,6 +49,14 @@\n- vendor/ruby/\n policy: pull\n \n+.danger-review-cache:\n",
81
+ },
82
+ {
83
+ "old_path" => "renamed_before-from-api",
84
+ "new_path" => "renamed_after-from-api",
85
+ "a_mode" => "100644",
86
+ "b_mode" => "100644",
87
+ "new_file" => false,
88
+ "renamed_file" => true,
89
+ "deleted_file" => false,
90
+ "diff" => "@@ -49,6 +49,14 @@\n- vendor/ruby/\n policy: pull\n \n+.danger-review-cache:\n",
91
+ },
92
+ {
93
+ "old_path" => "deleted-from-api",
94
+ "new_path" => "deleted-from-api",
95
+ "a_mode" => "100644",
96
+ "b_mode" => "100644",
97
+ "new_file" => false,
98
+ "renamed_file" => false,
99
+ "deleted_file" => true,
100
+ "diff" => "@@ -49,6 +49,14 @@\n- vendor/ruby/\n policy: pull\n \n+.danger-review-cache:\n",
101
+ },
102
+ ],
103
+ }
104
+ end
59
105
 
60
106
  let(:fake_git) { double("fake-git", added_files: added_files, modified_files: modified_files, deleted_files: deleted_files, renamed_files: renamed_files) }
61
107
  let(:fake_helper) { double("fake-helper", changes: changes, mr_iid: 1234, mr_title: mr_title, mr_labels: mr_labels) }