gitlab-qa 10.4.1 → 11.1.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.gitlab/changelog_config.yml +13 -0
  3. data/.gitlab/merge_request_templates/Release.md +9 -36
  4. data/.rubocop_todo.yml +0 -12
  5. data/Dangerfile +1 -5
  6. data/Gemfile.lock +4 -4
  7. data/README.md +1 -2
  8. data/docs/running_against_remote_grid.md +39 -2
  9. data/gitlab-qa.gemspec +1 -1
  10. data/lib/gitlab/qa/component/gitaly_cluster.rb +0 -1
  11. data/lib/gitlab/qa/component/gitlab.rb +10 -9
  12. data/lib/gitlab/qa/component/selenoid.rb +8 -3
  13. data/lib/gitlab/qa/runtime/env.rb +21 -41
  14. data/lib/gitlab/qa/scenario/test/instance/airgapped.rb +0 -2
  15. data/lib/gitlab/qa/scenario/test/integration/gitaly_cluster.rb +0 -2
  16. data/lib/gitlab/qa/scenario/test/integration/mtls.rb +0 -1
  17. data/lib/gitlab/qa/scenario/test/integration/praefect.rb +0 -2
  18. data/lib/gitlab/qa/scenario/test/integration/registry_with_cdn.rb +2 -2
  19. data/lib/gitlab/qa/version.rb +1 -1
  20. data/lib/gitlab/qa.rb +0 -1
  21. data/support/data/admin_access_token_seed.rb +4 -1
  22. metadata +5 -39
  23. data/bin/slack +0 -14
  24. data/exe/gitlab-qa-report +0 -10
  25. data/lib/gitlab/qa/report/base_test_results.rb +0 -39
  26. data/lib/gitlab/qa/report/find_set_dri.rb +0 -43
  27. data/lib/gitlab/qa/report/generate_test_session.rb +0 -275
  28. data/lib/gitlab/qa/report/gitlab_issue_client.rb +0 -190
  29. data/lib/gitlab/qa/report/gitlab_issue_dry_client.rb +0 -28
  30. data/lib/gitlab/qa/report/j_unit_test_results.rb +0 -27
  31. data/lib/gitlab/qa/report/json_test_results.rb +0 -29
  32. data/lib/gitlab/qa/report/prepare_stage_reports.rb +0 -86
  33. data/lib/gitlab/qa/report/relate_failure_issue.rb +0 -374
  34. data/lib/gitlab/qa/report/report_as_issue.rb +0 -176
  35. data/lib/gitlab/qa/report/report_results.rb +0 -64
  36. data/lib/gitlab/qa/report/results_in_issues.rb +0 -126
  37. data/lib/gitlab/qa/report/results_in_testcases.rb +0 -111
  38. data/lib/gitlab/qa/report/results_reporter_shared.rb +0 -70
  39. data/lib/gitlab/qa/report/summary_table.rb +0 -43
  40. data/lib/gitlab/qa/report/test_result.rb +0 -184
  41. data/lib/gitlab/qa/report/update_screenshot_path.rb +0 -63
  42. data/lib/gitlab/qa/reporter.rb +0 -131
  43. data/lib/gitlab/qa/runtime/token_finder.rb +0 -44
  44. data/lib/gitlab/qa/slack/post_to_slack.rb +0 -30
  45. data/lib/gitlab/qa/system_logs/finders/json_log_finder.rb +0 -65
  46. data/lib/gitlab/qa/system_logs/finders/rails/api_log_finder.rb +0 -21
  47. data/lib/gitlab/qa/system_logs/finders/rails/application_log_finder.rb +0 -21
  48. data/lib/gitlab/qa/system_logs/finders/rails/exception_log_finder.rb +0 -21
  49. data/lib/gitlab/qa/system_logs/finders/rails/graphql_log_finder.rb +0 -21
  50. data/lib/gitlab/qa/system_logs/log_types/log.rb +0 -38
  51. data/lib/gitlab/qa/system_logs/log_types/rails/api_log.rb +0 -34
  52. data/lib/gitlab/qa/system_logs/log_types/rails/application_log.rb +0 -27
  53. data/lib/gitlab/qa/system_logs/log_types/rails/exception_log.rb +0 -23
  54. data/lib/gitlab/qa/system_logs/log_types/rails/graphql_log.rb +0 -30
  55. data/lib/gitlab/qa/system_logs/shared_fields.rb +0 -29
  56. data/lib/gitlab/qa/system_logs/system_logs_formatter.rb +0 -65
@@ -1,176 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'set'
4
-
5
- module Gitlab
6
- module QA
7
- module Report
8
- class ReportAsIssue
9
- MAX_TITLE_LENGTH = 255
10
-
11
- def initialize(token:, input_files:, project: nil, dry_run: false, **kwargs)
12
- @project = project
13
- @gitlab = (dry_run ? GitlabIssueDryClient : GitlabIssueClient).new(token: token, project: project)
14
- @files = Array(input_files)
15
- end
16
-
17
- def invoke!
18
- validate_input!
19
-
20
- run!
21
- end
22
-
23
- private
24
-
25
- attr_reader :gitlab, :files, :project, :issue_type
26
-
27
- def run!
28
- raise NotImplementedError
29
- end
30
-
31
- def new_issue_title(test)
32
- "#{partial_file_path(test.file)} | #{search_safe(test.name)}".strip
33
- end
34
-
35
- def new_issue_description(test)
36
- "### Full description\n\n#{search_safe(test.name)}\n\n### File path\n\n#{test.file}"
37
- end
38
-
39
- def new_issue_labels(test)
40
- []
41
- end
42
-
43
- def validate_input!
44
- assert_project!
45
- assert_input_files!(files)
46
- gitlab.assert_user_permission!
47
- end
48
-
49
- def assert_project!
50
- return if project
51
-
52
- abort "Please provide a valid project ID or path with the `-p/--project` option!"
53
- end
54
-
55
- def assert_input_files!(files)
56
- return if Dir.glob(files).any?
57
-
58
- abort "Please provide valid JUnit report files. No files were found matching `#{files.join(',')}`"
59
- end
60
-
61
- def test_results_per_file
62
- Dir.glob(files).each do |path|
63
- extension = File.extname(path)
64
-
65
- test_results =
66
- case extension
67
- when '.json'
68
- Report::JsonTestResults.new(path)
69
- when '.xml'
70
- Report::JUnitTestResults.new(path)
71
- else
72
- raise "Unknown extension #{extension}"
73
- end
74
-
75
- yield test_results
76
- end
77
- end
78
-
79
- def create_issue(test)
80
- issue = gitlab.create_issue(
81
- title: title_from_test(test),
82
- description: new_issue_description(test),
83
- labels: new_issue_labels(test).to_a,
84
- issue_type: issue_type
85
- )
86
-
87
- new_link = issue_type == 'test_case' ? issue.web_url.sub('/issues/', '/quality/test_cases/') : issue.web_url
88
-
89
- puts "Created new #{issue_type}: #{new_link}"
90
-
91
- issue
92
- end
93
-
94
- def issue_labels(issue)
95
- issue&.labels&.to_set || Set.new
96
- end
97
-
98
- def update_labels(issue, test, new_labels = Set.new)
99
- labels = up_to_date_labels(test: test, issue: issue, new_labels: new_labels)
100
-
101
- return if issue_labels(issue) == labels
102
-
103
- gitlab.edit_issue(iid: issue.iid, options: { labels: labels.to_a })
104
- end
105
-
106
- def up_to_date_labels(test:, issue: nil, new_labels: Set.new)
107
- labels = issue_labels(issue)
108
- labels |= new_labels
109
- ee_test?(test) ? labels << 'Enterprise Edition' : labels.delete('Enterprise Edition')
110
-
111
- if test.quarantine?
112
- labels << 'quarantine'
113
- labels << "quarantine::#{test.quarantine_type}"
114
- else
115
- labels.delete_if { |label| label.include?('quarantine') }
116
- end
117
-
118
- labels
119
- end
120
-
121
- def pipeline_name_label
122
- case pipeline
123
- when 'production'
124
- 'found:gitlab.com'
125
- when 'canary', 'staging'
126
- "found:#{pipeline}.gitlab.com"
127
- when 'staging-canary'
128
- "found:canary.staging.gitlab.com"
129
- when 'preprod'
130
- 'found:pre.gitlab.com'
131
- when 'nightly', QA::Runtime::Env.default_branch, 'staging-ref', 'release'
132
- "found:#{pipeline}"
133
- else
134
- raise "No `found:*` label for the `#{pipeline}` pipeline!"
135
- end
136
- end
137
-
138
- def ee_test?(test)
139
- test.file =~ %r{features/ee/(api|browser_ui)}
140
- end
141
-
142
- def partial_file_path(path)
143
- path.match(/((ee|api|browser_ui).*)/i)[1]
144
- end
145
-
146
- def title_from_test(test)
147
- title = new_issue_title(test)
148
-
149
- return title unless title.length > MAX_TITLE_LENGTH
150
-
151
- "#{title[0...MAX_TITLE_LENGTH - 3]}..."
152
- end
153
-
154
- def search_safe(value)
155
- value.delete('"')
156
- end
157
-
158
- def pipeline
159
- # Gets the name of the pipeline the test was run in, to be used as the key of a scoped label
160
- #
161
- # Tests can be run in several pipelines:
162
- # gitlab, nightly, staging, canary, production, preprod, MRs, and the default branch (master/main)
163
- #
164
- # Some of those run in their own project, so CI_PROJECT_NAME is the name we need. Those are:
165
- # nightly, staging, canary, production, and preprod
166
- #
167
- # MR, master/main, and gitlab tests run in gitlab-qa, but we only want to report tests run on
168
- # master/main because the other pipelines will be monitored by the author of the MR that triggered them.
169
- # So we assume that we're reporting a master/main pipeline if the project name is 'gitlab'.
170
-
171
- @pipeline ||= Runtime::Env.pipeline_from_project_name
172
- end
173
- end
174
- end
175
- end
176
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Gitlab
4
- module QA
5
- module Report
6
- # Uses the API to create or update GitLab test cases and issues with the results of tests from RSpec report files.
7
- class ReportResults < ReportAsIssue
8
- attr_accessor :testcase_project_reporter, :results_issue_project_reporter, :files, :test_case_project, :results_issue_project, :gitlab
9
-
10
- def initialize(token:, input_files:, test_case_project: nil, results_issue_project: nil, dry_run: false, **kwargs)
11
- @testcase_project_reporter = Gitlab::QA::Report::ResultsInTestCases.new(token: token, input_files: input_files, project: test_case_project, dry_run: dry_run, **kwargs)
12
- @results_issue_project_reporter = Gitlab::QA::Report::ResultsInIssues.new(token: token, input_files: input_files, project: results_issue_project, dry_run: dry_run, **kwargs)
13
- @test_case_project = test_case_project
14
- @results_issue_project = results_issue_project
15
- @files = Array(input_files)
16
- @gitlab = testcase_project_reporter.gitlab
17
- end
18
-
19
- def assert_project!
20
- return if test_case_project && results_issue_project
21
-
22
- abort "Please provide valid project IDs or paths with the `--results-issue-project` and `--test-case-project` options!"
23
- end
24
-
25
- # rubocop:disable Metrics/AbcSize
26
- def run!
27
- puts "Reporting test results in `#{files.join(',')}` as test cases in project `#{test_case_project}`"\
28
- " and issues in project `#{results_issue_project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
29
-
30
- test_results_per_file do |test_results|
31
- puts "Reporting tests in #{test_results.path}"
32
-
33
- test_results.each do |test|
34
- next if test.file.include?('/features/sanity/') || test.skipped
35
-
36
- puts "Reporting test: #{test.file} | #{test.name}\n"
37
-
38
- report_test(test)
39
- end
40
-
41
- test_results.write
42
- end
43
- end
44
- # rubocop:enable Metrics/AbcSize
45
-
46
- private
47
-
48
- def report_test(test)
49
- testcase = testcase_project_reporter.find_or_create_testcase(test)
50
- # The API returns the test case with an issue URL since it is technically a type of issue.
51
- # This updates the URL to a valid test case link.
52
- test.testcase = testcase.web_url.sub('/issues/', '/quality/test_cases/')
53
-
54
- issue, is_new = results_issue_project_reporter.get_related_issue(testcase, test)
55
-
56
- testcase_project_reporter.add_issue_link_to_testcase(testcase, issue, test) if is_new
57
-
58
- testcase_project_reporter.update_testcase(testcase, test)
59
- results_issue_project_reporter.update_issue(issue, test)
60
- end
61
- end
62
- end
63
- end
64
- end
@@ -1,126 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Gitlab
4
- module QA
5
- module Report
6
- # Uses the API to create or update GitLab test result issues with the results of tests from RSpec report files.
7
- class ResultsInIssues < ReportAsIssue
8
- include ResultsReporterShared
9
-
10
- def initialize(**kwargs)
11
- super
12
- @issue_type = 'issue'
13
- end
14
-
15
- def get_related_issue(testcase, test)
16
- issue = find_linked_results_issue_by_iid(testcase, test)
17
- is_new = false
18
-
19
- if issue
20
- issue = update_issue_title(issue, test) if issue_title_needs_updating?(issue, test)
21
- else
22
- puts "No valid issue link found"
23
- issue = find_or_create_results_issue(test)
24
- is_new = true
25
- end
26
-
27
- [issue, is_new]
28
- end
29
-
30
- def update_issue(issue, test)
31
- new_labels = issue_labels(issue)
32
- new_labels |= ['Testcase Linked']
33
-
34
- labels_updated = update_labels(issue, test, new_labels)
35
- note_posted = note_status(issue, test)
36
-
37
- if labels_updated || note_posted
38
- puts "Issue updated: #{issue.web_url}"
39
- else
40
- puts "Test passed, no results issue update needed."
41
- end
42
- end
43
-
44
- private
45
-
46
- def find_linked_results_issue_by_iid(testcase, test)
47
- iid = issue_iid_from_testcase(testcase)
48
-
49
- return unless iid
50
-
51
- find_issue_by_iid(iid)
52
- end
53
-
54
- def find_or_create_results_issue(test)
55
- find_issue(test) || create_issue(test)
56
- end
57
-
58
- def issue_iid_from_testcase(testcase)
59
- results = testcase.description.partition(TEST_CASE_RESULTS_SECTION_TEMPLATE).last if testcase.description.include?(TEST_CASE_RESULTS_SECTION_TEMPLATE)
60
-
61
- return puts "No issue link found" unless results
62
-
63
- issue_iid = results.split('/').last
64
-
65
- issue_iid&.to_i
66
- end
67
-
68
- def note_status(issue, test)
69
- return false if test.skipped
70
- return false if test.failures.empty?
71
-
72
- note = note_content(test)
73
-
74
- gitlab.find_issue_discussions(iid: issue.iid).each do |discussion|
75
- return gitlab.add_note_to_issue_discussion_as_thread(iid: issue.iid, discussion_id: discussion.id, body: failure_summary) if new_note_matches_discussion?(note, discussion)
76
- end
77
-
78
- gitlab.create_issue_note(iid: issue.iid, note: note)
79
-
80
- true
81
- end
82
-
83
- def note_content(test)
84
- errors = test.failures.each_with_object([]) do |failure, text|
85
- text << <<~TEXT
86
- Error:
87
- ```
88
- #{failure['message']}
89
- ```
90
-
91
- Stacktrace:
92
- ```
93
- #{failure['stacktrace']}
94
- ```
95
- TEXT
96
- end.join("\n\n")
97
-
98
- "#{failure_summary}\n\n#{errors}"
99
- end
100
-
101
- def failure_summary
102
- summary = [":x: ~\"#{pipeline}::failed\""]
103
- summary << "in job `#{Runtime::Env.ci_job_name}` in #{Runtime::Env.ci_job_url}"
104
- summary.join(' ')
105
- end
106
-
107
- def new_note_matches_discussion?(note, discussion)
108
- note_error = error_and_stack_trace(note)
109
- discussion_error = error_and_stack_trace(discussion.notes.first['body'])
110
-
111
- return false if note_error.empty? || discussion_error.empty?
112
-
113
- note_error == discussion_error
114
- end
115
-
116
- def error_and_stack_trace(text)
117
- text.strip[/Error:(.*)/m, 1].to_s
118
- end
119
-
120
- def updated_description(issue, test)
121
- new_issue_description(test)
122
- end
123
- end
124
- end
125
- end
126
- end
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'erb'
4
-
5
- module Gitlab
6
- module QA
7
- module Report
8
- # Uses the API to create or update GitLab test cases with the results of tests from RSpec report files.
9
- class ResultsInTestCases < ReportAsIssue
10
- attr_reader :issue_type, :gitlab
11
-
12
- include ResultsReporterShared
13
-
14
- def initialize(**kwargs)
15
- super
16
- @issue_type = 'test_case'
17
- end
18
-
19
- def find_or_create_testcase(test)
20
- find_testcase(test) || create_issue(test)
21
- end
22
-
23
- def add_issue_link_to_testcase(testcase, issue, test)
24
- results_section = testcase.description.include?(TEST_CASE_RESULTS_SECTION_TEMPLATE) ? '' : TEST_CASE_RESULTS_SECTION_TEMPLATE
25
-
26
- gitlab.edit_issue(iid: testcase.iid, options: { description: (testcase.description + results_section + "\n\n#{issue.web_url}") })
27
- # We are using test.testcase for the url here instead of testcase.web_url since it has the updated test case path
28
- puts "Added results issue #{issue.web_url} link to test case #{test.testcase}"
29
- end
30
-
31
- def update_testcase(testcase, test)
32
- puts "Test case labels updated." if update_labels(testcase, test)
33
- puts "Test case quarantine section updated." if update_quarantine_link(testcase, test)
34
- end
35
-
36
- private
37
-
38
- def find_testcase(test)
39
- testcase = find_testcase_by_iid(test)
40
-
41
- if testcase
42
- testcase = update_issue_title(testcase, test) if issue_title_needs_updating?(testcase, test)
43
- else
44
- testcase = find_issue(test)
45
- end
46
-
47
- testcase
48
- end
49
-
50
- def find_testcase_by_iid(test)
51
- iid = testcase_iid_from_url(test.testcase)
52
-
53
- return unless iid
54
-
55
- find_issue_by_iid(iid)
56
- end
57
-
58
- def testcase_iid_from_url(url)
59
- return warn(%(\nPlease update #{url} to test case url")) if url&.include?('/-/issues/')
60
-
61
- url && url.split('/').last.to_i
62
- end
63
-
64
- def new_issue_description(test)
65
- quarantine_section = test.quarantine? && test.quarantine_issue ? "\n\n### Quarantine issue\n\n#{test.quarantine_issue}" : ''
66
-
67
- "#{super}#{quarantine_section}\n\n#{execution_graph_section(test)}"
68
- end
69
-
70
- def execution_graph_section(test)
71
- formatted_title = ERB::Util.url_encode(test.name)
72
-
73
- <<~MKDOWN.strip
74
- ### Executions
75
-
76
- All Environments:
77
- <img src="https://dashboards.quality.gitlab.net/render/d-solo/cW0UMgv7k/spec-health?orgId=1&var-run_type=All&var-name=#{formatted_title}&panelId=4&width=1000&height=500" />
78
- MKDOWN
79
- end
80
-
81
- def updated_description(testcase, test)
82
- historical_results_section = testcase.description.match(/### DO NOT EDIT BELOW THIS LINE[\s\S]+/)
83
-
84
- "#{new_issue_description(test)}\n\n#{historical_results_section}"
85
- end
86
-
87
- def issue_title_needs_updating?(testcase, test)
88
- super || !testcase.description.include?(execution_graph_section(test)) && !%w[canary production preprod release].include?(pipeline)
89
- end
90
-
91
- def quarantine_link_needs_updating?(testcase, test)
92
- if test.quarantine? && test.quarantine_issue
93
- return false if testcase.description.include?(test.quarantine_issue)
94
- else
95
- return false unless testcase.description.include?('Quarantine issue')
96
- end
97
-
98
- true
99
- end
100
-
101
- def update_quarantine_link(testcase, test)
102
- return unless quarantine_link_needs_updating?(testcase, test)
103
-
104
- new_description = updated_description(testcase, test)
105
-
106
- gitlab.edit_issue(iid: testcase.iid, options: { description: new_description })
107
- end
108
- end
109
- end
110
- end
111
- end
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'active_support/core_ext/enumerable'
4
-
5
- module Gitlab
6
- module QA
7
- module Report
8
- module ResultsReporterShared
9
- TEST_CASE_RESULTS_SECTION_TEMPLATE = "\n\n### DO NOT EDIT BELOW THIS LINE\n\nActive and historical test results:"
10
-
11
- def find_issue(test)
12
- issues = search_for_issues(test)
13
-
14
- warn(%(Too many #{issue_type}s found with the file path "#{test.file}" and name "#{test.name}")) if issues.many?
15
- puts "Found existing #{issue_type}: #{issues.first.web_url}" unless issues.empty?
16
-
17
- issues.first
18
- end
19
-
20
- def find_issue_by_iid(iid)
21
- issues = gitlab.find_issues(iid: iid) do |issue|
22
- issue.state == 'opened' && issue.issue_type == issue_type
23
- end
24
-
25
- warn(%(#{issue_type} iid "#{iid}" not valid)) if issues.empty?
26
-
27
- issues.first
28
- end
29
-
30
- def issue_title_needs_updating?(issue, test)
31
- issue.title.strip != title_from_test(test) && !%w[canary production preprod release].include?(pipeline)
32
- end
33
-
34
- def new_issue_labels(test)
35
- %w[Quality status::automated]
36
- end
37
-
38
- def search_term(test)
39
- %("#{partial_file_path(test.file)}" "#{search_safe(test.name)}")
40
- end
41
-
42
- def up_to_date_labels(test:, issue: nil, new_labels: Set.new)
43
- labels = super
44
- labels |= new_issue_labels(test).to_set
45
- labels.delete_if { |label| label.start_with?("#{pipeline}::") }
46
- labels << (test.failures.empty? ? "#{pipeline}::passed" : "#{pipeline}::failed")
47
- end
48
-
49
- def update_issue_title(issue, test)
50
- old_title = issue.title.strip
51
- new_title = title_from_test(test)
52
-
53
- warn(%(#{issue_type} title needs to be updated from '#{old_title}' to '#{new_title}'))
54
-
55
- new_description = updated_description(issue, test)
56
-
57
- gitlab.edit_issue(iid: issue.iid, options: { title: new_title, description: new_description })
58
- end
59
-
60
- private
61
-
62
- def search_for_issues(test)
63
- gitlab.find_issues(options: { search: search_term(test) }) do |issue|
64
- issue.state == 'opened' && issue.issue_type == issue_type && issue.title.strip == title_from_test(test)
65
- end
66
- end
67
- end
68
- end
69
- end
70
- end
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'nokogiri'
4
- require 'table_print'
5
-
6
- module Gitlab
7
- module QA
8
- module Report
9
- class SummaryTable
10
- def self.create(input_files:)
11
- "```\n#{TablePrint::Printer.table_print(collect_results(input_files))}```\n"
12
- end
13
-
14
- # rubocop:disable Metrics/AbcSize
15
- def self.collect_results(input_files)
16
- stage_wise_results = []
17
-
18
- Dir.glob(input_files).each do |report_file|
19
- stage_hash = {}
20
- stage_hash["Dev Stage"] = File.basename(report_file, ".*").capitalize
21
-
22
- report_stats = Nokogiri::XML(File.open(report_file)).children[0].attributes
23
-
24
- stage_hash["Total"] = report_stats["tests"].value
25
- stage_hash["Failures"] = report_stats["failures"].value
26
- stage_hash["Errors"] = report_stats["errors"].value
27
- stage_hash["Skipped"] = report_stats["skipped"].value
28
- stage_hash["Result"] = result_emoji(report_stats)
29
-
30
- stage_wise_results << stage_hash
31
- end
32
-
33
- stage_wise_results
34
- end
35
- # rubocop:enable Metrics/AbcSize
36
-
37
- def self.result_emoji(report_stats)
38
- report_stats["failures"].value.to_i.positive? || report_stats["errors"].value.to_i.positive? ? "❌" : "✅"
39
- end
40
- end
41
- end
42
- end
43
- end