confiner 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|