gitlab_quality-test_tooling 1.5.4 → 1.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +6 -1
- data/README.md +21 -0
- data/exe/flaky-test-issues +54 -0
- data/exe/relate-failure-issue +9 -0
- data/lib/gitlab_quality/test_tooling/gitlab_issue_client.rb +10 -0
- data/lib/gitlab_quality/test_tooling/report/concerns/issue_reports.rb +83 -0
- data/lib/gitlab_quality/test_tooling/report/flaky_test_issue.rb +97 -0
- data/lib/gitlab_quality/test_tooling/report/merge_request_slow_tests_report.rb +0 -2
- data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +47 -65
- data/lib/gitlab_quality/test_tooling/report/report_as_issue.rb +19 -9
- data/lib/gitlab_quality/test_tooling/report/slow_test_issue.rb +1 -1
- data/lib/gitlab_quality/test_tooling/test_metric/json_test_metric.rb +46 -0
- data/lib/gitlab_quality/test_tooling/test_metrics/json_test_metric_collection.rb +45 -0
- data/lib/gitlab_quality/test_tooling/test_result/json_test_result.rb +19 -1
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- metadata +22 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4ee3407afd5274be75c1bb63c7f7aeeb0d2353cf62f0fb75c1393bd2103c8c1e
|
4
|
+
data.tar.gz: a4965dd9d979a58c4202c6f25315d60e08d597d51322db491a077a0ae797cf34
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1619807ec2456f02c9d4a1c09dfb0aeba598de7d51fe7a0377c1e5a9f3f9fc82cf607fa34387bb5ee1691ad28b5637e75c18c38ce068269c3bba76e3c902df22
|
7
|
+
data.tar.gz: '0940057d402dfb8fbcd3a6d2d09f7ff698e26d140ebdda659fcc5347445574fa7f398b2bd91e8a39c872b0f8a9f368fa1f734e59e10262790ed2d507fb97e514'
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
gitlab_quality-test_tooling (1.
|
4
|
+
gitlab_quality-test_tooling (1.8.0)
|
5
5
|
activesupport (>= 6.1, < 7.2)
|
6
6
|
amatch (~> 0.4.1)
|
7
7
|
gitlab (~> 4.19)
|
@@ -29,6 +29,7 @@ GEM
|
|
29
29
|
ast (2.4.2)
|
30
30
|
backport (1.2.0)
|
31
31
|
benchmark (0.2.1)
|
32
|
+
byebug (11.1.3)
|
32
33
|
claide (1.1.0)
|
33
34
|
claide-plugins (0.9.2)
|
34
35
|
cork
|
@@ -160,6 +161,9 @@ GEM
|
|
160
161
|
pry (0.14.2)
|
161
162
|
coderay (~> 1.1)
|
162
163
|
method_source (~> 1.0)
|
164
|
+
pry-byebug (3.10.1)
|
165
|
+
byebug (~> 11.0)
|
166
|
+
pry (>= 0.13, < 0.15)
|
163
167
|
public_suffix (5.0.1)
|
164
168
|
racc (1.6.2)
|
165
169
|
rack (3.0.7)
|
@@ -279,6 +283,7 @@ DEPENDENCIES
|
|
279
283
|
gitlab_quality-test_tooling!
|
280
284
|
guard-rspec (~> 4.7)
|
281
285
|
lefthook (~> 1.3)
|
286
|
+
pry-byebug (= 3.10.1)
|
282
287
|
rake (~> 13.0)
|
283
288
|
rspec (~> 3.12)
|
284
289
|
simplecov (~> 0.22)
|
data/README.md
CHANGED
@@ -77,14 +77,20 @@ Usage: exe/prepare-stage-reports [options]
|
|
77
77
|
Purpose: Relate test failures to failure issues from RSpec report files (JSON or JUnit XML)
|
78
78
|
Usage: exe/relate-failure-issue [options]
|
79
79
|
-i, --input-files INPUT_FILES RSpec report files (JSON or JUnit XML)
|
80
|
+
-m METRICS_FILES, Test metrics files (JSON)
|
81
|
+
--metrics-files
|
80
82
|
--max-diff-ratio MAX_DIFF_RATO
|
81
83
|
Max stacktrace diff ratio for failure issues detection
|
82
84
|
-p, --project PROJECT Can be an integer or a group/project string
|
83
85
|
-t, --token TOKEN A valid access token with `api` scope and Maintainer permission in PROJECT
|
86
|
+
-r RELATED_ISSUES_FILE, The file path for the related issues
|
87
|
+
--related-issues-file
|
84
88
|
--system-log-files SYSTEM_LOG_FILES
|
85
89
|
Include errors from system logs in failure issues
|
86
90
|
--base-issue-labels BASE_ISSUE_LABELS
|
87
91
|
Labels to add to new failure issues
|
92
|
+
--exclude-labels-for-search EXCLUDE_LABELS_FOR_SEARCH
|
93
|
+
Labels to exclude when searching for existing issues
|
88
94
|
--confidential Makes created new issues confidential
|
89
95
|
--dry-run Perform a dry-run (don't create or update issues)
|
90
96
|
-v, --version Show the version
|
@@ -133,6 +139,21 @@ Usage: exe/slow-test-issue [options]
|
|
133
139
|
-h, --help Show the usage
|
134
140
|
```
|
135
141
|
|
142
|
+
### `exe/flaky-test-issues`
|
143
|
+
|
144
|
+
```shell
|
145
|
+
Purpose: Purpose: Create flaky test issues for any passed test coming from rspec-retry JSON report files
|
146
|
+
Usage: exe/flaky-test-issue [options]
|
147
|
+
-i, --input-files INPUT_FILES JSON rspec-retry report files
|
148
|
+
-p, --project PROJECT Can be an integer or a group/project string
|
149
|
+
-m MERGE_REQUEST_IID, An integer merge request IID
|
150
|
+
--merge_request_iid
|
151
|
+
-t, --token TOKEN A valid access token with `api` scope and Maintainer permission in PROJECT
|
152
|
+
--dry-run Perform a dry-run (don't create note)
|
153
|
+
-v, --version Show the version
|
154
|
+
-h, --help Show the usage
|
155
|
+
```
|
156
|
+
|
136
157
|
### `exe/slow-test-merge-request-report-note`
|
137
158
|
|
138
159
|
```shell
|
@@ -0,0 +1,54 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "optparse"
|
6
|
+
|
7
|
+
require_relative "../lib/gitlab_quality/test_tooling"
|
8
|
+
|
9
|
+
params = {}
|
10
|
+
|
11
|
+
options = OptionParser.new do |opts|
|
12
|
+
opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
|
13
|
+
|
14
|
+
opts.on('-i', '--input-files INPUT_FILES', String, 'JSON rspec-retry report files') do |input_files|
|
15
|
+
params[:input_files] = input_files
|
16
|
+
end
|
17
|
+
|
18
|
+
opts.on('-p', '--project PROJECT', String, 'Can be an integer or a group/project string') do |project|
|
19
|
+
params[:project] = project
|
20
|
+
end
|
21
|
+
|
22
|
+
opts.on('-m', '--merge_request_iid MERGE_REQUEST_IID', String, 'An integer merge request IID') do |merge_request_iid|
|
23
|
+
params[:merge_request_iid] = merge_request_iid
|
24
|
+
end
|
25
|
+
|
26
|
+
opts.on('-t', '--token TOKEN', String, 'A valid access token with `api` scope and Maintainer permission in PROJECT') do |token|
|
27
|
+
params[:token] = token
|
28
|
+
end
|
29
|
+
|
30
|
+
opts.on('--dry-run', "Perform a dry-run (don't create issues)") do
|
31
|
+
params[:dry_run] = true
|
32
|
+
end
|
33
|
+
|
34
|
+
opts.on_tail('-v', '--version', 'Show the version') do
|
35
|
+
require_relative "../lib/gitlab_quality/test_tooling/version"
|
36
|
+
puts "#{$PROGRAM_NAME} : #{GitlabQuality::TestTooling::VERSION}"
|
37
|
+
exit
|
38
|
+
end
|
39
|
+
|
40
|
+
opts.on_tail('-h', '--help', 'Show the usage') do
|
41
|
+
puts "Purpose: Create flaky test issues for any passed test coming from rspec-retry JSON report files."
|
42
|
+
puts opts
|
43
|
+
exit
|
44
|
+
end
|
45
|
+
|
46
|
+
opts.parse(ARGV)
|
47
|
+
end
|
48
|
+
|
49
|
+
if params.any?
|
50
|
+
GitlabQuality::TestTooling::Report::FlakyTestIssue.new(**params).invoke!
|
51
|
+
else
|
52
|
+
puts options
|
53
|
+
exit 1
|
54
|
+
end
|
data/exe/relate-failure-issue
CHANGED
@@ -15,6 +15,10 @@ options = OptionParser.new do |opts|
|
|
15
15
|
params[:input_files] = input_files
|
16
16
|
end
|
17
17
|
|
18
|
+
opts.on('-m', '--metrics-files METRICS_FILES', String, 'Test metrics files (JSON)') do |metrics_files|
|
19
|
+
params[:metrics_files] = metrics_files
|
20
|
+
end
|
21
|
+
|
18
22
|
opts.on('--max-diff-ratio MAX_DIFF_RATO', Float, 'Max stacktrace diff ratio for failure issues detection') do |max_diff_ratio|
|
19
23
|
params[:max_diff_ratio] = max_diff_ratio
|
20
24
|
end
|
@@ -41,6 +45,11 @@ options = OptionParser.new do |opts|
|
|
41
45
|
params[:base_issue_labels] = base_issue_labels.split(',')
|
42
46
|
end
|
43
47
|
|
48
|
+
opts.on('--exclude-labels-for-search EXCLUDE_LABELS_FOR_SEARCH', String,
|
49
|
+
'Labels to exclude when searching for existing issues') do |exclude_labels_for_search|
|
50
|
+
params[:exclude_labels_for_search] = exclude_labels_for_search.split(',')
|
51
|
+
end
|
52
|
+
|
44
53
|
opts.on('--confidential', "Makes created new issues confidential") do
|
45
54
|
params[:confidential] = true
|
46
55
|
end
|
@@ -55,6 +55,16 @@ module GitlabQuality
|
|
55
55
|
end
|
56
56
|
end
|
57
57
|
|
58
|
+
def find_issues_by_hash(test_hash, &select)
|
59
|
+
select ||= :itself
|
60
|
+
|
61
|
+
handle_gitlab_client_exceptions do
|
62
|
+
client.search_in_project(project, 'issues', test_hash)
|
63
|
+
.auto_paginate
|
64
|
+
.select(&select)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
58
68
|
def find_issue_discussions(iid:)
|
59
69
|
handle_gitlab_client_exceptions do
|
60
70
|
client.issue_discussions(project, iid, order_by: 'created_at', sort: 'asc').auto_paginate
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitlabQuality
|
4
|
+
module TestTooling
|
5
|
+
module Report
|
6
|
+
module Concerns
|
7
|
+
module IssueReports
|
8
|
+
JOB_URL_REGEX = %r{(?<job_url>https://(?<host>[\w.]+)/(?<project_path>[\w\-./]+)/-/jobs/\d+)}
|
9
|
+
FAILED_JOB_DESCRIPTION_REGEX = /First happened in #{JOB_URL_REGEX}\./m
|
10
|
+
REPORT_ITEM_REGEX = /^1\. \d{4}-\d{2}-\d{2}: #{JOB_URL_REGEX} \((?<pipeline_url>.+)\)$/
|
11
|
+
|
12
|
+
def initial_reports_section(test)
|
13
|
+
<<~REPORTS
|
14
|
+
### Reports (1)
|
15
|
+
|
16
|
+
#{report_list_item(test)}
|
17
|
+
REPORTS
|
18
|
+
end
|
19
|
+
|
20
|
+
def add_report_to_issue_description(issue, test)
|
21
|
+
issue_description = issue.description
|
22
|
+
|
23
|
+
# We include the number of reports in the header, for visibility.
|
24
|
+
new_issue_description =
|
25
|
+
if issue_description.include?('### Reports')
|
26
|
+
# We count the number of existing reports.
|
27
|
+
reports_count = issue_description
|
28
|
+
.scan(REPORT_ITEM_REGEX)
|
29
|
+
.size.to_i + 1
|
30
|
+
issue_description.sub(/^### Reports.*$/, "### Reports (#{reports_count})")
|
31
|
+
else # For issue with the legacy format, we add the Reports section
|
32
|
+
update_legacy_issue_description(issue_description)
|
33
|
+
end
|
34
|
+
|
35
|
+
[new_issue_description, report_list_item(test)].join("\n")
|
36
|
+
end
|
37
|
+
|
38
|
+
def failed_issue_job_url(issue)
|
39
|
+
job_urls_from_description(issue.description, REPORT_ITEM_REGEX).last ||
|
40
|
+
# Legacy format
|
41
|
+
job_urls_from_description(issue.description, FAILED_JOB_DESCRIPTION_REGEX).last
|
42
|
+
end
|
43
|
+
|
44
|
+
def failed_issue_job_urls(issue)
|
45
|
+
job_urls_from_description(issue.description, REPORT_ITEM_REGEX) +
|
46
|
+
# Legacy format
|
47
|
+
job_urls_from_description(issue.description, FAILED_JOB_DESCRIPTION_REGEX)
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def report_list_item(test)
|
53
|
+
"1. #{Time.new.utc.strftime('%F')}: #{test.ci_job_url} (#{ENV.fetch('CI_PIPELINE_URL', 'pipeline url is missing')})"
|
54
|
+
end
|
55
|
+
|
56
|
+
def job_urls_from_description(issue_description, regex)
|
57
|
+
issue_description.lines.filter_map do |line|
|
58
|
+
match = line.match(regex)
|
59
|
+
match[:job_url] if match
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def update_legacy_issue_description(issue_description)
|
64
|
+
test_captures = issue_description.scan(JOB_URL_REGEX)
|
65
|
+
reports_count = test_captures.size.to_i + 1
|
66
|
+
|
67
|
+
updated_description = "#{issue_description}\n\n### Reports (#{reports_count})\n"
|
68
|
+
updated_description = [updated_description, *test_captures_to_report_items(test_captures)].join("\n") unless test_captures.empty?
|
69
|
+
updated_description
|
70
|
+
end
|
71
|
+
|
72
|
+
def test_captures_to_report_items(test_captures)
|
73
|
+
test_captures.map do |ci_job_url, _, _|
|
74
|
+
report_list_item(GitlabQuality::TestTooling::TestResult::JsonTestResult.new(
|
75
|
+
'ci_job_url' => ci_job_url
|
76
|
+
))
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitlabQuality
|
4
|
+
module TestTooling
|
5
|
+
module Report
|
6
|
+
# Uses the API to create GitLab issues for any passed test coming from JSON test reports.
|
7
|
+
# We expect the test reports to come from rspec-retry, or from a new RSpec process where
|
8
|
+
# we retried failing specs.
|
9
|
+
#
|
10
|
+
# - Takes the JSON test reports like rspec-*.json (typically from rspec-retry gem)`
|
11
|
+
# - Takes a project where flaky test issues should be created
|
12
|
+
# - For every passed test in the report:
|
13
|
+
# - Find issue by test hash
|
14
|
+
# - Reopen issue if it already exists, but is closed
|
15
|
+
class FlakyTestIssue < ReportAsIssue
|
16
|
+
include Concerns::IssueReports
|
17
|
+
|
18
|
+
NEW_ISSUE_LABELS = Set.new(['type::maintenance', 'priority::3', 'severity::3', 'failure::flaky-test']).freeze
|
19
|
+
FOUND_IN_MR_LABEL = 'found:in MR'
|
20
|
+
FOUND_IN_MASTER_LABEL = 'found:master'
|
21
|
+
|
22
|
+
def initialize(token:, input_files:, project: nil, merge_request_iid: nil, confidential: false, dry_run: false, **_kwargs)
|
23
|
+
super(token: token, input_files: input_files, project: project, confidential: confidential, dry_run: dry_run)
|
24
|
+
@merge_request_iid = merge_request_iid
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
attr_reader :merge_request_iid
|
30
|
+
|
31
|
+
def run!
|
32
|
+
puts "Reporting flaky tests in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
|
33
|
+
|
34
|
+
TestResults::Builder.new(files).test_results_per_file do |test_results|
|
35
|
+
puts "=> Reporting #{test_results.count} tests in #{test_results.path}"
|
36
|
+
|
37
|
+
test_results.each do |test|
|
38
|
+
next if test.status != 'passed' # We only want failed tests that passed in the end
|
39
|
+
|
40
|
+
create_flaky_issue(test)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def new_issue_title(test)
|
46
|
+
"Flaky test in #{super}"
|
47
|
+
end
|
48
|
+
|
49
|
+
def new_issue_labels(_test)
|
50
|
+
found_label =
|
51
|
+
if !merge_request_iid || merge_request_iid.empty?
|
52
|
+
FOUND_IN_MASTER_LABEL
|
53
|
+
else
|
54
|
+
FOUND_IN_MR_LABEL
|
55
|
+
end
|
56
|
+
|
57
|
+
NEW_ISSUE_LABELS + [found_label]
|
58
|
+
end
|
59
|
+
|
60
|
+
def new_issue_description(test)
|
61
|
+
super + [
|
62
|
+
"Flaky tests were detected, please refer to the [Flaky tests documentation](https://docs.gitlab.com/ee/development/testing_guide/flaky_tests.html) " \
|
63
|
+
"to learn more about how to reproduce them.",
|
64
|
+
initial_reports_section(test)
|
65
|
+
].compact.join("\n\n")
|
66
|
+
end
|
67
|
+
|
68
|
+
def create_flaky_issue(test)
|
69
|
+
puts " => Finding existing issues for flaky test '#{test.name}' (run time: #{test.run_time} seconds)..."
|
70
|
+
|
71
|
+
issues = find_issues_by_hash(test_hash(test))
|
72
|
+
issues.each do |issue|
|
73
|
+
puts " => Existing issue link #{issue.web_url}."
|
74
|
+
|
75
|
+
puts " => Adding the flaky test to the existing issue..."
|
76
|
+
add_report_to_issue(issue, test)
|
77
|
+
|
78
|
+
if issue.state == 'closed'
|
79
|
+
puts " => Issue is closed. Reopening it."
|
80
|
+
reopen_issue(issue)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
create_issue(test) unless issues.any?
|
85
|
+
end
|
86
|
+
|
87
|
+
def add_report_to_issue(issue, test)
|
88
|
+
gitlab.edit_issue(iid: issue.iid, options: { description: add_report_to_issue_description(issue, test) })
|
89
|
+
end
|
90
|
+
|
91
|
+
def reopen_issue(issue)
|
92
|
+
gitlab.edit_issue(iid: issue.iid, options: { state_event: 'reopen' })
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -7,7 +7,6 @@ module GitlabQuality
|
|
7
7
|
SLOW_TEST_MESSAGE = '<!-- slow-test -->'
|
8
8
|
SLOW_TEST_LABEL = '/label ~"rspec:slow test detected"'
|
9
9
|
SLOW_TEST_NOTE_SOURCE_CODE = 'Generated by [`gitlab_quality-test_tooling`](https://gitlab.com/gitlab-org/ruby/gems/gitlab_quality-test_tooling/-/blob/main/lib/gitlab_quality/test_tooling/report/merge_request_slow_tests_report.rb).'
|
10
|
-
SLOW_TEST_NOTE_FEEDBACK = 'Please [share your feedback and suggestions](https://gitlab.com/gitlab-org/quality/engineering-productivity/team/-/issues/289).'
|
11
10
|
|
12
11
|
def initialize(token:, input_files:, merge_request_iid:, project: nil, dry_run: false, **_kwargs)
|
13
12
|
@project = project
|
@@ -70,7 +69,6 @@ module GitlabQuality
|
|
70
69
|
SLOW_TEST_MESSAGE,
|
71
70
|
SLOW_TEST_LABEL,
|
72
71
|
":tools: #{SLOW_TEST_NOTE_SOURCE_CODE}\n",
|
73
|
-
":recycle: #{SLOW_TEST_NOTE_FEEDBACK}\n",
|
74
72
|
"---\n",
|
75
73
|
":snail: Slow tests detected in this merge request. These slow tests might be related to this merge request's changes.",
|
76
74
|
"<details><summary>Click to expand</summary>\n",
|
@@ -18,6 +18,7 @@ module GitlabQuality
|
|
18
18
|
class RelateFailureIssue < ReportAsIssue
|
19
19
|
include Concerns::FindSetDri
|
20
20
|
include Concerns::GroupAndCategoryLabels
|
21
|
+
include Concerns::IssueReports
|
21
22
|
include Amatch
|
22
23
|
|
23
24
|
DEFAULT_MAX_DIFF_RATIO_FOR_DETECTION = 0.15
|
@@ -25,9 +26,7 @@ module GitlabQuality
|
|
25
26
|
SPAM_THRESHOLD_FOR_FAILURE_ISSUES = 3
|
26
27
|
FAILURE_STACKTRACE_REGEX = %r{(?:(?:.*Failure/Error:(?<stacktrace>.+))|(?<stacktrace>.+))}m
|
27
28
|
ISSUE_STACKTRACE_REGEX = /### Stack trace\s*(```)#{FAILURE_STACKTRACE_REGEX}(```)/m
|
28
|
-
|
29
|
-
FAILED_JOB_DESCRIPTION_REGEX = /First happened in #{JOB_URL_REGEX}\./m
|
30
|
-
REPORT_ITEM_REGEX = /^1\. \d{4}-\d{2}-\d{2}: #{JOB_URL_REGEX} \((?<pipeline_url>.+)\)$/
|
29
|
+
|
31
30
|
NEW_ISSUE_LABELS = Set.new(%w[test failure::new priority::2]).freeze
|
32
31
|
IGNORED_FAILURES = [
|
33
32
|
'Net::ReadTimeout',
|
@@ -37,18 +36,26 @@ module GitlabQuality
|
|
37
36
|
|
38
37
|
MultipleIssuesFound = Class.new(StandardError)
|
39
38
|
|
40
|
-
def initialize(
|
39
|
+
def initialize(
|
40
|
+
max_diff_ratio: DEFAULT_MAX_DIFF_RATIO_FOR_DETECTION,
|
41
|
+
system_logs: [],
|
42
|
+
base_issue_labels: nil,
|
43
|
+
exclude_labels_for_search: nil,
|
44
|
+
metrics_files: [],
|
45
|
+
**kwargs)
|
41
46
|
super
|
42
47
|
@max_diff_ratio = max_diff_ratio.to_f
|
43
48
|
@system_logs = Dir.glob(system_logs)
|
44
49
|
@base_issue_labels = Set.new(base_issue_labels)
|
50
|
+
@exclude_labels_for_search = Set.new(exclude_labels_for_search)
|
45
51
|
@issue_type = 'issue'
|
46
52
|
@commented_issue_list = Set.new
|
53
|
+
@metrics_files = Array(metrics_files)
|
47
54
|
end
|
48
55
|
|
49
56
|
private
|
50
57
|
|
51
|
-
attr_reader :max_diff_ratio, :system_logs, :base_issue_labels
|
58
|
+
attr_reader :max_diff_ratio, :system_logs, :base_issue_labels, :exclude_labels_for_search, :metrics_files
|
52
59
|
|
53
60
|
def run!
|
54
61
|
puts "Reporting test failures in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
|
@@ -61,16 +68,39 @@ module GitlabQuality
|
|
61
68
|
write_issues_log_file
|
62
69
|
end
|
63
70
|
|
71
|
+
def test_metric_collections
|
72
|
+
@test_metric_collections ||= Dir.glob(metrics_files).map do |path|
|
73
|
+
TestMetrics::JsonTestMetricCollection.new(path)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
64
77
|
def process_test_results(test_results)
|
65
78
|
systemic_failures = systemic_failures_for_test_results(test_results)
|
66
79
|
|
67
80
|
test_results.each do |test|
|
68
81
|
collect_issues(test, relate_failure_to_issue(test)) if should_report?(test, systemic_failures)
|
82
|
+
|
83
|
+
copy_failure_issue_to_test_metrics(test) if metrics_files.any?
|
69
84
|
end
|
70
85
|
|
71
86
|
test_results.write
|
72
87
|
end
|
73
88
|
|
89
|
+
def copy_failure_issue_to_test_metrics(test)
|
90
|
+
failure_issue = test.failure_issue
|
91
|
+
|
92
|
+
return unless failure_issue
|
93
|
+
|
94
|
+
test_metric_collections.find do |test_metric_collection|
|
95
|
+
test_metric = test_metric_collection.metric_for_test_id(test.example_id)
|
96
|
+
|
97
|
+
if test_metric
|
98
|
+
test_metric.fields['failure_issue'] = failure_issue
|
99
|
+
test_metric_collection.write
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
74
104
|
def systemic_failures_for_test_results(test_results)
|
75
105
|
test_results
|
76
106
|
.flat_map { |test| test.failures.map { |failure| failure['message'].lines.first.chomp } }
|
@@ -136,35 +166,15 @@ module GitlabQuality
|
|
136
166
|
|
137
167
|
def pipeline_issues_with_similar_stacktrace(test)
|
138
168
|
search_labels = (base_issue_labels + Set.new(%w[test failure::new])).to_a
|
169
|
+
not_labels = exclude_labels_for_search.to_a
|
139
170
|
gitlab.find_issues(options: { labels: search_labels,
|
171
|
+
not: { labels: not_labels },
|
140
172
|
created_after: past_timestamp(2) }).select do |issue|
|
141
173
|
job_url_from_issue = failed_issue_job_url(issue)
|
142
174
|
|
143
175
|
next if pipeline != pipeline_env_from_job_url(job_url_from_issue)
|
144
176
|
|
145
|
-
|
146
|
-
stack_trace_from_test = cleaned_stacktrace_from_test(test)
|
147
|
-
diff_ratio = compare_stack_traces(stack_trace_from_test, stack_trace_from_issue)
|
148
|
-
diff_ratio < max_diff_ratio
|
149
|
-
end
|
150
|
-
end
|
151
|
-
|
152
|
-
def failed_issue_job_url(issue)
|
153
|
-
job_urls_from_description(issue.description, REPORT_ITEM_REGEX).last ||
|
154
|
-
# Legacy format
|
155
|
-
job_urls_from_description(issue.description, FAILED_JOB_DESCRIPTION_REGEX).last
|
156
|
-
end
|
157
|
-
|
158
|
-
def failed_issue_job_urls(issue)
|
159
|
-
job_urls_from_description(issue.description, REPORT_ITEM_REGEX) +
|
160
|
-
# Legacy format
|
161
|
-
job_urls_from_description(issue.description, FAILED_JOB_DESCRIPTION_REGEX)
|
162
|
-
end
|
163
|
-
|
164
|
-
def job_urls_from_description(issue_description, regex)
|
165
|
-
issue_description.lines.filter_map do |line|
|
166
|
-
match = line.match(regex)
|
167
|
-
match[:job_url] if match
|
177
|
+
compare_stack_traces(cleaned_stack_trace_from_test(test), cleaned_stack_trace_from_issue(issue)) < max_diff_ratio
|
168
178
|
end
|
169
179
|
end
|
170
180
|
|
@@ -184,7 +194,11 @@ module GitlabQuality
|
|
184
194
|
end
|
185
195
|
|
186
196
|
def failure_issues(test)
|
187
|
-
|
197
|
+
find_issues_for_test(
|
198
|
+
test,
|
199
|
+
labels: base_issue_labels + Set.new(%w[test]),
|
200
|
+
not_labels: exclude_labels_for_search
|
201
|
+
)
|
188
202
|
end
|
189
203
|
|
190
204
|
def full_stacktrace(test)
|
@@ -205,7 +219,7 @@ module GitlabQuality
|
|
205
219
|
remove_unique_resource_names(relevant_issue_stacktrace)
|
206
220
|
end
|
207
221
|
|
208
|
-
def
|
222
|
+
def cleaned_stack_trace_from_test(test)
|
209
223
|
test_failure_stacktrace = sanitize_stacktrace(full_stacktrace(test),
|
210
224
|
FAILURE_STACKTRACE_REGEX) || full_stacktrace(test)
|
211
225
|
remove_unique_resource_names(test_failure_stacktrace)
|
@@ -221,7 +235,7 @@ module GitlabQuality
|
|
221
235
|
end
|
222
236
|
|
223
237
|
def find_relevant_failure_issues(test) # rubocop:disable Metrics/AbcSize
|
224
|
-
clean_first_test_failure_stacktrace =
|
238
|
+
clean_first_test_failure_stacktrace = cleaned_stack_trace_from_test(test)
|
225
239
|
# Search with the `search` param returns 500 errors, so we filter by `base_issue_labels` and then filter further in Ruby
|
226
240
|
failure_issues(test).each_with_object({}) do |issue, memo|
|
227
241
|
clean_relevant_issue_stacktrace = cleaned_stack_trace_from_issue(issue)
|
@@ -289,7 +303,7 @@ module GitlabQuality
|
|
289
303
|
"```\n#{full_stacktrace(test)}\n```",
|
290
304
|
screenshot_section(test),
|
291
305
|
system_log_errors_section(test),
|
292
|
-
|
306
|
+
initial_reports_section(test)
|
293
307
|
].compact.join("\n\n")
|
294
308
|
end
|
295
309
|
|
@@ -312,18 +326,6 @@ module GitlabQuality
|
|
312
326
|
section
|
313
327
|
end
|
314
328
|
|
315
|
-
def reports_section(test)
|
316
|
-
<<~REPORTS
|
317
|
-
### Reports (1)
|
318
|
-
|
319
|
-
#{report_list_item(test)}
|
320
|
-
REPORTS
|
321
|
-
end
|
322
|
-
|
323
|
-
def report_list_item(test)
|
324
|
-
"1. #{Time.new.utc.strftime('%F')}: #{test.ci_job_url} (#{ENV.fetch('CI_PIPELINE_URL', 'pipeline url is missing')})"
|
325
|
-
end
|
326
|
-
|
327
329
|
def up_to_date_labels(test:, issue: nil, new_labels: Set.new)
|
328
330
|
(Set.new(base_issue_labels) + (super << pipeline_name_label)).to_a
|
329
331
|
end
|
@@ -348,7 +350,7 @@ module GitlabQuality
|
|
348
350
|
state_event = issue.state == 'closed' ? 'reopen' : nil
|
349
351
|
|
350
352
|
issue_attrs = {
|
351
|
-
description:
|
353
|
+
description: add_report_to_issue_description(issue, test),
|
352
354
|
labels: up_to_date_labels(test: test, issue: issue)
|
353
355
|
}
|
354
356
|
issue_attrs[:state_event] = state_event if state_event
|
@@ -357,26 +359,6 @@ module GitlabQuality
|
|
357
359
|
puts " => Added a report in '#{issue.title}': #{issue.web_url}!"
|
358
360
|
end
|
359
361
|
|
360
|
-
def up_to_date_issue_description(issue_description, test)
|
361
|
-
# We include the number of reports in the header, for visibility.
|
362
|
-
new_issue_description =
|
363
|
-
if issue_description.include?('### Reports')
|
364
|
-
# We count the number of existing reports.
|
365
|
-
reports_count = issue_description
|
366
|
-
.scan(REPORT_ITEM_REGEX)
|
367
|
-
.size.to_i + 1
|
368
|
-
issue_description.sub(/^### Reports.*$/, "### Reports (#{reports_count})")
|
369
|
-
else # For issue with the legacy format, we add the Reports section
|
370
|
-
reports_count = issue_description
|
371
|
-
.scan(JOB_URL_REGEX)
|
372
|
-
.size.to_i + 1
|
373
|
-
|
374
|
-
"#{issue_description}\n\n### Reports (#{reports_count})"
|
375
|
-
end
|
376
|
-
|
377
|
-
"#{new_issue_description}\n#{report_list_item(test)}"
|
378
|
-
end
|
379
|
-
|
380
362
|
def new_issue_title(test)
|
381
363
|
"Failure in #{super}"
|
382
364
|
end
|
@@ -39,7 +39,10 @@ module GitlabQuality
|
|
39
39
|
end
|
40
40
|
|
41
41
|
def test_hash(test)
|
42
|
-
|
42
|
+
# Should not be more than 50 characters if we want it indexed.
|
43
|
+
#
|
44
|
+
# See https://gitlab.com/gitlab-org/ruby/gems/gitlab_quality-test_tooling/-/issues/27#note_1607276486
|
45
|
+
OpenSSL::Digest.hexdigest('SHA256', "#{test.file}#{test.name}")[..40]
|
43
46
|
end
|
44
47
|
|
45
48
|
def new_issue_description(test)
|
@@ -48,7 +51,8 @@ module GitlabQuality
|
|
48
51
|
|
49
52
|
| Field | Value |
|
50
53
|
| ------ | ------ |
|
51
|
-
| File | #{test.test_file_link} |
|
54
|
+
| File URL | #{test.test_file_link} |
|
55
|
+
| Filename | `#{test.file}` |
|
52
56
|
| Description | `#{test.name}` |
|
53
57
|
| Test level | #{test.level} |
|
54
58
|
| Hash | `#{test_hash(test)}` |
|
@@ -134,15 +138,21 @@ module GitlabQuality
|
|
134
138
|
labels
|
135
139
|
end
|
136
140
|
|
137
|
-
def
|
138
|
-
|
141
|
+
def find_issues_by_hash(test_hash)
|
142
|
+
gitlab.find_issues_by_hash(test_hash)
|
143
|
+
end
|
144
|
+
|
145
|
+
def find_issues_for_test(test, labels:, not_labels: Set.new, state: nil)
|
146
|
+
search_options = { labels: labels.to_a, not: { labels: not_labels.to_a } }
|
139
147
|
search_options[:state] = state if state
|
140
148
|
|
141
|
-
gitlab.find_issues(options: search_options).find_all
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
149
|
+
gitlab.find_issues(options: search_options).find_all { |issue| issue_match_test?(issue, test) }
|
150
|
+
end
|
151
|
+
|
152
|
+
def issue_match_test?(issue, test)
|
153
|
+
issue_title = issue.title.strip
|
154
|
+
test_file_path_found = !test.file.to_s.empty? && issue_title.include?(partial_file_path(test.file))
|
155
|
+
issue_title.include?(test.name) || test_file_path_found
|
146
156
|
end
|
147
157
|
|
148
158
|
def pipeline_name_label
|
@@ -68,7 +68,7 @@ module GitlabQuality
|
|
68
68
|
def create_slow_issue(test)
|
69
69
|
puts " => Finding existing issues for slow test '#{test.name}' (run time: #{test.run_time} seconds)..."
|
70
70
|
|
71
|
-
issues =
|
71
|
+
issues = find_issues_for_test(test, labels: SEARCH_LABELS, state: 'opened')
|
72
72
|
|
73
73
|
if issues.blank?
|
74
74
|
issues << create_issue(test)
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitlabQuality
|
4
|
+
module TestTooling
|
5
|
+
module TestMetric
|
6
|
+
class JsonTestMetric
|
7
|
+
attr_reader :metric
|
8
|
+
|
9
|
+
def initialize(metric)
|
10
|
+
@metric = metric
|
11
|
+
end
|
12
|
+
|
13
|
+
def name
|
14
|
+
metric.fetch('name')
|
15
|
+
end
|
16
|
+
|
17
|
+
def time
|
18
|
+
metric.fetch('time')
|
19
|
+
end
|
20
|
+
|
21
|
+
def tags
|
22
|
+
@tags ||= metric.fetch('tags')
|
23
|
+
end
|
24
|
+
|
25
|
+
def fields
|
26
|
+
@fields ||= metric.fetch('fields')
|
27
|
+
end
|
28
|
+
|
29
|
+
def to_json(*options)
|
30
|
+
as_json.to_json(*options)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def as_json
|
36
|
+
{
|
37
|
+
name: name,
|
38
|
+
time: time,
|
39
|
+
tags: tags,
|
40
|
+
fields: fields
|
41
|
+
}
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module GitlabQuality
|
6
|
+
module TestTooling
|
7
|
+
module TestMetrics
|
8
|
+
class JsonTestMetricCollection
|
9
|
+
include Enumerable
|
10
|
+
|
11
|
+
attr_reader :path, :metrics
|
12
|
+
|
13
|
+
def initialize(path)
|
14
|
+
@path = path
|
15
|
+
@metrics = process
|
16
|
+
end
|
17
|
+
|
18
|
+
def metric_for_test_id(test_id)
|
19
|
+
metrics.find do |metric|
|
20
|
+
metric.fields['id'] == test_id
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def write
|
25
|
+
File.write(path, JSON.pretty_generate(metrics))
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def parse
|
31
|
+
JSON.parse(File.read(path))
|
32
|
+
rescue JSON::ParserError
|
33
|
+
Runtime::Logger.debug("#{self.class.name}##{__method__} attempted to parse invalid JSON at path: #{path}")
|
34
|
+
{}
|
35
|
+
end
|
36
|
+
|
37
|
+
def process
|
38
|
+
parse.map do |test|
|
39
|
+
GitlabQuality::TestTooling::TestMetric::JsonTestMetric.new(test)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -18,7 +18,25 @@ module GitlabQuality
|
|
18
18
|
].freeze
|
19
19
|
|
20
20
|
def name
|
21
|
-
|
21
|
+
# If we see a string representation of an object in a test full_description, we discard it.
|
22
|
+
#
|
23
|
+
# This is to ensure that tests would have a reproducible name, in case they don't have a name.
|
24
|
+
#
|
25
|
+
# Test example:
|
26
|
+
#
|
27
|
+
# it { is_expected.to eq(secondary_node) }
|
28
|
+
#
|
29
|
+
# Would have its full_description as follows:
|
30
|
+
#
|
31
|
+
# Gitlab::Geo.proxied_site on a primary for a proxied request with a proxy extra data header
|
32
|
+
# for an existing site is expected to eq #<GeoNode id: 116, primary: false, oauth_application_id: 97
|
33
|
+
# , enabled: true, access_key: [FILTERED], e...pdated_at: "2023-10-10 08:49:49.797128469 +0000",
|
34
|
+
# sync_object_storage: true, secret_access_key: nil>
|
35
|
+
#
|
36
|
+
# Which would change for every test run due to the timestamps.
|
37
|
+
#
|
38
|
+
# See https://gitlab.com/gitlab-org/ruby/gems/gitlab_quality-test_tooling/-/merge_requests/77#note_1608793804
|
39
|
+
report.fetch('full_description').split('#<').first
|
22
40
|
end
|
23
41
|
|
24
42
|
def file
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: gitlab_quality-test_tooling
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- GitLab Quality
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-11-
|
11
|
+
date: 2023-11-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: climate_control
|
@@ -80,6 +80,20 @@ dependencies:
|
|
80
80
|
- - "~>"
|
81
81
|
- !ruby/object:Gem::Version
|
82
82
|
version: '1.3'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: pry-byebug
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - '='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 3.10.1
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - '='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 3.10.1
|
83
97
|
- !ruby/object:Gem::Dependency
|
84
98
|
name: rake
|
85
99
|
requirement: !ruby/object:Gem::Requirement
|
@@ -332,6 +346,7 @@ description: A collection of test-related tools.
|
|
332
346
|
email:
|
333
347
|
- quality@gitlab.com
|
334
348
|
executables:
|
349
|
+
- flaky-test-issues
|
335
350
|
- generate-test-session
|
336
351
|
- post-to-slack
|
337
352
|
- prepare-stage-reports
|
@@ -357,6 +372,7 @@ files:
|
|
357
372
|
- LICENSE.txt
|
358
373
|
- README.md
|
359
374
|
- Rakefile
|
375
|
+
- exe/flaky-test-issues
|
360
376
|
- exe/generate-test-session
|
361
377
|
- exe/post-to-slack
|
362
378
|
- exe/prepare-stage-reports
|
@@ -374,8 +390,10 @@ files:
|
|
374
390
|
- lib/gitlab_quality/test_tooling/labels_inference.rb
|
375
391
|
- lib/gitlab_quality/test_tooling/report/concerns/find_set_dri.rb
|
376
392
|
- lib/gitlab_quality/test_tooling/report/concerns/group_and_category_labels.rb
|
393
|
+
- lib/gitlab_quality/test_tooling/report/concerns/issue_reports.rb
|
377
394
|
- lib/gitlab_quality/test_tooling/report/concerns/results_reporter.rb
|
378
395
|
- lib/gitlab_quality/test_tooling/report/concerns/utils.rb
|
396
|
+
- lib/gitlab_quality/test_tooling/report/flaky_test_issue.rb
|
379
397
|
- lib/gitlab_quality/test_tooling/report/generate_test_session.rb
|
380
398
|
- lib/gitlab_quality/test_tooling/report/issue_logger.rb
|
381
399
|
- lib/gitlab_quality/test_tooling/report/merge_request_slow_tests_report.rb
|
@@ -404,6 +422,8 @@ files:
|
|
404
422
|
- lib/gitlab_quality/test_tooling/system_logs/log_types/rails/graphql_log.rb
|
405
423
|
- lib/gitlab_quality/test_tooling/system_logs/shared_fields.rb
|
406
424
|
- lib/gitlab_quality/test_tooling/system_logs/system_logs_formatter.rb
|
425
|
+
- lib/gitlab_quality/test_tooling/test_metric/json_test_metric.rb
|
426
|
+
- lib/gitlab_quality/test_tooling/test_metrics/json_test_metric_collection.rb
|
407
427
|
- lib/gitlab_quality/test_tooling/test_result/base_test_result.rb
|
408
428
|
- lib/gitlab_quality/test_tooling/test_result/j_unit_test_result.rb
|
409
429
|
- lib/gitlab_quality/test_tooling/test_result/json_test_result.rb
|