gitlab-dangerfiles 0.8.1 → 2.0.0

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,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) }