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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 737a2e86ac9d3545cf1c5f5b9344fead1e03d12dd4a6fe0f9b7ed44b00f604e6
4
- data.tar.gz: 99cac62fe8e0672f28930be527ad4ef73743ca8d63c5e59734f610e2e3624ca9
3
+ metadata.gz: c38aa70a45607f4aa4fb6b5a30370451b4220983ff420e05bac40260bc361c70
4
+ data.tar.gz: 25fa008b29d0d8470efbd5ed2f5029b693031ab9b8fddba6884a9d4075241ee4
5
5
  SHA512:
6
- metadata.gz: dac0023706ab1428abc046d6189e7c6df3ad684e4a7f70fcbed84633505c20a515e399e4050c3427584abceb1008e61908c9aacc5286f9dd74431d04e1423149
7
- data.tar.gz: 876fa1ec99dcba949757b9f9073a4b118c4f98d877c1c2edfd3bcb55850cbf9de48683684de427536d13b21639d7f66aff98a552abe6d80ec1bd582db6385058
6
+ metadata.gz: 36061bbe33b1a7bab05481ccf8cebe1146a27846862373a6178cedddf3cc7f73ced1d7e98197a753650d5a82ea25ea4886b27f74f6fca13a2986d699e35a7b16
7
+ data.tar.gz: 54612a3243704341c2b19c3f6141cca89c88c2e62189b074643da8f5b7527008feb8df525b426a05daddd7107aec24bae32a36950a862dba8230e4099aadbc33
data/.rspec CHANGED
@@ -1,3 +1,4 @@
1
1
  --color
2
- --format documentation
2
+ --order random
3
+ --format progress
3
4
  --require spec_helper
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (2.9.0)
4
+ gitlab_quality-test_tooling (2.11.0)
5
5
  activesupport (>= 7.0, < 7.3)
6
6
  amatch (~> 0.4.1)
7
7
  fog-google (~> 1.24, >= 1.24.1)
@@ -187,6 +187,7 @@ GEM
187
187
  http-cookie (~> 1.0)
188
188
  http-form_data (~> 2.2)
189
189
  llhttp-ffi (~> 0.5.0)
190
+ http-accept (1.7.0)
190
191
  http-cookie (1.0.7)
191
192
  domain_name (~> 0.5)
192
193
  http-form_data (2.3.0)
@@ -233,6 +234,7 @@ GEM
233
234
  nenv (0.3.0)
234
235
  net-http (0.4.1)
235
236
  uri
237
+ netrc (0.11.0)
236
238
  nokogiri (1.16.7)
237
239
  mini_portile2 (~> 2.8.2)
238
240
  racc (~> 1.4)
@@ -273,6 +275,11 @@ GEM
273
275
  declarative (< 0.1.0)
274
276
  trailblazer-option (>= 0.1.1, < 0.2.0)
275
277
  uber (< 0.2.0)
278
+ rest-client (2.1.0)
279
+ http-accept (>= 1.7.0, < 2.0)
280
+ http-cookie (>= 1.0.2, < 2.0)
281
+ mime-types (>= 1.16, < 4.0)
282
+ netrc (~> 0.8)
276
283
  retriable (3.1.2)
277
284
  reverse_markdown (2.1.1)
278
285
  nokogiri
@@ -408,6 +415,7 @@ DEPENDENCIES
408
415
  lefthook (~> 1.3)
409
416
  pry-byebug (= 3.10.1)
410
417
  rake (~> 13.0)
418
+ rest-client (~> 2.1.0)
411
419
  rspec (~> 3.12)
412
420
  rspec_junit_formatter (~> 0.6.0)
413
421
  simplecov (~> 0.22)
data/README.md CHANGED
@@ -161,9 +161,17 @@ Usage: exe/knapsack-report-issues [options]
161
161
  ```shell
162
162
  Purpose: Relate test failures to failure issues from RSpec report files (JSON or JUnit XML)
163
163
  Usage: exe/failed-test-issues [options]
164
- -i, --input-files INPUT_FILES RSpec report files (JSON or JUnit XML)
165
- -p, --project PROJECT Can be an integer or a group/project string
166
- -t, --token TOKEN A valid access token with `api` scope and Maintainer permission in PROJECT
164
+ -i, --input-files INPUT_FILES RSpec report files (JSON or JUnit XML) [REQUIRED]
165
+ -p, --project PROJECT Can be an integer or a group/project string [REQUIRED]
166
+ -t, --token TOKEN A valid access token with `api` scope and Maintainer permission in PROJECT [REQUIRED]
167
+ --enable-issue-update [BOOLEAN]
168
+ Enable/disable test health issue creation/update (default: true)
169
+ --enable-gcs [BOOLEAN] Enable/disable the push of JSON data to a Google Cloud Storage (GCS) bucket (default: false)
170
+ --gcs-project-id GCS_PROJECT_ID
171
+ Google Cloud project ID for GCS bucket access
172
+ --gcs-bucket GCS_BUCKET Name of the GCS bucket to store metrics data
173
+ --gcs-credentials GCS_CREDENTIALS
174
+ GCS service account credentials (file path or string)
167
175
  --max-diff-ratio MAX_DIFF_RATO
168
176
  Max stacktrace diff ratio for failure issues detection
169
177
  -r RELATED_ISSUES_FILE, The file path for the related issues
@@ -249,6 +257,39 @@ Usage: exe/update-test-meta [options]
249
257
  -h, --help Show the usage
250
258
  ```
251
259
 
260
+ ### `exe/feature-readiness-checklist`
261
+
262
+ ```shell
263
+ Purpose: Conditionally create operational readiness checklist and link it to the epic
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
283
+ -t, --token TOKEN A valid access token with `api` scope and Maintainer permission in PROJECT
284
+ -l, --search_labels LABELS A comma seperated list of labels to filter the epics on
285
+ -m, --minutes MINUTES Limit the search to issues and epics created with last MINUTES. Optional.
286
+ -e, --epic-iid EPIC_IID evaluate an epic with iid
287
+ -o, --operational-readinessr Perform the operational readiness checklist automation
288
+ --dry-run Perform a dry-run (don't create branches, commits or MRs)
289
+ -v, --version Show the version
290
+ -h, --help Show the usage
291
+ ```
292
+
252
293
  ## Development
253
294
 
254
295
  ### Initial setup
@@ -9,20 +9,48 @@ require_relative "../lib/gitlab_quality/test_tooling"
9
9
  params = {}
10
10
 
11
11
  options = OptionParser.new do |opts|
12
+ def to_boolean(value, default)
13
+ return default if value.nil?
14
+ return true if value == true || value.to_s.casecmp('true').zero?
15
+
16
+ false
17
+ end
18
+
12
19
  opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
13
20
 
14
- opts.on('-i', '--input-files INPUT_FILES', String, 'RSpec report files (JSON or JUnit XML)') do |input_files|
21
+ opts.on('-i', '--input-files INPUT_FILES', String, 'RSpec report files (JSON or JUnit XML) [REQUIRED]') do |input_files|
15
22
  params[:input_files] = input_files
16
23
  end
17
24
 
18
- opts.on('-p', '--project PROJECT', String, 'Can be an integer or a group/project string') do |project|
25
+ opts.on('-p', '--project PROJECT', String, 'Can be an integer or a group/project string [REQUIRED]') do |project|
19
26
  params[:project] = project
20
27
  end
21
28
 
22
- opts.on('-t', '--token TOKEN', String, 'A valid access token with `api` scope and Maintainer permission in PROJECT') do |token|
29
+ opts.on('-t', '--token TOKEN', String, 'A valid access token with `api` scope and Maintainer permission in PROJECT [REQUIRED]') do |token|
23
30
  params[:token] = token
24
31
  end
25
32
 
33
+ opts.on('--enable-issue-update [BOOLEAN]', "Enable/disable test health issue creation/update (default: true)") do |toggle|
34
+ params[:issue_update_enabled] = to_boolean(toggle, true)
35
+ end
36
+
37
+ # Google Cloud Storage (GCS) options
38
+ opts.on('--enable-gcs [BOOLEAN]', "Enable/disable the push of JSON data to a Google Cloud Storage (GCS) bucket (default: false)") do |toggle|
39
+ params[:gcs_enabled] = to_boolean(toggle, false)
40
+ end
41
+
42
+ opts.on('--gcs-project-id GCS_PROJECT_ID', String, 'Google Cloud project ID for GCS bucket access') do |project_id|
43
+ params[:gcs_project_id] = project_id
44
+ end
45
+
46
+ opts.on('--gcs-bucket GCS_BUCKET', String, 'Name of the GCS bucket to store metrics data') do |bucket|
47
+ params[:gcs_bucket] = bucket
48
+ end
49
+
50
+ opts.on('--gcs-credentials GCS_CREDENTIALS', String, 'GCS service account credentials (file path or string)') do |credentials|
51
+ params[:gcs_credentials] = credentials
52
+ end
53
+
26
54
  opts.on('--max-diff-ratio MAX_DIFF_RATO', Float, 'Max stacktrace diff ratio for failure issues detection') do |max_diff_ratio|
27
55
  params[:max_diff_ratio] = max_diff_ratio
28
56
  end
@@ -56,13 +84,30 @@ options = OptionParser.new do |opts|
56
84
  puts opts
57
85
  exit
58
86
  end
59
-
60
- opts.parse(ARGV)
61
87
  end
62
88
 
63
- if params.any?
89
+ begin
90
+ options.parse!(ARGV)
91
+
92
+ required_options = [:input_files, :project, :token]
93
+ missing_options = required_options.select { |option| params[option].nil? }
94
+
95
+ if params[:gcs_enabled]
96
+ gcs_required_options = [:gcs_project_id, :gcs_bucket, :gcs_credentials]
97
+ missing_gcs_options = gcs_required_options.select { |option| params[option].nil? }
98
+
99
+ missing_options.concat(missing_gcs_options) if missing_gcs_options.any?
100
+ end
101
+
102
+ if missing_options.any?
103
+ warn "Error: Missing required options: #{missing_options.map { |o| "--#{o.to_s.tr('_', '-')}" }.join(', ')}\n"
104
+ warn options
105
+ exit 1
106
+ end
107
+
64
108
  GitlabQuality::TestTooling::Report::FailedTestIssue.new(**params).invoke!
65
- else
66
- puts options
109
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
110
+ warn "Error: #{e.message}"
111
+ warn options
67
112
  exit 1
68
113
  end
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+
6
+ require_relative "../lib/gitlab_quality/test_tooling"
7
+ params = {}
8
+
9
+ options = OptionParser.new do |opts|
10
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
11
+
12
+ opts.on('-p', '--project PROJECT', String, 'Name of the project') do |project|
13
+ params[:project] = project
14
+ end
15
+ opts.on('-g', '--group GROUP', String, 'Name of the group') do |group|
16
+ params[:group] = group
17
+ end
18
+
19
+ opts.on('-t', '--token TOKEN', String, 'A valid access token with `api` scope and Maintainer permission in PROJECT') do |token|
20
+ params[:token] = token
21
+ end
22
+
23
+ opts.on('-l', '--search_labels LABELS', String, 'A comma seperated list of labels to filter the epics on') do |search_labels|
24
+ params[:search_labels] = search_labels.split(',')
25
+ end
26
+
27
+ opts.on('-m', '--minutes MINUTES', Integer, 'Limit the search to issues and epics created with last MINUTES. Optional.') do |minutes|
28
+ params[:limit_to_minutes] = minutes
29
+ end
30
+
31
+ opts.on('-b', '--blocking', 'Block the epic by any issues created and linked to it') do
32
+ params[:issue_is_blocking] = true
33
+ end
34
+
35
+ opts.on('--dry-run', "Perform a dry-run (don't create branches, commits or MRs)") do
36
+ params[:dry_run] = true
37
+ end
38
+
39
+ opts.on_tail('-v', '--version', 'Show the version') do
40
+ require_relative "../lib/gitlab_quality/test_tooling/version"
41
+ puts "#{$PROGRAM_NAME} : #{GitlabQuality::TestTooling::VERSION}"
42
+ exit
43
+ end
44
+
45
+ opts.on_tail('-h', '--help', 'Show the usage') do
46
+ puts "Purpose: Conditionally create operational readiness checklist and link it to the epic"
47
+ puts opts
48
+ exit
49
+ end
50
+
51
+ opts.parse(ARGV)
52
+ end
53
+
54
+ raise ArgumentError, "Missing argument(s). Required arguments are: --project, --group, --token" if params.empty? || ([:project, :group, :token] - params.keys).any?
55
+
56
+ if params.any?
57
+ GitlabQuality::TestTooling::FeatureReadiness::OperationalReadinessCheck.new(**params).invoke!
58
+ else
59
+ puts options
60
+ exit 1
61
+ end
@@ -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