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.
- checksums.yaml +4 -4
- data/.rspec +2 -1
- data/Gemfile.lock +9 -1
- data/README.md +44 -3
- data/exe/failed-test-issues +53 -8
- data/exe/feature-readiness-checklist +61 -0
- data/exe/feature-readiness-evaluation +62 -0
- data/lib/gitlab_quality/test_tooling/feature_readiness/analyzed_items/analyzed_epic.rb +94 -0
- data/lib/gitlab_quality/test_tooling/feature_readiness/analyzed_items/analyzed_issue.rb +92 -0
- data/lib/gitlab_quality/test_tooling/feature_readiness/analyzed_items/analyzed_merge_request.rb +139 -0
- data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/issue_concern.rb +34 -0
- data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/work_item_concern.rb +128 -0
- data/lib/gitlab_quality/test_tooling/feature_readiness/evaluation.rb +82 -0
- data/lib/gitlab_quality/test_tooling/feature_readiness/operational_readiness_check.rb +82 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/gitlab_graphql_client.rb +54 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/issues_client.rb +38 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/issues_dry_client.rb +3 -3
- data/lib/gitlab_quality/test_tooling/gitlab_client/labels_client.rb +13 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/merge_requests_client.rb +21 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/merge_requests_dry_client.rb +0 -10
- data/lib/gitlab_quality/test_tooling/gitlab_client/work_items_client.rb +277 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/work_items_dry_client.rb +25 -0
- data/lib/gitlab_quality/test_tooling/labels_inference.rb +4 -0
- data/lib/gitlab_quality/test_tooling/report/failed_test_issue.rb +6 -6
- data/lib/gitlab_quality/test_tooling/report/feature_readiness/report_on_epic.rb +174 -0
- data/lib/gitlab_quality/test_tooling/report/health_problem_reporter.rb +120 -20
- data/lib/gitlab_quality/test_tooling/report/knapsack_report_issue.rb +1 -1
- data/lib/gitlab_quality/test_tooling/report/prepare_stage_reports.rb +1 -1
- data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +1 -1
- data/lib/gitlab_quality/test_tooling/runtime/env.rb +11 -6
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/support/gcs_tools.rb +18 -5
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- 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
|
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
221
|
-
|
222
|
-
|
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
|