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 +7 -0
- data/bin/confiner +6 -0
- data/lib/confiner/cli.rb +174 -0
- data/lib/confiner/example.rb +38 -0
- data/lib/confiner/logger.rb +26 -0
- data/lib/confiner/plugin.rb +46 -0
- data/lib/confiner/plugins/debug.rb +17 -0
- data/lib/confiner/plugins/gitlab.rb +256 -0
- data/lib/confiner.rb +11 -0
- metadata +94 -0
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
data/lib/confiner/cli.rb
ADDED
@@ -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,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
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: []
|