gitlab_quality-test_tooling 2.10.0 → 2.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 548aaf25c91235b88c18601ebdeb3cecf4c6b6caedbaceb6ddec4bdbaaf3519a
4
- data.tar.gz: 04de3d4ce782f42ab487c9d7872f80236d7115250bd28b4a58e02c83aebc0ec9
3
+ metadata.gz: ed414366558c053e3eaef1211a9bd431899106af38b4f1f80b8a68334294b633
4
+ data.tar.gz: fb31640455e0cda1ca5551f30c804c38d03c17d93e17e6f6a267347c7460dd18
5
5
  SHA512:
6
- metadata.gz: f5cb5a3f95f291e7f00b7454f9fe211c2f2db662bb08fe47e72c587468dc2658b4ce8019fdaf963de4f5845032863bb71c69d684a5d10702b0efb28caea207eb
7
- data.tar.gz: 92ef6b0c7360c676294b9f9e7d15e3ce2b0651ceb5b0e2ccc65a328ae0806f87997a2fed1a74ca398427ba6ecf431299e9bf16e952c4cd1c10cd26332de06332
6
+ metadata.gz: c95d137404f52c79e6679c74327124bf3288b074c582cc3caf90a0125dc1f1b19e2ff08d71053c60f01d53db99e4aa2bacebae69f8f2f5c1e5897dd637b602e2
7
+ data.tar.gz: ebb57aa657a848b93d4aeb763bcfa9f682b09d2e4f0d79aa17894536e78feaed1769fb843fb739c6d60fa71522db6a7f1210e3b21ae2ef97094ab9d698412c1f
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (2.10.0)
4
+ gitlab_quality-test_tooling (2.12.0)
5
5
  activesupport (>= 7.0, < 7.3)
6
6
  amatch (~> 0.4.1)
7
7
  fog-google (~> 1.24, >= 1.24.1)
@@ -11,7 +11,7 @@ PATH
11
11
  nokogiri (~> 1.10)
12
12
  parallel (>= 1, < 2)
13
13
  rainbow (>= 3, < 4)
14
- rspec-parameterized (~> 1.0.0)
14
+ rspec-parameterized (>= 1.0.0, < 3.0)
15
15
  table_print (= 1.5.7)
16
16
  zeitwerk (>= 2, < 3)
17
17
 
data/README.md CHANGED
@@ -257,17 +257,34 @@ Usage: exe/update-test-meta [options]
257
257
  -h, --help Show the usage
258
258
  ```
259
259
 
260
- ### `exe/feature-readiness-check`
260
+ ### `exe/feature-readiness-checklist`
261
261
 
262
262
  ```shell
263
263
  Purpose: Conditionally create operational readiness checklist and link it to the epic
264
- Usage: exe/feature-readiness-check [options]
265
- -p, --project PROJECT Can be an integer or a group/project string
266
- -g, --group GROUP Can be an integer or a group/project string
264
+ Usage: exe/feature-readiness-checklist [options]
265
+ -p, --project PROJECT Name of the project
266
+ -g, --group GROUP Name of the group
267
+ -t, --token TOKEN A valid access token with `api` scope and Maintainer permission in PROJECT
268
+ -l, --search_labels LABELS A comma seperated list of labels to filter the epics on
269
+ -m, --minutes MINUTES Limit the search to issues and epics created with last MINUTES. Optional.
270
+ -b, --blocking Block the epic by any issues created and linked to it
271
+ --dry-run Perform a dry-run (don't create branches, commits or MRs)
272
+ -v, --version Show the version
273
+ -h, --help Show the usage
274
+ ```
275
+
276
+ ### `exe/feature-readiness-evaluation`
277
+
278
+ ```shell
279
+ Purpose: Evaluate MRs for multiple feature readiness criteria and report the status to the corresponding epic
280
+ Usage: exe/feature-readiness-evaluation [options]
281
+ -p, --project PROJECT Name of the project
282
+ -g, --group GROUP Name of the group
267
283
  -t, --token TOKEN A valid access token with `api` scope and Maintainer permission in PROJECT
268
284
  -l, --search_labels LABELS A comma seperated list of labels to filter the epics on
269
285
  -m, --minutes MINUTES Limit the search to issues and epics created with last MINUTES. Optional.
270
- -b, --blocking Any issues created and linked to the epic will block the epic
286
+ -e, --epic-iid EPIC_IID evaluate an epic with iid
287
+ -o, --operational-readinessr Perform the operational readiness checklist automation
271
288
  --dry-run Perform a dry-run (don't create branches, commits or MRs)
272
289
  -v, --version Show the version
273
290
  -h, --help Show the usage
@@ -9,10 +9,10 @@ params = {}
9
9
  options = OptionParser.new do |opts|
10
10
  opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
11
11
 
12
- opts.on('-p', '--project PROJECT', String, 'Can be an integer or a group/project string') do |project|
12
+ opts.on('-p', '--project PROJECT', String, 'Name of the project') do |project|
13
13
  params[:project] = project
14
14
  end
15
- opts.on('-g', '--group GROUP', String, 'Can be an integer or a group/project string') do |group|
15
+ opts.on('-g', '--group GROUP', String, 'Name of the group') do |group|
16
16
  params[:group] = group
17
17
  end
18
18
 
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require 'active_support/core_ext/hash'
6
+
7
+ require_relative "../lib/gitlab_quality/test_tooling"
8
+ params = {}
9
+
10
+ options = OptionParser.new do |opts|
11
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
12
+
13
+ opts.on('-p', '--project PROJECT', String, 'Name of the project') do |project|
14
+ params[:project] = project
15
+ end
16
+ opts.on('-g', '--group GROUP', String, 'Name of the group') do |group|
17
+ params[:group] = group
18
+ end
19
+
20
+ opts.on('-t', '--token TOKEN', String, 'A valid access token with `api` scope and Maintainer permission in PROJECT') do |token|
21
+ params[:token] = token
22
+ end
23
+
24
+ opts.on('-l', '--search_labels LABELS', String, 'A comma seperated list of labels to filter the epics on') do |search_labels|
25
+ params[:search_labels] = search_labels.split(',')
26
+ end
27
+
28
+ opts.on('-m', '--minutes MINUTES', Integer, 'Limit the search to issues and epics created with last MINUTES. Optional.') do |minutes|
29
+ params[:limit_to_minutes] = minutes
30
+ end
31
+
32
+ opts.on('-ei', '--epic-iid EPIC_IID', String, 'evaluate an epic with iid') do |epic_iid|
33
+ params[:epic_iid] = epic_iid
34
+ end
35
+
36
+ opts.on('--dry-run', "Perform a dry-run (don't create branches, commits or MRs)") do
37
+ params[:dry_run] = true
38
+ end
39
+
40
+ opts.on_tail('-v', '--version', 'Show the version') do
41
+ require_relative "../lib/gitlab_quality/test_tooling/version"
42
+ puts "#{$PROGRAM_NAME} : #{GitlabQuality::TestTooling::VERSION}"
43
+ exit
44
+ end
45
+
46
+ opts.on_tail('-h', '--help', 'Show the usage') do
47
+ puts "Purpose: Evaluate MRs for multiple feature readiness criteria and report the status to the corresponding epic"
48
+ puts opts
49
+ exit
50
+ end
51
+
52
+ opts.parse(ARGV)
53
+ end
54
+
55
+ raise ArgumentError, "Missing argument(s). Required arguments are: --project, --group, --token" if params.empty? || ([:project, :group, :token] - params.keys).any?
56
+
57
+ if params.any?
58
+ GitlabQuality::TestTooling::FeatureReadiness::Evaluation.new(**params).invoke!
59
+ else
60
+ puts options
61
+ exit 1
62
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module FeatureReadiness
6
+ module AnalyzedItems
7
+ class AnalyzedEpic
8
+ include Concerns::WorkItemConcern
9
+
10
+ def initialize(epic:, token:, project:, group:, dry_run:)
11
+ @token = token
12
+ @project = project
13
+ @group = group
14
+ @dry_run = dry_run
15
+ @epic = epic
16
+ @analyzed_issues = []
17
+ end
18
+
19
+ def analyze
20
+ issue_iids = get_issue_iids(epic, project)
21
+ return if issue_iids.none?
22
+
23
+ open_issues = fetch_open_issues(issue_iids)
24
+
25
+ puts "\nProcessing epic: #{epic[:webUrl]} with #{open_issues.count} open feature addition issues."
26
+
27
+ process_issues(open_issues)
28
+ end
29
+
30
+ def process_issues(issues)
31
+ @analyzed_issues.concat(
32
+ issues.map do |issue|
33
+ AnalyzedIssue.new(issue: issue, token: token, project: project, group: group, dry_run: dry_run)
34
+ .tap(&:analyze)
35
+ .result
36
+ end
37
+ )
38
+ end
39
+
40
+ def result
41
+ {
42
+ epic_iid: epic[:iid],
43
+ epic_id: epic[:id],
44
+ epic_web_url: epic[:webUrl],
45
+ issues: analyzed_issues,
46
+ doc_mrs: doc_mrs,
47
+ feature_spec_mrs: feature_spec_mrs,
48
+ e2e_spec_mrs: e2e_spec_mrs,
49
+ feature_flag_mrs: feature_flag_mrs
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ attr_reader :epic, :token, :project, :dry_run, :group, :analyzed_issues
56
+
57
+ def work_items_client
58
+ @work_items_client ||= (dry_run ? GitlabClient::WorkItemsDryClient : GitlabClient::WorkItemsClient).new(token: token, project: project, group: group)
59
+ end
60
+
61
+ def issue_client
62
+ @issue_client ||= (dry_run ? GitlabClient::IssuesDryClient : GitlabClient::IssuesClient).new(token: token, project: project)
63
+ end
64
+
65
+ def fetch_open_issues(issue_iids)
66
+ issue_client.find_issues(
67
+ options: {
68
+ iids: issue_iids,
69
+ labels: Evaluation::BASE_LABELS_FOR_SEARCH,
70
+ state: 'opened'
71
+ }
72
+ )
73
+ end
74
+
75
+ def doc_mrs
76
+ analyzed_issues.flat_map { |issue| issue[:doc_mrs].any? ? issue[:doc_mrs] : [] }
77
+ end
78
+
79
+ def feature_spec_mrs
80
+ analyzed_issues.flat_map { |issue| issue[:feature_spec_mrs].any? ? issue[:feature_spec_mrs] : [] }
81
+ end
82
+
83
+ def e2e_spec_mrs
84
+ analyzed_issues.flat_map { |issue| issue[:e2e_spec_mrs].any? ? issue[:e2e_spec_mrs] : [] }
85
+ end
86
+
87
+ def feature_flag_mrs
88
+ analyzed_issues.flat_map { |issue| issue[:feature_flag_mrs].any? ? issue[:feature_flag_mrs] : [] }
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module FeatureReadiness
6
+ module AnalyzedItems
7
+ class AnalyzedIssue
8
+ def initialize(issue:, token:, project:, group:, dry_run:)
9
+ @issue = issue
10
+ @token = token
11
+ @project = project
12
+ @group = group
13
+ @dry_run = dry_run
14
+ @analyzed_merge_requests = []
15
+ end
16
+
17
+ def analyze
18
+ contributing_mrs = contributing_mrs_for_issue(issue)
19
+ return if contributing_mrs.empty?
20
+
21
+ @analyzed_merge_requests.concat(
22
+ contributing_mrs
23
+ .reject { |mr| skip_merge_request?(mr, issue) }
24
+ .map do |merge_request|
25
+ AnalyzedMergeRequest.new(merge_request: merge_request, token: token, project: project, group: group, dry_run: dry_run)
26
+ .tap(&:analyze)
27
+ .result
28
+ end
29
+ )
30
+ end
31
+
32
+ def result
33
+ {
34
+ issue_iid: issue.iid,
35
+ issue_web_url: issue.web_url,
36
+ merge_requests: analyzed_merge_requests,
37
+ doc_mrs: doc_mrs,
38
+ feature_spec_mrs: feature_spec_mrs,
39
+ e2e_spec_mrs: e2e_spec_mrs,
40
+ feature_flag_mrs: feature_flag_mrs
41
+ }
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :issue, :token, :project, :group, :dry_run, :analyzed_merge_requests
47
+
48
+ def contributing_mrs_for_issue(issue)
49
+ related_mrs = issue_client.related_merge_requests(iid: issue.iid)
50
+
51
+ return [] if related_mrs.empty?
52
+
53
+ related_mrs.select do |mr|
54
+ mr.description&.include?("##{issue.iid}") || mr.description&.include?(issue.web_url)
55
+ end
56
+ end
57
+
58
+ def skip_merge_request?(merge_request, issue)
59
+ if (merge_request.target_project_id != issue.project_id) || (merge_request.draft == true)
60
+ puts "Skipping: #{merge_request.web_url} #{merge_request.draft == true ? 'as it is a draft' : 'due to different project id'}"
61
+ return true
62
+ end
63
+
64
+ false
65
+ end
66
+
67
+ def issue_client
68
+ @issue_client ||= (dry_run ? GitlabClient::IssuesDryClient : GitlabClient::IssuesClient).new(token: token, project: project)
69
+ end
70
+
71
+ def doc_mrs
72
+ analyzed_merge_requests
73
+ .select { |mr| mr[:has_docs] }
74
+ .map.with_index(1) { |mr, index| { "MR#{index}" => mr[:merge_request_web_url] } }
75
+ end
76
+
77
+ def feature_spec_mrs
78
+ analyzed_merge_requests.select { |mr| mr[:has_feature_specs] }.map.with_index(1) { |mr, index| { "MR#{index}" => mr[:merge_request_web_url] } }
79
+ end
80
+
81
+ def e2e_spec_mrs
82
+ analyzed_merge_requests.select { |mr| mr[:has_e2e_specs] }.map.with_index(1) { |mr, index| { "MR#{index}" => mr[:merge_request_web_url] } }
83
+ end
84
+
85
+ def feature_flag_mrs
86
+ analyzed_merge_requests.select { |mr| mr[:added_feature_flag] }.map.with_index(1) { |mr, index| { "MR#{index}" => mr[:merge_request_web_url] } }
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module FeatureReadiness
6
+ module AnalyzedItems
7
+ class AnalyzedMergeRequest
8
+ CODE_DIRECTORIES = %w[app lib config keeps scripts db].freeze
9
+ ADDITIONS_THRESHOLD = 5
10
+ CODE_FILES_EXT = "rb|js|vue"
11
+ SPEC_FILES_EXT = "rb|js"
12
+ DOC_FILES_EXT = "tmpl|yaml|yml|md"
13
+
14
+ def initialize(merge_request:, token:, project:, group:, dry_run:)
15
+ @merge_request = merge_request
16
+ @token = token
17
+ @project = project
18
+ @group = group
19
+ @dry_run = dry_run
20
+ end
21
+
22
+ def analyze
23
+ @files_with_missing_specs ||= fetch_files_with_missing_specs
24
+ end
25
+
26
+ def result
27
+ {
28
+ merge_request_iid: merge_request.iid,
29
+ merge_request_web_url: merge_request.web_url,
30
+ files_with_missing_specs: files_with_missing_specs,
31
+ has_docs: has_docs?,
32
+ has_feature_specs: has_feature_specs?,
33
+ has_e2e_specs: has_e2e_specs?,
34
+ added_feature_flag: added_feature_flag?
35
+ }
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :merge_request, :token, :project, :group, :dry_run, :files_with_missing_specs
41
+
42
+ def merge_request_client
43
+ @merge_request_client ||= (dry_run ? GitlabClient::MergeRequestsDryClient : GitlabClient::MergeRequestsClient).new(token: token, project: project)
44
+ end
45
+
46
+ def has_docs?
47
+ doc_diffs.any?
48
+ end
49
+
50
+ def has_feature_specs?
51
+ spec_diffs('features/').map { |diff| has_significant_addition?(diff) }.any?
52
+ end
53
+
54
+ def added_feature_flag?
55
+ filter_diffs(%r{^(ee/)?config/feature_flags/.*\.yml$}).any?(&:new_file)
56
+ end
57
+
58
+ def has_e2e_specs?
59
+ qa_spec_diffs.map { |diff| has_significant_addition?(diff) }.any?
60
+ end
61
+
62
+ def fetch_files_with_missing_specs
63
+ code_paths_with_missing_specs = []
64
+
65
+ CODE_DIRECTORIES.each do |dir|
66
+ code_diffs = diffs_for_dir(dir, CODE_FILES_EXT)
67
+ sp_diffs = spec_diffs
68
+ code_paths_with_missing_specs << collect_paths_with_missing_specs(code_diffs, sp_diffs)
69
+ end
70
+
71
+ code_paths_with_missing_specs.flatten
72
+ end
73
+
74
+ def collect_paths_with_missing_specs(code_diffs, sp_diffs)
75
+ result_array = []
76
+ code_diffs.each do |code_diff|
77
+ result_array << code_diff.new_path if has_significant_addition?(code_diff) && !skip_file?(code_diff.new_path) && !has_matching_spec_for_code?(code_diff, sp_diffs)
78
+ end
79
+
80
+ result_array
81
+ end
82
+
83
+ def has_matching_spec_for_code?(code_diff, sp_diffs)
84
+ spec_file_names = get_spec_file_names(code_diff.new_path)
85
+ sp_diffs.any? { |diff| ends_with_names?(diff.new_path, spec_file_names) }
86
+ end
87
+
88
+ def diffs
89
+ @diffs ||= merge_request_client.merge_request_diffs(merge_request_iid: merge_request.iid)
90
+ end
91
+
92
+ def has_significant_addition?(diff_obj)
93
+ content_lines = diff_obj.diff.split("\n")
94
+
95
+ additions = content_lines.count { |line| line.start_with?("+") }
96
+ subtractions = content_lines.count { |line| line.start_with?("-") }
97
+
98
+ # Return true if additions are above threshold and there are equal or more additions than subtractions
99
+ additions >= ADDITIONS_THRESHOLD && additions >= subtractions
100
+ end
101
+
102
+ def filter_diffs(pattern)
103
+ diffs.select { |diff| diff.new_path =~ pattern }
104
+ end
105
+
106
+ def diffs_for_dir(dir, ext)
107
+ filter_diffs(%r{^(ee/#{Regexp.escape(dir)}|#{Regexp.escape(dir)})/.*\.(#{ext})$})
108
+ end
109
+
110
+ def spec_diffs(spec_sub_dir = '')
111
+ filter_diffs(%r{^(spec|ee/spec)/#{spec_sub_dir}.+_spec\.(#{SPEC_FILES_EXT})$})
112
+ end
113
+
114
+ def qa_spec_diffs
115
+ filter_diffs(%r{^qa/qa/specs/features/.+(_spec|_shared_examples|_shared_context)\.rb$})
116
+ end
117
+
118
+ def doc_diffs
119
+ filter_diffs(%r{^doc/.+\.(#{DOC_FILES_EXT})$}o)
120
+ end
121
+
122
+ def get_spec_file_names(path)
123
+ filename = File.basename(path, ".*")
124
+
125
+ %W[#{filename}_spec #{filename}_shared_examples]
126
+ end
127
+
128
+ def ends_with_names?(path, target_names)
129
+ [File.basename(path, File.extname(path))].intersect?(target_names)
130
+ end
131
+
132
+ def skip_file?(path)
133
+ path.end_with?('index.js')
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -8,7 +8,7 @@ module GitlabQuality
8
8
  OPERATIONAL_READINESS_NOTE_ID = '<!-- OPERATIONAL READINESS PRECHECK COMMENT -->'
9
9
  OPERATIONAL_READINESS_TRACKING_LABEL = 'tracking operational readiness'
10
10
 
11
- def add_operational_readiness_precheck_comment(work_item, client)
11
+ def add_operational_readiness_precheck_comment(work_item, work_items_client, label_client)
12
12
  comment = <<~COMMENT
13
13
  #{OPERATIONAL_READINESS_NOTE_ID}
14
14
  ## Operational Readiness Pre-Check
@@ -24,21 +24,21 @@ module GitlabQuality
24
24
  3. Adds new services or changes existing services that will factor into the availability of the GitLab application.
25
25
  COMMENT
26
26
 
27
- add_labels(ids_for_labels([OPERATIONAL_READINESS_TRACKING_LABEL]), work_item[:id], client)
27
+ add_labels(ids_for_labels([OPERATIONAL_READINESS_TRACKING_LABEL], label_client), work_item[:id], work_items_client)
28
28
 
29
- discussion = existing_note_containing_text(OPERATIONAL_READINESS_NOTE_ID, work_item, client)
29
+ discussion = existing_note_containing_text(OPERATIONAL_READINESS_NOTE_ID, work_item[:iid], work_items_client)
30
30
 
31
31
  return discussion if discussion
32
32
 
33
- client.create_discussion(id: work_item[:id], note: comment)
33
+ work_items_client.create_discussion(id: work_item[:id], note: comment)
34
34
 
35
35
  puts "\nAdded operational readiness comment to epic work item: #{work_item[:webUrl]}\n"
36
36
 
37
- existing_note_containing_text(OPERATIONAL_READINESS_NOTE_ID, work_item, client)
37
+ existing_note_containing_text(OPERATIONAL_READINESS_NOTE_ID, work_item[:iid], work_items_client)
38
38
  end
39
39
 
40
- def existing_note_containing_text(text, work_item, client)
41
- work_item = fetch_work_item(work_item[:iid], client)
40
+ def existing_note_containing_text(text, work_item_iid, client)
41
+ work_item = fetch_work_item(work_item_iid, client, [:notes])
42
42
 
43
43
  work_item[:widgets]
44
44
  .find { |widget| widget.key?(:discussions) }
@@ -64,12 +64,26 @@ module GitlabQuality
64
64
  client.create_discussion_note(work_item_id: work_item[:id], discussion_id: precheck_comment.dig(:discussion, :id), text: comment_text)
65
65
  end
66
66
 
67
+ def get_labels(work_item)
68
+ labels_node = work_item[:widgets]&.find { |widget| widget.key?(:labels) }
69
+ labels_node && labels_node[:labels][:nodes].map { |label| label[:title] }
70
+ end
71
+
72
+ def get_issue_iids(work_item, project)
73
+ childern_node = work_item[:widgets]&.find { |widget| widget.key?(:children) }
74
+ childern_node && childern_node[:children][:nodes].filter_map { |issue| issue[:iid] if issue[:workItemType][:name] == "Issue" && issue[:project][:fullPath] == project }
75
+ end
76
+
77
+ def has_label?(work_item, label)
78
+ get_labels(work_item).include?(label)
79
+ end
80
+
67
81
  def add_labels(label_ids, work_item_id, client)
68
82
  client.add_labels(work_item_id: work_item_id, label_ids: label_gids(label_ids))
69
83
  end
70
84
 
71
- def fetch_work_item(iid, client)
72
- client.work_item(workitem_iid: iid)
85
+ def fetch_work_item(iid, client, widgets = [])
86
+ client.work_item(workitem_iid: iid, widgets: widgets)
73
87
  end
74
88
 
75
89
  def has_a_child_epic?(epic)
@@ -92,11 +106,11 @@ module GitlabQuality
92
106
  label_ids.map { |label_id| "gid://gitlab/Label/#{label_id}" }
93
107
  end
94
108
 
95
- def ids_for_labels(labels = [])
96
- labels.map { |label| get_id_for_label(label) }
109
+ def ids_for_labels(labels, label_client)
110
+ labels.map { |label| get_id_for_label(label, label_client) }
97
111
  end
98
112
 
99
- def get_id_for_label(label_name)
113
+ def get_id_for_label(label_name, label_client)
100
114
  labels = label_client.labels(options: { search: label_name })
101
115
 
102
116
  raise "No labels found with name: '#{label_name}'" if labels.empty?
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module GitlabQuality
6
+ module TestTooling
7
+ module FeatureReadiness
8
+ class Evaluation
9
+ include Concerns::IssueConcern
10
+ include Concerns::WorkItemConcern
11
+
12
+ BASE_LABELS_FOR_SEARCH = ['feature::addition'].freeze
13
+ FEATURE_READINESS_TRACKING_LABEL = 'tracking feature readiness'
14
+
15
+ def initialize(token:, project: nil, group: nil, limit_to_minutes: nil, epic_iid: nil, search_labels: [], dry_run: false)
16
+ @token = token
17
+ @project = "#{group}/#{project}"
18
+ @group = group
19
+ @limit_to_minutes = limit_to_minutes
20
+ @epic_iid = epic_iid
21
+ @search_labels = search_labels
22
+ @dry_run = dry_run
23
+ @analyzed_epics = []
24
+ end
25
+
26
+ def invoke!
27
+ created_after = utc_time_minus_mins(limit_to_minutes)
28
+
29
+ epics = fetch_epics(created_after)
30
+
31
+ epics.compact.each do |epic|
32
+ @analyzed_epics << process_epic(epic)
33
+ rescue StandardError => e
34
+ puts "ERROR processing epic #{epic[:epic_web_url]} due to: #{e}"
35
+ end
36
+
37
+ report_epics(analyzed_epics)
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :token, :project, :dry_run, :group, :limit_to_minutes, :epic_iid, :search_labels, :analyzed_epics
43
+
44
+ def work_items_client
45
+ @work_items_client ||= (dry_run ? GitlabClient::WorkItemsDryClient : GitlabClient::WorkItemsClient).new(token: token, project: project, group: group)
46
+ end
47
+
48
+ def fetch_epics(created_after)
49
+ return [fetch_work_item(epic_iid, work_items_client, [:hierarchy, :labels])] if epic_iid
50
+
51
+ work_items_client.paginated_call(:group_work_items,
52
+ labels: search_labels.concat(BASE_LABELS_FOR_SEARCH).uniq, state: 'opened', created_after: created_after)
53
+ end
54
+
55
+ def label_client
56
+ @label_client ||= GitlabClient::LabelsClient.new(token: token, project: project)
57
+ end
58
+
59
+ def process_epic(epic)
60
+ epic = fetch_work_item(epic[:iid], work_items_client, [:hierarchy, :labels])
61
+
62
+ AnalyzedItems::AnalyzedEpic.new(epic: epic, token: token, project: project, group: group, dry_run: dry_run)
63
+ .tap(&:analyze).result
64
+ end
65
+
66
+ def utc_time_minus_mins(mins)
67
+ (Time.now - (mins * 60)).utc.iso8601 if mins
68
+ end
69
+
70
+ def report_epics(epics)
71
+ epics.each do |epic|
72
+ add_labels(ids_for_labels([FEATURE_READINESS_TRACKING_LABEL], label_client), epic[:epic_id], work_items_client)
73
+
74
+ Report::FeatureReadiness::ReportOnEpic.report(epic, work_items_client)
75
+ rescue StandardError => e
76
+ puts "ERROR reporting epic #{epic[:epic_web_url]} due to: #{e}"
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -14,7 +14,7 @@ module GitlabQuality
14
14
 
15
15
  def initialize(token:, project: nil, group: nil, limit_to_minutes: nil, search_labels: [], issue_is_blocking: false, dry_run: false)
16
16
  @token = token
17
- @project = project
17
+ @project = "#{group}/#{project}"
18
18
  @group = group
19
19
  @limit_to_minutes = limit_to_minutes
20
20
  @search_labels = search_labels
@@ -26,7 +26,7 @@ module GitlabQuality
26
26
  created_after = utc_time_minus_mins(limit_to_minutes)
27
27
 
28
28
  epics = work_items_client.paginated_call(:group_work_items,
29
- labels: search_labels.concat(BASE_LABELS_FOR_SEARCH), state: 'opened', created_after: created_after, extras: [:work_item_fields])
29
+ labels: search_labels.concat(BASE_LABELS_FOR_SEARCH).uniq, state: 'opened', created_after: created_after, extras: [:work_item_fields])
30
30
 
31
31
  epics.each do |epic|
32
32
  process_epic(epic)
@@ -58,11 +58,11 @@ module GitlabQuality
58
58
  end
59
59
 
60
60
  def process_epic(epic) # rubocop:disable Metrics/AbcSize
61
- epic = fetch_work_item(epic[:iid], work_items_client)
61
+ epic = fetch_work_item(epic[:iid], work_items_client, [:notes, :linked_items, :labels, :hierarchy])
62
62
 
63
63
  return if has_a_child_epic?(epic)
64
64
 
65
- pre_check_comment = add_operational_readiness_precheck_comment(epic, work_items_client)
65
+ pre_check_comment = add_operational_readiness_precheck_comment(epic, work_items_client, label_client)
66
66
 
67
67
  return unless note_has_emoji?(pre_check_comment, 'white_check_mark') && !has_operational_readiness_issue_linked?(linked_issue_iids(epic), issue_client)
68
68
 
@@ -72,6 +72,12 @@ module GitlabQuality
72
72
  end
73
73
  end
74
74
 
75
+ def related_merge_requests(iid:)
76
+ handle_gitlab_client_exceptions do
77
+ client.related_merge_requests(project, iid).auto_paginate
78
+ end
79
+ end
80
+
75
81
  def find_issue_discussions(iid:)
76
82
  handle_gitlab_client_exceptions do
77
83
  client.issue_discussions(project, iid, order_by: 'created_at', sort: 'asc').auto_paginate
@@ -1,5 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'gitlab'
4
+
5
+ module Gitlab
6
+ # Monkey patch the Gitlab client to allow passing query options
7
+ class Client
8
+ def merge_request_diffs(project, merge_request_iid, options = {})
9
+ get("/projects/#{url_encode(project)}/merge_requests/#{merge_request_iid}/diffs", query: options).auto_paginate
10
+ end
11
+ end
12
+ end
13
+
3
14
  module GitlabQuality
4
15
  module TestTooling
5
16
  module GitlabClient
@@ -10,6 +21,12 @@ module GitlabQuality
10
21
  end
11
22
  end
12
23
 
24
+ def merge_request_diffs(merge_request_iid:)
25
+ handle_gitlab_client_exceptions do
26
+ client.merge_request_diffs(project, merge_request_iid, per_page: 100)
27
+ end
28
+ end
29
+
13
30
  def create_merge_request(title:, source_branch:, target_branch:, description:, labels:, assignee_id: nil, reviewer_ids: [])
14
31
  attrs = {
15
32
  source_branch: source_branch,
@@ -33,6 +50,10 @@ module GitlabQuality
33
50
  merge_request
34
51
  end
35
52
 
53
+ def merge_request(id:, options: {})
54
+ client.merge_request(project, id, options)
55
+ end
56
+
36
57
  def find(iid: nil, options: {}, &select)
37
58
  select ||= :itself
38
59
 
@@ -4,16 +4,6 @@ module GitlabQuality
4
4
  module TestTooling
5
5
  module GitlabClient
6
6
  class MergeRequestsDryClient < MergeRequestsClient
7
- def find_merge_request_changes(merge_request_iid:)
8
- puts "Finding changes for merge_request_id #{merge_request_iid}"
9
- puts "project: #{project}"
10
- end
11
-
12
- def merge_request_changed_files(merge_request_iid:)
13
- puts "Changed files for #{merge_request_iid}"
14
- []
15
- end
16
-
17
7
  def find_note(body:, merge_request_iid:)
18
8
  puts "Find note for #{merge_request_iid} with body: #{body} for mr_iid: #{merge_request_iid}"
19
9
  end
@@ -3,15 +3,14 @@
3
3
  module GitlabQuality
4
4
  module TestTooling
5
5
  module GitlabClient
6
- # The GitLab client is used for API access: https://github.com/NARKOZ/gitlab
7
6
  class WorkItemsClient < GitlabGraphqlClient
8
- def work_item(workitem_iid: nil)
7
+ def work_item(workitem_iid:, widgets: [:notes, :linked_items, :labels, :hierarchy])
9
8
  query = <<~GQL
10
9
  query {
11
10
  namespace(fullPath: "#{group}") {
12
11
  workItem(iid: "#{workitem_iid}") {
13
12
  #{work_item_fields}
14
- #{work_item_widgets}
13
+ #{work_item_widgets(widgets)}
15
14
  }
16
15
  }
17
16
  }
@@ -19,7 +18,7 @@ module GitlabQuality
19
18
  post(query)[:workItem]
20
19
  end
21
20
 
22
- def group_work_items(labels: [], cursor: '', state: 'opened', created_after: nil, extras: [])
21
+ def group_work_items(labels: [], cursor: '', state: 'opened', created_after: nil, extras: [:work_item_fields])
23
22
  query = <<~GQL
24
23
  query {
25
24
  group(fullPath: "#{group}") {
@@ -73,6 +72,18 @@ module GitlabQuality
73
72
  post(query)
74
73
  end
75
74
 
75
+ def update_note(note_id:, body:)
76
+ query = <<~GQL
77
+ mutation UpdateNote {
78
+ updateNote(input: { body: "#{body}", id: "#{note_id}" }) {
79
+ clientMutationId
80
+ errors
81
+ }
82
+ }
83
+ GQL
84
+ post(query)
85
+ end
86
+
76
87
  def create_linked_items(work_item_id:, item_ids:, link_type:)
77
88
  query = <<~GQL
78
89
  mutation WorkItemAddLinkedItems {
@@ -160,50 +171,76 @@ module GitlabQuality
160
171
  GQL
161
172
  end
162
173
 
163
- def work_item_widgets
174
+ def work_item_widget_notes
164
175
  <<~GQL
165
- widgets(onlyTypes: [LINKED_ITEMS, NOTES, LABELS, HIERARCHY]) {
166
- ... on WorkItemWidgetNotes {
167
- discussions(filter: ONLY_COMMENTS) {
168
- nodes {
169
- notes {
170
- nodes {
171
- #{note_fields}
172
- }
176
+ ... on WorkItemWidgetNotes {
177
+ discussions(filter: ONLY_COMMENTS) {
178
+ nodes {
179
+ notes {
180
+ nodes {
181
+ #{note_fields}
173
182
  }
174
183
  }
175
- }
176
- }
177
- ... on WorkItemWidgetLinkedItems {
178
- linkedItems {
179
- nodes {
180
- linkType
181
- workItem {
182
- #{work_item_fields}
183
- }
184
184
  }
185
- }
186
185
  }
186
+ }
187
+ GQL
188
+ end
187
189
 
188
- ... on WorkItemWidgetLabels{
189
- labels{
190
- nodes{
191
- title
192
- }
190
+ def work_item_widget_linked_items
191
+ <<~GQL
192
+ ... on WorkItemWidgetLinkedItems {
193
+ linkedItems {
194
+ nodes {
195
+ linkType
196
+ workItem {
197
+ #{work_item_fields}
198
+ }
199
+ }
193
200
  }
201
+ }
202
+ GQL
203
+ end
204
+
205
+ def work_item_widget_labels
206
+ <<~GQL
207
+ ... on WorkItemWidgetLabels{
208
+ labels{
209
+ nodes{
210
+ title
211
+ }
194
212
  }
213
+ }
214
+ GQL
215
+ end
195
216
 
196
- ... on WorkItemWidgetHierarchy {
197
- children {
198
- nodes{
199
- #{work_item_fields}
200
- }
201
- }
217
+ def work_item_widget_hierarchy
218
+ <<~GQL
219
+ ... on WorkItemWidgetHierarchy {
220
+ children {
221
+ nodes{
222
+ #{work_item_fields}
223
+ }
202
224
  }
203
225
  }
204
226
  GQL
205
227
  end
206
228
 
229
+ def work_item_widgets(widgets = [])
230
+ <<~GQL
231
+ widgets(onlyTypes: [#{types_for_widgets(widgets)}]) {
232
+ #{work_item_widget_notes if widgets.include?(:notes)}
233
+ #{work_item_widget_linked_items if widgets.include?(:linked_items)}
234
+ #{work_item_widget_labels if widgets.include?(:labels)}
235
+ #{work_item_widget_hierarchy if widgets.include?(:hierarchy)}
236
+ }
237
+ GQL
238
+ end
239
+
240
+ def types_for_widgets(widgets = [])
241
+ widgets.map(&:upcase).join(', ')
242
+ end
243
+
207
244
  # https://docs.gitlab.com/api/graphql/reference/#note
208
245
  def note_fields
209
246
  <<~GQL
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pp'
4
+ require 'stringio'
5
+
6
+ module GitlabQuality
7
+ module TestTooling
8
+ module Report
9
+ module FeatureReadiness
10
+ class ReportOnEpic
11
+ FEATURE_READINESS_REPORT_COMMENT_ID = '<!-- FEATURE READINESS REPORT COMMENT -->'
12
+
13
+ class << self
14
+ include GitlabQuality::TestTooling::FeatureReadiness::Concerns::WorkItemConcern
15
+
16
+ def report(analyzed_epic, work_item_client)
17
+ must_haves_report_rows = generate_report_rows(analyzed_epic, :must_haves)
18
+ should_haves_report_rows = generate_report_rows(analyzed_epic, :should_haves)
19
+
20
+ existing_note = existing_note_containing_text(FEATURE_READINESS_REPORT_COMMENT_ID, analyzed_epic[:epic_iid], work_item_client)
21
+
22
+ if existing_note
23
+ work_item_client.update_note(note_id: existing_note[:id],
24
+ body: comment({ must_haves: must_haves_report_rows, should_haves: should_haves_report_rows }, analyzed_epic).tr('"', "'"))
25
+ else
26
+ work_item_client.create_discussion(id: analyzed_epic[:epic_id],
27
+ note: comment({ must_haves: must_haves_report_rows, should_haves: should_haves_report_rows }, analyzed_epic).tr('"', "'"))
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def generate_report_rows(epic, type)
34
+ status_checks = check_statuses(epic)
35
+ create_rows(epic, type, status_checks)
36
+ end
37
+
38
+ def create_rows(epic, type, status_checks)
39
+ if type == :must_haves
40
+ [
41
+ create_documentation_row(epic, status_checks),
42
+ create_feature_flag_row(epic, status_checks),
43
+ create_unit_tests_coverage_row(status_checks)
44
+
45
+ ]
46
+ else
47
+ [
48
+ create_feature_tests_row(epic, status_checks),
49
+ create_e2e_tests_row(epic, status_checks)
50
+ ]
51
+ end
52
+ end
53
+
54
+ def create_documentation_row(epic, status_checks)
55
+ ["Documentation added?", status_icon(status_checks[:has_docs]),
56
+ prepend_text('Added in:', format_links(epic[:doc_mrs]))]
57
+ end
58
+
59
+ def create_feature_flag_row(epic, status_checks)
60
+ ["Feature Flag added?", status_icon(status_checks[:feature_flag_added]),
61
+ prepend_text('Added in:', format_links(epic[:feature_flag_mrs]))]
62
+ end
63
+
64
+ def create_feature_tests_row(epic, status_checks)
65
+ ["Feature tests added?", status_icon(status_checks[:has_feature_specs]),
66
+ format_links(epic[:feature_spec_mrs])]
67
+ end
68
+
69
+ def create_e2e_tests_row(epic, status_checks)
70
+ ["End-to-end tests added?", status_icon(status_checks[:has_e2e_specs]),
71
+ format_links(epic[:e2e_spec_mrs])]
72
+ end
73
+
74
+ def create_unit_tests_coverage_row(status_checks)
75
+ ["Unit tests coverage complete?", status_icon(status_checks[:has_complete_unit_tests]),
76
+ prepend_text('Coverage missing for:', format_links(status_checks[:missing_specs]))]
77
+ end
78
+
79
+ def prepend_text(prepend_text, text)
80
+ return "#{prepend_text} #{text}" unless text.empty?
81
+
82
+ text
83
+ end
84
+
85
+ def check_statuses(epic)
86
+ {
87
+ has_docs: epic[:doc_mrs].any?,
88
+ feature_flag_added: epic[:feature_flag_mrs].any?,
89
+ has_feature_specs: epic[:feature_spec_mrs].any?,
90
+ has_e2e_specs: epic[:e2e_spec_mrs].any?,
91
+ missing_specs: missing_spec_mrs(epic),
92
+ has_complete_unit_tests: missing_spec_mrs(epic).empty?
93
+ }
94
+ end
95
+
96
+ def comment(rows, epic)
97
+ # Generate markdown table
98
+ must_haves_table_rows = rows[:must_haves].map do |description, status, links|
99
+ "| #{description} | #{status} | #{links} |"
100
+ end.join("\n")
101
+
102
+ should_haves_table_rows = rows[:should_haves].map do |description, status, links|
103
+ "| #{description} | #{status} | #{links} |"
104
+ end.join("\n")
105
+
106
+ <<~COMMENT
107
+ #{FEATURE_READINESS_REPORT_COMMENT_ID}
108
+
109
+ # :vertical_traffic_light: Feature Readiness Evaluation Report
110
+
111
+ ### :octagonal_sign: Must haves
112
+
113
+ | Evaluation | Result | Notes |
114
+ |------------|--------|-------|
115
+ #{must_haves_table_rows}
116
+
117
+ ### :warning: Should haves
118
+
119
+ | Evaluation | Result | Notes |
120
+ |------------|--------|-------|
121
+ #{should_haves_table_rows}
122
+
123
+ #{data(epic)}
124
+
125
+ ---
126
+
127
+ _Please note that this automation is under testing. Please add any feedback on [this issue](https://gitlab.com/gitlab-org/quality/quality-engineering/team-tasks/-/issues/3587)._
128
+
129
+ COMMENT
130
+ end
131
+
132
+ def status_icon(condition)
133
+ condition ? ':white_check_mark:' : ':x:'
134
+ end
135
+
136
+ def format_links(data)
137
+ return '' if data.empty?
138
+
139
+ data.map do |item|
140
+ item.map { |key, url| "[#{key}](#{url})" }.first
141
+ end.join(", ")
142
+ end
143
+
144
+ def missing_spec_mrs(epic)
145
+ epic[:issues].flat_map do |issue|
146
+ issue[:merge_requests].flat_map do |mr|
147
+ next [] unless mr[:files_with_missing_specs]&.any?
148
+
149
+ mr[:files_with_missing_specs].map do |file|
150
+ { file => mr[:merge_request_web_url] }
151
+ end
152
+ end.compact
153
+ end
154
+ end
155
+
156
+ def data(epic)
157
+ output = StringIO.new
158
+ PP.pp(epic, output)
159
+ <<~DATA
160
+ <details><summary>Expand for data</summary>
161
+
162
+ ```ruby
163
+ #{output.string}
164
+ ```
165
+
166
+ </details>
167
+ DATA
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -318,7 +318,7 @@ module GitlabQuality
318
318
  initialize_gitlab_ops_client
319
319
 
320
320
  if Runtime::Env.ci_pipeline_url.include?('ops.gitlab.net')
321
- pipeline = ops_gitlab_client.find_pipeline(project, Runtime::Env.ci_pipeline_id.to_i)
321
+ pipeline = ops_gitlab_client.find_pipeline(Runtime::Env.ci_project_path, Runtime::Env.ci_pipeline_id.to_i)
322
322
  generate_ops_gitlab_diff(pipeline)
323
323
  else
324
324
  pipeline = gitlab.find_pipeline(project, Runtime::Env.ci_pipeline_id.to_i)
@@ -342,6 +342,8 @@ module GitlabQuality
342
342
  end
343
343
 
344
344
  def fetch_deployment_info(pipeline)
345
+ return 'No pipeline name set.' unless Runtime::Env.ci_pipeline_name
346
+
345
347
  pipeline_deploy_version = Runtime::Env.ci_pipeline_name.match(/(\d+\.\d+\.\d+)(?:-|$)/)&.captures&.first
346
348
  deployments = fetch_deployments(ops_gitlab_client, pipeline)
347
349
  found_deployment = find_matching_deployment(pipeline_deploy_version, deployments)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "2.10.0"
5
+ VERSION = "2.12.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: 2.10.0
4
+ version: 2.12.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: 2025-04-23 00:00:00.000000000 Z
11
+ date: 2025-05-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: climate_control
@@ -380,16 +380,22 @@ dependencies:
380
380
  name: rspec-parameterized
381
381
  requirement: !ruby/object:Gem::Requirement
382
382
  requirements:
383
- - - "~>"
383
+ - - ">="
384
+ - !ruby/object:Gem::Version
385
+ version: '1.0'
386
+ - - "<"
384
387
  - !ruby/object:Gem::Version
385
- version: 1.0.0
388
+ version: '3.0'
386
389
  type: :runtime
387
390
  prerelease: false
388
391
  version_requirements: !ruby/object:Gem::Requirement
389
392
  requirements:
390
- - - "~>"
393
+ - - ">="
394
+ - !ruby/object:Gem::Version
395
+ version: '1.0'
396
+ - - "<"
391
397
  - !ruby/object:Gem::Version
392
- version: 1.0.0
398
+ version: '3.0'
393
399
  - !ruby/object:Gem::Dependency
394
400
  name: table_print
395
401
  requirement: !ruby/object:Gem::Requirement
@@ -431,7 +437,8 @@ executables:
431
437
  - detect-infrastructure-failures
432
438
  - existing-test-health-issue
433
439
  - failed-test-issues
434
- - feature-readiness-check
440
+ - feature-readiness-checklist
441
+ - feature-readiness-evaluation
435
442
  - flaky-test-issues
436
443
  - generate-test-session
437
444
  - knapsack-report-issues
@@ -463,7 +470,8 @@ files:
463
470
  - exe/detect-infrastructure-failures
464
471
  - exe/existing-test-health-issue
465
472
  - exe/failed-test-issues
466
- - exe/feature-readiness-check
473
+ - exe/feature-readiness-checklist
474
+ - exe/feature-readiness-evaluation
467
475
  - exe/flaky-test-issues
468
476
  - exe/generate-test-session
469
477
  - exe/knapsack-report-issues
@@ -479,8 +487,12 @@ files:
479
487
  - lib/gitlab_quality/test_tooling.rb
480
488
  - lib/gitlab_quality/test_tooling/concerns/find_set_dri.rb
481
489
  - lib/gitlab_quality/test_tooling/failed_jobs_table.rb
490
+ - lib/gitlab_quality/test_tooling/feature_readiness/analyzed_items/analyzed_epic.rb
491
+ - lib/gitlab_quality/test_tooling/feature_readiness/analyzed_items/analyzed_issue.rb
492
+ - lib/gitlab_quality/test_tooling/feature_readiness/analyzed_items/analyzed_merge_request.rb
482
493
  - lib/gitlab_quality/test_tooling/feature_readiness/concerns/issue_concern.rb
483
494
  - lib/gitlab_quality/test_tooling/feature_readiness/concerns/work_item_concern.rb
495
+ - lib/gitlab_quality/test_tooling/feature_readiness/evaluation.rb
484
496
  - lib/gitlab_quality/test_tooling/feature_readiness/operational_readiness_check.rb
485
497
  - lib/gitlab_quality/test_tooling/gitlab_client/branches_client.rb
486
498
  - lib/gitlab_quality/test_tooling/gitlab_client/branches_dry_client.rb
@@ -507,6 +519,7 @@ files:
507
519
  - lib/gitlab_quality/test_tooling/report/concerns/results_reporter.rb
508
520
  - lib/gitlab_quality/test_tooling/report/concerns/utils.rb
509
521
  - lib/gitlab_quality/test_tooling/report/failed_test_issue.rb
522
+ - lib/gitlab_quality/test_tooling/report/feature_readiness/report_on_epic.rb
510
523
  - lib/gitlab_quality/test_tooling/report/flaky_test_issue.rb
511
524
  - lib/gitlab_quality/test_tooling/report/generate_test_session.rb
512
525
  - lib/gitlab_quality/test_tooling/report/health_problem_reporter.rb