confiner 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 75209090e950ca1c9174bf10ef33e4c33cfbaaaa0d0f77f6cbc55735b7be90b7
4
+ data.tar.gz: c187a788996ec52e9fecd5d42de5933d298345752a4d14c060914ec21826d859
5
+ SHA512:
6
+ metadata.gz: 0e5738fd37387d6c45921c696ac32629afa04da3ac91cf42545fedda2d14fc017caf1319d753344598976f8d3204ccb0ba398c4035e809b0c16adf0a2081dd3a
7
+ data.tar.gz: b8b248eb4f1956f38d3279dde330b8b7d8ee62980796fa89fb5b8b5cb821fb4b77fe7f07bd1888dd802cbffe15fde5243ad76227e2c597e9204b444ea4d32045
data/bin/confiner ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/confiner'
5
+
6
+ Confiner::Cli.run(*ARGV)
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'yaml'
5
+
6
+ module Confiner
7
+ class Cli
8
+ include Logger
9
+
10
+ attr_accessor :action, :plugins
11
+ attr_reader :parser, :plugin_options
12
+
13
+ def initialize(*options)
14
+ @plugins = []
15
+ @plugin_options = []
16
+ @rules_files = []
17
+ @rules = []
18
+
19
+ # default logging to standard out
20
+ Logger.log_to $stdout
21
+
22
+ if options.include?('--')
23
+ @plugin_options = options[options.index('--')..]
24
+ options = options[0..options.index('--')]
25
+ end
26
+
27
+ @parser = OptionParser.new do |opts|
28
+ opts.banner = <<~USAGE
29
+ Usage: #{$PROGRAM_NAME} [options] [-- plugin_options]
30
+
31
+ Examples:
32
+ Run all rules within .confiner directory outputting to a log file:
33
+ #{$PROGRAM_NAME} -o confiner.log
34
+ Run a specific rule file overriding specific arguments for plugins:
35
+ #{$PROGRAM_NAME} -r rules.yml -- --arg1=foo --arg2=bar
36
+ Run all all_rules within a specific directory:
37
+ #{$PROGRAM_NAME} -r ./all_rules
38
+
39
+ Options:
40
+ USAGE
41
+
42
+ opts.on('-h', '--help', 'Show the help') do
43
+ puts opts
44
+ exit 0
45
+ end
46
+
47
+ opts.on('-r RULES', '--rules RULES', 'Path to rule yaml file or directory of rules') do |rule|
48
+ rule.strip! # strip any trailing/leading spacess
49
+ rules = File.expand_path(rule)
50
+
51
+ raise "Rule file or directory `#{rules}` does not exist" unless File.exist?(rules)
52
+
53
+ @rules = if File.directory?(rules)
54
+ Dir[File.join(rules, '**', '*.yml')].each_with_object([]) do |definitions, all_rules|
55
+ all_rules << load_yaml(definitions)
56
+ end
57
+ else
58
+ [load_yaml(rules)]
59
+ end
60
+ end
61
+
62
+ opts.on('-v', '--version', 'Show the version') do
63
+ $stdout.puts "#{$PROGRAM_NAME} version #{VERSION}"
64
+ exit(0)
65
+ end
66
+
67
+ opts.on('-o OUTPUT', '--output-to OUTPUT', 'File to output the log to') do |output_to|
68
+ log_to(output_to)
69
+ end
70
+ end
71
+
72
+ @parser.parse!(options)
73
+
74
+ log :confiner, 'Program Start'
75
+
76
+ if @rules.empty?
77
+ # load any and all rules within .confiner
78
+ raise 'No rules to run. Are you missing a .confiner directory or -r argument?' unless Dir.exist?('.confiner')
79
+
80
+ @rules = Dir[File.join('.confiner', '**', '*.yml')].each_with_object([]) do |definitions, rules|
81
+ rules << load_yaml(definitions)
82
+ end
83
+ end
84
+
85
+ log :rules, 'Using rule files:'
86
+ @rules_files.each do |file|
87
+ log :loaded, file, 2
88
+ end
89
+ end
90
+
91
+ # Run the confiner
92
+ def run
93
+ @rules.each do |rules|
94
+ rules.each do |rule|
95
+ process_rule(rule)
96
+ end
97
+ end
98
+
99
+ log :confiner, 'Done'
100
+ end
101
+
102
+ def self.run(*argv)
103
+ new(*argv).run
104
+ end
105
+
106
+ private
107
+
108
+ # Process a singular rule
109
+ # @param [Hash] rule
110
+ def process_rule(rule)
111
+ log :rule, rule.keys.map { |k| "\t#{k}=#{rule[k]}" }.join(',')
112
+
113
+ rule['plugin']['args'].transform_keys!(&:to_sym) # 2.5 compatability
114
+
115
+ plugin = Plugins.const_get(translate_plugin_name(rule['plugin']['name'])).new(**rule['plugin']['args'])
116
+
117
+ # perform verification of actions before execution
118
+ rule['actions'].each do |action|
119
+ raise "YAML is invalid. Action `#{action}` does not exist." unless plugin.respond_to?(action)
120
+ end
121
+
122
+ # execute each action
123
+ rule['actions'].each do |action|
124
+ plugin.run(action) { |p| p.public_send(action) }
125
+ end
126
+ end
127
+
128
+ # Ensure that the rules are well-formed
129
+ def validate_rules(rules, file)
130
+ raise "YAML is invalid. Rules must be an array (from #{file})." unless rules.is_a? Array
131
+
132
+ rules.each do |rule|
133
+ # name is required
134
+ raise "YAML is invalid. Rule must have a name. (from #{file})" unless rule['name']
135
+
136
+ # actions are required
137
+ raise "YAML is invalid. Rule `#{rule['name']}` must have actions and it must be an Array (from #{file})" unless rule['actions']&.is_a? Array
138
+
139
+ # plugin is required and must be well-formed
140
+ raise "YAML is invalid. Rule `#{rule['name']}` must have a plugin and it must have a name (from #{file})" unless rule['plugin'] && rule['plugin']['name']
141
+
142
+ # Plugin must exist
143
+ plugin = begin
144
+ Plugins.const_get(translate_plugin_name(rule['plugin']['name']))
145
+ rescue NameError
146
+ raise "YAML is invalid. Rule `#{rule['name']}` does not have plugin `#{rule['plugin']['name']}` (from #{file})"
147
+ end
148
+
149
+ # Validate the actions
150
+ rule['actions'].each do |action|
151
+ begin
152
+ plugin.instance_method(action)
153
+ rescue NameError
154
+ raise "YAML is invalid. Rule `#{rule['name']}` plugin `#{rule['plugin']['name']}` has no action `#{action}` (from #{file})"
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ # Translate a plugin name from snake_case to PascalCase
161
+ def translate_plugin_name(plugin_name)
162
+ plugin_name.split('_').map(&:capitalize).join
163
+ end
164
+
165
+ # Load yaml file and validate all rules
166
+ def load_yaml(file)
167
+ raise 'File is not a YAML file' unless File.extname(file).match(/yml|yaml/i)
168
+
169
+ @rules_files << file
170
+
171
+ validate_rules(YAML.load_file(file), file)
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Confiner
4
+ # Representation of an example (test)
5
+ class Example
6
+ attr_accessor :status, :name, :classname, :file, :occurrence
7
+
8
+ def initialize(**attributes)
9
+ @status = attributes.fetch('status') { attributes.fetch(:status) }
10
+ @name = attributes.fetch('name') { attributes.fetch(:name) }
11
+ @classname = attributes.fetch('classname') { attributes.fetch(:classname) }
12
+ @file = attributes.fetch('file') { attributes.fetch(:file) }
13
+ @occurrence = attributes.fetch('occurrence') { attributes.fetch(:occurrence) }
14
+
15
+ yield(self) if block_given?
16
+ end
17
+
18
+ # Check if this example had passed
19
+ # @return [Boolean] true if the example had passed
20
+ def passed?
21
+ status == 'success'
22
+ end
23
+
24
+ # Check if this example had failed
25
+ # @return [Boolean] true if the example had failed
26
+ def failed?
27
+ status == 'failed'
28
+ end
29
+
30
+ # Check if this example had been skipped
31
+ # @return [Boolean] true if the example had been skipped
32
+ def skipped?
33
+ status == 'skipped'
34
+ end
35
+
36
+ def to_s; name; end
37
+ end
38
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Confiner
4
+ module Logger
5
+ # Log something with a specific level
6
+ def log(level, message, indentation = 1)
7
+ raise ArgumentError, 'Level must be less than 12 characters' if level.size > 12
8
+
9
+ output = "(#{Time.now.strftime('%F %H:%M:%S')})\t\e[0;35m#{level.to_s.upcase}#{' ' * (12 - level.size)}\e[m#{"\t" * indentation}#{message}"
10
+
11
+ Logger.log_to(File.open(Logger.log_to.strip, 'a+t')) if Logger.log_to.is_a?(String)
12
+ Logger.log_to.puts(output)
13
+ end
14
+
15
+ # Where to output the log
16
+ def self.log_to(file = $stdout)
17
+ @out_file ||= file
18
+ end
19
+
20
+ def run(action)
21
+ log :plugin, "#{self.class}##{action}"
22
+ super
23
+ log :plugin, "#{self.class}##{action} Done"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Confiner
4
+ # Plugin for Confiner
5
+ class Plugin
6
+ prepend Logger
7
+
8
+ attr_reader :examples
9
+
10
+ class << self
11
+ # Define arguments that the plugin will accept
12
+ # @param [Array<Symbol, Hash>] args the arguments that this plugin accepts
13
+ # @option args [Symbol] :argument the argument to pass with no default
14
+ # @option args [Hash] :argument the argument to pass with a default value
15
+ # @note the arguments should be well-formed in the .yml rule file
16
+ def arguments(*args)
17
+ @arguments ||= args
18
+
19
+ args.each do |arg|
20
+ if arg.is_a? Hash
21
+ arg.each do |a, default_value|
22
+ attr_writer a
23
+
24
+ define_method(a) do
25
+ instance_variable_get("@#{a}") || instance_variable_set("@#{a}", default_value)
26
+ end
27
+ end
28
+ else
29
+ attr_accessor arg
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ def initialize(**args)
36
+ args.each do |k, v|
37
+ self.public_send(:"#{k}=", v)
38
+ end
39
+ end
40
+
41
+ # Run the plugin
42
+ def run(action, &block)
43
+ yield self
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Confiner
4
+ module Plugins
5
+ class Debug < Plugin
6
+ arguments :arg1, :arg2
7
+
8
+ def action1
9
+ log :debug, arg1
10
+ end
11
+
12
+ def action2
13
+ log :debug, arg2
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gitlab'
4
+
5
+ module Confiner
6
+ module Plugins
7
+ class Gitlab < Plugin
8
+ arguments :private_token, # the GitLab API Private-Token to use
9
+ :project_id, # the project where the pipelines are fetch from
10
+ :target_project, # where failure issues will be searched, and where an MR will be filed
11
+ :failure_issue_labels, # what labels to search for when searching for the failure issue
12
+ :failure_issue_prefix, # what prefix an issue has in GitLab Issues to search for the failure issue
13
+ :timeout => 10, # the timeout that HTTParty will consume (timeout of requests)
14
+ :threshold => 3, # the failure / pass threshold
15
+ :endpoint => 'https://gitlab.com/api/v4', # the GitLab API Endpoint (e.g. https://gitlab.com/api/v4)
16
+ :pwd => '.', # the path of the working directory for the examples
17
+ :ref => 'master' # the default Git ref used when updating
18
+
19
+ MERGE_REQUEST_TITLE = '[QUARANTINE] %s'
20
+ QUARANTINE_METADATA = %(, quarantine: { issue: '%s', type: :investigating })
21
+
22
+ def initialize(**args)
23
+ super
24
+
25
+ ENV['GITLAB_API_HTTPARTY_OPTIONS'] = ENV.fetch('GITLAB_API_HTTPARTY_OPTIONS') { "{read_timeout: #{timeout}}" }
26
+
27
+ @gitlab_client = ::Gitlab.client(private_token: private_token, endpoint: endpoint)
28
+ end
29
+
30
+ # Quarantine Action - Automatically Quarantine tests
31
+ def quarantine
32
+ log :gitlab, 'Beginning Quarantine Process', 2
33
+
34
+ # store the examples from the pipelines
35
+ @examples = get_examples
36
+
37
+ @examples.select(&:failed?).map(&:name).uniq.each do |failed_example|
38
+ # count the number of failures consecutively for this example
39
+
40
+ number_of_failures = @examples.select { _1.name == failed_example && _1.failed? }.size
41
+ if number_of_failures >= threshold
42
+ example = @examples.find { _1.name == failed_example }
43
+
44
+ log :quarantine, "Quarantining #{failed_example} (#{number_of_failures} >= #{threshold})", 3
45
+
46
+ # check to see if there is a merge request first
47
+ # if there is no merge request...
48
+ # - Check for an existing issue
49
+ # - Check for an existing Quarantine MR
50
+ # - Add a quarantine tag: `it 'should be quarantined', quarantine: { issue: 'https://issue', type: :investigating }`
51
+ # - File the merge request
52
+
53
+ begin
54
+ # begin the quarantining process
55
+ failure_issue = get_failure_issue_for_example(example)
56
+ file_contents = get_example_file_contents(example)
57
+ new_contents, changed_line_no = add_quarantine_metadata(file_contents, example, failure_issue)
58
+
59
+ branch = create_branch(failure_issue, example)
60
+ commit_changes(branch, example, new_contents)
61
+ create_merge_request(example, branch, changed_line_no, failure_issue)
62
+
63
+ log :quarantine, "Done Quarantining #{failed_example}", 3
64
+ rescue => e
65
+ log :fatal, "There was an issue quarantining #{example}. Error was #{e.message}\n#{e.backtrace}"
66
+ end
67
+ end
68
+ end
69
+
70
+ log :gitlab, 'Done with Quarantine Process', 2
71
+ end
72
+
73
+ # Dequarantine Action - Automatically Dequarantine tests
74
+ def dequarantine
75
+
76
+ end
77
+
78
+ private
79
+
80
+ # Get last n amount of runs
81
+ # @param [Integer] threshold the amount of pipelines to fetch
82
+ # For instance, if threshold: 3, the amount of pipelines will return 6. (3x Successful, 3x Failed)
83
+ # @return [Array<Gitlab::ObjectifiedHash>] an array of pipelines returned from GitLab
84
+ def get_last_n_runs(threshold:)
85
+ pipelines = [] # collection of both passing and failing pipelines
86
+
87
+ pipelines << @gitlab_client.pipelines(project_id, per_page: threshold, status: :success)
88
+ pipelines << @gitlab_client.pipelines(project_id, per_page: threshold, status: :failed)
89
+ end
90
+
91
+ # Get examples from pipelines
92
+ # @param [Integer] threshold the amount of pipelines to fetch
93
+ # @note The threshold defaults to default threshold multiplied by two to the amount of pipelines
94
+ # @return [Array<Example>] array of examples
95
+ def get_examples(threshold: self.threshold * 2)
96
+ examples = []
97
+
98
+ get_last_n_runs(threshold: threshold).each do |run|
99
+ run.each do |pipeline|
100
+ # fetch the pipeline test report
101
+ @gitlab_client.pipeline_test_report(project_id, pipeline['id'])['test_suites'].each do |suite|
102
+ log :suite, "Suite: #{suite['name']} (#{pipeline['web_url']})", 4
103
+ suite['test_cases'].each do |example|
104
+ examples << Example.new(**example, occurrence: { job: suite['name'], pipeline_url: pipeline['web_url'] })
105
+ log :test, example['name'], 5
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ examples
112
+ end
113
+
114
+ # Query the GitLab Issues API to fetch QA failure issues
115
+ # @param [String,Example] example the name of the example to search for
116
+ # @option example [String] the name of the example to search for
117
+ # @option example [Example] an instance of Example
118
+ def get_failure_issue_for_example(example)
119
+ issues = @gitlab_client.issues(target_project,
120
+ labels: failure_issue_labels,
121
+ state: :opened,
122
+ search: "#{failure_issue_prefix}#{example}",
123
+ in: :title,
124
+ per_page: 1
125
+ )
126
+
127
+ if issues.any?
128
+ log :issue, "Found issue #{issues.first['web_url']} for `#{example}`", 4
129
+
130
+ issues.first
131
+ else
132
+ log :fatal, "No failure issue exists for `#{example}`. Skipping."
133
+ end
134
+ end
135
+
136
+ # Get the file contents of an example
137
+ # @param [Example] example the example to get the contents of
138
+ def get_example_file_contents(example)
139
+ example.file = example.file.sub('./', File.join(pwd, '/')) unless pwd == '.'
140
+
141
+ @gitlab_client.file_contents(target_project, example.file)
142
+ end
143
+
144
+ # Add quarantine metadata to the file content and replace it
145
+ # @param [String] content the content to
146
+ # @param [Example] example the example to find and replace
147
+ # @param [Gitlab::ObjectifiedHash] failure_issue the failure issue
148
+ # @return [Array<String, Integer>] first value holds the new content, the second value holds the line number of the test
149
+ def add_quarantine_metadata(content, example, failure_issue)
150
+ content_to_return = content.dup
151
+
152
+ best_match = -1
153
+ lines = content_to_return.split("\n")
154
+ example.name.split.reverse.each do |word|
155
+ # given a full-descriptive RSpec example name:
156
+ # Plan Group Iterations creates a group iteration
157
+ # scan these words backwards, incrementing the index until we find the best match
158
+ new_match = content_to_return.index(word)
159
+ best_match = new_match if new_match > best_match
160
+ end
161
+
162
+ # the matched line where the example is
163
+ match = content_to_return[best_match..].split("\n").first
164
+ matched_line_no = 0
165
+
166
+ lines.each_with_index do |line, line_no|
167
+ if line.match?(match)
168
+ matched_line_no = line_no + 1
169
+
170
+ log :rspec, "Found match on line #{example.file}:#{matched_line_no}", 4
171
+
172
+ if line.include?(',')
173
+ line[line.index(',')] = QUARANTINE_METADATA % failure_issue['web_url'] << ','
174
+ else
175
+ line[line.rindex(' ')] = QUARANTINE_METADATA % failure_issue['web_url'] << ' '
176
+ end
177
+ end
178
+ end
179
+
180
+ [lines.join("\n") << "\n", matched_line_no]
181
+ end
182
+
183
+ # Create a branch from the ref
184
+ # @param [Gitlab::ObjectifiedHash] failure_issue the existing failure issue
185
+ # @return [Gitlab::ObjectifiedHash] the new branch
186
+ def create_branch(failure_issue, example)
187
+ branch = @gitlab_client.create_branch(target_project, "#{failure_issue['iid']}-quarantine-#{example.name.tr(' ', '-')}", ref)
188
+
189
+ log :branch, "Created branch #{branch['name']} (#{branch['web_url']})", 4
190
+
191
+ branch
192
+ end
193
+
194
+ # Commit changes to a branch
195
+ # @param [Gitlab::ObjectifiedHash] branch the branch to commit to
196
+ # @param [Example] example the example
197
+ # @param [Gitlab::ObjectifiedHash] new_content the new content to commit
198
+ # @return [Gitlab::ObjectifiedHash] the commit
199
+ def commit_changes(branch, example, new_content)
200
+ message = <<~COMMIT
201
+ Quarantine end-to-end test
202
+
203
+ Quarantine #{example.name}
204
+ COMMIT
205
+
206
+ commit = @gitlab_client.create_commit(target_project, branch['name'], message, [
207
+ { action: :update, file_path: example.file, content: new_content}
208
+ ])
209
+
210
+ log :commit, "Created commit #{commit['id']} (#{commit['web_url']}) on #{branch['name']}", 4
211
+
212
+ commit
213
+ end
214
+
215
+ # Create a Merge Request with a given branch
216
+ # @param [Example] example the example
217
+ # @param [Gitlab::ObjectifiedHash] branch the branch
218
+ # @param [Integer] changed_line_number the line that was changed to quarantine
219
+ # @param [Gitlab::ObjectifiedHash] failure_issue the failure issue
220
+ # @return [Gitlab::ObjectifiedHash] the created merge request
221
+ def create_merge_request(example, branch, changed_line_number, failure_issue)
222
+ occurrences = @examples.select { _1.name == example.name && _1.failed? }
223
+ markdown_occurrences = []
224
+
225
+ @examples.map do |eggsample|
226
+ if eggsample.name == example.name && eggsample.failed?
227
+ markdown_occurrences << "1. [#{eggsample.occurrence[:job]}](#{eggsample.occurrence[:pipeline_url]})"
228
+ end
229
+ end
230
+
231
+ description = <<~MARKDOWN
232
+ ## What does this MR do?
233
+
234
+ Quarantines the test `#{example.name}` (#{example.file}:#{changed_line_number})
235
+
236
+ This test has been found by [Confiner](https://gitlab.com/gitlab-org/quality/confiner) to have been failing for
237
+ more than (or equal to) #{threshold} times. This test has failed #{occurrences.size} times.
238
+
239
+ #{markdown_occurrences.join("\n")}
240
+
241
+ > #{failure_issue['web_url']}
242
+
243
+ <div align="center">
244
+ (This MR was automatically generated by [Confiner](https://gitlab.com/gitlab-org/quality/confiner) at #{Time.now.utc})
245
+ </div>
246
+ MARKDOWN
247
+
248
+ merge_request = @gitlab_client.create_merge_request(target_project, MERGE_REQUEST_TITLE % example.name, source_branch: branch['name'], target_branch: ref, description: description, labels: failure_issue_labels, squash: true, remove_source_branch: true)
249
+
250
+ log :mr, "Created merge request #{merge_request['iid']} (#{merge_request['web_url']})", 4
251
+
252
+ merge_request
253
+ end
254
+ end
255
+ end
256
+ end
data/lib/confiner.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+
5
+ loader = Zeitwerk::Loader.new
6
+ loader.push_dir(__dir__)
7
+ loader.setup
8
+
9
+ module Confiner
10
+ VERSION = '0.1.0'
11
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: confiner
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - GitLab Quality
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-01-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 3.10.0
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 3.10.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: zeitwerk
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 2.5.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 2.5.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: gitlab
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 4.17.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 4.17.0
55
+ description:
56
+ email:
57
+ - quality+confiner@gitlab.com
58
+ executables:
59
+ - confiner
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - bin/confiner
64
+ - lib/confiner.rb
65
+ - lib/confiner/cli.rb
66
+ - lib/confiner/example.rb
67
+ - lib/confiner/logger.rb
68
+ - lib/confiner/plugin.rb
69
+ - lib/confiner/plugins/debug.rb
70
+ - lib/confiner/plugins/gitlab.rb
71
+ homepage: https://gitlab.com/gitlab-org/quality/confiner
72
+ licenses:
73
+ - MIT
74
+ metadata: {}
75
+ post_install_message:
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '2.5'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.2.22
91
+ signing_key:
92
+ specification_version: 4
93
+ summary: Yaml rule-based confinement
94
+ test_files: []