gitlab_quality-test_tooling 1.15.0 → 1.18.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8364acd36abab9ce89c0846c346b893ca041a6a740ced7dc5c784da5990ea74c
4
- data.tar.gz: 9468d8b559aeec587f9c3722e4f6e9d539d790828f9ecc7f815cc39ca22f2657
3
+ metadata.gz: 948fbcd939a7800a4538113fddc717642c4d9fed7dab6d650cd9052c1832f19b
4
+ data.tar.gz: fbc4cee23cbcd3f7631ac6a03d41f2b8e287590777fef813289013d982efc4f7
5
5
  SHA512:
6
- metadata.gz: 4d287ca74d5fe7bc1bd9acd25541eb6f150f717f95f9e8c0bdf7a53ac139f3dab5186565ec74f2f7279e50dd47268d22121eb94ba2e3955cc5f6de33e7a952c0
7
- data.tar.gz: e2954fea3d65bdce6e72f26a201965f45cc3aab583a34af9835f4d42f71540ad21582f4f8a96a12cd377a949644ab458ea213febfb2e6e90fbdc25c1867ddb72
6
+ metadata.gz: 34c8a65eaf48e4ef9800df34a66f0086d3915252444091631fbd76273731008f70be149e3c92d5954644e55839d8a314eb3442411e3a389061e460d66c9d1ea2
7
+ data.tar.gz: 29f34824d1c5bba8dbe979a10c0e93a8c4c06e7b0b174b96b0150c19e3d6f71bb0fa9a8ef285a2d650af407547084a5e1a7bb2925817dce5d200b6755e864d4c
data/Gemfile.lock CHANGED
@@ -1,8 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (1.15.0)
5
- activesupport (>= 6.1, < 7.1)
4
+ gitlab_quality-test_tooling (1.18.0)
5
+ activesupport (>= 6.1, < 7.2)
6
6
  amatch (~> 0.4.1)
7
7
  gitlab (~> 4.19)
8
8
  http (~> 5.0)
@@ -16,10 +16,15 @@ PATH
16
16
  GEM
17
17
  remote: https://rubygems.org/
18
18
  specs:
19
- activesupport (7.0.8)
19
+ activesupport (7.1.3.2)
20
+ base64
21
+ bigdecimal
20
22
  concurrent-ruby (~> 1.0, >= 1.0.2)
23
+ connection_pool (>= 2.2.5)
24
+ drb
21
25
  i18n (>= 1.6, < 2)
22
26
  minitest (>= 5.1)
27
+ mutex_m
23
28
  tzinfo (~> 2.0)
24
29
  addressable (2.8.6)
25
30
  public_suffix (>= 2.0.2, < 6.0)
@@ -28,7 +33,9 @@ GEM
28
33
  tins (~> 1.0)
29
34
  ast (2.4.2)
30
35
  backport (1.2.0)
36
+ base64 (0.2.0)
31
37
  benchmark (0.3.0)
38
+ bigdecimal (3.1.6)
32
39
  binding_of_caller (1.0.0)
33
40
  debug_inspector (>= 0.0.1)
34
41
  byebug (11.1.3)
@@ -41,6 +48,7 @@ GEM
41
48
  coderay (1.1.3)
42
49
  colored2 (3.1.2)
43
50
  concurrent-ruby (1.2.3)
51
+ connection_pool (2.4.1)
44
52
  cork (0.3.0)
45
53
  colored2 (~> 3.1)
46
54
  crack (0.4.5)
@@ -65,6 +73,7 @@ GEM
65
73
  diff-lcs (1.5.0)
66
74
  docile (1.4.0)
67
75
  domain_name (0.6.20240107)
76
+ drb (2.2.1)
68
77
  e2mmap (0.1.0)
69
78
  faraday (2.9.0)
70
79
  faraday-net_http (>= 2.0, < 3.2)
@@ -119,7 +128,7 @@ GEM
119
128
  httparty (0.21.0)
120
129
  mini_mime (>= 1.0.0)
121
130
  multi_xml (>= 0.5.2)
122
- i18n (1.14.1)
131
+ i18n (1.14.4)
123
132
  concurrent-ruby (~> 1.0)
124
133
  jaro_winkler (1.5.6)
125
134
  json (2.7.1)
@@ -138,10 +147,11 @@ GEM
138
147
  method_source (1.0.0)
139
148
  mini_mime (1.1.5)
140
149
  mini_portile2 (2.8.5)
141
- minitest (5.21.2)
150
+ minitest (5.22.2)
142
151
  mize (0.4.1)
143
152
  protocol (~> 2.0)
144
153
  multi_xml (0.6.0)
154
+ mutex_m (0.2.0)
145
155
  nap (1.1.0)
146
156
  nenv (0.3.0)
147
157
  net-http (0.4.1)
@@ -318,4 +328,4 @@ DEPENDENCIES
318
328
  webmock (= 3.7.0)
319
329
 
320
330
  BUNDLED WITH
321
- 2.4.2
331
+ 2.5.4
@@ -19,10 +19,6 @@ options = OptionParser.new do |opts|
19
19
  params[:project] = project
20
20
  end
21
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
22
  opts.on('--base-issue-labels BASE_ISSUE_LABELS', String,
27
23
  'Comma-separated labels (without tilde) to add to new flaky test issues') do |base_issue_labels|
28
24
  params[:base_issue_labels] = base_issue_labels.split(',')
@@ -32,6 +28,10 @@ options = OptionParser.new do |opts|
32
28
  params[:token] = token
33
29
  end
34
30
 
31
+ opts.on('-r', '--related-issues-file RELATED_ISSUES_FILE', String, 'The file path for the related issues') do |related_issues_file|
32
+ params[:related_issues_file] = related_issues_file
33
+ end
34
+
35
35
  opts.on('--dry-run', "Perform a dry-run (don't create issues)") do
36
36
  params[:dry_run] = true
37
37
  end
@@ -31,6 +31,10 @@ options = OptionParser.new do |opts|
31
31
  params[:issue_url_file] = issue_url_file
32
32
  end
33
33
 
34
+ opts.on('--pipeline-stages STAGES', STRING, 'Comma-separated list of pipeline stages to include in test session issue') do |pipeline_stages|
35
+ params[:pipeline_stages] = pipeline_stages.split(',')
36
+ end
37
+
34
38
  opts.on('--confidential', "Makes test session issue confidential") do
35
39
  params[:confidential] = true
36
40
  end
data/lefthook.yml CHANGED
@@ -13,3 +13,16 @@ pre-push:
13
13
  rubocop:
14
14
  run: bundle exec rubocop
15
15
  glob: '*.rb'
16
+
17
+ # Changelog git trailer for the first commit of the branch
18
+ changelog-on-first-commit:
19
+ run: |
20
+ first_commit_message=$(git log --format=%B -n 1 $(git log main..HEAD --pretty=format:"%h" | tail -1))
21
+ if ! echo ${first_commit_message} | grep "Changelog:"; then
22
+ echo Could not find a Changelog: git trailer on the first commit for this branch.
23
+ echo
24
+ echo Please add a trailer by amending the git commit message.
25
+ echo
26
+ echo See https://docs.gitlab.com/ee/development/changelog.html#overview for more info.
27
+ exit 1
28
+ fi
@@ -15,7 +15,7 @@ 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!
@@ -27,12 +27,20 @@ module GitlabQuality
27
27
 
28
28
  raise if @retry_backoff > RETRY_BACK_OFF_DELAY * MAX_RETRY_ATTEMPTS
29
29
 
30
- warn_exception(e)
30
+ warn("#{e.class.name} #{e.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,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)
@@ -13,11 +13,19 @@ 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'
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
21
29
 
22
30
  def initialize(
23
31
  token:,
@@ -25,18 +33,16 @@ module GitlabQuality
25
33
  base_issue_labels: nil,
26
34
  confidential: false,
27
35
  dry_run: false,
28
- merge_request_iid: nil,
29
36
  project: nil,
30
37
  **_kwargs)
31
38
  super(token: token, input_files: input_files, project: project, confidential: confidential, dry_run: dry_run)
32
39
 
33
40
  @base_issue_labels = Set.new(base_issue_labels)
34
- @merge_request_iid = merge_request_iid
35
41
  end
36
42
 
37
43
  private
38
44
 
39
- attr_reader :base_issue_labels, :merge_request_iid
45
+ attr_reader :base_issue_labels
40
46
 
41
47
  def run!
42
48
  puts "Reporting flaky tests in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
@@ -44,62 +50,91 @@ module GitlabQuality
44
50
  TestResults::Builder.new(files).test_results_per_file do |test_results|
45
51
  puts "=> Reporting #{test_results.count} tests in #{test_results.path}"
46
52
 
47
- test_results.each do |test|
48
- next if test.status != 'passed' # We only want failed tests that passed in the end
49
-
50
- create_flaky_issue(test)
51
- end
53
+ process_test_results(test_results)
52
54
  end
53
55
  end
54
56
 
55
- def new_issue_title(test)
56
- "Flaky test in #{super}"
57
- end
57
+ def process_test_results(test_results)
58
+ test_results.each do |test|
59
+ next unless test_is_applicable?(test)
58
60
 
59
- def new_issue_labels(_test)
60
- found_label =
61
- if !merge_request_iid || merge_request_iid.empty?
62
- FOUND_IN_MASTER_LABEL
63
- else
64
- FOUND_IN_MR_LABEL
65
- end
61
+ puts " => Reporting flakiness for test '#{test.name}'..."
66
62
 
67
- NEW_ISSUE_LABELS + base_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
68
69
  end
69
70
 
70
- def new_issue_description(test)
71
- super + [
72
- "Flaky tests were detected, please refer to the [Flaky tests documentation](https://docs.gitlab.com/ee/development/testing_guide/flaky_tests.html) " \
73
- "to learn more about how to reproduce them.",
74
- initial_reports_section(test)
75
- ].compact.join("\n\n")
71
+ def test_is_applicable?(test)
72
+ test.status == 'passed' # We only want failed tests that passed in the end
76
73
  end
77
74
 
78
- def create_flaky_issue(test)
79
- 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
80
78
 
81
- issues = find_issues_by_hash(test_hash(test))
79
+ def update_reports(issues, test)
82
80
  issues.each do |issue|
83
- 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
84
85
 
85
- puts " => Adding the flaky test to the existing issue..."
86
- 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
87
104
 
88
- if issue.state == 'closed'
89
- puts " => Issue is closed. Reopening it."
90
- reopen_issue(issue)
91
- 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)
92
108
  end
109
+ end
93
110
 
94
- 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
95
127
  end
96
128
 
97
- def add_report_to_issue(issue, test)
98
- 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})
99
132
  end
100
133
 
101
- def reopen_issue(issue)
102
- 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")
103
138
  end
104
139
  end
105
140
  end
@@ -7,15 +7,16 @@ module GitlabQuality
7
7
  module TestTooling
8
8
  module Report
9
9
  class GenerateTestSession < ReportAsIssue
10
- def initialize(ci_project_token:, **kwargs)
10
+ def initialize(ci_project_token:, pipeline_stages: nil, **kwargs)
11
11
  super
12
12
  @ci_project_token = ci_project_token
13
+ @pipeline_stages = Set.new(pipeline_stages)
13
14
  @issue_type = 'issue'
14
15
  end
15
16
 
16
17
  private
17
18
 
18
- attr_reader :ci_project_token
19
+ attr_reader :ci_project_token, :pipeline_stages
19
20
 
20
21
  # rubocop:disable Metrics/AbcSize
21
22
  def run!
@@ -27,6 +28,8 @@ module GitlabQuality
27
28
  TestResults::JsonTestResults.new(path).to_a
28
29
  end
29
30
 
31
+ tests = tests.select { |test| pipeline_stages.include? test.report["stage"] } unless pipeline_stages.empty?
32
+
30
33
  issue = gitlab.create_issue(
31
34
  title: "#{Time.now.strftime('%Y-%m-%d')} Test session report | #{Runtime::Env.qa_run_type}",
32
35
  description: generate_description(tests),
@@ -205,12 +208,9 @@ module GitlabQuality
205
208
 
206
209
  def generate_test_text(testcase, tests_with_same_testcase, passed)
207
210
  text = tests_with_same_testcase.map(&:name).uniq.join(', ')
208
- encoded_text = ERB::Util.url_encode(text)
209
211
 
210
212
  if testcase && !passed
211
- # Workaround for reducing system notes on testcase issues
212
- # The first regex extracts the link to the issues list page from a link to a single issue show page by removing the issue id.
213
- "[#{text}](#{testcase.match(%r{[\s\S]+/[^/\d]+})}?state=opened&search=#{encoded_text})"
213
+ "[#{text}](#{testcase})"
214
214
  else
215
215
  text
216
216
  end
@@ -99,7 +99,6 @@ module GitlabQuality
99
99
  end
100
100
 
101
101
  def update_issue(issue:, spec_run_time:)
102
- state_event = issue.state == 'closed' ? 'reopen' : nil
103
102
  updated_description = <<~MARKDOWN.chomp
104
103
  #{issue.description}
105
104
 
@@ -110,8 +109,6 @@ module GitlabQuality
110
109
  description: updated_description
111
110
  }
112
111
 
113
- issue_attrs[:state_event] = state_event if state_event
114
-
115
112
  gitlab.edit_issue(iid: issue.iid, options: issue_attrs)
116
113
  puts " => Added a report in #{issue.web_url}!"
117
114
  end
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'nokogiri'
4
- require 'active_support/core_ext/enumerable'
5
4
  require 'rubygems/text'
6
- require 'active_support/core_ext/integer/time'
7
5
  require 'amatch'
8
6
 
9
7
  module GitlabQuality
@@ -190,8 +188,8 @@ module GitlabQuality
190
188
  end
191
189
 
192
190
  def failure_issues(test)
193
- find_issues_for_test(
194
- test,
191
+ find_issues_by_hash(
192
+ test_hash(test),
195
193
  state: 'opened',
196
194
  labels: base_issue_labels + Set.new(%w[test]),
197
195
  not_labels: exclude_labels_for_search
@@ -339,7 +337,7 @@ module GitlabQuality
339
337
  def new_issue_due_date(test)
340
338
  return unless test.product_group?
341
339
 
342
- Date.today + 1.month
340
+ Date.today.next_month
343
341
  end
344
342
 
345
343
  def update_reports(issue, test)
@@ -347,7 +345,7 @@ module GitlabQuality
347
345
  state_event = issue.state == 'closed' ? 'reopen' : nil
348
346
 
349
347
  issue_attrs = {
350
- description: add_report_to_issue_description(issue, test),
348
+ description: increment_reports(current_reports_content: issue.description, test: test),
351
349
  labels: up_to_date_labels(test: test, issue: issue)
352
350
  }
353
351
  issue_attrs[:state_event] = state_event if state_event
@@ -356,10 +354,6 @@ module GitlabQuality
356
354
  puts " => Added a report in '#{issue.title}': #{issue.web_url}!"
357
355
  end
358
356
 
359
- def new_issue_title(test)
360
- "Failure in #{super}"
361
- end
362
-
363
357
  def screenshot_section(test)
364
358
  return unless test.screenshot?
365
359
 
@@ -60,7 +60,6 @@ module GitlabQuality
60
60
  | Description | `#{test.name}` |
61
61
  | Test level | #{test.level} |
62
62
  | Hash | `#{test_hash(test)}` |
63
- | Duration | #{test.run_time} seconds |
64
63
  | Expected duration | < #{test.max_duration_for_test} seconds |
65
64
  #{"| Test case | #{test.testcase} |" if test.testcase}
66
65
  DESCRIPTION
@@ -142,8 +141,10 @@ module GitlabQuality
142
141
  labels
143
142
  end
144
143
 
145
- def find_issues_by_hash(test_hash)
146
- search_options = { search: test_hash }
144
+ def find_issues_by_hash(test_hash, labels: Set.new, not_labels: Set.new, state: nil)
145
+ search_options = { search: test_hash, labels: labels.to_a, not: { labels: not_labels.to_a } }
146
+ search_options[:state] = state if state
147
+ search_options[:in] = 'description'
147
148
  gitlab.find_issues(options: search_options)
148
149
  end
149
150
 
@@ -10,16 +10,21 @@ module GitlabQuality
10
10
  # - Find issue by title (with test description or test file)
11
11
  # - Add test metadata, duration to the issue with group and category labels
12
12
  class SlowTestIssue < ReportAsIssue
13
- include TestTooling::Concerns::FindSetDri
14
13
  include Concerns::GroupAndCategoryLabels
14
+ include Concerns::IssueReports
15
15
 
16
- NEW_ISSUE_LABELS = Set.new(['test', 'type::maintenance', 'maintenance::performance', 'priority::3', 'severity::3', 'rspec profiling', 'rspec:slow test']).freeze
17
- SEARCH_LABELS = %w[test maintenance::performance].freeze
16
+ IDENTITY_LABELS = ['test', 'rspec:slow test', 'rspec profiling', 'automation:bot-authored'].freeze
17
+ NEW_ISSUE_LABELS = Set.new(['test', 'type::maintenance', 'maintenance::performance', 'priority::3', 'severity::3']).freeze
18
+ SEARCH_LABELS = ['test'].freeze
19
+ FOUND_IN_MR_LABEL = '~"found:in MR"'
20
+ FOUND_IN_MASTER_LABEL = '~"found:master"'
21
+ REPORT_SECTION_HEADER = '### Slowness reports'
22
+ REPORTS_DOCUMENTATION = <<~DOC
23
+ Slow tests were detected, please see the [test speed best practices guide](https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#test-speed)
24
+ to improve them. More context available about this issue in the [top slow tests guide](https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#top-slow-tests).
18
25
 
19
- JOB_URL_REGEX = %r{(?<job_url>https://(?<host>[\w.]+)/(?<project_path>[\w\-./]+)/-/jobs/\d+)}
20
- REPORT_ITEM_REGEX = /^1\. \d{4}-\d{2}-\d{2}: #{JOB_URL_REGEX} \((?<pipeline_url>.+)\)$/
21
-
22
- MultipleIssuesFound = Class.new(StandardError)
26
+ Add `allowed_to_be_slow: true` to the RSpec test if this is a legit slow test and close the issue.
27
+ DOC
23
28
 
24
29
  private
25
30
 
@@ -29,99 +34,87 @@ module GitlabQuality
29
34
  TestResults::Builder.new(files).test_results_per_file do |test_results|
30
35
  puts "=> Reporting #{test_results.count} tests in #{test_results.path}"
31
36
 
32
- test_results.each do |test|
33
- create_slow_issue(test) if test.slow_test?
34
- end
37
+ process_test_results(test_results)
35
38
  end
36
39
  end
37
40
 
38
- def new_issue_title(test)
39
- "Slow test in #{super}"
40
- end
41
+ def process_test_results(test_results)
42
+ test_results.each do |test|
43
+ next unless test.slow_test?
41
44
 
42
- def new_issue_description(test)
43
- super +
44
- <<~DESCRIPTION.chomp
45
- Slow tests were detected, please see the [test speed best practices guide](https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#test-speed)
46
- to improve them. More context available about this issue in the [top slow tests guide](https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#top-slow-tests).
45
+ puts " => Reporting slowness for test '#{test.name}'..."
47
46
 
48
- Add `allowed_to_be_slow: true` to the RSpec test if this is a legit slow test and close the issue.
47
+ issues = find_issues_by_hash(test_hash(test), state: 'opened', labels: SEARCH_LABELS)
48
+ issues << create_issue(test) if issues.empty?
49
49
 
50
- #{reports_section(test)}
51
- DESCRIPTION
50
+ update_reports(issues, test)
51
+ collect_issues(test, issues)
52
+ end
52
53
  end
53
54
 
54
- def reports_section(test)
55
- <<~REPORTS
56
- ### Reports (1)
57
-
58
- #{report_list_item(test)}
59
- REPORTS
55
+ def test_is_applicable?(test)
56
+ test.slow_test?
60
57
  end
61
58
 
62
- def report_list_item(test)
63
- "1. #{Time.new.utc.strftime('%F')}: #{test.ci_job_url} (#{ENV.fetch('CI_PIPELINE_URL', 'pipeline url is missing')})"
59
+ def update_reports(issues, test)
60
+ issues.each do |issue|
61
+ puts " => Adding the slow test to the existing issue: #{issue.web_url}"
62
+ add_report_to_issue(issue: issue, test: test, related_issues: (issues - [issue]))
63
+ end
64
64
  end
65
65
 
66
- def slow_test_issues(test)
67
- find_issues_for_test(
68
- test,
69
- state: 'opened',
70
- labels: SEARCH_LABELS
71
- )
66
+ def add_report_to_issue(issue:, test:, related_issues:)
67
+ reports_note = existing_reports_note(issue: issue)
68
+ note_body = [
69
+ report_body(reports_note: reports_note, test: test),
70
+ identity_labels_quick_action,
71
+ relate_issues_quick_actions(related_issues)
72
+ ].join("\n")
73
+
74
+ if reports_note
75
+ gitlab.edit_issue_note(
76
+ issue_iid: issue.iid,
77
+ note_id: reports_note.id,
78
+ note: note_body
79
+ )
80
+ else
81
+ gitlab.create_issue_note(iid: issue.iid, note: note_body)
82
+ end
72
83
  end
73
84
 
74
- def create_slow_issue(test)
75
- puts " => Finding existing issues for slow test '#{test.name}' (run time: #{test.run_time} seconds)..."
85
+ def existing_reports_note(issue:)
86
+ gitlab.find_issue_notes(iid: issue.iid).find do |note|
87
+ note.body.start_with?(REPORT_SECTION_HEADER)
88
+ end
89
+ end
76
90
 
77
- issues = slow_test_issues(test)
91
+ def report_body(reports_note:, test:)
92
+ increment_reports(
93
+ current_reports_content: reports_note&.body.to_s,
94
+ test: test,
95
+ reports_section_header: REPORT_SECTION_HEADER,
96
+ item_extra_content: "(#{test.run_time} seconds) #{found_label}",
97
+ reports_extra_content: REPORTS_DOCUMENTATION
98
+ )
99
+ end
78
100
 
79
- if issues.blank?
80
- issues << create_issue(test)
101
+ def found_label
102
+ if ENV.key?('CI_MERGE_REQUEST_IID')
103
+ FOUND_IN_MR_LABEL
81
104
  else
82
- issues.each do |issue|
83
- puts " => Existing issue link #{issue['web_url']}"
84
-
85
- update_reports(issue, test)
86
- end
105
+ FOUND_IN_MASTER_LABEL
87
106
  end
88
-
89
- collect_issues(test, issues)
90
- rescue MultipleIssuesFound => e
91
- warn(e.message)
92
107
  end
93
108
 
94
- def update_reports(issue, test)
95
- # We reopen closed issues to not lose any history
96
- state_event = issue.state == 'closed' ? 'reopen' : nil
97
-
98
- issue_attrs = {
99
- description: up_to_date_issue_description(issue.description, test)
100
- }
101
-
102
- issue_attrs[:state_event] = state_event if state_event
103
-
104
- gitlab.edit_issue(iid: issue.iid, options: issue_attrs)
105
- puts " => Added a report in '#{issue.title}': #{issue.web_url}!"
109
+ def identity_labels_quick_action
110
+ labels_list = IDENTITY_LABELS.map { |label| %(~"#{label}") }.join(' ')
111
+ %(/label #{labels_list})
106
112
  end
107
113
 
108
- def up_to_date_issue_description(issue_description, test)
109
- new_issue_description =
110
- if issue_description.include?('### Reports')
111
- # We count the number of existing reports.
112
- reports_count = issue_description
113
- .scan(REPORT_ITEM_REGEX)
114
- .size.to_i + 1
115
- issue_description.sub(/^### Reports.*$/, "### Reports (#{reports_count})")
116
- else # For issue with the legacy format, we add the Reports section
117
- reports_count = issue_description
118
- .scan(JOB_URL_REGEX)
119
- .size.to_i + 1
120
-
121
- "#{issue_description}\n\n### Reports (#{reports_count})"
122
- end
123
-
124
- "#{new_issue_description}\n#{report_list_item(test)}"
114
+ def relate_issues_quick_actions(issues)
115
+ issues.map do |issue|
116
+ "/relate #{issue.web_url}"
117
+ end.join("\n")
125
118
  end
126
119
  end
127
120
  end
@@ -38,7 +38,7 @@ module GitlabQuality
38
38
  console_log = console_logger(source: source, level: Env.log_level)
39
39
  file_log = file_logger(source: source, path: log_path)
40
40
 
41
- console_log.extend(ActiveSupport::Logger.broadcast(file_log))
41
+ ActiveSupport::BroadcastLogger.new(console_log, file_log, file_log)
42
42
  end
43
43
  end
44
44
 
@@ -16,10 +16,11 @@ module GitlabQuality
16
16
  @context = context
17
17
  @existing_mrs = nil
18
18
  @file_path = spec["file_path"]
19
+ testcase = spec["testcase"]
19
20
  devops_stage = spec["stage"]
20
21
  product_group = spec["product_group"]
21
22
  @example_name = spec["name"]
22
- @mr_title = format("%{prefix} %{example_name}", prefix: '[PROMOTE TO BLOCKING]', example_name: example_name)
23
+ @mr_title = format("%{prefix} %{example_name}", prefix: '[PROMOTE TO BLOCKING]', example_name: example_name).truncate(72, omission: '')
23
24
 
24
25
  @file_contents = context.get_file_contents(file_path)
25
26
 
@@ -48,8 +49,13 @@ module GitlabQuality
48
49
 
49
50
  This test was identified in the reliable e2e test report: #{context.report_issue}
50
51
 
52
+ [Testcase link](#{testcase})
53
+
54
+ [Spec metrics link](#{context.single_spec_metrics_link(example_name)})
55
+
51
56
  /label ~"Quality" ~"QA" ~"type::maintenance"
52
57
  /label ~"devops::#{devops_stage}"
58
+ #{context.label_from_product_group(product_group)}
53
59
 
54
60
  <div align="center">
55
61
  (This MR was automatically generated by [`gitlab_quality-test_tooling`](https://gitlab.com/gitlab-org/ruby/gems/gitlab_quality-test_tooling) at #{Time.now.utc})
@@ -120,8 +126,8 @@ module GitlabQuality
120
126
  end
121
127
 
122
128
  context.update_matched_line(matched_lines.last, file_contents.dup) do |line|
123
- if line.include?(',')
124
- line[line.index(',')] = format(BLOCKING_METADATA, suffix: ',')
129
+ if line.sub(DESCRIPTION_REGEX, '').include?(',')
130
+ line[line.index(',', end_of_description_index(line))] = format(BLOCKING_METADATA, suffix: ',')
125
131
  else
126
132
  line[line.rindex(' ')] = format(BLOCKING_METADATA, suffix: ' ')
127
133
  end
@@ -26,11 +26,13 @@ module GitlabQuality
26
26
  @context = context
27
27
  @existing_mrs = nil
28
28
  @file_path = spec["file_path"]
29
+ testcase = spec["testcase"]
29
30
  devops_stage = spec["stage"]
31
+ product_group = spec["product_group"]
30
32
  @failure_issue_url = spec["failure_issue"]
31
33
  @example_name = spec["name"]
32
34
  @issue_id = failure_issue_url.split('/').last # split url segment, last segment of path is the issue id
33
- @mr_title = format("%{prefix} %{example_name}", prefix: '[QUARANTINE]', example_name: example_name)
35
+ @mr_title = format("%{prefix} %{example_name}", prefix: '[QUARANTINE]', example_name: example_name).truncate(72, omission: '')
34
36
  @failure_issue = context.fetch_issue(iid: issue_id)
35
37
 
36
38
  @file_contents = context.get_file_contents(file_path)
@@ -57,6 +59,10 @@ module GitlabQuality
57
59
 
58
60
  This test was identified in the reliable e2e test report: #{context.report_issue}
59
61
 
62
+ [Testcase link](#{testcase})
63
+
64
+ [Spec metrics link](#{context.single_spec_metrics_link(example_name)})
65
+
60
66
  ### E2E Test Failure issue(s)
61
67
 
62
68
  #{failure_issue_url}
@@ -81,6 +87,7 @@ module GitlabQuality
81
87
  `qa/specs/features/browser_ui/3_create/web_ide/add_file_template_spec.rb`.
82
88
  -->
83
89
  /label ~"devops::#{devops_stage}"
90
+ #{context.label_from_product_group(product_group)}
84
91
 
85
92
  <div align="center">
86
93
  (This MR was automatically generated by [`gitlab_quality-test_tooling`](https://gitlab.com/gitlab-org/ruby/gems/gitlab_quality-test_tooling) at #{Time.now.utc})
@@ -153,7 +160,7 @@ module GitlabQuality
153
160
  context.update_matched_line(matched_lines.last, file_contents.dup) do |line|
154
161
  indentation = context.indentation(line)
155
162
 
156
- if line.include?(',') && line.split.last != 'do'
163
+ if line.sub(DESCRIPTION_REGEX, '').include?(',') && line.split.last != 'do'
157
164
  line[line.rindex(',')] = format(QUARANTINE_METADATA.rstrip, issue_url: failure_issue_url, indentation: indentation, suffix: ',', quarantine_type: quarantine_type)
158
165
  else
159
166
  line[line.rindex(' ')] = format(QUARANTINE_METADATA.rstrip, issue_url: failure_issue_url, indentation: indentation, suffix: ' ', quarantine_type: quarantine_type)
@@ -170,8 +177,6 @@ module GitlabQuality
170
177
  case context.issue_scoped_label(failure_issue, 'failure')&.split('::')&.last
171
178
  when 'new', 'investigating'
172
179
  ':investigating'
173
- when 'external-dependency'
174
- ':external_dependency'
175
180
  when 'broken-test'
176
181
  ':broken'
177
182
  when 'bug'
@@ -8,6 +8,8 @@ module GitlabQuality
8
8
  module Processor
9
9
  class MetaProcessor
10
10
  class << self
11
+ DESCRIPTION_REGEX = /('.*?')|(".*?")/
12
+
11
13
  def execute
12
14
  raise 'method not implemented'
13
15
  end
@@ -24,6 +26,16 @@ module GitlabQuality
24
26
  def existing_mrs
25
27
  @existing_mrs ||= context.existing_merge_requests(title: mr_title)
26
28
  end
29
+
30
+ # Returns the index of the end of test description
31
+ #
32
+ # @param [String] line The line containing the test description
33
+ # @return [Integer]
34
+ def end_of_description_index(line)
35
+ description_length = line.match(DESCRIPTION_REGEX)[0].length
36
+ description_start_index = line.index(DESCRIPTION_REGEX)
37
+ description_start_index + description_length
38
+ end
27
39
  end
28
40
  end
29
41
  end
@@ -252,6 +252,25 @@ module GitlabQuality
252
252
  processed_records[file_path] && processed_records[file_path] == changed_line_no
253
253
  end
254
254
 
255
+ # Infers product group label from the provided product group
256
+ #
257
+ # @param [String] product_group product group
258
+ # @return [String]
259
+ def label_from_product_group(product_group)
260
+ label = labels_inference.infer_labels_from_product_group(product_group).to_a.first
261
+
262
+ label ? %(/label ~"#{label}") : ''
263
+ end
264
+
265
+ # Returns the link to the Grafana dashboard for single spec metrics
266
+ #
267
+ # @param [String] example_name the full example name
268
+ # @return [String]
269
+ def single_spec_metrics_link(example_name)
270
+ base_url = "https://dashboards.quality.gitlab.net/d/cW0UMgv7k/single-spec-metrics?orgId=1&var-run_type=All&var-name="
271
+ base_url + CGI.escape(example_name)
272
+ end
273
+
255
274
  private
256
275
 
257
276
  attr_reader :token, :specs_file, :dry_run, :processor
@@ -281,6 +300,13 @@ module GitlabQuality
281
300
  project: project
282
301
  )
283
302
  end
303
+
304
+ # Returns a cached instance of GitlabQuality::TestTooling::LabelsInference
305
+ #
306
+ # @return [GitlabQuality::TestTooling::LabelsInference]
307
+ def labels_inference
308
+ @labels_inference ||= GitlabQuality::TestTooling::LabelsInference.new
309
+ end
284
310
  end
285
311
  end
286
312
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "1.15.0"
5
+ VERSION = "1.18.0"
6
6
  end
7
7
  end
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.15.0
4
+ version: 1.18.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: 2024-02-08 00:00:00.000000000 Z
11
+ date: 2024-03-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: climate_control
@@ -201,7 +201,7 @@ dependencies:
201
201
  version: '6.1'
202
202
  - - "<"
203
203
  - !ruby/object:Gem::Version
204
- version: '7.1'
204
+ version: '7.2'
205
205
  type: :runtime
206
206
  prerelease: false
207
207
  version_requirements: !ruby/object:Gem::Requirement
@@ -211,7 +211,7 @@ dependencies:
211
211
  version: '6.1'
212
212
  - - "<"
213
213
  - !ruby/object:Gem::Version
214
- version: '7.1'
214
+ version: '7.2'
215
215
  - !ruby/object:Gem::Dependency
216
216
  name: amatch
217
217
  requirement: !ruby/object:Gem::Requirement