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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +93 -68
  3. data/README.md +35 -2
  4. data/exe/flaky-test-issues +7 -2
  5. data/exe/knapsack-report-issues +54 -0
  6. data/exe/update-test-meta +70 -0
  7. data/lefthook.yml +13 -0
  8. data/lib/gitlab_quality/test_tooling/concerns/find_set_dri.rb +47 -0
  9. data/lib/gitlab_quality/test_tooling/gitlab_client/branches_client.rb +18 -0
  10. data/lib/gitlab_quality/test_tooling/gitlab_client/branches_dry_client.rb +15 -0
  11. data/lib/gitlab_quality/test_tooling/gitlab_client/commits_client.rb +20 -0
  12. data/lib/gitlab_quality/test_tooling/gitlab_client/commits_dry_client.rb +14 -0
  13. data/lib/gitlab_quality/test_tooling/gitlab_client/gitlab_client.rb +12 -13
  14. data/lib/gitlab_quality/test_tooling/gitlab_client/issues_client.rb +6 -6
  15. data/lib/gitlab_quality/test_tooling/gitlab_client/merge_requests_client.rb +18 -10
  16. data/lib/gitlab_quality/test_tooling/gitlab_client/merge_requests_dry_client.rb +4 -2
  17. data/lib/gitlab_quality/test_tooling/gitlab_client/repository_files_client.rb +23 -0
  18. data/lib/gitlab_quality/test_tooling/knapsack_reports/spec_run_time.rb +85 -0
  19. data/lib/gitlab_quality/test_tooling/knapsack_reports/spec_run_time_report.rb +60 -0
  20. data/lib/gitlab_quality/test_tooling/report/concerns/issue_reports.rb +41 -28
  21. data/lib/gitlab_quality/test_tooling/report/concerns/utils.rb +23 -1
  22. data/lib/gitlab_quality/test_tooling/report/flaky_test_issue.rb +89 -44
  23. data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +1 -4
  24. data/lib/gitlab_quality/test_tooling/report/knapsack_report_issue.rb +139 -0
  25. data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +6 -12
  26. data/lib/gitlab_quality/test_tooling/report/report_as_issue.rb +16 -5
  27. data/lib/gitlab_quality/test_tooling/report/slow_test_issue.rb +71 -80
  28. data/lib/gitlab_quality/test_tooling/runtime/env.rb +5 -1
  29. data/lib/gitlab_quality/test_tooling/slack/post_to_slack_dry.rb +14 -0
  30. data/lib/gitlab_quality/test_tooling/test_meta/processor/add_to_blocking_processor.rb +143 -0
  31. data/lib/gitlab_quality/test_tooling/test_meta/processor/add_to_quarantine_processor.rb +199 -0
  32. data/lib/gitlab_quality/test_tooling/test_meta/processor/meta_processor.rb +44 -0
  33. data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +313 -0
  34. data/lib/gitlab_quality/test_tooling/version.rb +1 -1
  35. metadata +40 -9
  36. 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 # rubocop:disable Metrics/AbcSize
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
- warn_exception(e)
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
- `#{error_msg}`
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
- client.merge_request_changes(project, merge_request_iid)
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
- source_branch: source_branch,
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} description: #{description} labels: #{labels}"
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 = %r{(?<job_url>https://(?<host>[\w.]+)/(?<project_path>[\w\-./]+)/-/jobs/\d+)}
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 = /^1\. \d{4}-\d{2}-\d{2}: #{JOB_URL_REGEX} \((?<pipeline_url>.+)\)$/
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 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
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
- [new_issue_description, report_list_item(test)].join("\n")
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 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
+ 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
- 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)
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
- @merge_request_iid = merge_request_iid
39
+
40
+ @base_issue_labels = Set.new(base_issue_labels)
25
41
  end
26
42
 
27
43
  private
28
44
 
29
- attr_reader :merge_request_iid
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.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
53
+ process_test_results(test_results)
42
54
  end
43
55
  end
44
56
 
45
- def new_issue_title(test)
46
- "Flaky test in #{super}"
47
- end
57
+ def process_test_results(test_results)
58
+ test_results.each do |test|
59
+ next unless test_is_applicable?(test)
48
60
 
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
61
+ puts " => Reporting flakiness for test '#{test.name}'..."
56
62
 
57
- NEW_ISSUE_LABELS + [found_label]
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 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")
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 create_flaky_issue(test)
69
- puts " => Finding existing issues for flaky test '#{test.name}' (run time: #{test.run_time} seconds)..."
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
- issues = find_issues_by_hash(test_hash(test))
79
+ def update_reports(issues, test)
72
80
  issues.each do |issue|
73
- puts " => Existing issue link #{issue.web_url}."
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
- puts " => Adding the flaky test to the existing issue..."
76
- add_report_to_issue(issue, test)
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
- if issue.state == 'closed'
79
- puts " => Issue is closed. Reopening it."
80
- reopen_issue(issue)
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
- create_issue(test) unless issues.any?
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 add_report_to_issue(issue, test)
88
- gitlab.edit_issue(iid: issue.iid, options: { description: add_report_to_issue_description(issue, test) })
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 reopen_issue(issue)
92
- gitlab.edit_issue(iid: issue.iid, options: { state_event: 'reopen' })
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