Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-12-19 14:01:45 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-12-19 14:01:45 +0300
commit9297025d0b7ddf095eb618dfaaab2ff8f2018d8b (patch)
tree865198c01d1824a9b098127baa3ab980c9cd2c06 /scripts
parent6372471f43ee03c05a7c1f8b0c6ac6b8a7431dbe (diff)
Add latest changes from gitlab-org/gitlab@16-7-stable-eev16.7.0-rc42
Diffstat (limited to 'scripts')
-rw-r--r--scripts/database/query_analyzers.rb14
-rw-r--r--scripts/database/query_analyzers.yml11
-rw-r--r--scripts/database/query_analyzers/base.rb4
-rw-r--r--scripts/database/query_analyzers/multiple_partition_scan_detector.rb10
-rwxr-xr-xscripts/decomposition/generate-loose-foreign-key19
-rwxr-xr-xscripts/duo_chat/reporter.rb204
-rwxr-xr-xscripts/flaky_examples/prune-old-flaky-examples6
-rwxr-xr-xscripts/generate-e2e-pipeline2
-rwxr-xr-xscripts/generate-failed-package-and-test-mr-message.rb2
-rwxr-xr-xscripts/generate-message-to-run-e2e-pipeline.rb6
-rwxr-xr-xscripts/internal_events/cli.rb135
-rwxr-xr-xscripts/internal_events/cli/event.rb55
-rwxr-xr-xscripts/internal_events/cli/event_definer.rb180
-rwxr-xr-xscripts/internal_events/cli/helpers.rb27
-rwxr-xr-xscripts/internal_events/cli/helpers/cli_inputs.rb60
-rwxr-xr-xscripts/internal_events/cli/helpers/event_options.rb80
-rwxr-xr-xscripts/internal_events/cli/helpers/files.rb36
-rwxr-xr-xscripts/internal_events/cli/helpers/formatting.rb89
-rwxr-xr-xscripts/internal_events/cli/helpers/group_ownership.rb71
-rwxr-xr-xscripts/internal_events/cli/helpers/metric_options.rb124
-rwxr-xr-xscripts/internal_events/cli/metric.rb155
-rwxr-xr-xscripts/internal_events/cli/metric_definer.rb310
-rwxr-xr-xscripts/internal_events/cli/text.rb216
-rwxr-xr-xscripts/internal_events/cli/usage_viewer.rb247
-rwxr-xr-xscripts/qa/quarantine-types-check2
-rwxr-xr-xscripts/remote_development/run-e2e-tests.sh2
-rwxr-xr-xscripts/remote_development/run-smoke-test-suite.sh72
-rw-r--r--scripts/review_apps/base-config.yaml7
-rwxr-xr-xscripts/review_apps/review-apps.sh26
-rw-r--r--scripts/rspec_helpers.sh4
-rwxr-xr-xscripts/trigger-build.rb27
-rw-r--r--scripts/utils.sh20
-rwxr-xr-xscripts/verify-tff-mapping143
33 files changed, 2187 insertions, 179 deletions
diff --git a/scripts/database/query_analyzers.rb b/scripts/database/query_analyzers.rb
index 390851df81a..89eae5e6f2c 100644
--- a/scripts/database/query_analyzers.rb
+++ b/scripts/database/query_analyzers.rb
@@ -1,11 +1,17 @@
# frozen_string_literal: true
+require 'yaml'
+
class Database
class QueryAnalyzers
attr_reader :analyzers
def initialize
- @analyzers = ObjectSpace.each_object(::Class).select { |c| c < Base }.map(&:new)
+ config = YAML.safe_load_file(File.expand_path('query_analyzers.yml', __dir__))
+ @analyzers = self.class.all.map do |subclass|
+ subclass_name = subclass.to_s.split('::').last
+ subclass.new(config[subclass_name])
+ end
end
def analyze(query)
@@ -15,6 +21,12 @@ class Database
def save!
analyzers.each(&:save!)
end
+
+ class << self
+ def all
+ ObjectSpace.each_object(::Class).select { |c| c < Base }
+ end
+ end
end
end
diff --git a/scripts/database/query_analyzers.yml b/scripts/database/query_analyzers.yml
new file mode 100644
index 00000000000..8f87d96500d
--- /dev/null
+++ b/scripts/database/query_analyzers.yml
@@ -0,0 +1,11 @@
+MultiplePartitionScanDetector:
+ tables:
+ - p_ci_builds
+ - p_ci_builds_metadata
+ - p_ci_job_annotations
+ - p_ci_runner_machine_builds
+ todos:
+ # List query fingerprints which should be ignored until they are fixed.
+ # These fingerprints can be found in the auto_explain pipeline artifacts.
+ # Example:
+ # - c2cfe803a497101b
diff --git a/scripts/database/query_analyzers/base.rb b/scripts/database/query_analyzers/base.rb
index 4bf47a32da1..e56a61b3c39 100644
--- a/scripts/database/query_analyzers/base.rb
+++ b/scripts/database/query_analyzers/base.rb
@@ -7,9 +7,11 @@ class Database
class QueryAnalyzers
class Base
attr_accessor :output
+ attr_reader :config
- def initialize
+ def initialize(config)
@output = {}
+ @config = config
end
def filename
diff --git a/scripts/database/query_analyzers/multiple_partition_scan_detector.rb b/scripts/database/query_analyzers/multiple_partition_scan_detector.rb
index 3afce51f87a..1a1415dd8f2 100644
--- a/scripts/database/query_analyzers/multiple_partition_scan_detector.rb
+++ b/scripts/database/query_analyzers/multiple_partition_scan_detector.rb
@@ -5,14 +5,12 @@ require_relative 'base'
class Database
class QueryAnalyzers
class MultiplePartitionScanDetector < Database::QueryAnalyzers::Base
- TABLES = %w[
- p_ci_builds p_ci_builds_metadata p_ci_job_annotations p_ci_runner_machine_builds
- ].freeze
-
def analyze(query)
super
- TABLES.each do |table_name|
+ return if config['todos']&.include?(query['fingerprint'])
+
+ config['tables'].each do |table_name|
if query['query'].include?(table_name) && query['plan'].to_s.include?('"Subplans Removed"=>0')
(output[table_name] ||= []) << query
end
@@ -20,7 +18,7 @@ class Database
end
def save!
- TABLES.each do |table_name|
+ config['tables'].each do |table_name|
next unless output[table_name]
Zlib::GzipWriter.open(output_path("#{table_name}_multiple_partition_scans.ndjson")) do |file|
diff --git a/scripts/decomposition/generate-loose-foreign-key b/scripts/decomposition/generate-loose-foreign-key
index d52fa2b4f3f..af318411119 100755
--- a/scripts/decomposition/generate-loose-foreign-key
+++ b/scripts/decomposition/generate-loose-foreign-key
@@ -12,7 +12,7 @@ $options = {
}
OptionParser.new do |opts|
- opts.banner = "Usage: #{$0} [options] <filters...>"
+ opts.banner = "Usage: #{$0} [options] <regexp filters...>"
opts.on("-c", "--cross-schema", "Show only cross-schema foreign keys") do |v|
$options[:cross_schema] = v
@@ -94,11 +94,11 @@ def has_lfk?(definition)
end
end
-def matching_filter?(definition, filters)
+def foreign_key_matching?(definition, filters)
filters.all? do |filter|
- definition.from_table.include?(filter) ||
- definition.to_table.include?(filter) ||
- definition.column.include?(filter)
+ definition.from_table.match?(filter) ||
+ definition.to_table.match?(filter) ||
+ definition.column.match?(filter)
end
end
@@ -161,7 +161,8 @@ def generate_migration(definition)
content = <<-EOF.strip_heredoc
# frozen_string_literal: true
- class Remove#{definition.to_table.camelcase}#{definition.from_table.camelcase}#{definition.column.camelcase}Fk < Gitlab::Database::Migration[2.1]
+ class Remove#{definition.to_table.camelcase}#{definition.from_table.camelcase}#{definition.column.camelcase}Fk < Gitlab::Database::Migration[#{Gitlab::Database::Migration.current_version}]
+ milestone '#{Gitlab.current_milestone}'
disable_ddl_transaction!
FOREIGN_KEY_NAME = "#{definition.name}"
@@ -215,8 +216,8 @@ def add_test_to_specs(definition)
spec_test = <<-EOF.strip_heredoc.indent(2)
context 'with loose foreign key on #{definition.from_table}.#{definition.column}' do
it_behaves_like 'cleanup by a loose foreign key' do
- let!(:parent) { create(:#{definition.to_table.singularize}) }
- let!(:model) { create(:#{definition.from_table.singularize}, #{definition.column.delete_suffix("_id").singularize}: parent) }
+ let_it_be(:parent) { create(:#{definition.to_table.singularize}) }
+ let_it_be(:model) { create(:#{definition.from_table.singularize}, #{definition.column.delete_suffix("_id").singularize}: parent) }
end
end
EOF
@@ -290,7 +291,7 @@ puts
puts "Generating Loose Foreign Key for given filters: #{ARGV}"
all_foreign_keys.each_with_index do |definition, idx|
- next unless matching_filter?(definition, ARGV)
+ next unless foreign_key_matching?(definition, ARGV.map { |arg| Regexp.new(arg) })
puts "Matched: #{idx} (#{definition.from_table}, #{definition.to_table}, #{definition.column})"
diff --git a/scripts/duo_chat/reporter.rb b/scripts/duo_chat/reporter.rb
index 686a49164a7..0136c39ccb1 100755
--- a/scripts/duo_chat/reporter.rb
+++ b/scripts/duo_chat/reporter.rb
@@ -5,7 +5,10 @@ require 'gitlab'
require 'json'
class Reporter
- IDENTIFIABLE_NOTE_TAG = 'gitlab-org/ai-powered/ai-framework:duo-chat-qa-evaluation-'
+ GITLAB_COM_API_V4_ENDPOINT = "https://gitlab.com/api/v4"
+ QA_EVALUATION_PROJECT_ID = 52020045 # https://gitlab.com/gitlab-org/ai-powered/ai-framework/qa-evaluation
+ AGGREGATED_REPORT_ISSUE_IID = 1 # https://gitlab.com/gitlab-org/ai-powered/ai-framework/qa-evaluation/-/issues/1
+ IDENTIFIABLE_NOTE_TAG = 'gitlab-org/ai-powered/ai-framework:duo-chat-qa-evaluation'
GRADE_TO_EMOJI_MAPPING = {
correct: ":white_check_mark:",
@@ -14,60 +17,19 @@ class Reporter
}.freeze
def run
- merge_request_iid = ENV['CI_MERGE_REQUEST_IID']
- ci_project_id = ENV['CI_PROJECT_ID']
-
- puts "Saving #{artifact_path}"
- File.write(artifact_path, report_note)
-
- # Look for an existing note
- report_notes = com_gitlab_client
- .merge_request_notes(ci_project_id, merge_request_iid)
- .auto_paginate
- .select do |note|
- note.body.include? note_identifier_tag
- end
-
- note = report_notes.max_by { |note| Time.parse(note.created_at) }
-
- if note && note.type != 'DiscussionNote'
- # The latest note has not led to a discussion. Update it.
- com_gitlab_client.edit_merge_request_note(ci_project_id, merge_request_iid, note.id, report_note)
-
- puts "Updated comment."
+ if pipeline_running_on_master_branch?
+ snippet_web_url = upload_data_as_snippet
+ report_issue_url = create_report_issue
+ update_aggregation_issue(report_issue_url, snippet_web_url)
else
- # This is the first note or the latest note has been discussed on the MR.
- # Don't update, create new note instead.
- com_gitlab_client.create_merge_request_note(ci_project_id, merge_request_iid, report_note)
-
- puts "Posted comment."
+ save_report_as_artifact
+ post_or_update_report_note
end
end
- private
-
- def report_filename
- "#{ENV['DUO_RSPEC']}.md"
- end
-
- def artifact_path
- File.join(ENV['CI_PROJECT_DIR'], report_filename)
- end
-
- def note_identifier_tag
- "#{IDENTIFIABLE_NOTE_TAG}#{ENV['DUO_RSPEC']}"
- end
-
- def com_gitlab_client
- @com_gitlab_client ||= Gitlab.client(
- endpoint: "https://gitlab.com/api/v4",
- private_token: ENV['PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE']
- )
- end
-
- def report_note
- report = <<~MARKDOWN
- <!-- #{note_identifier_tag} -->
+ def markdown_report
+ @report ||= <<~MARKDOWN
+ <!-- #{IDENTIFIABLE_NOTE_TAG} -->
## GitLab Duo Chat QA evaluation
@@ -93,7 +55,7 @@ class Reporter
- Note: if an evaluation request failed or its response was not parsable, it was ignored. For example, :x: :warning: would count as `INCORRECT`.
- - The number of evaluations in which LLMs disagreed: #{summary_numbers[:disagreed]} (#{summary_numbers[:disagreed_ratio]}%)
+ - The number of evaluations in which LLMs disagreed: #{summary_numbers[:disagreed]} (#{summary_numbers[:disagreed_ratio]}%)
### Evaluations
@@ -103,29 +65,137 @@ class Reporter
MARKDOWN
- if report.length > 1000000
- return <<~MARKDOWN
- <!-- #{note_identifier_tag} -->
+ # Do this to avoid pinging users in notes/issues.
+ quote_usernames(@report)
+ end
- ## GitLab Duo Chat QA evaluation
+ private
- Report generated for "#{ENV['CI_JOB_NAME']}". This report is generated and refreshed automatically. Do not edit.
+ def quote_usernames(text)
+ text.gsub(/(@\w+)/, '`\\1`')
+ end
- **:warning: the evaluation report is too long (> `1000000`) and cannot be posted as a note.**
+ def pipeline_running_on_master_branch?
+ ENV['CI_COMMIT_BRANCH'] == ENV['CI_DEFAULT_BRANCH']
+ end
- Please check out the artifact for the CI job "#{ENV['CI_JOB_NAME']}":
+ def utc_timestamp
+ @utc_timestamp ||= Time.now.utc
+ end
- https://gitlab.com/gitlab-org/gitlab/-/jobs/#{ENV['CI_JOB_ID']}/artifacts/file/#{report_filename}
+ def upload_data_as_snippet
+ filename = "#{utc_timestamp.to_i}.json"
+ title = utc_timestamp.to_s
+ snippet_content = ::JSON.pretty_generate({
+ commit: ENV["CI_COMMIT_SHA"],
+ pipeline_url: ENV["CI_PIPELINE_URL"],
+ data: report_data
+ })
+
+ puts "Creating a snippet #{filename}."
+ snippet = qa_evaluation_project_client.create_snippet(
+ QA_EVALUATION_PROJECT_ID,
+ {
+ title: title,
+ files: [{ file_path: filename, content: snippet_content }],
+ visibility: 'private'
+ }
+ )
- MARKDOWN
+ snippet.web_url
+ end
+
+ def create_report_issue
+ puts "Creating a report issue."
+ issue_title = "Report #{utc_timestamp}"
+ new_issue = qa_evaluation_project_client.create_issue(
+ QA_EVALUATION_PROJECT_ID, issue_title, { description: markdown_report }
+ )
+
+ new_issue.web_url
+ end
+
+ def update_aggregation_issue(report_issue_url, snippet_web_url)
+ puts "Updating the aggregated report issue."
+
+ new_line = ["\n|"]
+ new_line << "#{utc_timestamp} |"
+ new_line << "#{summary_numbers[:total]} |"
+ new_line << "#{summary_numbers[:correct_ratio]}% |"
+ new_line << "#{summary_numbers[:incorrect_ratio]}% |"
+ new_line << "#{summary_numbers[:disagreed_ratio]}% |"
+ new_line << "#{report_issue_url} |"
+ new_line << "#{snippet_web_url} |"
+ new_line = new_line.join(' ')
+
+ aggregated_report_issue = qa_evaluation_project_client.issue(QA_EVALUATION_PROJECT_ID, AGGREGATED_REPORT_ISSUE_IID)
+ updated_description = aggregated_report_issue.description + new_line
+ qa_evaluation_project_client.edit_issue(
+ QA_EVALUATION_PROJECT_ID, AGGREGATED_REPORT_ISSUE_IID, { description: updated_description }
+ )
+ end
+
+ def save_report_as_artifact
+ artifact_path = File.join(base_dir, ENV['QA_EVAL_REPORT_FILENAME'])
+
+ puts "Saving #{artifact_path}"
+ File.write(artifact_path, markdown_report)
+ end
+
+ def post_or_update_report_note
+ note = existing_report_note
+ if note && note.type != 'DiscussionNote'
+ # The latest note has not led to a discussion. Update it.
+ gitlab_project_client.edit_merge_request_note(ci_project_id, merge_request_iid, note.id, markdown_report)
+
+ puts "Updated comment."
+ else
+ # This is the first note or the latest note has been discussed on the MR.
+ # Don't update, create new note instead.
+ gitlab_project_client.create_merge_request_note(ci_project_id, merge_request_iid, markdown_report)
+
+ puts "Posted comment."
end
+ end
+
+ def existing_report_note
+ # Look for an existing note using `IDENTIFIABLE_NOTE_TAG`
+ gitlab_project_client
+ .merge_request_notes(ci_project_id, merge_request_iid)
+ .auto_paginate
+ .select { |note| note.body.include? IDENTIFIABLE_NOTE_TAG }
+ .max_by { |note| Time.parse(note.created_at) }
+ end
+
+ def gitlab_project_client
+ @gitlab_project_client ||= Gitlab.client(
+ endpoint: GITLAB_COM_API_V4_ENDPOINT,
+ private_token: ENV['PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE']
+ )
+ end
+
+ def qa_evaluation_project_client
+ @qa_evaluation_project_client ||= Gitlab.client(
+ endpoint: GITLAB_COM_API_V4_ENDPOINT,
+ private_token: ENV['CHAT_QA_EVALUATION_PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE']
+ )
+ end
+
+ def base_dir
+ ENV['CI_PROJECT_DIR'] || "./"
+ end
+
+ def merge_request_iid
+ ENV['CI_MERGE_REQUEST_IID']
+ end
- report
+ def ci_project_id
+ ENV['CI_PROJECT_ID']
end
def report_data
- @report_data ||= Dir[File.join(ENV['CI_PROJECT_DIR'], "tmp/duo_chat/qa*.json")]
- .map { |file| JSON.parse(File.read(file)) }
+ @report_data ||= Dir[File.join(base_dir, "tmp/duo_chat/qa*.json")]
+ .flat_map { |file| JSON.parse(File.read(file)) }
end
def eval_content
@@ -168,7 +238,9 @@ class Reporter
end
def summary_numbers
- @graded_evaluations ||= report_data.map { |data| data["evaluations"].map { |eval| parse_grade(eval) } }
+ @graded_evaluations ||= report_data
+ .map { |data| data["evaluations"].map { |eval| parse_grade(eval) } }
+ .reject { |grades| !(grades.include? :correct) && !(grades.include? :incorrect) }
total = @graded_evaluations.size
correct = @graded_evaluations.count { |grades| !(grades.include? :incorrect) }
@@ -230,4 +302,4 @@ class Reporter
end
end
-Reporter.new.run
+Reporter.new.run if $PROGRAM_NAME == __FILE__
diff --git a/scripts/flaky_examples/prune-old-flaky-examples b/scripts/flaky_examples/prune-old-flaky-examples
index fc31f0f6996..61ea44fe55c 100755
--- a/scripts/flaky_examples/prune-old-flaky-examples
+++ b/scripts/flaky_examples/prune-old-flaky-examples
@@ -6,7 +6,7 @@ require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
- gem 'rspec_flaky', path: 'gems/rspec_flaky'
+ gem 'gitlab-rspec_flaky', path: 'gems/gitlab-rspec_flaky'
end
report_file = ARGV.shift
@@ -16,12 +16,12 @@ unless report_file
end
new_report_file = ARGV.shift || report_file
-report = RspecFlaky::Report.load(report_file)
+report = Gitlab::RspecFlaky::Report.load(report_file)
puts "Loading #{report_file}..."
puts "Current report has #{report.size} entries."
new_report = report.prune_outdated
puts "New report has #{new_report.size} entries: #{report.size - new_report.size} entries older than " \
- "#{RspecFlaky::Report::OUTDATED_DAYS_THRESHOLD} days were removed."
+ "#{Gitlab::RspecFlaky::Report::OUTDATED_DAYS_THRESHOLD} days were removed."
puts "Saved #{new_report_file}." if new_report.write(new_report_file)
diff --git a/scripts/generate-e2e-pipeline b/scripts/generate-e2e-pipeline
index 31a3122050a..e8efcaee740 100755
--- a/scripts/generate-e2e-pipeline
+++ b/scripts/generate-e2e-pipeline
@@ -40,8 +40,8 @@ variables:
GIT_SUBMODULE_STRATEGY: "none"
GITLAB_QA_CACHE_KEY: "$qa_cache_key"
GITLAB_SEMVER_VERSION: "$(cat VERSION)"
+ FEATURE_FLAGS: "${QA_FEATURE_FLAGS}"
QA_EXPORT_TEST_METRICS: "${QA_EXPORT_TEST_METRICS:-true}"
- QA_FEATURE_FLAGS: "${QA_FEATURE_FLAGS}"
QA_FRAMEWORK_CHANGES: "${QA_FRAMEWORK_CHANGES:-false}"
QA_RUN_ALL_TESTS: "${QA_RUN_ALL_TESTS:-false}"
QA_RUN_ALL_E2E_LABEL: "${QA_RUN_ALL_E2E_LABEL:-false}"
diff --git a/scripts/generate-failed-package-and-test-mr-message.rb b/scripts/generate-failed-package-and-test-mr-message.rb
index c57f132d563..5db28942291 100755
--- a/scripts/generate-failed-package-and-test-mr-message.rb
+++ b/scripts/generate-failed-package-and-test-mr-message.rb
@@ -54,7 +54,7 @@ class GenerateFailedPackageAndTestMrMessage
investigated to guarantee this backport complies with the Quality standards.
Ping your team's associated Software Engineer in Test (SET) to confirm the failures are unrelated to the merge request.
- If there's no SET assigned, ask for assistance on the `#quality` Slack channel.
+ If there's no SET assigned, ask for assistance on the `#test-platform` Slack channel.
MARKDOWN
end
diff --git a/scripts/generate-message-to-run-e2e-pipeline.rb b/scripts/generate-message-to-run-e2e-pipeline.rb
index ccbaba8a3eb..773aa4145d1 100755
--- a/scripts/generate-message-to-run-e2e-pipeline.rb
+++ b/scripts/generate-message-to-run-e2e-pipeline.rb
@@ -72,8 +72,8 @@ class GenerateMessageToRunE2ePipeline
<!-- Run e2e warning begin -->
@#{author_username} Some end-to-end (E2E) tests should run based on the stage label.
- Please start the `trigger-omnibus-and-follow-up-e2e` job in the `qa` stage and ensure tests in the `follow-up-e2e:package-and-test-ee` pipeline
- pass **before this MR is merged**.
+ Please start the `trigger-omnibus-and-follow-up-e2e` job in the `qa` stage and wait for the tests in the `follow-up-e2e:package-and-test-ee` pipeline
+ to pass **before merging this MR**. Do not use **Auto-merge**, unless these tests have already completed successfully, because a failure in these tests do not block the auto-merge.
(E2E tests are computationally intensive and don't run automatically for every push/rebase, so we ask you to run this job manually at least once.)
To run all E2E tests, apply the ~"pipeline:run-all-e2e" label and run a new pipeline.
@@ -83,7 +83,7 @@ class GenerateMessageToRunE2ePipeline
Once done, apply the ✅ emoji on this comment.
- **Team members only:** for any questions or help, reach out on the internal `#quality` Slack channel.
+ **Team members only:** for any questions or help, reach out on the internal `#test-platform` Slack channel.
<!-- Run e2e warning end -->
MARKDOWN
end
diff --git a/scripts/internal_events/cli.rb b/scripts/internal_events/cli.rb
new file mode 100755
index 00000000000..6cc9f599608
--- /dev/null
+++ b/scripts/internal_events/cli.rb
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+# !/usr/bin/env ruby
+#
+# Generate a metric/event files in the correct locations.
+
+require 'tty-prompt'
+require 'net/http'
+require 'yaml'
+
+require_relative './cli/helpers'
+require_relative './cli/usage_viewer'
+require_relative './cli/metric_definer'
+require_relative './cli/event_definer'
+require_relative './cli/metric'
+require_relative './cli/event'
+require_relative './cli/text'
+
+class Cli
+ include ::InternalEventsCli::Helpers
+
+ attr_reader :cli
+
+ def initialize(cli)
+ @cli = cli
+ end
+
+ def run
+ cli.say InternalEventsCli::Text::FEEDBACK_NOTICE
+ cli.say InternalEventsCli::Text::CLI_INSTRUCTIONS
+
+ task = cli.select("What would you like to do?", **select_opts) do |menu|
+ menu.enum "."
+
+ menu.choice "New Event -- track when a specific scenario occurs on gitlab instances\n " \
+ "ex) a user applies a label to an issue", :new_event
+ menu.choice "New Metric -- track the count of existing events over time\n " \
+ "ex) count unique users who assign labels to issues per month", :new_metric
+ menu.choice 'View Usage -- look at code examples for an existing event', :view_usage
+ menu.choice '...am I in the right place?', :help_decide
+ end
+
+ case task
+ when :new_event
+ InternalEventsCli::EventDefiner.new(cli).run
+ when :new_metric
+ InternalEventsCli::MetricDefiner.new(cli).run
+ when :view_usage
+ InternalEventsCli::UsageViewer.new(cli).run
+ when :help_decide
+ help_decide
+ end
+ end
+
+ private
+
+ def help_decide
+ return use_case_error unless goal_is_tracking_usage?
+ return use_case_error unless usage_trackable_with_internal_events?
+
+ event_already_tracked? ? proceed_to_metric_definition : proceed_to_event_definition
+ end
+
+ def goal_is_tracking_usage?
+ new_page!
+
+ cli.say format_info("First, let's check your objective.\n")
+
+ cli.yes?('Are you trying to track customer usage of a GitLab feature?', **yes_no_opts)
+ end
+
+ def usage_trackable_with_internal_events?
+ new_page!
+
+ cli.say format_info("Excellent! Let's check that this tool will fit your needs.\n")
+ cli.say InternalEventsCli::Text::EVENT_TRACKING_EXAMPLES
+
+ cli.yes?(
+ 'Can usage for the feature be measured with a count of specific user actions or events? ' \
+ 'Or counting a set of events?', **yes_no_opts
+ )
+ end
+
+ def event_already_tracked?
+ new_page!
+
+ cli.say format_info("Super! Let's figure out if the event is already tracked & usable.\n")
+ cli.say InternalEventsCli::Text::EVENT_EXISTENCE_CHECK_INSTRUCTIONS
+
+ cli.yes?('Is the event already tracked?', **yes_no_opts)
+ end
+
+ def use_case_error
+ new_page!
+
+ cli.error("Oh no! This probably isn't the tool you need!\n")
+ cli.say InternalEventsCli::Text::ALTERNATE_RESOURCES_NOTICE
+ cli.say InternalEventsCli::Text::FEEDBACK_NOTICE
+ end
+
+ def proceed_to_metric_definition
+ new_page!
+
+ cli.say format_info("Amazing! The next step is adding a new metric! (~8 min)\n")
+
+ return not_ready_error('New Metric') unless cli.yes?(format_prompt('Ready to start?'))
+
+ InternalEventsCli::MetricDefiner.new(cli).run
+ end
+
+ def proceed_to_event_definition
+ new_page!
+
+ cli.say format_info("Okay! The next step is adding a new event! (~5 min)\n")
+
+ return not_ready_error('New Event') unless cli.yes?(format_prompt('Ready to start?'))
+
+ InternalEventsCli::EventDefiner.new(cli).run
+ end
+
+ def not_ready_error(description)
+ cli.say "\nNo problem! When you're ready, run the CLI & select '#{description}'\n"
+ cli.say InternalEventsCli::Text::FEEDBACK_NOTICE
+ end
+end
+
+if $PROGRAM_NAME == __FILE__
+ begin
+ Cli.new(TTY::Prompt.new).run
+ rescue Interrupt
+ puts "\n"
+ end
+end
+
+# vim: ft=ruby
diff --git a/scripts/internal_events/cli/event.rb b/scripts/internal_events/cli/event.rb
new file mode 100755
index 00000000000..d98aa8a6bd1
--- /dev/null
+++ b/scripts/internal_events/cli/event.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module InternalEventsCli
+ NEW_EVENT_FIELDS = [
+ :description,
+ :category,
+ :action,
+ :label_description,
+ :property_description,
+ :value_description,
+ :value_type,
+ :extra_properties,
+ :identifiers,
+ :product_section,
+ :product_stage,
+ :product_group,
+ :milestone,
+ :introduced_by_url,
+ :distributions,
+ :tiers
+ ].freeze
+
+ EVENT_DEFAULTS = {
+ product_section: nil,
+ product_stage: nil,
+ product_group: nil,
+ introduced_by_url: 'TODO',
+ category: 'InternalEventTracking'
+ }.freeze
+
+ Event = Struct.new(*NEW_EVENT_FIELDS, keyword_init: true) do
+ def formatted_output
+ EVENT_DEFAULTS
+ .merge(to_h.compact)
+ .slice(*NEW_EVENT_FIELDS)
+ .transform_keys(&:to_s)
+ .to_yaml(line_width: 150)
+ end
+
+ def file_path
+ File.join(
+ *[
+ ('ee' unless distributions.include?('ce')),
+ 'config',
+ 'events',
+ "#{action}.yml"
+ ].compact
+ )
+ end
+
+ def bulk_assign(key_value_pairs)
+ key_value_pairs.each { |key, value| self[key] = value }
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/event_definer.rb b/scripts/internal_events/cli/event_definer.rb
new file mode 100755
index 00000000000..e029f0e7cf6
--- /dev/null
+++ b/scripts/internal_events/cli/event_definer.rb
@@ -0,0 +1,180 @@
+# frozen_string_literal: true
+
+require_relative './helpers'
+
+module InternalEventsCli
+ class EventDefiner
+ include Helpers
+
+ STEPS = [
+ 'New Event',
+ 'Description',
+ 'Name',
+ 'Context',
+ 'URL',
+ 'Group',
+ 'Tiers',
+ 'Save files'
+ ].freeze
+
+ IDENTIFIER_OPTIONS = {
+ %w[project namespace user] => 'Use case: For project-level user actions ' \
+ '(ex - issue_assignee_changed) [MOST COMMON]',
+ %w[namespace user] => 'Use case: For namespace-level user actions (ex - epic_assigned_to_milestone)',
+ %w[user] => 'Use case: For user-only actions (ex - admin_impersonated_user)',
+ %w[project namespace] => 'Use case: For project-level events without user interaction ' \
+ '(ex - service_desk_request_received)',
+ %w[namespace] => 'Use case: For namespace-level events without user interaction ' \
+ '(ex - stale_runners_cleaned_up)',
+ %w[] => "Use case: For instance-level events without user interaction [LEAST COMMON]"
+ }.freeze
+
+ IDENTIFIER_FORMATTING_BUFFER = "[#{IDENTIFIER_OPTIONS.keys.max_by(&:length).join(', ')}]".length
+
+ attr_reader :cli, :event
+
+ def initialize(cli)
+ @cli = cli
+ @event = Event.new(milestone: MILESTONE)
+ end
+
+ def run
+ prompt_for_description
+ prompt_for_action
+ prompt_for_identifiers
+ prompt_for_url
+ prompt_for_product_ownership
+ prompt_for_tier
+
+ outcome = create_event_file
+ display_result(outcome)
+
+ prompt_for_next_steps
+ end
+
+ private
+
+ def prompt_for_description
+ new_page!(1, 7, STEPS)
+ cli.say Text::EVENT_DESCRIPTION_INTRO
+
+ event.description = cli.ask("Describe what the event tracks: #{input_required_text}", **input_opts) do |q|
+ q.required true
+ q.modify :trim
+ q.messages[:required?] = Text::EVENT_DESCRIPTION_HELP
+ end
+ end
+
+ def prompt_for_action
+ new_page!(2, 7, STEPS)
+ cli.say Text::EVENT_ACTION_INTRO
+
+ event.action = cli.ask("Define the event name: #{input_required_text}", **input_opts) do |q|
+ q.required true
+ q.validate ->(input) { input =~ /\A\w+\z/ && !events_by_filepath.values.map(&:action).include?(input) } # rubocop:disable Rails/NegateInclude -- Not rails
+ q.modify :trim
+ q.messages[:valid?] = format_warning("Invalid event name. Only letters/numbers/underscores allowed. " \
+ "Ensure %{value} is not an existing event.")
+ q.messages[:required?] = Text::EVENT_ACTION_HELP
+ end
+ end
+
+ def prompt_for_identifiers
+ new_page!(3, 7, STEPS)
+ cli.say Text::EVENT_IDENTIFIERS_INTRO % event.action
+
+ identifiers = prompt_for_array_selection(
+ 'Which identifiers are available when the event occurs?',
+ IDENTIFIER_OPTIONS.keys
+ ) { |choice| format_identifier_choice(choice) }
+
+ event.identifiers = identifiers if identifiers.any?
+ end
+
+ def format_identifier_choice(choice)
+ formatted_choice = choice.empty? ? 'None' : "[#{choice.sort.join(', ')}]"
+ buffer = IDENTIFIER_FORMATTING_BUFFER - formatted_choice.length
+
+ "#{formatted_choice}#{' ' * buffer} -- #{IDENTIFIER_OPTIONS[choice]}"
+ end
+
+ def prompt_for_url
+ new_page!(4, 7, STEPS)
+
+ event.introduced_by_url = prompt_for_text('Which MR URL will merge the event definition?')
+ end
+
+ def prompt_for_product_ownership
+ new_page!(5, 7, STEPS)
+
+ ownership = prompt_for_group_ownership({
+ product_section: 'Which section will own the event?',
+ product_stage: 'Which stage will own the event?',
+ product_group: 'Which group will own the event?'
+ })
+
+ event.bulk_assign(ownership)
+ end
+
+ def prompt_for_tier
+ new_page!(6, 7, STEPS)
+
+ event.tiers = prompt_for_array_selection(
+ 'Which tiers will the event be recorded on?',
+ [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]]
+ )
+
+ event.distributions = event.tiers.include?('free') ? %w[ce ee] : %w[ee]
+ end
+
+ def create_event_file
+ new_page!(7, 7, STEPS)
+
+ prompt_to_save_file(event.file_path, event.formatted_output)
+ end
+
+ def display_result(outcome)
+ new_page!
+
+ cli.say <<~TEXT
+ #{divider}
+ #{format_info('Done with event definition!')}
+
+ #{outcome || ' No files saved.'}
+
+ #{divider}
+
+ Want to have data reported in Snowplow/Sisense/ServicePing? Add a new metric for your event!
+
+ TEXT
+ end
+
+ def prompt_for_next_steps
+ next_step = cli.select("How would you like to proceed?", **select_opts) do |menu|
+ menu.enum "."
+
+ if File.exist?(event.file_path)
+ menu.choice "Create Metric -- define a new metric using #{event.action}.yml", :add_metric
+ else
+ menu.choice "Save & Create Metric -- save #{event.action}.yml and define a matching metric", :save_and_add
+ end
+
+ menu.choice "View Usage -- look at code examples for #{event.action}.yml", :view_usage
+ menu.choice 'Exit', :exit
+ end
+
+ case next_step
+ when :add_metric
+ MetricDefiner.new(cli, event.file_path).run
+ when :save_and_add
+ write_to_file(event.file_path, event.formatted_output, 'create')
+
+ MetricDefiner.new(cli, event.file_path).run
+ when :view_usage
+ UsageViewer.new(cli, event.file_path, event).run
+ when :exit
+ cli.say Text::FEEDBACK_NOTICE
+ end
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/helpers.rb b/scripts/internal_events/cli/helpers.rb
new file mode 100755
index 00000000000..95672325652
--- /dev/null
+++ b/scripts/internal_events/cli/helpers.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require_relative './helpers/cli_inputs'
+require_relative './helpers/files'
+require_relative './helpers/formatting'
+require_relative './helpers/group_ownership'
+require_relative './helpers/event_options'
+require_relative './helpers/metric_options'
+
+module InternalEventsCli
+ module Helpers
+ include CliInputs
+ include Files
+ include Formatting
+ include GroupOwnership
+ include EventOptions
+ include MetricOptions
+
+ MILESTONE = File.read('VERSION').strip.match(/(\d+\.\d+)/).captures.first
+
+ def new_page!(page = nil, total = nil, steps = [])
+ cli.say TTY::Cursor.clear_screen
+ cli.say TTY::Cursor.move_to(0, 0)
+ cli.say "#{progress_bar(page, total, steps)}\n" if page && total
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/helpers/cli_inputs.rb b/scripts/internal_events/cli/helpers/cli_inputs.rb
new file mode 100755
index 00000000000..106d854d0b3
--- /dev/null
+++ b/scripts/internal_events/cli/helpers/cli_inputs.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+# Helpers related to configuration of TTY::Prompt prompts
+module InternalEventsCli
+ module Helpers
+ module CliInputs
+ def prompt_for_array_selection(message, choices, default = nil, &formatter)
+ formatter ||= ->(choice) { choice.sort.join(", ") }
+
+ choices = choices.map do |choice|
+ { name: formatter.call(choice), value: choice }
+ end
+
+ cli.select(message, choices, **select_opts) do |menu|
+ menu.enum "."
+ menu.default formatter.call(default) if default
+ end
+ end
+
+ def prompt_for_text(message, value = nil)
+ help_message = "(enter to #{value ? 'submit' : 'skip'})"
+
+ cli.ask(
+ "#{message} #{format_help(help_message)}",
+ value: value || '',
+ **input_opts
+ )
+ end
+
+ def input_opts
+ { prefix: format_prompt('Input text: ') }
+ end
+
+ def yes_no_opts
+ { prefix: format_prompt('Yes/No: ') }
+ end
+
+ def select_opts
+ { prefix: format_prompt('Select one: '), cycle: true, show_help: :always }
+ end
+
+ def multiselect_opts
+ { prefix: format_prompt('Select multiple: '), cycle: true, show_help: :always, min: 1 }
+ end
+
+ # Accepts a number of lines occupied by text, so remaining
+ # screen real estate can be filled with select options
+ def filter_opts(header_size: nil)
+ {
+ filter: true,
+ per_page: header_size ? [(window_height - header_size), 10].max : 30
+ }
+ end
+
+ def input_required_text
+ format_help("(leave blank for help)")
+ end
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/helpers/event_options.rb b/scripts/internal_events/cli/helpers/event_options.rb
new file mode 100755
index 00000000000..f53127798aa
--- /dev/null
+++ b/scripts/internal_events/cli/helpers/event_options.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+# Helpers related to listing existing event definitions
+module InternalEventsCli
+ module Helpers
+ module EventOptions
+ def get_event_options(events)
+ options = events.filter_map do |(path, event)|
+ next if duplicate_events?(event.action, events.values)
+
+ description = format_help(" - #{trim_description(event.description)}")
+
+ {
+ name: "#{format_event_name(event)}#{description}",
+ value: path
+ }
+ end
+
+ options.sort_by do |option|
+ category = events.dig(option[:value], 'category')
+ event_sort_param(category, option[:name])
+ end
+ end
+
+ def events_by_filepath(event_paths = [])
+ event_paths = load_event_paths if event_paths.none?
+
+ get_existing_events_for_paths(event_paths)
+ end
+
+ private
+
+ def trim_description(description)
+ return description if description.to_s.length < 50
+
+ "#{description[0, 50]}..."
+ end
+
+ def format_event_name(event)
+ case event.category
+ when 'InternalEventTracking', 'default'
+ event.action
+ else
+ "#{event.category}:#{event.action}"
+ end
+ end
+
+ def event_sort_param(category, name)
+ case category
+ when 'InternalEventTracking'
+ "0#{name}"
+ when 'default'
+ "1#{name}"
+ else
+ "2#{category}#{name}"
+ end
+ end
+
+ def get_existing_events_for_paths(event_paths)
+ event_paths.each_with_object({}) do |filepath, events|
+ details = YAML.safe_load(File.read(filepath))
+ fields = InternalEventsCli::NEW_EVENT_FIELDS.map(&:to_s)
+
+ events[filepath] = Event.new(**details.slice(*fields))
+ end
+ end
+
+ def duplicate_events?(action, events)
+ events.count { |event| action == event.action } > 1
+ end
+
+ def load_event_paths
+ [
+ Dir["config/events/*.yml"],
+ Dir["ee/config/events/*.yml"]
+ ].flatten
+ end
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/helpers/files.rb b/scripts/internal_events/cli/helpers/files.rb
new file mode 100755
index 00000000000..b613350353f
--- /dev/null
+++ b/scripts/internal_events/cli/helpers/files.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+# Helpers related reading/writing definition files
+module InternalEventsCli
+ module Helpers
+ module Files
+ def prompt_to_save_file(filepath, content)
+ cli.say <<~TEXT.chomp
+ #{format_info('Preparing to generate definition with these attributes:')}
+ #{filepath}
+ #{content}
+ TEXT
+
+ if File.exist?(filepath)
+ cli.error("Oh no! This file already exists!\n")
+
+ return if cli.no?(format_prompt('Overwrite file?'))
+
+ write_to_file(filepath, content, 'update')
+ elsif cli.yes?(format_prompt('Create file?'))
+ write_to_file(filepath, content, 'create')
+ end
+ end
+
+ def file_saved_message(verb, filepath)
+ " #{format_selection(verb)} #{filepath}"
+ end
+
+ def write_to_file(filepath, content, verb)
+ File.write(filepath, content)
+
+ file_saved_message(verb, filepath).tap { |message| cli.say "\n#{message}\n" }
+ end
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/helpers/formatting.rb b/scripts/internal_events/cli/helpers/formatting.rb
new file mode 100755
index 00000000000..87be585c739
--- /dev/null
+++ b/scripts/internal_events/cli/helpers/formatting.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+# Helpers related to visual formatting of outputs
+module InternalEventsCli
+ module Helpers
+ module Formatting
+ DEFAULT_WINDOW_WIDTH = 100
+ DEFAULT_WINDOW_HEIGHT = 30
+
+ def format_info(string)
+ pastel.cyan(string)
+ end
+
+ def format_warning(string)
+ pastel.yellow(string)
+ end
+
+ def format_selection(string)
+ pastel.green(string)
+ end
+
+ def format_help(string)
+ pastel.bright_black(string)
+ end
+
+ def format_prompt(string)
+ pastel.magenta(string)
+ end
+
+ def format_error(string)
+ pastel.red(string)
+ end
+
+ def format_heading(string)
+ [divider, pastel.cyan(string), divider].join("\n")
+ end
+
+ def divider
+ "-" * window_size
+ end
+
+ def progress_bar(step, total, titles = [])
+ breadcrumbs = [
+ titles[0..(step - 1)],
+ format_selection(titles[step]),
+ titles[(step + 1)..]
+ ]
+
+ status = " Step #{step} / #{total} : #{breadcrumbs.flatten.join(' > ')}"
+ total_length = window_size - 4
+ step_length = step / total.to_f * total_length
+
+ incomplete = '-' * [(total_length - step_length - 1), 0].max
+ complete = '=' * [(step_length - 1), 0].max
+ "#{status}\n|==#{complete}>#{incomplete}|\n"
+ end
+
+ def counter(idx, total)
+ format_prompt("(#{idx + 1}/#{total})") if total > 1
+ end
+
+ private
+
+ def pastel
+ @pastel ||= Pastel.new
+ end
+
+ def window_size
+ Integer(fetch_window_size)
+ rescue StandardError
+ DEFAULT_WINDOW_WIDTH
+ end
+
+ def window_height
+ Integer(fetch_window_height)
+ rescue StandardError
+ DEFAULT_WINDOW_HEIGHT
+ end
+
+ def fetch_window_size
+ `tput cols`
+ end
+
+ def fetch_window_height
+ `tput lines`
+ end
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/helpers/group_ownership.rb b/scripts/internal_events/cli/helpers/group_ownership.rb
new file mode 100755
index 00000000000..9846f0ca2f5
--- /dev/null
+++ b/scripts/internal_events/cli/helpers/group_ownership.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+# Helpers related to Stage/Section/Group ownership
+module InternalEventsCli
+ module Helpers
+ module GroupOwnership
+ STAGES_YML = 'https://gitlab.com/gitlab-com/www-gitlab-com/-/raw/master/data/stages.yml'
+
+ def prompt_for_group_ownership(messages, defaults = {})
+ groups = fetch_group_choices
+
+ if groups
+ prompt_for_ownership_from_ssot(messages[:product_group], defaults, groups)
+ else
+ prompt_for_ownership_manually(messages, defaults)
+ end
+ end
+
+ private
+
+ def prompt_for_ownership_from_ssot(prompt, defaults, groups)
+ sorted_defaults = defaults.values_at(:product_section, :product_stage, :product_group)
+ default = sorted_defaults.join(':')
+
+ cli.select(prompt, groups, **select_opts, **filter_opts) do |menu|
+ if sorted_defaults.all?
+ if groups.any? { |group| group[:name] == default }
+ # We have a complete group selection -> set as default in menu
+ menu.default(default)
+ else
+ cli.error format_error(">>> Failed to find group matching #{default}. Select another.\n")
+ end
+ elsif sorted_defaults.any?
+ # We have a partial selection -> filter the list by the most unique field
+ menu.instance_variable_set(:@filter, sorted_defaults.compact.last.split(''))
+ end
+ end
+ end
+
+ def prompt_for_ownership_manually(messages, defaults)
+ {
+ product_section: prompt_for_text(messages[:product_section], defaults[:product_section]),
+ product_stage: prompt_for_text(messages[:product_stage], defaults[:product_stage]),
+ product_group: prompt_for_text(messages[:product_group], defaults[:product_group])
+ }
+ end
+
+ # @return Array[<Hash - matches #prompt_for_ownership_manually output format>]
+ def fetch_group_choices
+ response = Timeout.timeout(5) { Net::HTTP.get(URI(STAGES_YML)) }
+ stages = YAML.safe_load(response)
+
+ stages['stages'].flat_map do |stage, value|
+ value['groups'].map do |group, _|
+ section = value['section']
+
+ {
+ name: [section, stage, group].join(':'),
+ value: {
+ product_group: group,
+ product_section: section,
+ product_stage: stage
+ }
+ }
+ end
+ end
+ rescue StandardError
+ end
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/helpers/metric_options.rb b/scripts/internal_events/cli/helpers/metric_options.rb
new file mode 100755
index 00000000000..01512115e05
--- /dev/null
+++ b/scripts/internal_events/cli/helpers/metric_options.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+# Helpers related to listing existing metric definitions
+module InternalEventsCli
+ module Helpers
+ module MetricOptions
+ EVENT_PHRASES = {
+ 'user' => "who triggered %s",
+ 'namespace' => "where %s occurred",
+ 'project' => "where %s occurred",
+ nil => "%s occurrences"
+ }.freeze
+
+ def get_metric_options(events)
+ options = get_all_metric_options
+ identifiers = get_identifiers_for_events(events)
+ existing_metrics = get_existing_metrics_for_events(events)
+ metric_name = format_metric_name_for_events(events)
+
+ options = options.group_by do |metric|
+ [
+ metric.identifier,
+ metric_already_exists?(existing_metrics, metric),
+ metric.time_frame == 'all'
+ ]
+ end
+
+ options.map do |(identifier, defined, _), metrics|
+ format_metric_option(
+ identifier,
+ metric_name,
+ metrics,
+ defined: defined,
+ supported: [*identifiers, nil].include?(identifier)
+ )
+ end
+ end
+
+ private
+
+ def get_all_metric_options
+ [
+ Metric.new(time_frame: '28d', identifier: 'user'),
+ Metric.new(time_frame: '7d', identifier: 'user'),
+ Metric.new(time_frame: '28d', identifier: 'project'),
+ Metric.new(time_frame: '7d', identifier: 'project'),
+ Metric.new(time_frame: '28d', identifier: 'namespace'),
+ Metric.new(time_frame: '7d', identifier: 'namespace'),
+ Metric.new(time_frame: '28d'),
+ Metric.new(time_frame: '7d'),
+ Metric.new(time_frame: 'all')
+ ]
+ end
+
+ def load_metric_paths
+ [
+ Dir["config/metrics/counts_all/*.yml"],
+ Dir["config/metrics/counts_7d/*.yml"],
+ Dir["config/metrics/counts_28d/*.yml"],
+ Dir["ee/config/metrics/counts_all/*.yml"],
+ Dir["ee/config/metrics/counts_7d/*.yml"],
+ Dir["ee/config/metrics/counts_28d/*.yml"]
+ ].flatten
+ end
+
+ def get_existing_metrics_for_events(events)
+ actions = events.map(&:action).sort
+
+ load_metric_paths.filter_map do |path|
+ details = YAML.safe_load(File.read(path))
+ fields = InternalEventsCli::NEW_METRIC_FIELDS.map(&:to_s)
+
+ metric = Metric.new(**details.slice(*fields))
+ next unless metric.actions
+
+ metric if (metric.actions & actions).any?
+ end
+ end
+
+ def format_metric_name_for_events(events)
+ return events.first.action if events.length == 1
+
+ "any of #{events.length} events"
+ end
+
+ # Get only the identifiers in common for all events
+ def get_identifiers_for_events(events)
+ events.map(&:identifiers).reduce(&:&) || []
+ end
+
+ def metric_already_exists?(existing_metrics, metric)
+ existing_metrics.any? do |existing_metric|
+ time_frame = existing_metric.time_frame || 'all'
+ identifier = existing_metric.events&.dig(0, 'unique')&.chomp('.id')
+
+ metric.time_frame == time_frame && metric.identifier == identifier
+ end
+ end
+
+ def format_metric_option(identifier, event_name, metrics, defined:, supported:)
+ time_frame = metrics.map(&:time_frame_prefix).join('/')
+ unique_by = "unique #{identifier}s " if identifier
+ event_phrase = EVENT_PHRASES[identifier] % event_name
+
+ if supported && !defined
+ time_frame = format_info(time_frame)
+ unique_by = format_info(unique_by)
+ end
+
+ name = "#{time_frame} count of #{unique_by}[#{event_phrase}]"
+
+ if supported && defined
+ disabled = format_warning("(already defined)")
+ name = format_help(name)
+ elsif !supported
+ disabled = format_warning("(#{identifier} unavailable)")
+ name = format_help(name)
+ end
+
+ { name: name, value: metrics, disabled: disabled }.compact
+ end
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/metric.rb b/scripts/internal_events/cli/metric.rb
new file mode 100755
index 00000000000..63961d29810
--- /dev/null
+++ b/scripts/internal_events/cli/metric.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+module InternalEventsCli
+ NEW_METRIC_FIELDS = [
+ :key_path,
+ :description,
+ :product_section,
+ :product_stage,
+ :product_group,
+ :performance_indicator_type,
+ :value_type,
+ :status,
+ :milestone,
+ :introduced_by_url,
+ :time_frame,
+ :data_source,
+ :data_category,
+ :product_category,
+ :instrumentation_class,
+ :distribution,
+ :tier,
+ :options,
+ :events
+ ].freeze
+
+ ADDITIONAL_METRIC_FIELDS = [
+ :milestone_removed,
+ :removed_by_url,
+ :removed_by,
+ :repair_issue_url,
+ :value_json_schema,
+ :name
+ ].freeze
+
+ METRIC_DEFAULTS = {
+ product_section: nil,
+ product_stage: nil,
+ product_group: nil,
+ introduced_by_url: 'TODO',
+ value_type: 'number',
+ status: 'active',
+ data_source: 'internal_events',
+ data_category: 'optional',
+ performance_indicator_type: []
+ }.freeze
+
+ Metric = Struct.new(*NEW_METRIC_FIELDS, *ADDITIONAL_METRIC_FIELDS, :identifier, keyword_init: true) do
+ def formatted_output
+ METRIC_DEFAULTS
+ .merge(to_h.compact)
+ .merge(
+ key_path: key_path,
+ instrumentation_class: instrumentation_class,
+ events: events)
+ .slice(*NEW_METRIC_FIELDS)
+ .transform_keys(&:to_s)
+ .to_yaml(line_width: 150)
+ end
+
+ def file_path
+ File.join(
+ *[
+ ('ee' unless distribution.include?('ce')),
+ 'config',
+ 'metrics',
+ "counts_#{time_frame}",
+ "#{key}.yml"
+ ].compact
+ )
+ end
+
+ def key
+ [
+ 'count',
+ (identifier ? "distinct_#{identifier}_id_from" : 'total'),
+ actions.join('_and_'),
+ (time_frame_prefix&.downcase if time_frame != 'all')
+ ].compact.join('_')
+ end
+
+ def key_path
+ self[:key_path] ||= "#{key_path_prefix}.#{key}"
+ end
+
+ def instrumentation_class
+ self[:instrumentation_class] ||= identifier ? 'RedisHLLMetric' : 'TotalCountMetric'
+ end
+
+ def events
+ self[:events] ||= actions.map do |action|
+ if identifier
+ {
+ 'name' => action,
+ 'unique' => "#{identifier}.id"
+ }
+ else
+ { 'name' => action }
+ end
+ end
+ end
+
+ def key_path_prefix
+ case instrumentation_class
+ when 'RedisHLLMetric'
+ 'redis_hll_counters'
+ when 'TotalCountMetric'
+ 'counts'
+ end
+ end
+
+ def actions
+ options&.dig('events')&.sort || []
+ end
+
+ def identifier_prefix
+ if identifier
+ "count of unique #{identifier}s"
+ else
+ "count of"
+ end
+ end
+
+ def time_frame_prefix
+ case time_frame
+ when '7d'
+ 'Weekly'
+ when '28d'
+ 'Monthly'
+ when 'all'
+ 'Total'
+ end
+ end
+
+ def prefix
+ [time_frame_prefix, identifier_prefix].join(' ')
+ end
+
+ def technical_description
+ simple_event_list = actions.join(' or ')
+
+ case identifier
+ when 'user'
+ "#{prefix} who triggered #{simple_event_list}"
+ when 'project', 'namespace'
+ "#{prefix} where #{simple_event_list} occurred"
+ else
+ "#{prefix} #{simple_event_list} occurrences"
+ end
+ end
+
+ def bulk_assign(key_value_pairs)
+ key_value_pairs.each { |key, value| self[key] = value }
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/metric_definer.rb b/scripts/internal_events/cli/metric_definer.rb
new file mode 100755
index 00000000000..7688f03200f
--- /dev/null
+++ b/scripts/internal_events/cli/metric_definer.rb
@@ -0,0 +1,310 @@
+# frozen_string_literal: true
+
+require_relative './helpers'
+
+module InternalEventsCli
+ class MetricDefiner
+ include Helpers
+
+ STEPS = [
+ 'New Metric',
+ 'Type',
+ 'Events',
+ 'Scope',
+ 'Descriptions',
+ 'Copy event',
+ 'Group',
+ 'URL',
+ 'Tiers',
+ 'Save files'
+ ].freeze
+
+ attr_reader :cli
+
+ def initialize(cli, starting_event = nil)
+ @cli = cli
+ @selected_event_paths = Array(starting_event)
+ @metrics = []
+ end
+
+ def run
+ type = prompt_for_metric_type
+ prompt_for_events(type)
+
+ return unless @selected_event_paths.any?
+
+ prompt_for_metrics
+
+ return unless @metrics.any?
+
+ prompt_for_description
+ defaults = prompt_for_copying_event_properties
+ prompt_for_product_ownership(defaults)
+ prompt_for_url(defaults)
+ prompt_for_tier(defaults)
+ outcomes = create_metric_files
+ prompt_for_next_steps(outcomes)
+ end
+
+ private
+
+ def events
+ @events ||= events_by_filepath(@selected_event_paths)
+ end
+
+ def selected_events
+ @selected_events ||= events.values_at(*@selected_event_paths)
+ end
+
+ def prompt_for_metric_type
+ return if @selected_event_paths.any?
+
+ new_page!(1, 9, STEPS)
+
+ cli.select("Which best describes what the metric should track?", **select_opts) do |menu|
+ menu.enum "."
+
+ menu.choice 'Single event -- count occurrences of a specific event or user interaction', :event_metric
+ menu.choice 'Multiple events -- count occurrences of several separate events or interactions', :aggregate_metric
+ menu.choice 'Database -- record value of a particular field or count of database rows', :database_metric
+ end
+ end
+
+ def prompt_for_events(type)
+ return if @selected_event_paths.any?
+
+ new_page!(2, 9, STEPS)
+
+ case type
+ when :event_metric
+ cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n"
+
+ @selected_event_paths = [cli.select(
+ 'Which event does this metric track?',
+ get_event_options(events),
+ **select_opts,
+ **filter_opts(header_size: 7)
+ )]
+ when :aggregate_metric
+ cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n"
+
+ @selected_event_paths = cli.multi_select(
+ 'Which events does this metric track? (Space to select)',
+ get_event_options(events),
+ **multiselect_opts,
+ **filter_opts(header_size: 7)
+ )
+ when :database_metric
+ cli.error Text::DATABASE_METRIC_NOTICE
+ cli.say Text::FEEDBACK_NOTICE
+ end
+ end
+
+ def prompt_for_metrics
+ eligible_metrics = get_metric_options(selected_events)
+
+ if eligible_metrics.all? { |metric| metric[:disabled] }
+ cli.error Text::ALL_METRICS_EXIST_NOTICE
+ cli.say Text::FEEDBACK_NOTICE
+
+ return
+ end
+
+ new_page!(3, 9, STEPS)
+
+ @metrics = cli.select('Which metrics do you want to add?', eligible_metrics, **select_opts)
+
+ assign_shared_attrs(:options, :milestone) do
+ {
+ options: { 'events' => selected_events.map(&:action) },
+ milestone: MILESTONE
+ }
+ end
+ end
+
+ def prompt_for_description
+ new_page!(4, 9, STEPS)
+
+ cli.say Text::METRIC_DESCRIPTION_INTRO
+ cli.say selected_event_descriptions.join('')
+
+ base_description = nil
+
+ @metrics.each_with_index do |metric, idx|
+ multiline_prompt = [
+ counter(idx, @metrics.length),
+ format_prompt("Complete the text:"),
+ "How would you describe this metric to a non-technical person?",
+ input_required_text,
+ "\n\n Technical description: #{metric.technical_description}"
+ ].compact.join(' ')
+
+ last_line_of_prompt = "\n Finish the description: #{format_info("#{metric.prefix}...")}"
+
+ cli.say("\n")
+ cli.say(multiline_prompt)
+
+ description_help_message = [
+ Text::METRIC_DESCRIPTION_HELP,
+ multiline_prompt,
+ "\n\n"
+ ].join("\n")
+
+ # Reassign base_description so the next metric's default value is their own input
+ base_description = cli.ask(last_line_of_prompt, value: base_description.to_s) do |q|
+ q.required true
+ q.modify :trim
+ q.messages[:required?] = description_help_message
+ end
+
+ cli.say("\n") # looks like multiline input, but isn't. Spacer improves clarity.
+
+ metric.description = "#{metric.prefix} #{base_description}"
+ end
+ end
+
+ def selected_event_descriptions
+ @selected_event_descriptions ||= selected_events.map do |event|
+ " #{event.action} - #{format_selection(event.description)}\n"
+ end
+ end
+
+ # Check existing event files for attributes to copy over
+ def prompt_for_copying_event_properties
+ defaults = collect_values_for_shared_event_properties
+
+ return {} if defaults.none?
+
+ new_page!(5, 9, STEPS)
+
+ cli.say <<~TEXT
+ #{format_info('Convenient! We can copy these attributes from the event definition(s):')}
+
+ #{defaults.compact.transform_keys(&:to_s).to_yaml(line_width: 150)}
+ #{format_info('If any of these attributes are incorrect, you can also change them manually from your text editor later.')}
+
+ TEXT
+
+ cli.select('What would you like to do?', **select_opts) do |menu|
+ menu.enum '.'
+ menu.choice 'Copy & continue', -> { bulk_assign(defaults) }
+ menu.choice 'Modify attributes'
+ end
+
+ defaults
+ end
+
+ def collect_values_for_shared_event_properties
+ fields = Hash.new { |h, k| h[k] = [] }
+
+ selected_events.each do |event|
+ fields[:introduced_by_url] << event.introduced_by_url
+ fields[:product_section] << event.product_section
+ fields[:product_stage] << event.product_stage
+ fields[:product_group] << event.product_group
+ fields[:distribution] << event.distributions&.sort
+ fields[:tier] << event.tiers&.sort
+ end
+
+ # Keep event values if every selected event is the same
+ fields.each_with_object({}) do |(attr, values), defaults|
+ next unless values.compact.uniq.length == 1
+
+ defaults[attr] ||= values.first
+ end
+ end
+
+ def prompt_for_product_ownership(defaults)
+ assign_shared_attrs(:product_section, :product_stage, :product_group) do
+ new_page!(6, 9, STEPS)
+
+ prompt_for_group_ownership(
+ {
+ product_section: 'Which section owns the metric?',
+ product_stage: 'Which stage owns the metric?',
+ product_group: 'Which group owns the metric?'
+ },
+ defaults.slice(:product_section, :product_stage, :product_group)
+ )
+ end
+ end
+
+ def prompt_for_url(defaults)
+ assign_shared_attr(:introduced_by_url) do
+ new_page!(7, 9, STEPS)
+
+ prompt_for_text(
+ "Which MR URL introduced the metric?",
+ defaults[:introduced_by_url]
+ )
+ end
+ end
+
+ def prompt_for_tier(defaults)
+ assign_shared_attr(:tier) do
+ new_page!(8, 9, STEPS)
+
+ prompt_for_array_selection(
+ 'Which tiers will the metric be reported from?',
+ [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]],
+ defaults[:tier]
+ )
+ end
+
+ assign_shared_attr(:distribution) do |metric|
+ metric.tier.include?('free') ? %w[ce ee] : %w[ee]
+ end
+ end
+
+ def create_metric_files
+ @metrics.map.with_index do |metric, idx|
+ new_page!(9, 9, STEPS) # Repeat the same step number but increment metric counter
+
+ cli.say format_prompt("SAVING FILE #{counter(idx, @metrics.length)}: #{metric.technical_description}\n")
+
+ prompt_to_save_file(metric.file_path, metric.formatted_output)
+ end
+ end
+
+ def prompt_for_next_steps(outcomes = [])
+ new_page!
+
+ outcome = outcomes.any? ? outcomes.compact.join("\n") : ' No files saved.'
+
+ cli.say <<~TEXT
+ #{divider}
+ #{format_info('Done with metric definitions!')}
+
+ #{outcome}
+
+ #{divider}
+ TEXT
+
+ cli.select("How would you like to proceed?", **select_opts) do |menu|
+ menu.enum "."
+ menu.choice "View Usage -- look at code examples for #{@selected_event_paths.first}", -> do
+ UsageViewer.new(cli, @selected_event_paths.first, selected_events.first).run
+ end
+ menu.choice 'Exit', -> { cli.say Text::FEEDBACK_NOTICE }
+ end
+ end
+
+ def assign_shared_attrs(...)
+ metric = @metrics.first
+ attrs = metric.to_h.slice(...)
+ attrs = yield(metric) unless attrs.values.all?
+
+ bulk_assign(attrs)
+ end
+
+ def assign_shared_attr(key)
+ assign_shared_attrs(key) do |metric|
+ { key => yield(metric) }
+ end
+ end
+
+ def bulk_assign(attrs)
+ @metrics.each { |metric| metric.bulk_assign(attrs) }
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/text.rb b/scripts/internal_events/cli/text.rb
new file mode 100755
index 00000000000..4cb1cc23326
--- /dev/null
+++ b/scripts/internal_events/cli/text.rb
@@ -0,0 +1,216 @@
+# frozen_string_literal: true
+
+# Blocks of text rendered in CLI
+module InternalEventsCli
+ module Text
+ extend Helpers
+
+ CLI_INSTRUCTIONS = <<~TEXT.freeze
+ #{format_info('INSTRUCTIONS:')}
+ To start tracking usage of a feature...
+
+ 1) Define event (using CLI)
+ 2) Trigger event (from code)
+ 3) Define metric (using CLI)
+ 4) View data in Sisense (after merge & deploy)
+
+ This CLI will help you create the correct defintion files, then provide code examples for instrumentation and testing.
+
+ Learn more: https://docs.gitlab.com/ee/development/internal_analytics/#fundamental-concepts
+
+ TEXT
+
+ # TODO: Remove "NEW TOOL" comment after 3 months
+ FEEDBACK_NOTICE = format_heading <<~TEXT.chomp
+ Thanks for using the Internal Events CLI!
+
+ Please reach out with any feedback!
+ About Internal Events: https://gitlab.com/gitlab-org/analytics-section/analytics-instrumentation/internal/-/issues/687
+ About CLI: https://gitlab.com/gitlab-org/gitlab/-/issues/434038
+ In Slack: #g_analyze_analytics_instrumentation
+
+ Let us know that you used the CLI! React with 👍 on the feedback issue or post in Slack!
+ TEXT
+
+ ALTERNATE_RESOURCES_NOTICE = <<~TEXT.freeze
+ Other resources:
+
+ #{format_warning('Tracking GitLab feature usage from database info:')}
+ https://docs.gitlab.com/ee/development/internal_analytics/metrics/metrics_instrumentation.html#database-metrics
+
+ #{format_warning('Migrating existing metrics to use Internal Events:')}
+ https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/migration.html
+
+ #{format_warning('Remove an existing metric:')}
+ https://docs.gitlab.com/ee/development/internal_analytics/metrics/metrics_lifecycle.html
+
+ #{format_warning('Finding existing usage data for GitLab features:')}
+ https://metrics.gitlab.com/ (Customize Table > Sisense query)
+ https://app.periscopedata.com/app/gitlab/1049395/Service-Ping-Exploration-Dashboard
+
+ #{format_warning('Customer wants usage data for their own GitLab instance:')}
+ https://docs.gitlab.com/ee/user/analytics/
+
+ #{format_warning('Customer wants usage data for their own products:')}
+ https://docs.gitlab.com/ee/user/product_analytics/
+ TEXT
+
+ EVENT_TRACKING_EXAMPLES = <<~TEXT
+ Product usage can be tracked in several ways.
+
+ By tracking events: ex) a user changes the assignee on an issue
+ ex) a user uploads a CI template
+ ex) a service desk request is received
+ ex) all stale runners are cleaned up
+ ex) a user copies code to the clipboard from markdown
+ ex) a user uploads an issue template OR a user uploads an MR template
+
+ From database data: ex) track whether each gitlab instance allows signups
+ ex) query how many projects are on each gitlab instance
+
+ TEXT
+
+ EVENT_EXISTENCE_CHECK_INSTRUCTIONS = <<~TEXT.freeze
+ To determine what to do next, let's figure out if the event is already tracked & usable.
+
+ If you're unsure whether an event exists, you can check the existing defintions.
+
+ #{format_info('FROM GDK')}: Check `config/events/` or `ee/config/events`
+ #{format_info('FROM BROWSER')}: Check https://metrics.gitlab.com/snowplow
+
+ Find one? Create a new metric for the event.
+ Otherwise? Create a new event.
+
+ If you find a relevant event that has a different category from 'InternalEventTracking', it can be migrated to
+ Internal Events. See https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/migration.html
+
+ TEXT
+
+ EVENT_DESCRIPTION_INTRO = <<~TEXT.freeze
+ #{format_info('EVENT DESCRIPTION')}
+ Include what the event is supposed to track, where, and when.
+
+ The description field helps others find & reuse this event. This will be used by Engineering, Product, Data team, Support -- and also GitLab customers directly. Be specific and explicit.
+ ex - Debian package published to the registry using a deploy token
+ ex - Issue confidentiality was changed
+
+ TEXT
+
+ EVENT_DESCRIPTION_HELP = <<~TEXT.freeze
+ #{format_warning('Required. 10+ words likely, but length may vary.')}
+
+ #{format_info('GOOD EXAMPLES:')}
+ - Pipeline is created with a CI Template file included in its configuration
+ - Quick action `/assign @user1` used to assign a single individual to an issuable
+ - Quick action `/target_branch` used on a Merge Request
+ - Quick actions `/unlabel` or `/remove_label` used to remove one or more specific labels
+ - User edits file using the single file editor
+ - User edits file using the Web IDE
+ - User removed issue link between issue and incident
+ - Debian package published to the registry using a deploy token
+
+ #{format_info('GUT CHECK:')}
+ For your description...
+ 1. Would two different engineers likely instrument the event from the same code locations?
+ 2. Would a new GitLab user find where the event is triggered in the product?
+ 3. Would a GitLab customer understand what the description says?
+
+
+ TEXT
+
+ EVENT_ACTION_INTRO = <<~TEXT.freeze
+ #{format_info('EVENT NAME')}
+ The event name is a unique identifier used from both a) app code and b) metric definitions.
+ The name should concisely communicate the same information as the event description.
+
+ ex - change_time_estimate_on_issue
+ ex - push_package_to_repository
+ ex - publish_go_module_to_the_registry_from_pipeline
+ ex - admin_user_comments_on_issue_while_impersonating_blocked_user
+
+ #{format_info('EXPECTED FORMAT:')} #{format_selection('<action>_<target_of_action>_<where/when>')}
+
+ ex) click_save_button_in_issue_description_within_15s_of_page_load
+ - TARGET: save button
+ - ACTION: click
+ - WHERE: in issue description
+ - WHEN: within 15s of page load
+
+ TEXT
+
+ EVENT_ACTION_HELP = <<~TEXT.freeze
+ #{format_warning('Required. Must be globally unique. Must use only letters/numbers/underscores.')}
+
+ #{format_info('FAQs:')}
+ - Q: Present tense or past tense?
+ A: Prefer present tense! But it's up to you.
+ - Q: Other event names have prefixes like `i_` or the `g_group_name`. Why?
+ A: Those are leftovers from legacy naming schemes. Changing the names of old events/metrics can break dashboards, so stability is better than uniformity.
+
+
+ TEXT
+
+ EVENT_IDENTIFIERS_INTRO = <<~TEXT.freeze
+ #{format_info('EVENT CONTEXT')}
+ Identifies the attributes recorded when the event occurs. Generally, we want to include every identifier available to us when the event is triggered.
+
+ #{format_info('BACKEND')}: Attributes must be specified when the event is triggered
+ ex) If the backend event was instrumentuser/project/namespace are the identifiers for this backend instrumentation:
+
+ Gitlab::InternalEvents.track_event(
+ '%s',
+ user: user,
+ project: project,
+ namespace: project.namespace
+ )
+
+ #{format_info('FRONTEND')}: Attributes are automatically included from the URL
+ ex) When a user takes an action on the MR list page, the URL is https://gitlab.com/gitlab-org/gitlab/-/merge_requests
+ Because this URL is for a project, we know that all of user/project/namespace are available for the event
+
+ #{format_info('NOTE')}: If you're planning to instrument a unique-by-user metric, you should still include project & namespace when possible. This is especially helpful in the data warehouse, where namespace and project can make events relevant for CSM use-cases.
+
+ TEXT
+
+ DATABASE_METRIC_NOTICE = <<~TEXT
+
+ For right now, this script can only define metrics for internal events.
+
+ For more info on instrumenting database-backed metrics, see https://docs.gitlab.com/ee/development/internal_analytics/metrics/metrics_instrumentation.html
+ TEXT
+
+ ALL_METRICS_EXIST_NOTICE = <<~TEXT
+
+ Looks like the potential metrics for this event either already exist or are unsupported.
+
+ Check out https://metrics.gitlab.com/ for improved event/metric search capabilities.
+ TEXT
+
+ METRIC_DESCRIPTION_INTRO = <<~TEXT.freeze
+ #{format_info('METRIC DESCRIPTION')}
+ Describes which occurrences of an event are tracked in the metric and how they're grouped.
+
+ The description field is critical for helping others find & reuse this event. This will be used by Engineering, Product, Data team, Support -- and also GitLab customers directly. Be specific and explicit.
+
+ #{format_info('GOOD EXAMPLES:')}
+ - Total count of analytics dashboard list views
+ - Weekly count of unique users who viewed the analytics dashboard list
+ - Monthly count of unique projects where the analytics dashboard list was viewed
+ - Total count of issue updates
+
+ #{format_info('SELECTED EVENT(S):')}
+ TEXT
+
+ METRIC_DESCRIPTION_HELP = <<~TEXT.freeze
+ #{format_warning('Required. 10+ words likely, but length may vary.')}
+
+ An event description can often be rearranged to work as a metric description.
+
+ ex) Event description: A merge request was created
+ Metric description: Total count of merge requests created
+ Metric description: Weekly count of unqiue users who created merge requests
+
+ Look at the event descriptions above to get ideas!
+ TEXT
+ end
+end
diff --git a/scripts/internal_events/cli/usage_viewer.rb b/scripts/internal_events/cli/usage_viewer.rb
new file mode 100755
index 00000000000..6a9be38e25d
--- /dev/null
+++ b/scripts/internal_events/cli/usage_viewer.rb
@@ -0,0 +1,247 @@
+# frozen_string_literal: true
+
+require_relative './helpers'
+
+module InternalEventsCli
+ class UsageViewer
+ include Helpers
+
+ IDENTIFIER_EXAMPLES = {
+ %w[namespace project user] => { "namespace" => "project.namespace" },
+ %w[namespace user] => { "namespace" => "group" }
+ }.freeze
+
+ attr_reader :cli, :event
+
+ def initialize(cli, event_path = nil, event = nil)
+ @cli = cli
+ @event = event
+ @selected_event_path = event_path
+ end
+
+ def run
+ prompt_for_eligible_event
+ prompt_for_usage_location
+ end
+
+ def prompt_for_eligible_event
+ return if event
+
+ event_details = events_by_filepath
+
+ @selected_event_path = cli.select(
+ "Show examples for which event?",
+ get_event_options(event_details),
+ **select_opts,
+ **filter_opts
+ )
+
+ @event = event_details[@selected_event_path]
+ end
+
+ def prompt_for_usage_location(default = 'ruby/rails')
+ choices = [
+ { name: 'ruby/rails', value: :rails },
+ { name: 'rspec', value: :rspec },
+ { name: 'javascript (vue)', value: :vue },
+ { name: 'javascript (plain)', value: :js },
+ { name: 'vue template', value: :vue_template },
+ { name: 'haml', value: :haml },
+ { name: 'View examples for a different event', value: :other_event },
+ { name: 'Exit', value: :exit }
+ ]
+
+ usage_location = cli.select(
+ 'Select a use-case to view examples for:',
+ choices,
+ **select_opts,
+ per_page: 10
+ ) do |menu|
+ menu.enum '.'
+ menu.default default
+ end
+
+ case usage_location
+ when :rails
+ rails_examples
+ prompt_for_usage_location('ruby/rails')
+ when :rspec
+ rspec_examples
+ prompt_for_usage_location('rspec')
+ when :haml
+ haml_examples
+ prompt_for_usage_location('haml')
+ when :js
+ js_examples
+ prompt_for_usage_location('javascript (plain)')
+ when :vue
+ vue_examples
+ prompt_for_usage_location('javascript (vue)')
+ when :vue_template
+ vue_template_examples
+ prompt_for_usage_location('vue template')
+ when :other_event
+ self.class.new(cli).run
+ when :exit
+ cli.say(Text::FEEDBACK_NOTICE)
+ end
+ end
+
+ def rails_examples
+ args = Array(event['identifiers']).map do |identifier|
+ " #{identifier}: #{identifier_examples[identifier]}"
+ end
+ action = args.any? ? "\n '#{event['action']}',\n" : "'#{event['action']}'"
+
+ cli.say format_warning <<~TEXT
+ #{divider}
+ #{format_help('# RAILS')}
+
+ Gitlab::InternalEvents.track_event(#{action}#{args.join(",\n")}#{"\n" unless args.empty?})
+
+ #{divider}
+ TEXT
+ end
+
+ def rspec_examples
+ cli.say format_warning <<~TEXT
+ #{divider}
+ #{format_help('# RSPEC')}
+
+ it_behaves_like 'internal event tracking' do
+ let(:event) { '#{event['action']}' }
+ #{
+ Array(event['identifiers']).map do |identifier|
+ " let(:#{identifier}) { #{identifier_examples[identifier]} }\n"
+ end.join('')
+ }end
+
+ #{divider}
+ TEXT
+ end
+
+ def identifier_examples
+ event['identifiers']
+ .to_h { |identifier| [identifier, identifier] }
+ .merge(IDENTIFIER_EXAMPLES[event['identifiers'].sort] || {})
+ end
+
+ def haml_examples
+ cli.say <<~TEXT
+ #{divider}
+ #{format_help('# HAML -- ON-CLICK')}
+
+ .gl-display-inline-block{ #{format_warning("data: { event_tracking: '#{event['action']}' }")} }
+ = _('Important Text')
+
+ #{divider}
+ #{format_help('# HAML -- COMPONENT ON-CLICK')}
+
+ = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking: '#{event['action']}' }")} })
+
+ #{divider}
+ #{format_help('# HAML -- COMPONENT ON-LOAD')}
+
+ = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking_load: true, event_tracking: '#{event['action']}' }")} })
+
+ #{divider}
+ TEXT
+
+ cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
+ end
+
+ def vue_template_examples
+ cli.say <<~TEXT
+ #{divider}
+ #{format_help('// VUE TEMPLATE -- ON-CLICK')}
+
+ <script>
+ import { GlButton } from '@gitlab/ui';
+
+ export default {
+ components: { GlButton }
+ };
+ </script>
+
+ <template>
+ <gl-button #{format_warning("data-event-tracking=\"#{event['action']}\"")}>
+ Click Me
+ </gl-button>
+ </template>
+
+ #{divider}
+ #{format_help('// VUE TEMPLATE -- ON-LOAD')}
+
+ <script>
+ import { GlButton } from '@gitlab/ui';
+
+ export default {
+ components: { GlButton }
+ };
+ </script>
+
+ <template>
+ <gl-button #{format_warning("data-event-tracking-load=\"#{event['action']}\"")}>
+ Click Me
+ </gl-button>
+ </template>
+
+ #{divider}
+ TEXT
+
+ cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
+ end
+
+ def js_examples
+ cli.say <<~TEXT
+ #{divider}
+ #{format_help('// FRONTEND -- RAW JAVASCRIPT')}
+
+ #{format_warning("import { InternalEvents } from '~/tracking';")}
+
+ export const performAction = () => {
+ #{format_warning("InternalEvents.trackEvent('#{event['action']}');")}
+
+ return true;
+ };
+
+ #{divider}
+ TEXT
+
+ # https://docs.snowplow.io/docs/understanding-your-pipeline/schemas/
+ cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
+ end
+
+ def vue_examples
+ cli.say <<~TEXT
+ #{divider}
+ #{format_help('// VUE')}
+
+ <script>
+ #{format_warning("import { InternalEvents } from '~/tracking';")}
+ import { GlButton } from '@gitlab/ui';
+
+ #{format_warning('const trackingMixin = InternalEvents.mixin();')}
+
+ export default {
+ #{format_warning('mixins: [trackingMixin]')},
+ components: { GlButton },
+ methods: {
+ performAction() {
+ #{format_warning("this.trackEvent('#{event['action']}');")}
+ },
+ },
+ };
+ </script>
+
+ <template>
+ <gl-button @click=performAction>Click Me</gl-button>
+ </template>
+
+ #{divider}
+ TEXT
+
+ cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
+ end
+ end
+end
diff --git a/scripts/qa/quarantine-types-check b/scripts/qa/quarantine-types-check
index 8c2768b6722..b6b4c4988f0 100755
--- a/scripts/qa/quarantine-types-check
+++ b/scripts/qa/quarantine-types-check
@@ -57,6 +57,6 @@ else
end
puts missing_issue_message % missing_issues unless missing_issues.empty?
- puts "See https://about.gitlab.com/handbook/engineering/quality/quality-engineering/debugging-qa-test-failures/#quarantining-tests"
+ puts "See https://about.gitlab.com/handbook/engineering/infrastructure/test-platform/debugging-qa-test-failures/#quarantining-tests"
exit 1
end
diff --git a/scripts/remote_development/run-e2e-tests.sh b/scripts/remote_development/run-e2e-tests.sh
index 1c1fb07ea53..c46ce8b0926 100755
--- a/scripts/remote_development/run-e2e-tests.sh
+++ b/scripts/remote_development/run-e2e-tests.sh
@@ -12,7 +12,6 @@
DEFAULT_PASSWORD='5iveL!fe'
export WEBDRIVER_HEADLESS="${WEBDRIVER_HEADLESS:-0}"
-export QA_SUPER_SIDEBAR_ENABLED="${QA_SUPER_SIDEBAR_ENABLED:-1}" # This is currently necessary for the test to pass
export GITLAB_USERNAME="${GITLAB_USERNAME:-root}"
export GITLAB_PASSWORD="${GITLAB_PASSWORD:-${DEFAULT_PASSWORD}}"
export DEVFILE_PROJECT="${DEVFILE_PROJECT:-Gitlab Org / Gitlab Shell}"
@@ -20,7 +19,6 @@ export AGENT_NAME="${AGENT_NAME:-remotedev}"
export TEST_INSTANCE_URL="${TEST_INSTANCE_URL:-http://gdk.test:3000}"
echo "WEBDRIVER_HEADLESS: ${WEBDRIVER_HEADLESS}"
-echo "QA_SUPER_SIDEBAR_ENABLED: ${QA_SUPER_SIDEBAR_ENABLED}"
echo "GITLAB_USERNAME: ${GITLAB_USERNAME}"
echo "DEVFILE_PROJECT: ${DEVFILE_PROJECT}"
echo "AGENT_NAME: ${AGENT_NAME}"
diff --git a/scripts/remote_development/run-smoke-test-suite.sh b/scripts/remote_development/run-smoke-test-suite.sh
index 14c50678fba..24362b2359e 100755
--- a/scripts/remote_development/run-smoke-test-suite.sh
+++ b/scripts/remote_development/run-smoke-test-suite.sh
@@ -10,6 +10,10 @@
set -o errexit # AKA -e - exit immediately on errors (http://mywiki.wooledge.org/BashFAQ/105)
+###########
+## Setup ##
+###########
+
# https://stackoverflow.com/a/28938235
BCyan='\033[1;36m' # Bold Cyan
BRed='\033[1;31m' # Bold Red
@@ -19,25 +23,67 @@ Color_Off='\033[0m' # Text Reset
function onexit_err() {
local exit_status=${1:-$?}
- printf "\n❌❌❌ ${BRed}Remote Development specs failed!${Color_Off} ❌❌❌\n"
+ printf "\n❌❌❌ ${BRed}Remote Development smoke test failed!${Color_Off} ❌❌❌\n"
+
+ if [ ${REVEAL_RUBOCOP_TODO} -ne 0 ]; then
+ printf "\n(If the failure was due to rubocop, set REVEAL_RUBOCOP_TODO=0 to ignore TODOs)\n"
+ fi
+
exit "${exit_status}"
}
trap onexit_err ERR
set -o errexit
-printf "${BCyan}"
-printf "\nStarting Remote Development specs.\n\n"
-printf "${Color_Off}"
+#####################
+## Invoke commands ##
+#####################
+
+printf "${BCyan}\nStarting Remote Development smoke test...\n\n${Color_Off}"
+
+#############
+## RUBOCOP ##
+#############
+
+printf "${BBlue}Running RuboCop for Remote Development and related files${Color_Off}\n\n"
+
+# TODO: Also run rubocop for the other non-remote-development files once they are passing rubocop
+# with REVEAL_RUBOCOP_TODO=1
+while IFS= read -r -d '' file; do
+ files_for_rubocop+=("$file")
+done < <(find . -path './**/remote_development/*.rb' -print0)
+
+REVEAL_RUBOCOP_TODO=${REVEAL_RUBOCOP_TODO:-1} bundle exec rubocop --parallel --force-exclusion --no-server "${files_for_rubocop[@]}"
+
+###########
+## RSPEC ##
+###########
+
+printf "\n\n${BBlue}Running Remote Development and related backend RSpec specs${Color_Off}\n\n"
+
+while IFS= read -r file; do
+ files_for_rspec+=("$file")
+done < <(find . -path './**/remote_development/*_spec.rb' | grep -v 'qa/qa')
+
+files_for_rspec+=(
+ "ee/spec/graphql/types/query_type_spec.rb"
+ "ee/spec/graphql/types/subscription_type_spec.rb"
+ "ee/spec/requests/api/internal/kubernetes_spec.rb"
+ "spec/graphql/types/subscription_type_spec.rb"
+ "spec/lib/result_spec.rb"
+ "spec/support_specs/matchers/result_matchers_spec.rb"
+)
+bin/rspec -r spec_helper "${files_for_rspec[@]}"
+
+##########
+## JEST ##
+##########
+
+printf "\n\n${BBlue}Running Remote Development frontend Jest specs${Color_Off}\n\n"
-printf "${BBlue}Running Remote Development backend specs${Color_Off}\n\n"
+yarn jest ee/spec/frontend/remote_development
-bin/rspec -r spec_helper \
-$(find . -path './**/remote_development/*_spec.rb' | grep -v 'qa/qa') \
-ee/spec/graphql/types/query_type_spec.rb \
-ee/spec/graphql/types/subscription_type_spec.rb \
-ee/spec/requests/api/internal/kubernetes_spec.rb \
-spec/graphql/types/subscription_type_spec.rb \
-spec/lib/result_spec.rb \
-spec/support_specs/matchers/result_matchers_spec.rb
+###########################
+## Print success message ##
+###########################
printf "\n✅✅✅ ${BGreen}All Remote Development specs passed successfully!${Color_Off} ✅✅✅\n"
diff --git a/scripts/review_apps/base-config.yaml b/scripts/review_apps/base-config.yaml
index 721733f6f68..70941b264c5 100644
--- a/scripts/review_apps/base-config.yaml
+++ b/scripts/review_apps/base-config.yaml
@@ -16,8 +16,6 @@ global:
image:
pullPolicy: Always
ingress:
- annotations:
- external-dns.alpha.kubernetes.io/ttl: 10
configureCertmanager: false
tls:
secretName: review-apps-tls
@@ -93,10 +91,10 @@ gitlab:
# Based on https://console.cloud.google.com/monitoring/metrics-explorer;duration=P14D?pageState=%7B%22xyChart%22:%7B%22constantLines%22:%5B%5D,%22dataSets%22:%5B%7B%22plotType%22:%22LINE%22,%22targetAxis%22:%22Y1%22,%22timeSeriesFilter%22:%7B%22aggregations%22:%5B%7B%22crossSeriesReducer%22:%22REDUCE_NONE%22,%22groupByFields%22:%5B%5D,%22perSeriesAligner%22:%22ALIGN_RATE%22%7D,%7B%22crossSeriesReducer%22:%22REDUCE_NONE%22,%22groupByFields%22:%5B%5D,%22perSeriesAligner%22:%22ALIGN_MEAN%22%7D%5D,%22apiSource%22:%22DEFAULT_CLOUD%22,%22crossSeriesReducer%22:%22REDUCE_NONE%22,%22filter%22:%22metric.type%3D%5C%22kubernetes.io%2Fcontainer%2Fcpu%2Fcore_usage_time%5C%22%20resource.type%3D%5C%22k8s_container%5C%22%20resource.label.%5C%22container_name%5C%22%3D%5C%22sidekiq%5C%22%22,%22groupByFields%22:%5B%5D,%22minAlignmentPeriod%22:%2260s%22,%22perSeriesAligner%22:%22ALIGN_RATE%22,%22secondaryCrossSeriesReducer%22:%22REDUCE_NONE%22,%22secondaryGroupByFields%22:%5B%5D%7D%7D%5D,%22options%22:%7B%22mode%22:%22STATS%22%7D,%22y1Axis%22:%7B%22label%22:%22%22,%22scale%22:%22LINEAR%22%7D%7D%7D&project=gitlab-review-apps
cpu: 400m
# Based on https://console.cloud.google.com/monitoring/metrics-explorer;duration=P14D?pageState=%7B%22xyChart%22:%7B%22constantLines%22:%5B%5D,%22dataSets%22:%5B%7B%22plotType%22:%22LINE%22,%22targetAxis%22:%22Y1%22,%22timeSeriesFilter%22:%7B%22aggregations%22:%5B%7B%22crossSeriesReducer%22:%22REDUCE_NONE%22,%22groupByFields%22:%5B%5D,%22perSeriesAligner%22:%22ALIGN_MEAN%22%7D%5D,%22apiSource%22:%22DEFAULT_CLOUD%22,%22crossSeriesReducer%22:%22REDUCE_NONE%22,%22filter%22:%22metric.type%3D%5C%22kubernetes.io%2Fcontainer%2Fmemory%2Fused_bytes%5C%22%20resource.type%3D%5C%22k8s_container%5C%22%20resource.label.%5C%22container_name%5C%22%3D%5C%22sidekiq%5C%22%22,%22groupByFields%22:%5B%5D,%22minAlignmentPeriod%22:%2260s%22,%22perSeriesAligner%22:%22ALIGN_MEAN%22%7D%7D%5D,%22options%22:%7B%22mode%22:%22STATS%22%7D,%22y1Axis%22:%7B%22label%22:%22%22,%22scale%22:%22LINEAR%22%7D%7D%7D&project=gitlab-review-apps
- memory: 1300Mi
+ memory: 1500Mi
limits:
cpu: 700m
- memory: 1800Mi
+ memory: 2000Mi
hpa:
cpu:
targetAverageValue: 650m
@@ -161,6 +159,7 @@ gitlab-runner:
memory: 150Mi
nodeSelector:
preemptible: "true"
+ terminationGracePeriodSeconds: 60 # Wait for 1min before killing gitlab-runner
podAnnotations:
<<: *safe-to-evict
diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh
index ab1675871ee..523087671ed 100755
--- a/scripts/review_apps/review-apps.sh
+++ b/scripts/review_apps/review-apps.sh
@@ -72,12 +72,34 @@ function delete_helm_release() {
fi
if deploy_exists "${namespace}" "${release}"; then
+ echoinfo "[$(date '+%H:%M:%S')] Release exists. Deleting it first."
helm uninstall --namespace="${namespace}" "${release}"
fi
if namespace_exists "${namespace}"; then
- echoinfo "Deleting namespace ${namespace}..." true
- kubectl delete namespace "${namespace}" --wait
+ echoinfo "[$(date '+%H:%M:%S')] Deleting namespace ${namespace}..." true
+
+ # Capture status of command, and check whether the status was successful.
+ if ! kubectl delete namespace "${namespace}" --wait --timeout=1200s; then
+ echoerr
+ echoerr "Could not delete the namespace ${namespace} in time."
+ echoerr
+ echoerr "It can happen that some resources cannot be deleted right away, causing a delay in the namespace deletion."
+ echoerr
+ echoerr "You can see further below which resources are blocking the deletion of the namespace (under DEBUG information)"
+ echoerr
+ echoerr "If retrying the job does not fix the issue, please contact Engineering Productivity for further investigation."
+ echoerr
+ echoerr "DEBUG INFORMATION:"
+ echoerr
+ echoerr "RUNBOOK: https://gitlab.com/gitlab-org/quality/engineering-productivity/team/-/blob/main/runbooks/review-apps.md#namespace-stuck-in-terminating-state"
+ echoerr
+ kubectl describe namespace "${namespace}"
+ echoinfo
+ kubectl api-resources --verbs=list --namespaced -o name | xargs -n 1 kubectl get -n "${namespace}"
+
+ exit 1
+ fi
fi
}
diff --git a/scripts/rspec_helpers.sh b/scripts/rspec_helpers.sh
index 6c39f126afa..60b0db1eaf2 100644
--- a/scripts/rspec_helpers.sh
+++ b/scripts/rspec_helpers.sh
@@ -226,7 +226,9 @@ function handle_retry_rspec_in_new_process() {
exit "${rspec_run_status}"
}
-function rspec_paralellized_job() {
+function rspec_parallelized_job() {
+ echo "[$(date '+%H:%M:%S')] Starting rspec_parallelized_job"
+
read -ra job_name <<< "${CI_JOB_NAME}"
local test_tool="${job_name[0]}"
local test_level="${job_name[1]}"
diff --git a/scripts/trigger-build.rb b/scripts/trigger-build.rb
index 98ca8112d62..19b39ce7023 100755
--- a/scripts/trigger-build.rb
+++ b/scripts/trigger-build.rb
@@ -25,6 +25,7 @@ module Trigger
class Base
# Can be overridden
+ STABLE_BRANCH_REGEX = /^[\d-]+-stable(-ee|-jh)?$/
def self.access_token
ENV['PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE']
end
@@ -113,21 +114,33 @@ module Trigger
end
def stable_branch?
- ENV['CI_COMMIT_REF_NAME'] =~ /^[\d-]+-stable(-ee|-jh)?$/
+ ENV['CI_COMMIT_REF_NAME'] =~ STABLE_BRANCH_REGEX
+ end
+
+ def mr_target_stable_branch?
+ ENV['CI_MERGE_REQUEST_TARGET_BRANCH_NAME'] =~ STABLE_BRANCH_REGEX
end
def fallback_ref
- if trigger_stable_branch_if_detected? && stable_branch?
- if ENV['CI_PROJECT_NAMESPACE'] == 'gitlab-cn'
- ENV['CI_COMMIT_REF_NAME'].delete_suffix('-jh')
- elsif ENV['CI_PROJECT_NAMESPACE'] == 'gitlab-org'
- ENV['CI_COMMIT_REF_NAME'].delete_suffix('-ee')
- end
+ return primary_ref unless trigger_stable_branch_if_detected?
+
+ if stable_branch?
+ normalize_stable_branch_name(ENV['CI_COMMIT_REF_NAME'])
+ elsif mr_target_stable_branch?
+ normalize_stable_branch_name(ENV['CI_MERGE_REQUEST_TARGET_BRANCH_NAME'])
else
primary_ref
end
end
+ def normalize_stable_branch_name(branch_name)
+ if ENV['CI_PROJECT_NAMESPACE'] == 'gitlab-cn'
+ branch_name.delete_suffix('-jh')
+ elsif ENV['CI_PROJECT_NAMESPACE'] == 'gitlab-org'
+ branch_name.delete_suffix('-ee')
+ end
+ end
+
def ref
ENV.fetch(ref_param_name, fallback_ref)
end
diff --git a/scripts/utils.sh b/scripts/utils.sh
index 4a5e74353f6..5996fe7724c 100644
--- a/scripts/utils.sh
+++ b/scripts/utils.sh
@@ -196,7 +196,7 @@ function install_gitlab_gem() {
}
function install_tff_gem() {
- run_timed_command "gem install test_file_finder --no-document --version 0.1.4"
+ run_timed_command "gem install test_file_finder --no-document --version 0.2.1"
}
function install_activesupport_gem() {
@@ -453,3 +453,21 @@ function download_local_gems() {
rm "${output}"
done
}
+
+function define_trigger_branch_in_build_env() {
+ target_branch_name="${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-${CI_COMMIT_REF_NAME}}"
+ stable_branch_regex="^[0-9-]+-stable(-ee)?$"
+
+ echo "target_branch_name: ${target_branch_name}"
+
+ if [[ $target_branch_name =~ $stable_branch_regex ]]
+ then
+ export TRIGGER_BRANCH="${target_branch_name%-ee}"
+ else
+ export TRIGGER_BRANCH=master
+ fi
+
+ if [ -f "$BUILD_ENV" ]; then
+ echo "TRIGGER_BRANCH=${TRIGGER_BRANCH}" >> $BUILD_ENV
+ fi
+}
diff --git a/scripts/verify-tff-mapping b/scripts/verify-tff-mapping
index 0bf4db52698..330bbff3aed 100755
--- a/scripts/verify-tff-mapping
+++ b/scripts/verify-tff-mapping
@@ -13,186 +13,192 @@ require 'test_file_finder'
tests = [
{
explanation: 'EE code should map to respective spec',
- source: 'ee/app/controllers/admin/licenses_controller.rb',
+ changed_file: 'ee/app/controllers/admin/licenses_controller.rb',
expected: ['ee/spec/controllers/admin/licenses_controller_spec.rb']
},
{
explanation: 'FOSS code should map to respective spec',
- source: 'app/finders/admin/projects_finder.rb',
+ changed_file: 'app/finders/admin/projects_finder.rb',
expected: ['spec/finders/admin/projects_finder_spec.rb']
},
{
explanation: 'EE extension should map to its EE extension spec and its FOSS class spec',
- source: 'ee/app/finders/ee/projects_finder.rb',
+ changed_file: 'ee/app/finders/ee/projects_finder.rb',
expected: ['ee/spec/finders/ee/projects_finder_spec.rb', 'spec/finders/projects_finder_spec.rb']
},
{
explanation: 'EE lib should map to respective spec.',
- source: 'ee/lib/world.rb',
+ changed_file: 'ee/lib/world.rb',
expected: ['ee/spec/lib/world_spec.rb']
},
{
explanation: 'FOSS lib should map to respective spec',
- source: 'lib/gitaly/server.rb',
+ changed_file: 'lib/gitaly/server.rb',
expected: ['spec/lib/gitaly/server_spec.rb']
},
{
explanation: 'https://gitlab.com/gitlab-org/gitlab/-/issues/368628',
- source: 'lib/gitlab/usage_data_counters/wiki_page_counter.rb',
+ changed_file: 'lib/gitlab/usage_data_counters/wiki_page_counter.rb',
expected: ['spec/lib/gitlab/usage_data_spec.rb', 'spec/lib/gitlab/usage_data_counters/wiki_page_counter_spec.rb']
},
{
explanation: 'EE usage counters map to usage data spec',
- source: 'ee/lib/gitlab/usage_data_counters/licenses_list.rb',
+ changed_file: 'ee/lib/gitlab/usage_data_counters/licenses_list.rb',
expected: ['ee/spec/lib/gitlab/usage_data_counters/licenses_list_spec.rb', 'spec/lib/gitlab/usage_data_spec.rb']
},
{
explanation: 'https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/54#note_1160811638',
- source: 'lib/gitlab/ci/config/base.rb',
+ changed_file: 'lib/gitlab/ci/config/base.rb',
expected: ['spec/lib/gitlab/ci/yaml_processor_spec.rb']
},
{
explanation: 'https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/54#note_1160811638',
- source: 'ee/lib/gitlab/ci/config/base.rb',
+ changed_file: 'ee/lib/gitlab/ci/config/base.rb',
expected: ['spec/lib/gitlab/ci/yaml_processor_spec.rb', 'ee/spec/lib/gitlab/ci/yaml_processor_spec.rb']
},
{
explanation: 'Tooling should map to respective spec',
- source: 'tooling/danger/specs/project_factory_suggestion.rb',
+ changed_file: 'tooling/danger/specs/project_factory_suggestion.rb',
expected: ['spec/tooling/danger/specs/project_factory_suggestion_spec.rb']
},
{
explanation: 'Map RuboCop related files to respective specs',
- source: 'rubocop/cop/gettext/static_identifier.rb',
+ changed_file: 'rubocop/cop/gettext/static_identifier.rb',
expected: ['spec/rubocop/cop/gettext/static_identifier_spec.rb']
},
{
explanation: 'Initializers should map to respective spec',
- source: 'config/initializers/action_mailer_hooks.rb',
+ changed_file: 'config/initializers/action_mailer_hooks.rb',
expected: ['spec/initializers/action_mailer_hooks_spec.rb']
},
{
explanation: 'DB structure should map to schema spec',
- source: 'db/structure.sql',
+ changed_file: 'db/structure.sql',
expected: ['spec/db/schema_spec.rb']
},
{
explanation: 'Migration should map to its non-timestamped spec',
- source: 'db/migrate/20221011062254_sync_new_amount_used_for_ci_project_monthly_usages.rb',
- expected: ['spec/migrations/sync_new_amount_used_for_ci_project_monthly_usages_spec.rb']
+ changed_file: 'db/migrate/20230707003301_add_expiry_notified_at_to_member.rb',
+ expected: ['spec/migrations/add_expiry_notified_at_to_member_spec.rb']
},
# rubocop:disable Layout/LineLength
{
explanation: 'Migration should map to its timestamped spec',
- source: 'db/post_migrate/20230105172120_sync_new_amount_used_with_amount_used_on_ci_namespace_monthly_usages_table.rb',
+ changed_file: 'db/post_migrate/20230105172120_sync_new_amount_used_with_amount_used_on_ci_namespace_monthly_usages_table.rb',
expected: ['spec/migrations/20230105172120_sync_new_amount_used_with_amount_used_on_ci_namespace_monthly_usages_table_spec.rb']
},
# rubocop:enable Layout/LineLength
{
explanation: 'FOSS views should map to respective spec',
- source: 'app/views/admin/dashboard/index.html.haml',
+ changed_file: 'app/views/admin/dashboard/index.html.haml',
expected: ['spec/views/admin/dashboard/index.html.haml_spec.rb']
},
{
explanation: 'EE views should map to respective spec',
- source: 'ee/app/views/subscriptions/new.html.haml',
+ changed_file: 'ee/app/views/subscriptions/new.html.haml',
expected: ['ee/spec/views/subscriptions/new.html.haml_spec.rb']
},
{
explanation: 'FOSS spec code should map to itself',
- source: 'spec/models/issue_spec.rb',
+ changed_file: 'spec/models/issue_spec.rb',
expected: ['spec/models/issue_spec.rb']
},
{
explanation: 'EE spec code should map to itself',
- source: 'ee/spec/models/ee/user_spec.rb',
+ changed_file: 'ee/spec/models/ee/user_spec.rb',
expected: ['ee/spec/models/ee/user_spec.rb', 'spec/models/user_spec.rb']
},
{
explanation: 'EE extension spec should map to itself and the FOSS class spec',
- source: 'ee/spec/services/ee/notification_service_spec.rb',
+ changed_file: 'ee/spec/services/ee/notification_service_spec.rb',
expected: ['ee/spec/services/ee/notification_service_spec.rb', 'spec/services/notification_service_spec.rb']
},
{
explanation: 'FOSS factory should map to factories spec',
- source: 'spec/factories/users.rb',
+ changed_file: 'spec/factories/users.rb',
expected: ['ee/spec/models/factories_spec.rb']
},
{
explanation: 'EE factory should map to factories spec',
- source: 'ee/spec/factories/users.rb',
+ changed_file: 'ee/spec/factories/users.rb',
expected: ['ee/spec/models/factories_spec.rb']
},
{
explanation: 'Whats New should map to its respective spec',
- source: 'data/whats_new/202101140001_13_08.yml',
+ changed_file: 'data/whats_new/202101140001_13_08.yml',
expected: ['spec/lib/release_highlights/validator_spec.rb']
},
{
explanation: 'The documentation index page is used in this haml_lint spec',
- source: 'doc/index.md',
+ changed_file: 'doc/index.md',
expected: ['spec/haml_lint/linter/documentation_links_spec.rb']
},
{
- explanation: 'Spec for FOSS sidekiq worker',
- source: 'app/workers/new_worker.rb',
- expected: ['spec/workers/every_sidekiq_worker_spec.rb']
+ explanation: 'Spec for FOSS model',
+ changed_file: 'app/models/some_new_model.rb',
+ expected: ['spec/models/every_model_spec.rb']
},
{
- explanation: 'Spec for EE sidekiq worker',
- source: 'ee/app/workers/new_worker.rb',
+ explanation: 'Spec for EE model',
+ changed_file: 'ee/app/models/some_new_model.rb',
+ expected: ['spec/models/every_model_spec.rb']
+ },
+
+ {
+ explanation: 'Spec for FOSS sidekiq worker',
+ changed_file: 'app/workers/new_worker.rb',
expected: ['spec/workers/every_sidekiq_worker_spec.rb']
},
{
- explanation: 'Known events',
- source: 'lib/gitlab/usage_data_counters/known_events/common.yml',
- expected: ['spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb', 'spec/lib/gitlab/usage_data_spec.rb']
+ explanation: 'Spec for EE sidekiq worker',
+ changed_file: 'ee/app/workers/new_worker.rb',
+ expected: ['spec/workers/every_sidekiq_worker_spec.rb']
},
{
explanation: 'FOSS mailer previews',
- source: 'app/mailers/previews/foo.rb',
+ changed_file: 'app/mailers/previews/foo.rb',
expected: ['spec/mailers/previews_spec.rb']
},
{
explanation: 'EE mailer previews',
- source: 'ee/app/mailers/previews/foo.rb',
+ changed_file: 'ee/app/mailers/previews/foo.rb',
expected: ['spec/mailers/previews_spec.rb']
},
{
explanation: 'EE mailer extension previews',
- source: 'ee/app/mailers/previews/license_mailer_preview.rb',
+ changed_file: 'ee/app/mailers/previews/license_mailer_preview.rb',
expected: ['spec/mailers/previews_spec.rb']
},
{
explanation: 'GLFM spec and config files for CE and EE should map to respective markdown snapshot specs',
- source: 'glfm_specification/foo',
+ changed_file: 'glfm_specification/foo',
expected: ['spec/requests/api/markdown_snapshot_spec.rb', 'ee/spec/requests/api/markdown_snapshot_spec.rb']
},
@@ -200,56 +206,64 @@ tests = [
explanation: 'https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/287#note_1192008962',
# Note: The metrics seem to be changed every year or so, so this test will fail once a year or so.
# You will need to change the metric below for another metric present in the project.
- source: 'ee/config/metrics/counts_all/20221114065035_delete_merge_request.yml',
+ changed_file: 'ee/config/metrics/counts_all/20221114065035_delete_merge_request.yml',
expected: ['ee/spec/config/metrics/every_metric_definition_spec.rb']
},
{
- explanation: 'https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/287#note_1192008962',
- source: 'ee/lib/ee/gitlab/usage_data_counters/known_events/common.yml',
- expected: ['ee/spec/config/metrics/every_metric_definition_spec.rb']
- },
- {
explanation: 'https://gitlab.com/gitlab-org/quality/engineering-productivity/team/-/issues/146',
- source: 'config/feature_categories.yml',
+ changed_file: 'config/feature_categories.yml',
expected: ['spec/db/docs_spec.rb', 'ee/spec/lib/ee/gitlab/database/docs/docs_spec.rb']
},
{
explanation: 'https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/1360',
- source: 'vendor/project_templates/gitbook.tar.gz',
+ changed_file: 'vendor/project_templates/gitbook.tar.gz',
expected: ['spec/lib/gitlab/project_template_spec.rb']
},
{
explanation: 'https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/1683#note_1385966977',
- source: 'app/finders/members_finder.rb',
+ changed_file: 'app/finders/members_finder.rb',
expected: ['spec/finders/members_finder_spec.rb', 'spec/graphql/types/project_member_relation_enum_spec.rb']
},
{
explanation: 'Map FOSS rake tasks',
- source: 'lib/tasks/import.rake',
+ changed_file: 'lib/tasks/import.rake',
expected: ['spec/tasks/import_rake_spec.rb']
},
{
explanation: 'Map EE rake tasks',
- source: 'ee/lib/tasks/geo.rake',
+ changed_file: 'ee/lib/tasks/geo.rake',
expected: ['ee/spec/tasks/geo_rake_spec.rb']
+ },
+ {
+ explanation: 'Map controllers to request specs',
+ changed_file: 'app/controllers/admin/abuse_reports_controller.rb',
+ expected: ['spec/requests/admin/abuse_reports_controller_spec.rb']
+ },
+ {
+ explanation: 'Map EE controllers to controller and request specs',
+ changed_file: 'ee/app/controllers/users_controller.rb',
+ expected: [
+ 'ee/spec/controllers/users_controller_spec.rb',
+ 'ee/spec/requests/users_controller_spec.rb'
+ ]
}
]
class MappingTest
- def initialize(explanation:, source:, expected:, strategy:)
+ def initialize(explanation:, changed_file:, expected:, strategy:)
@explanation = explanation
- @source = source
+ @changed_file = changed_file
@strategy = strategy
@expected_set = Set.new(expected)
@actual_set = Set.new(actual)
end
def passed?
- expected_set.eql?(actual_set)
+ expected_set == actual_set
end
def failed?
@@ -259,24 +273,25 @@ class MappingTest
def failure_message
<<~MESSAGE
#{explanation}:
- Source #{source}
- Expected #{expected_set.to_a}
- Actual #{actual_set.to_a}
+ Changed file #{changed_file}
+ Expected #{expected_set.to_a}
+ Actual #{actual_set.to_a}
MESSAGE
end
private
- attr_reader :explanation, :source, :expected_set, :actual_set, :mapping
+ attr_reader :explanation, :changed_file, :expected_set, :actual_set, :mapping
def actual
- tff = TestFileFinder::FileFinder.new(paths: [source])
+ tff = TestFileFinder::FileFinder.new(paths: [changed_file])
tff.use @strategy
tff.test_files
end
end
-strategy = TestFileFinder::MappingStrategies::PatternMatching.load('tests.yml')
+mapping_file = 'tests.yml'
+strategy = TestFileFinder::MappingStrategies::PatternMatching.load(mapping_file)
results = tests.map { |test| MappingTest.new(strategy: strategy, **test) }
failed_tests = results.select(&:failed?)
@@ -290,4 +305,18 @@ if failed_tests.any?
exit 1
end
+bad_sources = YAML.load_file(mapping_file)['mapping'].filter_map do |map|
+ map['source'].match(/(?<!\\)\.\w+\z/)&.string
+end
+
+if bad_sources.any?
+ puts "Suspicious metacharacter detected. Are these correct?"
+
+ bad_sources.each do |bad|
+ puts " #{bad} => Did you mean: #{bad.sub(/(\.\w+)\z/, '\\\\\1')}"
+ end
+
+ exit 1
+end
+
puts 'tff mapping verification passed.'