quality_gate 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +571 -0
- data/config/37signals.yml +26 -0
- data/config/cops.yml +37 -0
- data/config/reek.yml +6 -0
- data/config/rubocop.yml +60 -0
- data/config/ruby.yml +8 -0
- data/docs/codex.md +31 -0
- data/docs/dogfood-log.md +47 -0
- data/docs/incidents.md +18 -0
- data/docs/releasing.md +46 -0
- data/exe/quality_gate +6 -0
- data/lib/generators/quality_gate/install/install_generator.rb +66 -0
- data/lib/generators/quality_gate/install/templates/agents_section.md.tt +15 -0
- data/lib/generators/quality_gate/install/templates/bullet.rb.tt +10 -0
- data/lib/generators/quality_gate/install/templates/claude_settings.json.tt +29 -0
- data/lib/generators/quality_gate/install/templates/hook_log_filesystem.rb.tt +76 -0
- data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +42 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +248 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_verify_stop.rb.tt +466 -0
- data/lib/generators/quality_gate/install/templates/rubocop.yml.tt +1 -0
- data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +15 -0
- data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +40 -0
- data/lib/generators/quality_gate/install/templates/ruby_rubocop.yml.tt +1 -0
- data/lib/generators/quality_gate/install/templates/ruby_simplecov.rb.tt +11 -0
- data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +10 -0
- data/lib/generators/quality_gate/install/templates/strong_migrations.rb.tt +10 -0
- data/lib/quality_gate/adapter.rb +328 -0
- data/lib/quality_gate/adapters/brakeman.rb +125 -0
- data/lib/quality_gate/adapters/bundler_audit.rb +235 -0
- data/lib/quality_gate/adapters/reek.rb +108 -0
- data/lib/quality_gate/adapters/rubocop.rb +142 -0
- data/lib/quality_gate/adapters/simplecov.rb +103 -0
- data/lib/quality_gate/adapters/test_suite.rb +81 -0
- data/lib/quality_gate/adapters/undercover.rb +357 -0
- data/lib/quality_gate/cli.rb +451 -0
- data/lib/quality_gate/config.rb +290 -0
- data/lib/quality_gate/exit_code.rb +14 -0
- data/lib/quality_gate/finding.rb +50 -0
- data/lib/quality_gate/hook_log.rb +131 -0
- data/lib/quality_gate/init_command.rb +90 -0
- data/lib/quality_gate/installation.rb +1577 -0
- data/lib/quality_gate/installer.rb +104 -0
- data/lib/quality_gate/railtie.rb +16 -0
- data/lib/quality_gate/reporters/field_sanitizer.rb +42 -0
- data/lib/quality_gate/reporters/json.rb +59 -0
- data/lib/quality_gate/reporters/text.rb +45 -0
- data/lib/quality_gate/rubocop.rb +34 -0
- data/lib/quality_gate/ruby_profile.rb +170 -0
- data/lib/quality_gate/runner.rb +150 -0
- data/lib/quality_gate/version.rb +5 -0
- data/lib/quality_gate.rb +27 -0
- data/lib/rubocop/cop/quality_gate/association_default_block_value.rb +46 -0
- data/lib/rubocop/cop/quality_gate/broadcast_in_controller.rb +64 -0
- data/lib/rubocop/cop/quality_gate/controller_instance_variables.rb +47 -0
- data/lib/rubocop/cop/quality_gate/prefer_after_save_commit.rb +84 -0
- data/lib/rubocop/cop/quality_gate/private_only_concern.rb +77 -0
- data/llm.txt +13 -0
- data/sig/quality_gate.rbs +226 -0
- metadata +260 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module QualityGate
|
|
6
|
+
# Reports a configuration file that could not be parsed or read.
|
|
7
|
+
class ConfigError < Error
|
|
8
|
+
attr_reader :path
|
|
9
|
+
|
|
10
|
+
def initialize(path:, cause_message:)
|
|
11
|
+
@path = path.dup.freeze
|
|
12
|
+
super("Could not load config #{@path}: #{cause_message}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Loads and exposes quality gate settings resolved against shipped defaults.
|
|
17
|
+
# rubocop:disable Metrics/ClassLength
|
|
18
|
+
class Config
|
|
19
|
+
DEFAULTS = {
|
|
20
|
+
format: "text",
|
|
21
|
+
files: [].freeze,
|
|
22
|
+
adapters: {
|
|
23
|
+
fast: ["rubocop"].freeze,
|
|
24
|
+
verify: %w[reek test_suite undercover].map!(&:freeze).freeze,
|
|
25
|
+
audit: %w[brakeman bundler_audit].map!(&:freeze).freeze
|
|
26
|
+
}.freeze,
|
|
27
|
+
commands: {
|
|
28
|
+
fast: {}.freeze,
|
|
29
|
+
verify: { test_suite: %w[bin/rails test].map!(&:freeze).freeze }.freeze,
|
|
30
|
+
audit: {}.freeze
|
|
31
|
+
}.freeze,
|
|
32
|
+
timeouts: { default: 120, rubocop: 10, test_suite: 120, undercover: 120 }.freeze,
|
|
33
|
+
coverage: nil,
|
|
34
|
+
compare_point: nil,
|
|
35
|
+
rubocop_config: nil
|
|
36
|
+
}.freeze
|
|
37
|
+
ADAPTER_LAYERS = %i[fast verify audit].freeze
|
|
38
|
+
COVERAGE_KEYS = %i[minimum_line minimum_branch].freeze
|
|
39
|
+
private_constant :ADAPTER_LAYERS, :COVERAGE_KEYS
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
def defaults = DEFAULTS
|
|
43
|
+
|
|
44
|
+
def load(dir:)
|
|
45
|
+
path = File.join(dir, ".quality_gate.yml")
|
|
46
|
+
return new(DEFAULTS) unless File.exist?(path)
|
|
47
|
+
|
|
48
|
+
load_file(path)
|
|
49
|
+
rescue Psych::Exception, SystemCallError, SystemStackError => e
|
|
50
|
+
fail ConfigError.new(path: path, cause_message: e.message) # rubocop:disable Style/SignalException
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def symbolize_keys(value)
|
|
56
|
+
case value
|
|
57
|
+
when Hash
|
|
58
|
+
value.to_h { |key, child| [key.to_sym, symbolize_keys(child)] }
|
|
59
|
+
when Array
|
|
60
|
+
value.map { |child| symbolize_keys(child) }
|
|
61
|
+
else
|
|
62
|
+
value
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def load_file(path)
|
|
67
|
+
document = YAML.safe_load(File.read(path))
|
|
68
|
+
validate_document(document, path)
|
|
69
|
+
overrides = symbolize_keys(document || {})
|
|
70
|
+
validate_known_settings(overrides, path)
|
|
71
|
+
|
|
72
|
+
new(
|
|
73
|
+
deep_merge(DEFAULTS, known_overrides(overrides)),
|
|
74
|
+
path: path,
|
|
75
|
+
unknown_keys: unknown_keys(overrides)
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def known_overrides(overrides)
|
|
80
|
+
overrides.select { |key, _value| DEFAULTS.key?(key) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def unknown_keys(overrides)
|
|
84
|
+
overrides.keys.reject { |key| DEFAULTS.key?(key) }.map(&:to_s)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_known_settings(overrides, path) # rubocop:disable Metrics/AbcSize
|
|
88
|
+
validate_format(overrides[:format], path) if overrides.key?(:format)
|
|
89
|
+
validate_files(overrides[:files], path) if overrides.key?(:files)
|
|
90
|
+
validate_adapters(overrides[:adapters], path) if overrides.key?(:adapters)
|
|
91
|
+
validate_commands(overrides[:commands], path) if overrides.key?(:commands)
|
|
92
|
+
validate_coverage(overrides[:coverage], path) if overrides.key?(:coverage)
|
|
93
|
+
validate_compare_point(overrides[:compare_point], path) if overrides.key?(:compare_point)
|
|
94
|
+
validate_rubocop_config(overrides[:rubocop_config], path) if overrides.key?(:rubocop_config)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_format(format, path)
|
|
98
|
+
return if %w[text json].include?(format)
|
|
99
|
+
|
|
100
|
+
fail ConfigError.new(path: path, cause_message: "format must be text or json") # rubocop:disable Style/SignalException
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate_files(files, path)
|
|
104
|
+
unless files.is_a?(Array)
|
|
105
|
+
fail ConfigError.new(path: path, cause_message: "files must be an array") # rubocop:disable Style/SignalException
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
return if files.all? { |file| file.is_a?(String) && !file.empty? }
|
|
109
|
+
|
|
110
|
+
fail ConfigError.new( # rubocop:disable Style/SignalException
|
|
111
|
+
path: path,
|
|
112
|
+
cause_message: "files entries must be non-empty strings"
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def validate_adapters(adapters, path)
|
|
117
|
+
unless adapters.is_a?(Hash)
|
|
118
|
+
fail ConfigError.new(path: path, cause_message: "adapters must be a mapping") # rubocop:disable Style/SignalException
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
invalid_keys = adapters.keys - ADAPTER_LAYERS
|
|
122
|
+
if invalid_keys.any?
|
|
123
|
+
fail ConfigError.new(path: path, cause_message: "adapters keys must be fast, verify, or audit") # rubocop:disable Style/SignalException
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
adapters.each do |layer, adapter_names|
|
|
127
|
+
validate_adapter_names(layer, adapter_names, path)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_adapter_names(layer, adapter_names, path)
|
|
132
|
+
unless adapter_names.is_a?(Array)
|
|
133
|
+
fail ConfigError.new(path: path, cause_message: "adapters.#{layer} must be an array") # rubocop:disable Style/SignalException
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
return if adapter_names.all? { |name| name.is_a?(String) && !name.empty? }
|
|
137
|
+
|
|
138
|
+
fail ConfigError.new(path: path, cause_message: "adapters.#{layer} entries must be non-empty strings") # rubocop:disable Style/SignalException
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validate_commands(commands, path)
|
|
142
|
+
validate_layer_mapping(commands, "commands", path)
|
|
143
|
+
|
|
144
|
+
commands.each do |layer, layer_commands|
|
|
145
|
+
unless layer_commands.is_a?(Hash)
|
|
146
|
+
fail ConfigError.new(path: path, cause_message: "commands.#{layer} must be a mapping") # rubocop:disable Style/SignalException
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
layer_commands.each do |command_name, argv|
|
|
150
|
+
validate_command_argv(layer, command_name, argv, path)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def validate_layer_mapping(value, setting, path)
|
|
156
|
+
unless value.is_a?(Hash)
|
|
157
|
+
fail ConfigError.new(path: path, cause_message: "#{setting} must be a mapping") # rubocop:disable Style/SignalException
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
return if (value.keys - ADAPTER_LAYERS).empty?
|
|
161
|
+
|
|
162
|
+
cause = "#{setting} keys must be fast, verify, or audit"
|
|
163
|
+
fail ConfigError.new(path: path, cause_message: cause) # rubocop:disable Style/SignalException
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def validate_command_argv(layer, command_name, argv, path)
|
|
167
|
+
unless argv.is_a?(Array) && argv.any?
|
|
168
|
+
cause = "commands.#{layer}.#{command_name} must be an argv array"
|
|
169
|
+
fail ConfigError.new(path: path, cause_message: cause) # rubocop:disable Style/SignalException
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
return if argv.all? { _1.is_a?(String) && !_1.empty? }
|
|
173
|
+
|
|
174
|
+
cause = "commands.#{layer}.#{command_name} entries must be non-empty strings"
|
|
175
|
+
fail ConfigError.new(path: path, cause_message: cause) # rubocop:disable Style/SignalException
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def validate_compare_point(compare_point, path)
|
|
179
|
+
return if compare_point.nil?
|
|
180
|
+
return if compare_point.is_a?(String) && !compare_point.empty?
|
|
181
|
+
|
|
182
|
+
cause = "compare_point must be nil or a non-empty String"
|
|
183
|
+
fail ConfigError.new(path: path, cause_message: cause) # rubocop:disable Style/SignalException
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def validate_coverage(coverage, path)
|
|
187
|
+
unless coverage.is_a?(Hash) && coverage.any?
|
|
188
|
+
cause = "coverage must be a non-empty mapping"
|
|
189
|
+
fail ConfigError.new(path: path, cause_message: cause) # rubocop:disable Style/SignalException
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
invalid_key = (coverage.keys - COVERAGE_KEYS).first
|
|
193
|
+
if invalid_key
|
|
194
|
+
cause = "unknown coverage key #{invalid_key}"
|
|
195
|
+
fail ConfigError.new(path: path, cause_message: cause) # rubocop:disable Style/SignalException
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
coverage.each do |key, value|
|
|
199
|
+
validate_coverage_value(key, value, path)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def validate_coverage_value(key, value, path)
|
|
204
|
+
unless value.is_a?(Numeric)
|
|
205
|
+
cause = "coverage.#{key} must be Numeric"
|
|
206
|
+
fail ConfigError.new(path: path, cause_message: cause) # rubocop:disable Style/SignalException
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
return if value.finite? && value.between?(0, 100)
|
|
210
|
+
|
|
211
|
+
cause = "coverage.#{key} must be within 0..100"
|
|
212
|
+
fail ConfigError.new(path: path, cause_message: cause) # rubocop:disable Style/SignalException
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def validate_rubocop_config(rubocop_config, path)
|
|
216
|
+
return if rubocop_config.nil?
|
|
217
|
+
return if rubocop_config.is_a?(String) && !rubocop_config.empty?
|
|
218
|
+
|
|
219
|
+
fail ConfigError.new(path: path, cause_message: "rubocop_config must be nil or a non-empty String") # rubocop:disable Style/SignalException
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def validate_document(document, path)
|
|
223
|
+
unless document.nil? || document.is_a?(Hash)
|
|
224
|
+
cause = "expected a mapping, got #{document.class}"
|
|
225
|
+
fail ConfigError.new(path: path, cause_message: cause) # rubocop:disable Style/SignalException
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
validate_keys(document, path)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def validate_keys(value, path)
|
|
232
|
+
case value
|
|
233
|
+
when Hash
|
|
234
|
+
value.each do |key, child|
|
|
235
|
+
validate_key(key, path)
|
|
236
|
+
validate_keys(child, path)
|
|
237
|
+
end
|
|
238
|
+
when Array
|
|
239
|
+
value.each { |child| validate_keys(child, path) }
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def validate_key(key, path)
|
|
244
|
+
return if key.is_a?(String)
|
|
245
|
+
|
|
246
|
+
cause = "configuration keys must be strings, got #{key.class}"
|
|
247
|
+
fail ConfigError.new(path: path, cause_message: cause) # rubocop:disable Style/SignalException
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def deep_merge(defaults, overrides)
|
|
251
|
+
defaults.merge(overrides) do |_key, default, override|
|
|
252
|
+
if default.is_a?(Hash) && override.is_a?(Hash)
|
|
253
|
+
deep_merge(default, override)
|
|
254
|
+
else
|
|
255
|
+
override
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
attr_reader :path, :unknown_keys
|
|
262
|
+
|
|
263
|
+
def initialize(settings, path: nil, unknown_keys: [])
|
|
264
|
+
@settings = deep_freeze(settings)
|
|
265
|
+
@path = path&.dup&.freeze
|
|
266
|
+
@unknown_keys = deep_freeze(unknown_keys)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def to_h = @settings
|
|
270
|
+
|
|
271
|
+
def fetch(key) = @settings.fetch(key)
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
def deep_freeze(value)
|
|
276
|
+
case value
|
|
277
|
+
when Hash
|
|
278
|
+
value.each do |key, child|
|
|
279
|
+
deep_freeze(key)
|
|
280
|
+
deep_freeze(child)
|
|
281
|
+
end
|
|
282
|
+
when Array
|
|
283
|
+
value.each { |child| deep_freeze(child) }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
value.freeze
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
# rubocop:enable Metrics/ClassLength
|
|
290
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QualityGate
|
|
4
|
+
# Provides stable process-independent result codes for command execution.
|
|
5
|
+
module ExitCode
|
|
6
|
+
CLEAN = 0
|
|
7
|
+
FINDINGS = 1
|
|
8
|
+
TOOL_FAILURE = 2
|
|
9
|
+
|
|
10
|
+
@all = [CLEAN, FINDINGS, TOOL_FAILURE].freeze
|
|
11
|
+
|
|
12
|
+
def self.all = @all
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
QualityGate::Finding = Data.define(:tool, :file, :line, :rule, :severity, :message)
|
|
4
|
+
|
|
5
|
+
QualityGate::Finding::SEVERITIES = %i[error warning info].freeze
|
|
6
|
+
QualityGate::Finding::TOOL_FAILURE_RULE = "tool_failure"
|
|
7
|
+
|
|
8
|
+
QualityGate::Finding.class_eval do
|
|
9
|
+
def initialize(**attributes)
|
|
10
|
+
super(
|
|
11
|
+
**attributes,
|
|
12
|
+
tool: copy_string(:tool, attributes.fetch(:tool)),
|
|
13
|
+
file: copy_string(:file, attributes.fetch(:file)),
|
|
14
|
+
line: validate_line(attributes.fetch(:line)),
|
|
15
|
+
rule: copy_string(:rule, attributes.fetch(:rule)),
|
|
16
|
+
message: copy_string(:message, attributes.fetch(:message))
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.tool_failure(tool:, message:)
|
|
21
|
+
new(
|
|
22
|
+
tool: tool,
|
|
23
|
+
file: "",
|
|
24
|
+
line: 0,
|
|
25
|
+
rule: self::TOOL_FAILURE_RULE,
|
|
26
|
+
severity: :error,
|
|
27
|
+
message: message
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def with(**attributes) = self.class.new(**to_h.merge(attributes))
|
|
32
|
+
|
|
33
|
+
def tool_failure? = rule == self.class::TOOL_FAILURE_RULE
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def copy_string(member, value)
|
|
38
|
+
raise TypeError, "#{member} must be a String" unless value.is_a?(String)
|
|
39
|
+
|
|
40
|
+
value.dup.freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate_line(value)
|
|
44
|
+
raise TypeError, "line must be an Integer" unless value.is_a?(Integer)
|
|
45
|
+
raise ArgumentError, "line must be greater than or equal to 0" if value.negative?
|
|
46
|
+
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
# rubocop:enable Metrics/BlockLength
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module QualityGate
|
|
6
|
+
# Reads the advisory health log written by the automatic hooks.
|
|
7
|
+
class HookLog
|
|
8
|
+
DEFAULT_PATH = "log/quality_gate_hooks.jsonl"
|
|
9
|
+
MAX_SCAN_BYTES = 1024 * 1024
|
|
10
|
+
MAX_LINE_BYTES = 64 * 1024
|
|
11
|
+
OUTCOMES = %w[
|
|
12
|
+
no_file skipped deleted clean findings unavailable
|
|
13
|
+
verify_skipped verify_debounced verify_clean verify_blocked verify_unavailable verify_cap
|
|
14
|
+
].map!(&:freeze).freeze
|
|
15
|
+
private_constant :MAX_SCAN_BYTES, :MAX_LINE_BYTES, :OUTCOMES
|
|
16
|
+
|
|
17
|
+
attr_reader :path
|
|
18
|
+
|
|
19
|
+
def initialize(path:)
|
|
20
|
+
@path = path.dup.freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def recent(limit: 20)
|
|
24
|
+
return [] unless limit.positive?
|
|
25
|
+
|
|
26
|
+
with_log_file { |io| recent_records(io, limit) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def unavailable_count(limit: 20)
|
|
30
|
+
recent(limit: limit).count { %w[unavailable verify_unavailable].include?(_1.fetch("outcome")) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def warning_line(limit: 20)
|
|
34
|
+
count = unavailable_count(limit: limit)
|
|
35
|
+
return if count.zero?
|
|
36
|
+
|
|
37
|
+
"Warning: #{count} of the last #{limit} automatic checks could not run; " \
|
|
38
|
+
"check bundle install and the Quality Gate hook setup."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def with_log_file
|
|
44
|
+
expected = File.lstat(path)
|
|
45
|
+
return [] unless expected.file?
|
|
46
|
+
|
|
47
|
+
File.open(path, read_flags) do |io|
|
|
48
|
+
return [] unless same_regular_file?(io.stat, expected, File.lstat(path))
|
|
49
|
+
|
|
50
|
+
io.binmode
|
|
51
|
+
yield io
|
|
52
|
+
end
|
|
53
|
+
rescue SystemCallError, IOError
|
|
54
|
+
[]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def recent_records(io, limit)
|
|
58
|
+
records = []
|
|
59
|
+
tail_bytes(io).split("\n".b).reverse_each do |line|
|
|
60
|
+
record = parse_line(line)
|
|
61
|
+
records << record if record
|
|
62
|
+
break if records.length >= limit
|
|
63
|
+
end
|
|
64
|
+
records.reverse
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def read_flags
|
|
68
|
+
%i[NONBLOCK NOFOLLOW].reduce(File::RDONLY) do |flags, name|
|
|
69
|
+
File.const_defined?(name) ? flags | File.const_get(name) : flags
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def same_regular_file?(opened, expected, current)
|
|
74
|
+
[opened, expected, current].all?(&:file?) &&
|
|
75
|
+
[opened.dev, opened.ino] == [expected.dev, expected.ino] &&
|
|
76
|
+
[opened.dev, opened.ino] == [current.dev, current.ino]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def tail_bytes(io)
|
|
80
|
+
size = io.stat.size
|
|
81
|
+
length = [size, MAX_SCAN_BYTES].min
|
|
82
|
+
offset = size - length
|
|
83
|
+
starts_at_boundary = line_boundary?(io, offset)
|
|
84
|
+
io.seek(offset, IO::SEEK_SET)
|
|
85
|
+
bytes = io.read(length) || "".b
|
|
86
|
+
|
|
87
|
+
discard_partial_line(bytes, starts_at_boundary)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def line_boundary?(io, offset)
|
|
91
|
+
return true if offset.zero?
|
|
92
|
+
|
|
93
|
+
io.seek(offset - 1, IO::SEEK_SET)
|
|
94
|
+
io.read(1) == "\n".b
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def discard_partial_line(bytes, starts_at_boundary)
|
|
98
|
+
return bytes if starts_at_boundary
|
|
99
|
+
|
|
100
|
+
newline = bytes.index("\n".b)
|
|
101
|
+
return "".b unless newline
|
|
102
|
+
|
|
103
|
+
bytes.byteslice(newline + 1, bytes.bytesize - newline - 1)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse_line(line)
|
|
107
|
+
return if line.bytesize > MAX_LINE_BYTES
|
|
108
|
+
|
|
109
|
+
line = line.dup.force_encoding(Encoding::UTF_8)
|
|
110
|
+
return unless line.valid_encoding?
|
|
111
|
+
|
|
112
|
+
record = JSON.parse(line)
|
|
113
|
+
record if valid_record?(record)
|
|
114
|
+
rescue JSON::ParserError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def valid_record?(record)
|
|
119
|
+
record.is_a?(Hash) &&
|
|
120
|
+
record["ts"].is_a?(String) &&
|
|
121
|
+
record.key?("file") &&
|
|
122
|
+
(record["file"].nil? || record["file"].is_a?(String)) &&
|
|
123
|
+
OUTCOMES.include?(record["outcome"]) &&
|
|
124
|
+
valid_duration?(record["duration_ms"])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def valid_duration?(duration)
|
|
128
|
+
duration.is_a?(Numeric) && duration.finite? && duration >= 0
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module QualityGate
|
|
6
|
+
# Parses and executes the framework-independent project initializer.
|
|
7
|
+
class InitCommand
|
|
8
|
+
class << self
|
|
9
|
+
def run(arguments, stdout:, stderr:, dir:)
|
|
10
|
+
installer(parse(arguments), dir: dir, stdout: stdout).call
|
|
11
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
12
|
+
report_failure(e, stderr)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def parse(arguments)
|
|
18
|
+
reject_format!(arguments)
|
|
19
|
+
options = { profile: "ruby", skip_coverage: false, agents: false, pretend: false }
|
|
20
|
+
option_parser(options).parse!(arguments)
|
|
21
|
+
raise OptionParser::InvalidOption, arguments.join(" ") if arguments.any?
|
|
22
|
+
|
|
23
|
+
options
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def installer(options, dir:, stdout:)
|
|
27
|
+
require_relative "installer"
|
|
28
|
+
|
|
29
|
+
Installer.new(destination_root: dir, options: options, stdout: stdout)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reject_format!(arguments = nil)
|
|
33
|
+
return unless arguments.nil? || arguments.any? { _1 == "--format" || _1.start_with?("--format=") }
|
|
34
|
+
|
|
35
|
+
raise OptionParser::InvalidArgument, "init does not support --format; init output is text only"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def option_parser(options)
|
|
39
|
+
OptionParser.new.tap { |parser| parser_definitions(parser, options) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parser_definitions(parser, options)
|
|
43
|
+
profile_options(parser, options)
|
|
44
|
+
test_options(parser, options)
|
|
45
|
+
boolean_options(parser, options)
|
|
46
|
+
parser.on("--format FORMAT") { reject_format! }
|
|
47
|
+
parser.on("-h", "--help") { options[:help] = true }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def profile_options(parser, options)
|
|
51
|
+
parser.on("--profile PROFILE", %w[ruby]) { |profile| options[:profile] = profile }
|
|
52
|
+
parser.on("--test-framework FRAMEWORK", %w[minitest rspec]) do |framework|
|
|
53
|
+
options[:test_framework] = framework
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def test_options(parser, options)
|
|
58
|
+
parser.on("--test-helper PATH") { |path| options[:test_helper] = path }
|
|
59
|
+
parser.on("--test-command COMMAND") { |command| options[:test_command] = command }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def boolean_options(parser, options)
|
|
63
|
+
parser.on("--skip-coverage") { options[:skip_coverage] = true }
|
|
64
|
+
parser.on("--agents") { options[:agents] = true }
|
|
65
|
+
parser.on("--pretend") { options[:pretend] = true }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def report_failure(error, stderr)
|
|
69
|
+
report_error(error, stderr)
|
|
70
|
+
write_line(stderr, "Usage: quality_gate init [options]") if error.is_a?(OptionParser::ParseError)
|
|
71
|
+
ExitCode::TOOL_FAILURE
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def report_error(error, stderr)
|
|
75
|
+
write_line(stderr, "Error: #{error.message}")
|
|
76
|
+
ExitCode::TOOL_FAILURE
|
|
77
|
+
rescue StandardError
|
|
78
|
+
write_line(stderr, "Error: quality_gate init failed")
|
|
79
|
+
ExitCode::TOOL_FAILURE
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def write_line(io, message)
|
|
83
|
+
io.puts(message)
|
|
84
|
+
true
|
|
85
|
+
rescue StandardError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|