gitlab_quality-test_tooling 2.9.0 → 2.11.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +2 -1
  3. data/Gemfile.lock +9 -1
  4. data/README.md +44 -3
  5. data/exe/failed-test-issues +53 -8
  6. data/exe/feature-readiness-checklist +61 -0
  7. data/exe/feature-readiness-evaluation +62 -0
  8. data/lib/gitlab_quality/test_tooling/feature_readiness/analyzed_items/analyzed_epic.rb +94 -0
  9. data/lib/gitlab_quality/test_tooling/feature_readiness/analyzed_items/analyzed_issue.rb +92 -0
  10. data/lib/gitlab_quality/test_tooling/feature_readiness/analyzed_items/analyzed_merge_request.rb +139 -0
  11. data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/issue_concern.rb +34 -0
  12. data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/work_item_concern.rb +128 -0
  13. data/lib/gitlab_quality/test_tooling/feature_readiness/evaluation.rb +82 -0
  14. data/lib/gitlab_quality/test_tooling/feature_readiness/operational_readiness_check.rb +82 -0
  15. data/lib/gitlab_quality/test_tooling/gitlab_client/gitlab_graphql_client.rb +54 -0
  16. data/lib/gitlab_quality/test_tooling/gitlab_client/issues_client.rb +38 -0
  17. data/lib/gitlab_quality/test_tooling/gitlab_client/issues_dry_client.rb +3 -3
  18. data/lib/gitlab_quality/test_tooling/gitlab_client/labels_client.rb +13 -0
  19. data/lib/gitlab_quality/test_tooling/gitlab_client/merge_requests_client.rb +21 -0
  20. data/lib/gitlab_quality/test_tooling/gitlab_client/merge_requests_dry_client.rb +0 -10
  21. data/lib/gitlab_quality/test_tooling/gitlab_client/work_items_client.rb +277 -0
  22. data/lib/gitlab_quality/test_tooling/gitlab_client/work_items_dry_client.rb +25 -0
  23. data/lib/gitlab_quality/test_tooling/labels_inference.rb +4 -0
  24. data/lib/gitlab_quality/test_tooling/report/failed_test_issue.rb +6 -6
  25. data/lib/gitlab_quality/test_tooling/report/feature_readiness/report_on_epic.rb +174 -0
  26. data/lib/gitlab_quality/test_tooling/report/health_problem_reporter.rb +120 -20
  27. data/lib/gitlab_quality/test_tooling/report/knapsack_report_issue.rb +1 -1
  28. data/lib/gitlab_quality/test_tooling/report/prepare_stage_reports.rb +1 -1
  29. data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +1 -1
  30. data/lib/gitlab_quality/test_tooling/runtime/env.rb +11 -6
  31. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/support/gcs_tools.rb +18 -5
  32. data/lib/gitlab_quality/test_tooling/version.rb +1 -1
  33. metadata +32 -2
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module GitlabClient
6
+ class WorkItemsClient < GitlabGraphqlClient
7
+ def work_item(workitem_iid:, widgets: [:notes, :linked_items, :labels, :hierarchy])
8
+ query = <<~GQL
9
+ query {
10
+ namespace(fullPath: "#{group}") {
11
+ workItem(iid: "#{workitem_iid}") {
12
+ #{work_item_fields}
13
+ #{work_item_widgets(widgets)}
14
+ }
15
+ }
16
+ }
17
+ GQL
18
+ post(query)[:workItem]
19
+ end
20
+
21
+ def group_work_items(labels: [], cursor: '', state: 'opened', created_after: nil, extras: [:work_item_fields])
22
+ query = <<~GQL
23
+ query {
24
+ group(fullPath: "#{group}") {
25
+ workItems(after: "#{cursor}",#{created_after ? " createdAfter: \"#{created_after}\"," : ''} labelName: [#{labels.map { |label| "\"#{label}\"" }.join(', ')}], state: #{state}) {
26
+ nodes {
27
+ #{construct_extras(extras)}
28
+ }
29
+ pageInfo {
30
+ hasNextPage
31
+ endCursor
32
+ }
33
+ }
34
+ }
35
+ }
36
+ GQL
37
+ begin
38
+ post(query)[:workItems]
39
+ rescue StandardError => e
40
+ puts "Error: #{e}"
41
+ end
42
+ end
43
+
44
+ def create_discussion(id:, note:)
45
+ post(
46
+ <<~GQL
47
+ mutation CreateDiscussion {
48
+ createDiscussion(input: {noteableId: "#{id}", body: "#{note}"}) {
49
+ clientMutationId
50
+ errors
51
+ note {
52
+ #{note_fields}
53
+ }
54
+ }
55
+ }
56
+ GQL
57
+ )
58
+ end
59
+
60
+ def create_discussion_note(work_item_id:, discussion_id:, text:)
61
+ query = <<~GQL
62
+ mutation CreateNote {
63
+ createNote(input: { discussionId: "#{discussion_id}", noteableId: "#{work_item_id}", body: "#{text}" }) {
64
+ clientMutationId
65
+ errors
66
+ note {
67
+ #{note_fields}
68
+ }
69
+ }
70
+ }
71
+ GQL
72
+ post(query)
73
+ end
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
+
87
+ def create_linked_items(work_item_id:, item_ids:, link_type:)
88
+ query = <<~GQL
89
+ mutation WorkItemAddLinkedItems {
90
+ workItemAddLinkedItems(
91
+ input: { id: "#{work_item_id}", workItemsIds: [#{item_ids.map { |id| "\"#{id}\"" }.join(', ')}], linkType: #{link_type} }
92
+ ) {
93
+ clientMutationId
94
+ }
95
+ }
96
+ GQL
97
+ post(query)
98
+ end
99
+
100
+ def add_labels(work_item_id:, label_ids:)
101
+ query =
102
+ <<~GQL
103
+ mutation WorkItemUpdate {
104
+ workItemUpdate(input: { id: "#{work_item_id}", labelsWidget: { addLabelIds: [#{label_ids.map { |id| "\"#{id}\"" }.join(', ')}] } }) {
105
+ clientMutationId
106
+ errors
107
+ }
108
+ }
109
+ GQL
110
+ post(query)
111
+ end
112
+
113
+ def paginated_call(method_name, args)
114
+ results = []
115
+
116
+ # Check if the method exists
117
+ raise ArgumentError, "Unknown method: #{method_name}" unless respond_to?(method_name, true)
118
+
119
+ method_obj = method(method_name)
120
+
121
+ loop do
122
+ # Call the method directly using the method object
123
+ response = method_obj.call(**args)
124
+
125
+ break unless response
126
+
127
+ results += response[:nodes]
128
+ break unless response[:pageInfo][:hasNextPage]
129
+
130
+ args[:cursor] = response[:pageInfo][:endCursor]
131
+ end
132
+
133
+ results
134
+ end
135
+
136
+ private
137
+
138
+ attr_reader :group
139
+
140
+ def construct_extras(extras)
141
+ extras_string = ""
142
+ extras.each do |extra|
143
+ next unless respond_to?(extra, true)
144
+
145
+ method_obj = method(extra)
146
+ extras_string += method_obj.call
147
+ extras_string += "\n"
148
+ end
149
+ extras_string
150
+ end
151
+
152
+ # https://docs.gitlab.com/api/graphql/reference/#workitem
153
+ def work_item_fields
154
+ <<~GQL
155
+ author {
156
+ username
157
+ }
158
+ createdAt
159
+ iid
160
+ id
161
+ project {
162
+ fullPath
163
+ id
164
+ }
165
+ state
166
+ title
167
+ webUrl
168
+ workItemType {
169
+ name
170
+ }
171
+ GQL
172
+ end
173
+
174
+ def work_item_widget_notes
175
+ <<~GQL
176
+ ... on WorkItemWidgetNotes {
177
+ discussions(filter: ONLY_COMMENTS) {
178
+ nodes {
179
+ notes {
180
+ nodes {
181
+ #{note_fields}
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
187
+ GQL
188
+ end
189
+
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
+ }
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
+ }
212
+ }
213
+ }
214
+ GQL
215
+ end
216
+
217
+ def work_item_widget_hierarchy
218
+ <<~GQL
219
+ ... on WorkItemWidgetHierarchy {
220
+ children {
221
+ nodes{
222
+ #{work_item_fields}
223
+ }
224
+ }
225
+ }
226
+ GQL
227
+ end
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
+
244
+ # https://docs.gitlab.com/api/graphql/reference/#note
245
+ def note_fields
246
+ <<~GQL
247
+ author {
248
+ username
249
+ }
250
+ awardEmoji {
251
+ nodes {
252
+ #{emoji_fields}
253
+ }
254
+ }
255
+ body
256
+ id
257
+ url
258
+ discussion {
259
+ id
260
+ }
261
+ GQL
262
+ end
263
+
264
+ # https://docs.gitlab.com/api/graphql/reference/#awardemoji
265
+ def emoji_fields
266
+ <<~GQL
267
+ emoji
268
+ name
269
+ user {
270
+ username
271
+ }
272
+ GQL
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module GitlabClient
6
+ class WorkItemsDryClient < WorkItemsClient
7
+ def create_discussion(id:, note:)
8
+ puts "The following discussion would have been posted on #{project}##{id} epic:\n\n #{note}"
9
+ end
10
+
11
+ def create_discussion_note(work_item_id:, discussion_id:, text:)
12
+ puts "The following discussion note would have been posted on #{project}##{work_item_id} (discussion #{discussion_id}) text:\n\n #{text}"
13
+ end
14
+
15
+ def create_linked_items(work_item_id:, item_ids:, link_type: 'BLOCKED_BY')
16
+ puts "The following items would have been linked on #{project}##{work_item_id} (link type: #{link_type}) item_ids: #{item_ids.join(', ')}"
17
+ end
18
+
19
+ def add_labels(work_item_id:, label_ids:)
20
+ puts "The following labels would have been added to #{project}##{work_item_id} work item: #{label_ids.join(', ')}"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -22,6 +22,10 @@ module GitlabQuality
22
22
  ].compact.to_set
23
23
  end
24
24
 
25
+ def product_group_from_feature_category(feature_category)
26
+ categories_mapping.dig(feature_category, 'group')
27
+ end
28
+
25
29
  private
26
30
 
27
31
  def categories_mapping
@@ -190,7 +190,7 @@ module GitlabQuality
190
190
  note_stacktrace = sanitize_stacktrace(stacktrace: note.body, regex: ISSUE_STACKTRACE_REGEX)
191
191
  return note_stacktrace if note_stacktrace
192
192
 
193
- puts " => [DEBUG] Stacktrace couldn't be found for #{issue.web_url}#note_#{note.id}!"
193
+ Runtime::Logger.debug " => Stacktrace couldn't be found for #{issue.web_url}#note_#{note.id}!"
194
194
  end
195
195
 
196
196
  def sanitize_stacktrace(stacktrace:, regex:)
@@ -199,7 +199,7 @@ module GitlabQuality
199
199
  if stacktrace_match
200
200
  stacktrace_match[:stacktrace].gsub(/^\s*#.*$/, '').gsub(/^[[:space:]]+/, '').strip
201
201
  else
202
- puts " => [DEBUG] Stacktrace doesn't match the regex (#{regex})!"
202
+ Runtime::Logger.debug " => Stacktrace doesn't match the regex (#{regex})!"
203
203
  end
204
204
  end
205
205
 
@@ -209,7 +209,7 @@ module GitlabQuality
209
209
  stack_trace_comparator = StackTraceComparator.new(test_stacktrace, note_stacktrace)
210
210
 
211
211
  if stack_trace_comparator.lower_or_equal_to_diff_ratio?(max_diff_ratio)
212
- puts " => [DEBUG] Note #{issue.web_url}#note_#{note.id} has an acceptable diff ratio of #{stack_trace_comparator.diff_percent}%."
212
+ Runtime::Logger.debug " => Note #{issue.web_url}#note_#{note.id} has an acceptable diff ratio of #{stack_trace_comparator.diff_percent}%."
213
213
  # The `Gitlab::ObjectifiedHash` class overrides `#hash` which is used by `Hash#[]=` to compute the hash key.
214
214
  # This leads to a `TypeError Exception: no implicit conversion of Hash into Integer` error, so we convert the object to a hash before using it as a Hash key.
215
215
  # See:
@@ -217,9 +217,9 @@ module GitlabQuality
217
217
  # - https://github.com/NARKOZ/gitlab/commit/cbdbd1e32623f018a8fae39932a8e3bc4d929abb?_pjax=%23js-repo-pjax-container#r44484494
218
218
  stack_trace_comparator.diff_ratio
219
219
  else
220
- puts " => [DEBUG] Found note #{issue.web_url}#note_#{note.id} but stacktraces are too different (#{stack_trace_comparator.diff_percent}%).\n"
221
- puts " => [DEBUG] Issue stacktrace:\n----------------\n#{note_stacktrace}\n----------------\n"
222
- puts " => [DEBUG] Failure stacktrace:\n----------------\n#{test_stacktrace}\n----------------\n"
220
+ Runtime::Logger.debug " => Found note #{issue.web_url}#note_#{note.id} but stacktraces are too different (#{stack_trace_comparator.diff_percent}%).\n"
221
+ Runtime::Logger.debug " => Issue stacktrace:\n----------------\n#{note_stacktrace}\n----------------\n"
222
+ Runtime::Logger.debug " => Failure stacktrace:\n----------------\n#{test_stacktrace}\n----------------\n"
223
223
  end
224
224
  end
225
225
  end
@@ -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