gitlab_quality-test_tooling 1.11.0 → 1.17.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 +4 -4
- data/Gemfile.lock +93 -68
- data/README.md +35 -2
- data/exe/flaky-test-issues +7 -2
- data/exe/knapsack-report-issues +54 -0
- data/exe/update-test-meta +70 -0
- data/lefthook.yml +13 -0
- data/lib/gitlab_quality/test_tooling/concerns/find_set_dri.rb +47 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/branches_client.rb +18 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/branches_dry_client.rb +15 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/commits_client.rb +20 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/commits_dry_client.rb +14 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/gitlab_client.rb +12 -13
- data/lib/gitlab_quality/test_tooling/gitlab_client/issues_client.rb +6 -6
- data/lib/gitlab_quality/test_tooling/gitlab_client/merge_requests_client.rb +18 -10
- data/lib/gitlab_quality/test_tooling/gitlab_client/merge_requests_dry_client.rb +4 -2
- data/lib/gitlab_quality/test_tooling/gitlab_client/repository_files_client.rb +23 -0
- data/lib/gitlab_quality/test_tooling/knapsack_reports/spec_run_time.rb +85 -0
- data/lib/gitlab_quality/test_tooling/knapsack_reports/spec_run_time_report.rb +60 -0
- data/lib/gitlab_quality/test_tooling/report/concerns/issue_reports.rb +41 -28
- data/lib/gitlab_quality/test_tooling/report/concerns/utils.rb +23 -1
- data/lib/gitlab_quality/test_tooling/report/flaky_test_issue.rb +89 -44
- data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +1 -4
- data/lib/gitlab_quality/test_tooling/report/knapsack_report_issue.rb +139 -0
- data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +6 -12
- data/lib/gitlab_quality/test_tooling/report/report_as_issue.rb +16 -5
- data/lib/gitlab_quality/test_tooling/report/slow_test_issue.rb +71 -80
- data/lib/gitlab_quality/test_tooling/runtime/env.rb +5 -1
- data/lib/gitlab_quality/test_tooling/slack/post_to_slack_dry.rb +14 -0
- data/lib/gitlab_quality/test_tooling/test_meta/processor/add_to_blocking_processor.rb +143 -0
- data/lib/gitlab_quality/test_tooling/test_meta/processor/add_to_quarantine_processor.rb +199 -0
- data/lib/gitlab_quality/test_tooling/test_meta/processor/meta_processor.rb +44 -0
- data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +313 -0
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- metadata +40 -9
- data/lib/gitlab_quality/test_tooling/report/concerns/find_set_dri.rb +0 -49
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitlabQuality
|
|
4
|
+
module TestTooling
|
|
5
|
+
module GitlabClient
|
|
6
|
+
class CommitsClient < GitlabClient
|
|
7
|
+
def create(branch_name, file_path, new_content, message)
|
|
8
|
+
commit = handle_gitlab_client_exceptions do
|
|
9
|
+
client.create_commit(project, branch_name, message, [
|
|
10
|
+
{ action: :update, file_path: file_path, content: new_content }
|
|
11
|
+
])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
Runtime::Logger.debug("Created commit #{commit['id']} (#{commit['web_url']}) on #{branch_name}") if commit
|
|
15
|
+
commit
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitlabQuality
|
|
4
|
+
module TestTooling
|
|
5
|
+
module GitlabClient
|
|
6
|
+
class CommitsDryClient < CommitsClient
|
|
7
|
+
def create(branch_name, file_path, new_content, message)
|
|
8
|
+
puts "A commit would have been created on branch_name: #{branch_name}, file_path: #{file_path}, message: #{message} and content:"
|
|
9
|
+
puts new_content
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -15,24 +15,32 @@ module GitlabQuality
|
|
|
15
15
|
@retry_backoff = 0
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def handle_gitlab_client_exceptions
|
|
18
|
+
def handle_gitlab_client_exceptions
|
|
19
19
|
yield
|
|
20
20
|
rescue Gitlab::Error::NotFound
|
|
21
21
|
# This error could be raised in assert_user_permission!
|
|
22
22
|
# If so, we want it to terminate at that point
|
|
23
23
|
raise
|
|
24
24
|
rescue SystemCallError, OpenSSL::SSL::SSLError, Net::OpenTimeout, Net::ReadTimeout,
|
|
25
|
-
Gitlab::Error::InternalServerError, Gitlab::Error::Parsing => e
|
|
25
|
+
Gitlab::Error::InternalServerError, Gitlab::Error::BadRequest, Gitlab::Error::ResponseError, Gitlab::Error::Parsing => e
|
|
26
26
|
@retry_backoff += RETRY_BACK_OFF_DELAY
|
|
27
27
|
|
|
28
28
|
raise if @retry_backoff > RETRY_BACK_OFF_DELAY * MAX_RETRY_ATTEMPTS
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
warn("#{error.class.name} #{error.message}")
|
|
31
31
|
warn("Sleeping for #{@retry_backoff} seconds before retrying...")
|
|
32
32
|
sleep @retry_backoff
|
|
33
33
|
|
|
34
34
|
retry
|
|
35
35
|
rescue StandardError => e
|
|
36
|
+
post_exception_to_slack(e) if Runtime::Env.ci_commit_ref_name == Runtime::Env.default_branch
|
|
37
|
+
|
|
38
|
+
raise e
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def post_exception_to_slack(error)
|
|
42
|
+
return unless ENV['CI_SLACK_WEBHOOK_URL']
|
|
43
|
+
|
|
36
44
|
pipeline = Runtime::Env.pipeline_from_project_name
|
|
37
45
|
channel = case pipeline
|
|
38
46
|
when "canary"
|
|
@@ -42,9 +50,6 @@ module GitlabQuality
|
|
|
42
50
|
else
|
|
43
51
|
"qa-#{pipeline}"
|
|
44
52
|
end
|
|
45
|
-
error_msg = warn_exception(e)
|
|
46
|
-
|
|
47
|
-
return unless Runtime::Env.ci_commit_ref_name == Runtime::Env.default_branch
|
|
48
53
|
|
|
49
54
|
slack_options = {
|
|
50
55
|
slack_webhook_url: ENV.fetch('CI_SLACK_WEBHOOK_URL', nil),
|
|
@@ -54,7 +59,7 @@ module GitlabQuality
|
|
|
54
59
|
message: <<~MSG
|
|
55
60
|
An unexpected error occurred while reporting test results in issues.
|
|
56
61
|
The error occurred in job: #{Runtime::Env.ci_job_url}
|
|
57
|
-
`#{
|
|
62
|
+
`#{error.class.name} #{error.message}`
|
|
58
63
|
MSG
|
|
59
64
|
}
|
|
60
65
|
puts "Posting Slack message to channel: #{channel}"
|
|
@@ -79,12 +84,6 @@ module GitlabQuality
|
|
|
79
84
|
private_token: token
|
|
80
85
|
)
|
|
81
86
|
end
|
|
82
|
-
|
|
83
|
-
def warn_exception(error)
|
|
84
|
-
error_msg = "#{error.class.name} #{error.message}"
|
|
85
|
-
warn(error_msg)
|
|
86
|
-
error_msg
|
|
87
|
-
end
|
|
88
87
|
end
|
|
89
88
|
end
|
|
90
89
|
end
|
|
@@ -48,6 +48,12 @@ module GitlabQuality
|
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
def find_issue_notes(iid:)
|
|
52
|
+
handle_gitlab_client_exceptions do
|
|
53
|
+
client.issue_notes(project, iid, order_by: 'created_at', sort: 'asc').auto_paginate
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
51
57
|
def find_issue_discussions(iid:)
|
|
52
58
|
handle_gitlab_client_exceptions do
|
|
53
59
|
client.issue_discussions(project, iid, order_by: 'created_at', sort: 'asc').auto_paginate
|
|
@@ -75,12 +81,6 @@ module GitlabQuality
|
|
|
75
81
|
end
|
|
76
82
|
end
|
|
77
83
|
|
|
78
|
-
def find_issue_notes(iid:)
|
|
79
|
-
handle_gitlab_client_exceptions do
|
|
80
|
-
client.issue_notes(project, iid, order_by: 'created_at', sort: 'asc')&.auto_paginate
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
84
|
def create_issue_note(iid:, note:)
|
|
85
85
|
handle_gitlab_client_exceptions do
|
|
86
86
|
client.create_issue_note(project, iid, note)
|
|
@@ -1,28 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'gitlab'
|
|
4
|
-
|
|
5
3
|
module GitlabQuality
|
|
6
4
|
module TestTooling
|
|
7
5
|
module GitlabClient
|
|
8
6
|
class MergeRequestsClient < GitlabClient
|
|
9
7
|
def find_merge_request_changes(merge_request_iid:)
|
|
10
|
-
|
|
8
|
+
handle_gitlab_client_exceptions do
|
|
9
|
+
client.merge_request_changes(project, merge_request_iid)
|
|
10
|
+
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def create_merge_request(title:, source_branch:, target_branch:, description:, labels:)
|
|
13
|
+
def create_merge_request(title:, source_branch:, target_branch:, description:, labels:, assignee_id: nil, reviewer_ids: [])
|
|
14
|
+
attrs = {
|
|
15
|
+
source_branch: source_branch,
|
|
16
|
+
target_branch: target_branch,
|
|
17
|
+
description: description,
|
|
18
|
+
labels: labels,
|
|
19
|
+
assignee_id: assignee_id,
|
|
20
|
+
squash: true,
|
|
21
|
+
remove_source_branch: true,
|
|
22
|
+
reviewer_ids: reviewer_ids
|
|
23
|
+
}.compact
|
|
24
|
+
|
|
14
25
|
merge_request = handle_gitlab_client_exceptions do
|
|
15
26
|
client.create_merge_request(project,
|
|
16
27
|
title,
|
|
17
|
-
|
|
18
|
-
target_branch: target_branch,
|
|
19
|
-
description: description,
|
|
20
|
-
labels: labels,
|
|
21
|
-
squash: true,
|
|
22
|
-
remove_source_branch: true)
|
|
28
|
+
attrs)
|
|
23
29
|
end
|
|
24
30
|
|
|
25
31
|
Runtime::Logger.debug("Created merge request #{merge_request['iid']} (#{merge_request['web_url']})") if merge_request
|
|
32
|
+
|
|
33
|
+
merge_request
|
|
26
34
|
end
|
|
27
35
|
|
|
28
36
|
def find(iid: nil, options: {}, &select)
|
|
@@ -26,9 +26,11 @@ module GitlabQuality
|
|
|
26
26
|
puts "The following note would have been updated id: #{id} with body: #{note} for mr_iid: #{merge_request_iid}"
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def create_merge_request(title:, source_branch:, target_branch:, description:, labels:)
|
|
29
|
+
def create_merge_request(title:, source_branch:, target_branch:, description:, labels:, assignee_id:, reviewer_ids:)
|
|
30
30
|
puts "A merge request would be created with title: #{title} " \
|
|
31
|
-
"source_branch: #{source_branch} target_branch: #{target_branch}
|
|
31
|
+
"source_branch: #{source_branch} target_branch: #{target_branch} " \
|
|
32
|
+
"description: #{description} labels: #{labels}, assignee_id: #{assignee_id}" \
|
|
33
|
+
"reviewer_ids: #{reviewer_ids}"
|
|
32
34
|
end
|
|
33
35
|
end
|
|
34
36
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitlabQuality
|
|
4
|
+
module TestTooling
|
|
5
|
+
module GitlabClient
|
|
6
|
+
class RepositoryFilesClient < GitlabClient
|
|
7
|
+
attr_reader :file_path
|
|
8
|
+
|
|
9
|
+
def initialize(file_path:, **kwargs)
|
|
10
|
+
@file_path = file_path
|
|
11
|
+
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def file_contents
|
|
16
|
+
handle_gitlab_client_exceptions do
|
|
17
|
+
client.file_contents(project, file_path.gsub(%r{^/}, ""))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitlabQuality
|
|
4
|
+
module TestTooling
|
|
5
|
+
module KnapsackReports
|
|
6
|
+
class SpecRunTime
|
|
7
|
+
attr_reader :file, :expected, :actual, :expected_suite_duration, :actual_suite_duration
|
|
8
|
+
|
|
9
|
+
ACTUAL_TO_EXPECTED_SPEC_RUN_TIME_RATIO_THRESHOLD = 1.5 # actual run time is longer than expected by 50% +
|
|
10
|
+
SPEC_WEIGHT_PERCENTAGE_TRESHOLD = 15 # a spec file takes 15%+ of the total test suite run time
|
|
11
|
+
SUITE_DURATION_THRESHOLD = 70 * 60 # if test suite takes more than 70 minutes, job risks timing out
|
|
12
|
+
|
|
13
|
+
def initialize(file:, expected:, actual:, expected_suite_duration:, actual_suite_duration:)
|
|
14
|
+
@file = file
|
|
15
|
+
@expected = expected.to_f
|
|
16
|
+
@actual = actual.to_f
|
|
17
|
+
@expected_suite_duration = expected_suite_duration.to_f
|
|
18
|
+
@actual_suite_duration = actual_suite_duration.to_f
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def should_report?
|
|
22
|
+
# guideline proposed in https://gitlab.com/gitlab-org/quality/engineering-productivity/team/-/issues/354
|
|
23
|
+
exceed_actual_to_expected_ratio_threshold? && test_suite_bottleneck?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ci_pipeline_url_markdown
|
|
27
|
+
"[#{ci_pipeline_id}](#{ci_pipeline_url})"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ci_pipeline_created_at
|
|
31
|
+
ENV.fetch('CI_PIPELINE_CREATED_AT', nil)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ci_job_link_markdown
|
|
35
|
+
"[#{ci_job_name}](#{ci_job_url})"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def file_link_markdown
|
|
39
|
+
"[#{file}](#{file_link})"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def actual_percentage
|
|
43
|
+
(actual / actual_suite_duration * 100).round(2)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def name
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def exceed_actual_to_expected_ratio_threshold?
|
|
53
|
+
actual / expected >= ACTUAL_TO_EXPECTED_SPEC_RUN_TIME_RATIO_THRESHOLD
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def test_suite_bottleneck?
|
|
57
|
+
# now we only report bottlenecks when they risk causing job timeouts
|
|
58
|
+
return unless actual_suite_duration > SUITE_DURATION_THRESHOLD
|
|
59
|
+
|
|
60
|
+
actual_percentage > SPEC_WEIGHT_PERCENTAGE_TRESHOLD
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def ci_job_url
|
|
64
|
+
ENV.fetch('CI_JOB_URL', nil)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def ci_job_name
|
|
68
|
+
ENV.fetch('CI_JOB_NAME_SLUG', nil)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ci_pipeline_id
|
|
72
|
+
ENV.fetch('CI_PIPELINE_IID', nil)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def ci_pipeline_url
|
|
76
|
+
ENV.fetch('CI_PIPELINE_URL', nil)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def file_link
|
|
80
|
+
"#{Runtime::Env.file_base_url}#{file}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module GitlabQuality
|
|
6
|
+
module TestTooling
|
|
7
|
+
module KnapsackReports
|
|
8
|
+
class SpecRunTimeReport
|
|
9
|
+
attr_reader :expected_report, :actual_report
|
|
10
|
+
|
|
11
|
+
def initialize(expected_report_path:, actual_report_path:)
|
|
12
|
+
@expected_report = parse(expected_report_path)
|
|
13
|
+
@actual_report = parse(actual_report_path)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def filtered_report
|
|
17
|
+
@filtered_report = actual_report.keys.filter_map do |spec_file|
|
|
18
|
+
expected_run_time = expected_report[spec_file]
|
|
19
|
+
actual_run_time = actual_report[spec_file]
|
|
20
|
+
|
|
21
|
+
if expected_run_time.nil?
|
|
22
|
+
puts "#{spec_file} missing from the expected Knapsack report, skipping."
|
|
23
|
+
next
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
spec_run_time = SpecRunTime.new(
|
|
27
|
+
file: spec_file,
|
|
28
|
+
expected: expected_run_time,
|
|
29
|
+
actual: actual_run_time,
|
|
30
|
+
expected_suite_duration: expected_test_suite_run_time_total,
|
|
31
|
+
actual_suite_duration: actual_test_suite_run_time_total
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
spec_run_time if spec_run_time.should_report?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def parse(report_path)
|
|
41
|
+
JSON.parse(File.read(report_path))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def expected_test_suite_run_time_total
|
|
45
|
+
@expected_test_suite_run_time_total ||=
|
|
46
|
+
expected_report.reduce(0) do |total_run_time, (_spec_file, run_time)|
|
|
47
|
+
total_run_time + run_time
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def actual_test_suite_run_time_total
|
|
52
|
+
@actual_test_suite_run_time_total ||=
|
|
53
|
+
actual_report.reduce(0) do |total_run_time, (_spec_file, run_time)|
|
|
54
|
+
total_run_time + run_time
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'active_support/core_ext/object/blank'
|
|
4
|
+
|
|
3
5
|
module GitlabQuality
|
|
4
6
|
module TestTooling
|
|
5
7
|
module Report
|
|
6
8
|
module Concerns
|
|
7
9
|
module IssueReports
|
|
8
|
-
JOB_URL_REGEX
|
|
10
|
+
JOB_URL_REGEX = %r{(?<job_url>https://(?<host>[\w.]+)/(?<project_path>[\w\-./]+)/-/jobs/\d+)}
|
|
9
11
|
FAILED_JOB_DESCRIPTION_REGEX = /First happened in #{JOB_URL_REGEX}\./m
|
|
10
|
-
REPORT_ITEM_REGEX
|
|
12
|
+
REPORT_ITEM_REGEX = /^1\. \d{4}-\d{2}-\d{2}: #{JOB_URL_REGEX} \((?<pipeline_url>\S+)\)/
|
|
13
|
+
LATEST_REPORTS_TO_SHOW = 10
|
|
11
14
|
|
|
12
15
|
def initial_reports_section(test)
|
|
13
16
|
<<~REPORTS
|
|
@@ -17,22 +20,21 @@ module GitlabQuality
|
|
|
17
20
|
REPORTS
|
|
18
21
|
end
|
|
19
22
|
|
|
20
|
-
def
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
23
|
+
def increment_reports(
|
|
24
|
+
current_reports_content:,
|
|
25
|
+
test:,
|
|
26
|
+
reports_section_header: '### Reports',
|
|
27
|
+
item_extra_content: nil,
|
|
28
|
+
reports_extra_content: nil)
|
|
29
|
+
preserved_content = current_reports_content.split(reports_section_header).first&.strip
|
|
30
|
+
reports = report_lines(current_reports_content) + [report_list_item(test, item_extra_content: item_extra_content)]
|
|
34
31
|
|
|
35
|
-
[
|
|
32
|
+
[
|
|
33
|
+
preserved_content,
|
|
34
|
+
"#{reports_section_header} (#{reports.size})",
|
|
35
|
+
reports_list(reports),
|
|
36
|
+
reports_extra_content
|
|
37
|
+
].reject(&:blank?).compact.join("\n\n")
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def failed_issue_job_url(issue)
|
|
@@ -49,8 +51,28 @@ module GitlabQuality
|
|
|
49
51
|
|
|
50
52
|
private
|
|
51
53
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
+
def report_lines(content)
|
|
55
|
+
content.lines.grep(REPORT_ITEM_REGEX).map(&:strip)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reports_list(reports)
|
|
59
|
+
sorted_reports = reports.sort.reverse
|
|
60
|
+
|
|
61
|
+
if sorted_reports.size > LATEST_REPORTS_TO_SHOW
|
|
62
|
+
[
|
|
63
|
+
"Last 10 reports:",
|
|
64
|
+
sorted_reports[...LATEST_REPORTS_TO_SHOW].join("\n"),
|
|
65
|
+
"<details><summary>See #{sorted_reports.size - LATEST_REPORTS_TO_SHOW} more reports</summary>",
|
|
66
|
+
sorted_reports[LATEST_REPORTS_TO_SHOW..].join("\n"),
|
|
67
|
+
"</details>"
|
|
68
|
+
].join("\n\n")
|
|
69
|
+
else
|
|
70
|
+
sorted_reports.join("\n")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def report_list_item(test, item_extra_content: nil)
|
|
75
|
+
"1. #{Time.new.utc.strftime('%F')}: #{test.ci_job_url} (#{ENV.fetch('CI_PIPELINE_URL', 'pipeline url is missing')}) #{item_extra_content}".strip
|
|
54
76
|
end
|
|
55
77
|
|
|
56
78
|
def job_urls_from_description(issue_description, regex)
|
|
@@ -60,15 +82,6 @@ module GitlabQuality
|
|
|
60
82
|
end
|
|
61
83
|
end
|
|
62
84
|
|
|
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
85
|
def test_captures_to_report_items(test_captures)
|
|
73
86
|
test_captures.map do |ci_job_url, _, _|
|
|
74
87
|
report_list_item(GitlabQuality::TestTooling::TestResult::JsonTestResult.new(
|
|
@@ -16,7 +16,7 @@ module GitlabQuality
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def new_issue_title(test)
|
|
19
|
-
"#{partial_file_path(test.file)} | #{search_safe(test.name)}".strip
|
|
19
|
+
"[Test] #{partial_file_path(test.file)} | #{search_safe(test.name)}".strip
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def partial_file_path(path)
|
|
@@ -45,6 +45,28 @@ module GitlabQuality
|
|
|
45
45
|
|
|
46
46
|
@pipeline ||= Runtime::Env.pipeline_from_project_name
|
|
47
47
|
end
|
|
48
|
+
|
|
49
|
+
def readable_duration(duration_in_seconds)
|
|
50
|
+
minutes = (duration_in_seconds / 60).to_i
|
|
51
|
+
seconds = (duration_in_seconds % 60).round(2)
|
|
52
|
+
|
|
53
|
+
min_output = normalize_duration_output(minutes, 'minute')
|
|
54
|
+
sec_output = normalize_duration_output(seconds, 'second')
|
|
55
|
+
|
|
56
|
+
"#{min_output} #{sec_output}".strip
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def normalize_duration_output(number, unit)
|
|
62
|
+
if number <= 0
|
|
63
|
+
""
|
|
64
|
+
elsif number <= 1
|
|
65
|
+
"#{number} #{unit}"
|
|
66
|
+
else
|
|
67
|
+
"#{number} #{unit}s"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
48
70
|
end
|
|
49
71
|
end
|
|
50
72
|
end
|
|
@@ -13,20 +13,36 @@ module GitlabQuality
|
|
|
13
13
|
# - Find issue by test hash
|
|
14
14
|
# - Reopen issue if it already exists, but is closed
|
|
15
15
|
class FlakyTestIssue < ReportAsIssue
|
|
16
|
+
include Concerns::GroupAndCategoryLabels
|
|
16
17
|
include Concerns::IssueReports
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
IDENTITY_LABELS = ['test', 'failure::flaky-test', 'automation:bot-authored'].freeze
|
|
20
|
+
NEW_ISSUE_LABELS = Set.new(['type::maintenance', 'priority::3', 'severity::3', *IDENTITY_LABELS]).freeze
|
|
21
|
+
SEARCH_LABELS = ['test'].freeze
|
|
22
|
+
FOUND_IN_MR_LABEL = '~"found:in MR"'
|
|
23
|
+
FOUND_IN_MASTER_LABEL = '~"found:master"'
|
|
24
|
+
REPORT_SECTION_HEADER = '### Flakiness reports'
|
|
25
|
+
REPORTS_DOCUMENTATION = <<~DOC
|
|
26
|
+
Flaky tests were detected, please refer to the [Flaky tests documentation](https://docs.gitlab.com/ee/development/testing_guide/flaky_tests.html)
|
|
27
|
+
to learn more about how to reproduce them.
|
|
28
|
+
DOC
|
|
29
|
+
|
|
30
|
+
def initialize(
|
|
31
|
+
token:,
|
|
32
|
+
input_files:,
|
|
33
|
+
base_issue_labels: nil,
|
|
34
|
+
confidential: false,
|
|
35
|
+
dry_run: false,
|
|
36
|
+
project: nil,
|
|
37
|
+
**_kwargs)
|
|
23
38
|
super(token: token, input_files: input_files, project: project, confidential: confidential, dry_run: dry_run)
|
|
24
|
-
|
|
39
|
+
|
|
40
|
+
@base_issue_labels = Set.new(base_issue_labels)
|
|
25
41
|
end
|
|
26
42
|
|
|
27
43
|
private
|
|
28
44
|
|
|
29
|
-
attr_reader :
|
|
45
|
+
attr_reader :base_issue_labels
|
|
30
46
|
|
|
31
47
|
def run!
|
|
32
48
|
puts "Reporting flaky tests in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
|
|
@@ -34,62 +50,91 @@ module GitlabQuality
|
|
|
34
50
|
TestResults::Builder.new(files).test_results_per_file do |test_results|
|
|
35
51
|
puts "=> Reporting #{test_results.count} tests in #{test_results.path}"
|
|
36
52
|
|
|
37
|
-
test_results
|
|
38
|
-
next if test.status != 'passed' # We only want failed tests that passed in the end
|
|
39
|
-
|
|
40
|
-
create_flaky_issue(test)
|
|
41
|
-
end
|
|
53
|
+
process_test_results(test_results)
|
|
42
54
|
end
|
|
43
55
|
end
|
|
44
56
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
def process_test_results(test_results)
|
|
58
|
+
test_results.each do |test|
|
|
59
|
+
next unless test_is_applicable?(test)
|
|
48
60
|
|
|
49
|
-
|
|
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
|
|
61
|
+
puts " => Reporting flakiness for test '#{test.name}'..."
|
|
56
62
|
|
|
57
|
-
|
|
63
|
+
issues = find_issues_by_hash(test_hash(test), state: 'opened', labels: SEARCH_LABELS)
|
|
64
|
+
issues << create_issue(test) if issues.empty?
|
|
65
|
+
|
|
66
|
+
update_reports(issues, test)
|
|
67
|
+
collect_issues(test, issues)
|
|
68
|
+
end
|
|
58
69
|
end
|
|
59
70
|
|
|
60
|
-
def
|
|
61
|
-
|
|
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")
|
|
71
|
+
def test_is_applicable?(test)
|
|
72
|
+
test.status == 'passed' # We only want failed tests that passed in the end
|
|
66
73
|
end
|
|
67
74
|
|
|
68
|
-
def
|
|
69
|
-
|
|
75
|
+
def up_to_date_labels(test:, issue: nil, new_labels: Set.new)
|
|
76
|
+
(base_issue_labels + super).to_a
|
|
77
|
+
end
|
|
70
78
|
|
|
71
|
-
|
|
79
|
+
def update_reports(issues, test)
|
|
72
80
|
issues.each do |issue|
|
|
73
|
-
puts " =>
|
|
81
|
+
puts " => Adding the flaky test to the existing issue: #{issue.web_url}"
|
|
82
|
+
add_report_to_issue(issue: issue, test: test, related_issues: (issues - [issue]))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
74
85
|
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
def add_report_to_issue(issue:, test:, related_issues:)
|
|
87
|
+
reports_note = existing_reports_note(issue: issue)
|
|
88
|
+
note_body = [
|
|
89
|
+
report_body(reports_note: reports_note, test: test),
|
|
90
|
+
identity_labels_quick_action,
|
|
91
|
+
relate_issues_quick_actions(related_issues)
|
|
92
|
+
].join("\n")
|
|
93
|
+
|
|
94
|
+
if reports_note
|
|
95
|
+
gitlab.edit_issue_note(
|
|
96
|
+
issue_iid: issue.iid,
|
|
97
|
+
note_id: reports_note.id,
|
|
98
|
+
note: note_body
|
|
99
|
+
)
|
|
100
|
+
else
|
|
101
|
+
gitlab.create_issue_note(iid: issue.iid, note: note_body)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
77
104
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
end
|
|
105
|
+
def existing_reports_note(issue:)
|
|
106
|
+
gitlab.find_issue_notes(iid: issue.iid).find do |note|
|
|
107
|
+
note.body.start_with?(REPORT_SECTION_HEADER)
|
|
82
108
|
end
|
|
109
|
+
end
|
|
83
110
|
|
|
84
|
-
|
|
111
|
+
def report_body(reports_note:, test:)
|
|
112
|
+
increment_reports(
|
|
113
|
+
current_reports_content: reports_note&.body.to_s,
|
|
114
|
+
test: test,
|
|
115
|
+
reports_section_header: REPORT_SECTION_HEADER,
|
|
116
|
+
item_extra_content: found_label,
|
|
117
|
+
reports_extra_content: REPORTS_DOCUMENTATION
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def found_label
|
|
122
|
+
if ENV.key?('CI_MERGE_REQUEST_IID')
|
|
123
|
+
FOUND_IN_MR_LABEL
|
|
124
|
+
else
|
|
125
|
+
FOUND_IN_MASTER_LABEL
|
|
126
|
+
end
|
|
85
127
|
end
|
|
86
128
|
|
|
87
|
-
def
|
|
88
|
-
|
|
129
|
+
def identity_labels_quick_action
|
|
130
|
+
labels_list = IDENTITY_LABELS.map { |label| %(~"#{label}") }.join(' ')
|
|
131
|
+
%(/label #{labels_list})
|
|
89
132
|
end
|
|
90
133
|
|
|
91
|
-
def
|
|
92
|
-
|
|
134
|
+
def relate_issues_quick_actions(issues)
|
|
135
|
+
issues.map do |issue|
|
|
136
|
+
"/relate #{issue.web_url}"
|
|
137
|
+
end.join("\n")
|
|
93
138
|
end
|
|
94
139
|
end
|
|
95
140
|
end
|