diff --git a/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml b/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml
index d04c54c44cd..f59795126ad 100644
--- a/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml
+++ b/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml
@@ -490,8 +490,6 @@ Layout/LineEndStringConcatenationIndentation:
- 'scripts/failed_tests.rb'
- 'scripts/flaky_examples/prune-old-flaky-examples'
- 'scripts/generate_rspec_pipeline.rb'
- - 'scripts/internal_events/cli.rb'
- - 'scripts/internal_events/cli/event_definer.rb'
- 'scripts/lint-docs-redirects.rb'
- 'scripts/qa/testcases-check'
- 'scripts/trigger-build.rb'
diff --git a/.rubocop_todo/sidekiq_load_balancing/worker_data_consistency.yml b/.rubocop_todo/sidekiq_load_balancing/worker_data_consistency.yml
index 34022051be0..c0c621004c3 100644
--- a/.rubocop_todo/sidekiq_load_balancing/worker_data_consistency.yml
+++ b/.rubocop_todo/sidekiq_load_balancing/worker_data_consistency.yml
@@ -5,7 +5,6 @@ SidekiqLoadBalancing/WorkerDataConsistency:
- 'app/workers/authorized_project_update/project_recalculate_worker.rb'
- 'app/workers/authorized_project_update/user_refresh_from_replica_worker.rb'
- 'app/workers/authorized_projects_worker.rb'
- - 'app/workers/auto_devops/disable_worker.rb'
- 'app/workers/bulk_import_worker.rb'
- 'app/workers/bulk_imports/entity_worker.rb'
- 'app/workers/bulk_imports/export_request_worker.rb'
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 085524d3ac0..13c98100026 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -324,8 +324,12 @@ module Ci
after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
pipeline.run_after_commit do
- pipeline.all_merge_requests.with_auto_merge_enabled.each do |merge_request|
- AutoMergeProcessWorker.perform_async(merge_request.id)
+ if Feature.enabled?(:auto_merge_process_worker_pipeline, pipeline.project)
+ AutoMergeProcessWorker.perform_async({ 'pipeline_id' => self.id })
+ else
+ pipeline.all_merge_requests.with_auto_merge_enabled.each do |merge_request|
+ AutoMergeProcessWorker.perform_async(merge_request.id)
+ end
end
if pipeline.auto_devops_source?
diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb
index 47d805d5284..ed85d910b06 100644
--- a/app/services/auto_merge/base_service.rb
+++ b/app/services/auto_merge/base_service.rb
@@ -12,7 +12,7 @@ module AutoMerge
end
notify(merge_request)
- AutoMergeProcessWorker.perform_async(merge_request.id)
+ AutoMergeProcessWorker.perform_async({ 'merge_request_id' => merge_request.id })
strategy.to_sym
rescue StandardError => e
diff --git a/app/services/deployments/update_environment_service.rb b/app/services/deployments/update_environment_service.rb
index 90a31ae9370..489681683cf 100644
--- a/app/services/deployments/update_environment_service.rb
+++ b/app/services/deployments/update_environment_service.rb
@@ -31,6 +31,7 @@ module Deployments
renew_external_url
renew_auto_stop_in
renew_deployment_tier
+ renew_cluster_agent
environment.fire_state_event(action)
if environment.save
@@ -67,6 +68,12 @@ module Deployments
ExpandVariables.expand(auto_stop_in, -> { variables.sort_and_expand_all })
end
+ def expanded_cluster_agent_path
+ return unless cluster_agent_path
+
+ ExpandVariables.expand(cluster_agent_path, -> { variables.sort_and_expand_all })
+ end
+
def environment_url
environment_options[:url]
end
@@ -79,6 +86,10 @@ module Deployments
deployable&.environment_auto_stop_in
end
+ def cluster_agent_path
+ environment_options.dig(:kubernetes, :agent)
+ end
+
def renew_external_url
if (url = expanded_environment_url)
environment.external_url = url
@@ -100,6 +111,25 @@ module Deployments
environment.tier = tier
end
end
+
+ def renew_cluster_agent
+ return unless cluster_agent_path && deployable.user
+
+ requested_project_path, requested_agent_name = expanded_cluster_agent_path.split(':')
+
+ matching_authorization = user_access_authorizations_for_project.find do |authorization|
+ requested_project_path == authorization.config_project.full_path &&
+ requested_agent_name == authorization.agent.name
+ end
+
+ return unless matching_authorization
+
+ environment.cluster_agent = matching_authorization.agent
+ end
+
+ def user_access_authorizations_for_project
+ Clusters::Agents::Authorizations::UserAccess::Finder.new(deployable.user, project: deployable.project).execute
+ end
end
end
diff --git a/app/services/discussions/resolve_service.rb b/app/services/discussions/resolve_service.rb
index 8f5260986c2..03a0ad902d1 100644
--- a/app/services/discussions/resolve_service.rb
+++ b/app/services/discussions/resolve_service.rb
@@ -90,7 +90,7 @@ module Discussions
)
)
else
- AutoMergeProcessWorker.perform_async(merge_request.id)
+ AutoMergeProcessWorker.perform_async({ 'merge_request_id' => merge_request.id })
end
end
diff --git a/app/workers/auto_devops/disable_worker.rb b/app/workers/auto_devops/disable_worker.rb
index 03613db3f29..982aa3ac14f 100644
--- a/app/workers/auto_devops/disable_worker.rb
+++ b/app/workers/auto_devops/disable_worker.rb
@@ -4,7 +4,7 @@ module AutoDevops
class DisableWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- data_consistency :always
+ data_consistency :sticky
sidekiq_options retry: 3
include AutoDevopsQueue
diff --git a/config/feature_flags/gitlab_com_derisk/auto_merge_process_worker_pipeline.yml b/config/feature_flags/gitlab_com_derisk/auto_merge_process_worker_pipeline.yml
new file mode 100644
index 00000000000..9573d2f11d6
--- /dev/null
+++ b/config/feature_flags/gitlab_com_derisk/auto_merge_process_worker_pipeline.yml
@@ -0,0 +1,9 @@
+---
+name: auto_merge_process_worker_pipeline
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/483008
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/167095
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/499877
+milestone: '17.6'
+group: group::ci platform
+type: gitlab_com_derisk
+default_enabled: false
diff --git a/db/docs/batched_background_migrations/backfill_boards_epic_board_labels_group_id.yml b/db/docs/batched_background_migrations/backfill_boards_epic_board_labels_group_id.yml
index 1a584a12770..9ef39215ca2 100644
--- a/db/docs/batched_background_migrations/backfill_boards_epic_board_labels_group_id.yml
+++ b/db/docs/batched_background_migrations/backfill_boards_epic_board_labels_group_id.yml
@@ -5,4 +5,4 @@ feature_category: portfolio_management
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/153674
milestone: '17.1'
queued_migration_version: 20240521090846
-finalized_by: # version of the migration that finalized this BBM
+finalized_by: '20241023232309'
diff --git a/db/post_migrate/20241023015533_remove_tmp_index_on_vulnerability_reads_state.rb b/db/post_migrate/20241023015533_remove_tmp_index_on_vulnerability_reads_state.rb
new file mode 100644
index 00000000000..b75e12a1970
--- /dev/null
+++ b/db/post_migrate/20241023015533_remove_tmp_index_on_vulnerability_reads_state.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class RemoveTmpIndexOnVulnerabilityReadsState < Gitlab::Database::Migration[2.2]
+ milestone '17.6'
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'tmp_index_vulnerability_reads_where_state_is_detected'
+ TABLE_NAME = :vulnerability_reads
+
+ def up
+ remove_concurrent_index_by_name TABLE_NAME, INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index TABLE_NAME, :id, name: INDEX_NAME, where: "state = 1"
+ end
+end
diff --git a/db/post_migrate/20241023232309_finalize_backfill_boards_epic_board_labels_group_id.rb b/db/post_migrate/20241023232309_finalize_backfill_boards_epic_board_labels_group_id.rb
new file mode 100644
index 00000000000..2db26d6b035
--- /dev/null
+++ b/db/post_migrate/20241023232309_finalize_backfill_boards_epic_board_labels_group_id.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class FinalizeBackfillBoardsEpicBoardLabelsGroupId < Gitlab::Database::Migration[2.2]
+ milestone '17.6'
+
+ disable_ddl_transaction!
+
+ restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
+
+ def up
+ ensure_batched_background_migration_is_finished(
+ job_class_name: 'BackfillBoardsEpicBoardLabelsGroupId',
+ table_name: :boards_epic_board_labels,
+ column_name: :id,
+ job_arguments: [:group_id, :boards_epic_boards, :group_id, :epic_board_id],
+ finalize: true
+ )
+ end
+
+ def down; end
+end
diff --git a/db/schema_migrations/20241023015533 b/db/schema_migrations/20241023015533
new file mode 100644
index 00000000000..4828ffa828a
--- /dev/null
+++ b/db/schema_migrations/20241023015533
@@ -0,0 +1 @@
+a0bb64a39809e232b50a522effd27104a6514876f193170847c1e6124231256d
\ No newline at end of file
diff --git a/db/schema_migrations/20241023232309 b/db/schema_migrations/20241023232309
new file mode 100644
index 00000000000..21b919e750b
--- /dev/null
+++ b/db/schema_migrations/20241023232309
@@ -0,0 +1 @@
+5f2b18d3d4242b240dae17ecf0e9ed96d3e674239bf0c81b9ee50c4571faf5d7
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index e072c2ee46a..49d0f8dd707 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -32029,8 +32029,6 @@ CREATE INDEX tmp_index_project_statistics_cont_registry_size ON project_statisti
CREATE INDEX tmp_index_vulnerability_overlong_title_html ON vulnerabilities USING btree (id) WHERE (length(title_html) > 800);
-CREATE INDEX tmp_index_vulnerability_reads_where_state_is_detected ON vulnerability_reads USING btree (id) WHERE (state = 1);
-
CREATE UNIQUE INDEX u_compliance_requirements_for_framework ON compliance_requirements USING btree (framework_id, name);
CREATE UNIQUE INDEX u_project_compliance_standards_adherence_for_reporting ON project_compliance_standards_adherence USING btree (project_id, check_name, standard);
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index f27c137b611..2e870b1148f 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -20893,6 +20893,7 @@ The currently authenticated GitLab user.
| `createdAt` | [`Time`](#time) | Timestamp of when the user was created. |
| `discord` | [`String`](#string) | Discord ID of the user. |
| `duoChatAvailable` **{warning-solid}** | [`Boolean!`](#boolean) | **Introduced** in GitLab 16.8. **Status**: Experiment. User access to AI chat feature. |
+| `duoChatAvailableFeatures` **{warning-solid}** | [`[String!]!`](#string) | **Introduced** in GitLab 17.6. **Status**: Experiment. List of available features for AI chat. |
| `duoCodeSuggestionsAvailable` **{warning-solid}** | [`Boolean!`](#boolean) | **Introduced** in GitLab 16.8. **Status**: Experiment. User access to code suggestions feature. |
| `email` **{warning-solid}** | [`String`](#string) | **Deprecated** in GitLab 13.7. This was renamed. Use: [`User.publicEmail`](#userpublicemail). |
| `emails` | [`EmailConnection`](#emailconnection) | User's email addresses. (see [Connections](#connections)) |
diff --git a/doc/ci/environments/kubernetes_dashboard.md b/doc/ci/environments/kubernetes_dashboard.md
index 885a96831ad..f8f1d2cfc85 100644
--- a/doc/ci/environments/kubernetes_dashboard.md
+++ b/doc/ci/environments/kubernetes_dashboard.md
@@ -63,6 +63,29 @@ Prerequisites:
::EndTabs
+### Configure a dashboard for a dynamic environment
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/467912) in GitLab 17.6.
+
+To configure a dashboard for a dynamic environment:
+
+- Specify the agent in your `.gitlab-ci.yml` file. You must specify the full path to the agent configuration project,
+followed by a colon and the name of the agent.
+
+For example:
+
+```yaml
+deploy_review_app:
+ stage: deploy
+ script: make deploy
+ environment:
+ name: review/$CI_COMMIT_REF_SLUG
+ kubernetes:
+ agent: path/to/agent/project:agent-name
+```
+
+For more information, see the [CI/CD YAML syntax reference](../yaml/index.md#environmentkubernetes).
+
## View a dashboard
> - Kubernetes watch API integration [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/422945) in GitLab 16.6 [with a flag](../../administration/feature_flags.md) named `k8s_watch_api`. Disabled by default.
diff --git a/doc/ci/steps/index.md b/doc/ci/steps/index.md
index 9eecadb5a8a..b17920652b2 100644
--- a/doc/ci/steps/index.md
+++ b/doc/ci/steps/index.md
@@ -11,276 +11,538 @@ DETAILS:
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
**Status:** Experimental
-Steps are reusable and composable pieces of a job.
-Each step defines structured inputs and outputs that can be consumed by other steps.
-Steps can come from local files, GitLab.com repositories, or any other Git source.
+Steps are reusable units of a job that when composed together replace the `script` used in a GitLab CI/CD job.
+While you are not required to use steps, the reusability, composability, testability, and independence
+of steps make it easier to understand and maintain CI/CD pipeline.
-Steps is an alternative to shell scripts for running jobs.
-They provide more structure, can be composed, and can be tested and reused.
-A `exec:command` is run by using an Exec system call, not by running a shell.
+To get started, you can try the [Set up steps tutorial](../../tutorials/setup_steps/index.md).
+To start creating your own steps, see [Creating your own step](#create-your-own-step).
-To get started, see the [Set up steps tutorial](../../tutorials/setup_steps/index.md).
+Follow the team's progress building steps at the epic, [CI Steps: Composability in GitLab CI/CD](https://gitlab.com/groups/gitlab-org/-/epics/11535).
+To report a bug, create an issue in the [step-runner](https://gitlab.com/gitlab-org/step-runner/-/issues) project.
-Support for a CI Catalog that publishes steps is proposed in [issue 425891](https://gitlab.com/gitlab-org/gitlab/-/issues/425891).
+CI/CD steps are different than [CI/CD components](../components/index.md). Components
+are reusable single pipeline configuration units. They are included in a pipeline when it is created,
+adding jobs and configuration to the pipeline. Files such as common scripts or programs
+from the component project cannot be referenced from a CI/CD job.
-## Define steps
+CI/CD Steps are reusable units of a job. When the job runs, the referenced step is downloaded to
+the execution environment or image, bringing along any extra files included with the step.
+Execution of the step replaces the `script` in the job.
-Steps are defined in a `step.yml` file.
-Each file has two documents, the spec and the definition.
+## Step workflow
-The spec provides inputs, outputs, types, descriptions, and defaults.
+A step either runs a sequence of steps or executes a command. Each step specifies inputs received and outputs returned, has
+access to CI/CD job variables, environment variables, and resources provided by the execution environment such as the file
+system and networking. Steps are hosted locally on the file system, in GitLab.com repositories, or in any other Git source.
+
+Additionally, steps:
+
+- Run in a Docker container created by the Steps team, you can review the [`Dockerfile`](https://gitlab.com/gitlab-org/step-runner/-/blob/main/Dockerfile).
+ Follow [epic 15073](https://gitlab.com/groups/gitlab-org/-/epics/15073+) to track
+ when steps will run inside the environment defined by the CI/CD job.
+- Are specific to Linux. Follow [epic 15074](https://gitlab.com/groups/gitlab-org/-/epics/15074)
+ to track when steps supports multiple operating systems.
+
+For example, this job uses the [`run`](../yaml/index.md#run) CI/CD keyword to run a step:
+
+```yaml
+job:
+ variables:
+ CI_SAY_HI_TO: "Sally"
+ run:
+ - name: say_hi
+ step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1.0.0
+ inputs:
+ message: "hello, ${{job.CI_SAY_HI_TO}}"
+```
+
+When this job runs, the message `hello, Sally` is printed to job log.
+The definition of the echo step is:
```yaml
-# Example spec
spec:
inputs:
- name:
+ message:
type: string
- default: joe steppy
---
-# (definition goes here)
+exec:
+ command:
+ - bash
+ - -c
+ - echo '${{inputs.message}}'
```
-The definition provides the implementation of the step.
-There are two kinds of step definitions:
+## Use CI/CD Steps
-- The `exec` type, which executes a command.
+Configure a GitLab CI/CD job to use CI Steps with the `run` keyword. You cannot use `before_script`,
+`after_script`, or `script` in a job when you are running CI/CD Steps.
- ```yaml
- # (spec goes here)
- ---
- # Example exec definition
- exec:
- command: [ docker, run, -it, ubuntu, uname, -a ]
- ```
+The `run` keyword accepts a list of steps to run. Steps are run one at a time in the order they are defined in the list.
+Each list item has a `name` and either `step`, `script`, or `action`.
-- The `steps` type, which runs a sequence of other steps.
+Name must consist only of alpha-numeric characters and underscores, and must not start with a number.
- ```yaml
- # (spec goes here)
- ---
- # Example steps definition
- steps:
- - name: greet_user
- step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1
- inputs:
- echo: hello ${{ inputs.name }}
- - name: print_system_information
- step: ./my-local-steps/uname
- ```
+### Run a step
-So that you can refactor step implementations, you can change steps from `exec` to `steps` type, and from `exec` to `steps` type without an affect on workflows (or calling steps).
+Run a step by providing the [step location](#step-location) using the `step` keyword.
-### Inputs
+Inputs and environment variables can be passed to the step, and these can contain expressions that interpolate values.
+Steps run in the directory defined by the `CI_BUILDS_DIR` [predefined variable](../variables/predefined_variables.md).
-Inputs can be the following types:
-
-- `string`
-- `number`
-- `boolean`
-- `array`
-- `struct`
-
-The default input type is `string`.
-
-If an input doesn't define a default then it is required.
-Defaults cannot use expressions (`${{ }}`) that are only permitted in the step definition.
-
-### Outputs
-
-Outputs can be the following types:
-
-- `string`
-- `number`
-- `boolean`
-- `array`
-- `struct`
-- `raw_string`
-- `step_result`
-
-Outputs are written to `${{ output_file }}` in the form `key=value` where `key` is the name of the output.
-The `value` should be written as JSON unless the type is `raw_string`.
-The value type written by the step must match the declared type. The default output type is `raw_string`.
-
-The special output type `step_result` is used when delegating step execution to another step.
-For example, the `script` and `action-runner` steps.
-
-Outputs for `steps` type definitions use expressions to aggregate from sub-step outputs.
-Because expressions are not permitted in the spec, the `outputs` keyword appear in the definition.
-To preserve encapsulation and allow refactoring, callers cannot directly access outputs from sub-steps.
+For example, the echo step loaded from the Git repository `gitlab.com/components/echo`
+receives the environment variable `USER: Fred` and the input `message: hello Sally`:
+
+```yaml
+job:
+ variables:
+ CI_SAY_HI_TO: "Sally"
+ run:
+ - name: say_hi
+ step: gitlab.com/components/echo@v1.0.0
+ env:
+ USER: "Fred"
+ inputs:
+ message: "hello ${{job.CI_SAY_HI_TO}}"
+```
+
+### Run a script
+
+Run a script in a shell with the `script` keyword. Environment variables passed to scripts
+using `env` are set in the shell. Script steps run in the directory defined by the `CI_BUILDS_DIR`
+[predefined variable](../variables/predefined_variables.md).
+
+For example, the following script prints the GitLab user to the job log:
+
+```yaml
+my-job:
+ run:
+ - name: say_hi
+ script: echo hello ${{job.GITLAB_USER_LOGIN}}
+```
+
+Script steps always use the `bash` shell. Follow [issue 109](https://gitlab.com/gitlab-org/step-runner/-/issues/109)
+to track when shell fallback is supported.
+
+### Run a GitHub action
+
+Run GitHub actions with the `action` keyword. Inputs and environment variables are passed directly to the
+action, and action outputs are returned as step outputs. Action steps run in the directory
+defined by the `CI_PROJECT_DIR` [predefined variable](../variables/predefined_variables.md).
+
+Running actions requires the `dind` service. For more information, see
+[Use Docker to build Docker images](../docker/using_docker_build.md).
+
+For example, the following step uses `action` to make `yq` available:
+
+```yaml
+my-job:
+ run:
+ - name: say_hi_again
+ action: mikefarah/yq@master
+ inputs:
+ cmd: echo ["hi ${{job.GITLAB_USER_LOGIN}} again!"] | yq .[0]
+```
+
+#### Known issues
+
+Actions running in GitLab do not support uploading artifacts directly.
+Artifacts must be written to the file system and cache instead, and selected with the
+existing [`artifacts` keyword](../yaml/index.md#artifacts) and [`cache` keyword](../yaml/index.md#cache).
+
+### Step location
+
+Steps are loaded from a relative path on the file system, GitLab.com repositories,
+or any other Git source.
+
+#### Load a step from the file system
+
+Load a step from the file system using a relative path that starts with a full-stop `.`.
+The folder referenced by the path must contain a `step.yml` step definition file.
+Path separators must always use forward-slashes `/`, regardless of operating system.
+
+For example:
+
+```yaml
+- name: my-step
+ step: ./path/to/my-step
+```
+
+#### Load a step from a Git repository
+
+Load a step from a Git repository by supplying the URL and revision (commit, branch, or tag) of the repository.
+You can also specify the relative directory of the step inside the repository.
+If the URL is specified without a directory, then `step.yml` is loaded from the root folder of the repository.
+
+For example:
+
+- Specify the step with a branch:
+
+ ```yaml
+ job:
+ run:
+ - name: my_echo_step
+ step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@main
+ ```
+
+- Specify the step with a tag:
+
+ ```yaml
+ job:
+ run:
+ - name: my_echo_step
+ step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1.0.0
+ ```
+
+- Specify the step with a Git rev and directory in a repository:
+
+ ```yaml
+ job:
+ run:
+ - name: specifying_a_revision_and_directory_within_the_repository
+ step:
+ git:
+ url: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step
+ dir: reverse
+ rev: main
+ ```
+
+Steps can't reference Git repositories using annotated tags. Follow [issue 123](https://gitlab.com/gitlab-org/step-runner/-/issues/123)
+to track when annotated tags are supported.
+
+### Expressions
+
+Expressions are a mini-language enclosed in double curly-braces `${{ }}`. Expressions are evaluated
+just prior to step execution in the job environment and can be used in:
+
+- Input values
+- Environment variable values
+- Step location URL
+- The executable command
+- The executable work directory
+- Outputs in a sequence of steps
+- The `script` step
+- The `action` step
+
+Expressions can reference the following variables:
+
+| Variable | Example | Description |
+|:----------------------------|:-----------------------------------|:------------|
+| `env` | `${{env.HOME}}` | Access to environment variables set on the execution environment or in previous steps. |
+| `export_file` | `echo name=FRED >${{export_file}}` | The path to the export file. Write to this file to export environment variables for use by subsequent running steps. |
+| `inputs` | `${{inputs.message}}` | Access inputs to the step. |
+| `job` | `${{job.GITLAB_USER_NAME}}` | Access GitLab CI/CD job variables, limited to those starting with `CI_`, `DOCKER_` or `GITLAB_`. |
+| `output_file` | `echo name=Fred >${{output_file}}` | The path to the output file. Write to this file to set output variables from the step. |
+| `step_dir` | `work_dir: ${{step_dir}}` | The folder to where the step has been downloaded. Use to refer to files in the step, or to set the work directory of an executable step. |
+| `steps.[step-name].outputs` | `${{steps.my-step.outputs.name}}` | Access to outputs from previously executed steps. Choose the specific step using the step name. |
+| `work_dir` | `${{work_dir}}` | The work directory of an executing step. |
+
+Expressions are different from template interpolation which uses double square-brackets (`$[[ ]]`)
+and are evaluated during job generation.
+
+Expressions only have access to CI/CD job variables with names starting with `CI_`, `DOCKER_`,
+or `GITLAB_`. Follow [epic 15073](https://gitlab.com/groups/gitlab-org/-/epics/15073+)
+to track when steps can access all CI/CD job variables.
+
+### Using prior step outputs
+
+Step inputs can reference outputs from prior steps by referencing the step name and output variable name.
+
+For example, if the `gitlab.com/components/random-string` step defined an output variable called `random_value`:
+
+```yaml
+job:
+ run:
+ - name: generate_rand
+ step: gitlab.com/components/random
+ - name: echo_random
+ step: gitlab.com/components/echo
+ inputs:
+ message: "The random value is: ${{steps.generate_rand.random_value}}"
+```
+
+### Environment variables
+
+Steps can [set](#set-environment-variables) environment variables, [export](#export-an-environment-variable)
+environment variables, and environment variables can be passed in when using `step`, `script`, or `action`.
+
+Environment variable precedence, from highest to lowest precedence, are variables set:
+
+1. By using `env` keyword in the `step.yml`.
+1. By using the `env` keyword passed to a step in a sequence of steps.
+1. By using the `env` keyword for all steps in a sequence.
+1. Where a previously run step has written to `${{export_file}}`.
+1. By the Runner.
+1. By the container.
+
+## Create your own step
+
+Create your own step by performing the following tasks:
+
+1. Create a GitLab project, a Git repository, or a directory on a file system that is accessible
+ when the CI/CD job runs.
+1. Create a `step.yml` file and place it in the root folder of the project, repository, or directory.
+1. Define the [specification](#the-step-specification) for the step in the `step.yml`.
+1. Define the [definition](#the-step-definition) for the step in the `step.yml`.
+1. Add any files that your step uses to the project, repository, or directory.
+
+After the step is created, you can [use the step in a job](#run-a-step).
+
+### The step specification
+
+The step specification is the first of two documents contained in the step `step.yml`.
+The specification defines inputs and outputs that the step receives and returns.
+
+#### Specify inputs
+
+Input names can only use alpha-numeric characters and underscores, and must not start with a number.
+Inputs must have a type, and they can optionally specify a default value. An input with no default value
+is a required input, it must be specified when using the step.
+
+Inputs must be one of the following types. The default input type is `string`.
+
+| Type | Example | Description |
+|:----------|:------------------------|:------------|
+| `array` | `["a","b"]` | A list of un-typed items. |
+| `boolean` | `true` | True or false. |
+| `number` | `56.77` | 64 bit float. |
+| `string` | `"brown cow"` | Text. |
+| `struct` | `{"k1":"v1","k2":"v2"}` | Structured content. |
+
+For example, to specify that the step accepts an optional input called `greeting` of type `string`:
+
+```yaml
+spec:
+ inputs:
+ greeting:
+ type: string
+ default: "hello, world"
+---
+```
+
+To provide the input when using the step:
+
+```yaml
+run:
+ - name: my_step
+ step: ./my-step
+ inputs:
+ greeting: "hello, another world"
+```
+
+#### Specify outputs
+
+Similar to inputs, output names can only use alpha-numeric characters and underscores,
+and must not start with a number. Outputs must have a type, and they can optionally specify a default value.
+The default value is returned when the step doesn't return the output.
+
+Outputs must be one of the following types. The default input type is `raw_string`.
+
+| Type | Example | Description |
+|:-------------|:------------------------|:------------|
+| `array` | `["a","b"]` | A list of un-typed items. |
+| `boolean` | `true` | True or false. |
+| `number` | `56.77` | 64 bit float. |
+| `raw_string` | `brown cow` | Text written without enclosing double-quotes. |
+| `string` | `"brown cow"` | Text. |
+| `struct` | `{"k1":"v1","k2":"v2"}` | Structured content. |
+
+For example, to specify that the step returns an output called `value` of type `number`:
```yaml
-# Example output from multiple steps
spec:
outputs:
- full_name:
- type: string
+ value:
+ type: number
---
-steps:
- - name: first_name
- step: ./fn
- - name: last_name
- step: ./ln
-outputs:
- full_name: "hello ${{ steps.first_name.outputs.name }} ${{ steps.last_name.outputs.name }}"
```
-## Using steps
-
-The keyword `step` points to a remote or local step.
-
-Remote step references are the URL of a Git repo, the character `@`, and the tag or branch (version).
-Step runner looks for a file `step.yml` at the root of the repository.
-
-Local steps begin with `.` and point to a directory where step-runner looks for `step.yml`.
-Local references always use the path separator `/` regardless of operating system.
-The OS appropriate separate is used when loading the file.
+To use the output when using the step:
```yaml
-# Example job using steps
-my-job:
- run:
- - name: greet_user
- step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1
- inputs:
- echo: hello $[[ GITLAB_USER_LOGIN ]]
- - name: print_system_information
- step: ./my-local-steps/uname
-```
-
-To use steps in a job, provide steps in a variable and invoke the step runner the job `script` keyword. Support to use steps in a job as a `run` keyword in a GitLab CI pipeline configuration is proposed in [epic 11525](https://gitlab.com/groups/gitlab-org/-/epics/11525).
-
-```yaml
-# Example work-around until run keyword is implemented
-my-job:
- image: registry.gitlab.com/gitlab-org/step-runner:v0
- variables:
- STEPS: |
- - name: greet_user
- step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1
- inputs:
- echo: hello $GITLAB_USER_LOGIN
- - name: print_system_information
- step: ./my-local-steps/uname
- script:
- # Run the step-runner's ci command which ready from the STEPS environment variable
- - /step-runner ci
-```
-
-### Set environment variables
-
-You do not need to declare environment variables for steps.
-Any exports written to `${{ export_file }}` in the form `key=value` are added to the global execution environment.
-Exported values are plain strings (no JSON).
-
-You can use the `env` keyword for steps to temporarily set environment variables during their execution:
-
-```yaml
-# Example job using env
-my-job:
- run:
- - name: greet_user
- step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1
- env:
- USER: $[[ GITLAB_USER_LOGIN ]]
- inputs:
- echo: hello ${{ env.USER }}
-```
-
-Step definitions can also temporarily set environment variables.
-
-```yaml
-# (spec goes here)
----
-# Example step definition using env
-env:
- USER: ${{ inputs.user }}
-steps:
- - name: greet_user
- step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1
+run:
+ - name: random_generator
+ step: ./random-generator
+ - name: echo_number
+ step: ./echo
inputs:
- echo: hello ${{ env.USER }}
+ message: "Random number generated was ${{step.random-generator.outputs.value}}"
```
-The order of precedence for environment variables is:
+#### Specify delegated outputs
-1. Step definition
-1. Step reference (calling a step)
-1. Global environment
+Instead of specifying output names and types, outputs can be entirely delegated to a sub-step.
+The outputs returned by the sub-step are returned by your step. The `delegate` keyword
+in the step definition determines which sub-step outputs are returned by the step.
-The `env` variables set in a step definition override variables that are set when the step is called, and so on.
-
-### Running steps locally
-
-To run steps locally, [download `step-runner`](https://gitlab.com/gitlab-org/step-runner) and run the `ci` command.
-This is the same binary that is used to run steps in production.
-
-```shell
-STEPS=$(yq '."my-job"'.run .gitlab-ci.yml) step-runner ci
-```
-
-You can debug with [`delve`](https://github.com/go-delve/delve).
-Set a break point at [`Run` in `pkg/runner.go`](https://gitlab.com/gitlab-org/step-runner/-/blob/ac25318db27ed049dc3ce0fd7d9ce507d215b690/pkg/runner/runner.go#L57).
-
-```shell
-STEPS=$(yq '."my-job"'.run .gitlab-ci.yml) dlv debug . ci
-```
-
-## Scripts
-
-While steps are usually used instead of shell scripts, sometimes a shell script is still needed.
-The `script` keyword will automatically select the correct shell and runs a script.
+For example, the following step returns outputs returned by the `random-generator`.
```yaml
-# Example job using script
-my-job:
- run:
- - name: greet_user
- script: echo hello $[[ GITLAB_USER_LOGIN ]]
+spec:
+ outputs: delegate
+---
+steps:
+ - name: random_generator
+ step: ./random-generator
+delegate: random-generator
+```
+
+#### Specify no inputs or outputs
+
+A step might not require any inputs or return any outputs. This could be when a step
+only writes to disk, sets an environment variable, or prints to STDOUT. In this case,
+`spec:` is empty:
+
+```yaml
+spec:
+---
+```
+
+### The step definition
+
+Steps can:
+
+- Set environment variables
+- Execute a command
+- Run a sequence of other steps.
+
+#### Set environment variables
+
+Set environment variables by using the `env` keyword. Environment variable names can only use
+alpha-numeric characters and underscores, and must not start with a number.
+
+Environment variables are made available either to the executable command or to all of the steps
+if running a sequence of steps. For example:
+
+```yaml
+spec:
+---
+env:
+ FIRST_NAME: Sally
+ LAST_NAME: Seashells
+steps:
+ # omitted for brevity
+```
+
+Steps only have access to a subset of environment variables from the runner environment.
+Follow [epic 15073](https://gitlab.com/groups/gitlab-org/-/epics/15073+) to track
+when steps can access all environment variables.
+
+#### Execute a command
+
+A step declares it executes a command by using the `exec` keyword. The command must be specified,
+but the working directory (`work_dir`) is optional. Environment variables set by the step
+are available to the running process.
+
+For example, the following step prints the step directory to the job log:
+
+```yaml
+spec:
+---
+exec:
+ work_dir: ${{step_dir}}
+ command:
+ - bash
+ - -c
+ - "echo ${PWD}"
```
NOTE:
-Only the `bash` shell is supported. Support for conditional expressions is proposed in [epic 12168](https://gitlab.com/groups/gitlab-org/-/epics/12168).
+Any dependency required by the executing step should also be installed by the step.
+For example, if a step calls `go`, it should first install it.
-## Actions
+##### Return an output
-You can run GitHub actions with the `action` keyword.
-Inputs and outputs work the same way as steps.
-Steps and actions can be used interchangably.
+Executable steps return an output by adding a line to the `${{output_file}}` in the format `name=value`,
+where the value is a JSON representation of the output type. The type of value written by the step
+must match the type of the output in the step specification.
+
+For example, to return the output named `car` with `string` value `range rover`:
```yaml
-# Example job using action
-my-job:
- run:
- - name: greet_user
- step: gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step@v1
- inputs:
- echo: hello $[[ GITLAB_USER_LOGIN ]]
- - name: greet_user_again
- action: mikefarah/yq@master
- inputs:
- cmd: echo ["${{ steps.greet_user.outputs.echo }} again!"] | yq .[0]
+spec:
+ outputs:
+ car:
+ type: string
+---
+exec:
+ command:
+ - bash
+ - -c
+ - echo car=\"range rover\" >>${{output_file}}
```
-### Known issues
+##### Export an environment variable
-Actions running in GitLab do not support uploading artifacts directly.
-Artifacts must be written to the file system and cache instead, and selected with the existing [`artifacts` keyword](../yaml/index.md#artifacts).
+Executable steps export an environment variable by adding a line to the `${{export_file}}`
+in the format `name=value`. Double quotation marks are not required around the value.
-Running actions requires the `dind` service.
-For more information, see [Use Docker to build Docker images](../docker/using_docker_build.md).
+For example, to set the variable `GOPATH` to value `/go`:
-Actions in GitLab are experimental and may contain bugs.
-To report a bug, create an issue in the [action-runner repo](https://gitlab.com/components/action-runner/-/issues).
+```yaml
+spec:
+---
+exec:
+ command:
+ - bash
+ - -c
+ - echo GOPATH=/go >${{export_file}}
+```
-## Expressions
+#### Run a sequence of steps
-Expressions is a mini-language enclosed in double curly-braces (`${{ }}`)
-They can reference `inputs`, `env` (the environment shared by steps) and the outputs of previous steps (`steps..outputs`).
+A step declares it runs a sequence of steps using the `steps` keyword. Steps run one at a time
+in the order they are defined in the list. This syntax is the same as the `run` keyword.
-Expressions can also reference `work_dir` which is the build directory.
-And `step_dir` where the step definition and associated files are cached.
-As well as `output_file` and `export_file` which is where outputs and exports are to be written.
+Steps must have a name consisting only of alpha-numeric characters and underscores, and must not start with a number.
-Expressions are different from template interpolation which uses double square-brackets (`$[[ ]]`) and is evaluated during job generation.
-Expressions are evaluated just before step execution in the job environment.
+For example, thisg step installs Go, then runs a second step that expects Go to already
+have been installed:
+
+```yaml
+spec:
+---
+steps:
+ - name: install_go
+ step: ./go-steps/install-go
+ inputs:
+ version: "1.22"
+ - name: format_go_code
+ step: ./go-steps/go-fmt
+ inputs:
+ code: path/to/go-code
+```
+
+##### Return an output
+
+Outputs are returned from a sequence of steps by using the `outputs` keyword.
+The type of value in the output must match the type of the output in the step specification.
+
+For example, the following step returns the installed Java version as an output.
+This assumes the `install_java` step returns an output named `java_version`.
+
+```yaml
+spec:
+ outputs:
+ java_version:
+ type: string
+---
+steps:
+ - name: install_java
+ step: ./common/install-java
+outputs:
+ java_version: "the java version is ${{steps.install_java.outputs.java_version}}"
+```
+
+Alternatively, all outputs of a sub-step can be returned using the `delegate` keyword.
+For example:
+
+```yaml
+spec:
+ outputs: delegate
+---
+steps:
+ - name: install_java
+ step: ./common/install-java
+delegate: install_java
+```
diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md
index 3783945908c..f9200631872 100644
--- a/doc/ci/yaml/index.md
+++ b/doc/ci/yaml/index.md
@@ -2345,8 +2345,10 @@ Every time the review app is deployed, that lifetime is also reset to `1 day`.
#### `environment:kubernetes`
-Use the `kubernetes` keyword to configure deployments to a
-[Kubernetes cluster](../../user/infrastructure/clusters/index.md) that is associated with your project.
+> - `agent` keyword [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/467912) in GitLab 17.6.
+
+Use the `kubernetes` keyword to configure the [dashboard for Kubernetes](../environments/kubernetes_dashboard.md)
+for an environment.
**Keyword type**: Job keyword. You can use it only as part of a job.
@@ -2359,21 +2361,19 @@ deploy:
environment:
name: production
kubernetes:
- namespace: production
+ agent: path/to/agent/project:agent-name
```
This configuration sets up the `deploy` job to deploy to the `production`
-environment, using the `production`
-[Kubernetes namespace](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/).
+environment, and associates the [agent](../../user/clusters/agent/index.md)
+named `agent-name` with the environment.
**Additional details**:
-- Kubernetes configuration is not supported for Kubernetes clusters
- [managed by GitLab](../../user/project/clusters/gitlab_managed_clusters.md).
-
-**Related topics**:
-
-- [Available settings for `kubernetes`](../environments/configure_kubernetes_deployments.md).
+- To use the dashboard, you must
+ [install the GitLab agent for Kubernetes](../../user/clusters/agent/install/index.md) and
+ [configure `user_access`](../../user/clusters/agent/user_access.md)
+ for the environment's project or its parent group.
#### `environment:deployment_tier`
diff --git a/lib/gitlab/ci/config/entry/kubernetes.rb b/lib/gitlab/ci/config/entry/kubernetes.rb
index 2f1595d4437..10cf4a16dc8 100644
--- a/lib/gitlab/ci/config/entry/kubernetes.rb
+++ b/lib/gitlab/ci/config/entry/kubernetes.rb
@@ -8,15 +8,16 @@ module Gitlab
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
- ALLOWED_KEYS = %i[namespace].freeze
+ ALLOWED_KEYS = %i[namespace agent].freeze
attributes ALLOWED_KEYS
validations do
- validates :config, type: Hash
+ validates :config, type: Hash, presence: true
validates :config, allowed_keys: ALLOWED_KEYS
- validates :namespace, type: String, presence: true
+ validates :namespace, type: String, allow_nil: true
+ validates :agent, type: String, allow_nil: true
end
end
end
diff --git a/scripts/internal_events/cli.rb b/scripts/internal_events/cli.rb
index e101216b8c0..ff2392e115c 100755
--- a/scripts/internal_events/cli.rb
+++ b/scripts/internal_events/cli.rb
@@ -10,10 +10,10 @@ require 'json_schemer'
require 'delegate'
require_relative './cli/helpers'
-require_relative './cli/usage_viewer'
-require_relative './cli/metric_definer'
-require_relative './cli/event_definer'
-require_relative './cli/flow_advisor'
+require_relative './cli/flows/usage_viewer'
+require_relative './cli/flows/metric_definer'
+require_relative './cli/flows/event_definer'
+require_relative './cli/flows/flow_advisor'
require_relative './cli/global_state'
require_relative './cli/metric'
require_relative './cli/event'
@@ -36,22 +36,22 @@ class Cli
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
+ "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
+ "ex) count unique users who assign labels to issues per month", :new_metric
menu.choice 'View Usage -- look at code and testing examples for existing events & metrics', :view_usage
menu.choice '...am I in the right place?', :help_decide
end
case task
when :new_event
- InternalEventsCli::EventDefiner.new(cli).run
+ InternalEventsCli::Flows::EventDefiner.new(cli).run
when :new_metric
- InternalEventsCli::MetricDefiner.new(cli).run
+ InternalEventsCli::Flows::MetricDefiner.new(cli).run
when :view_usage
- InternalEventsCli::UsageViewer.new(cli).run
+ InternalEventsCli::Flows::UsageViewer.new(cli).run
when :help_decide
- InternalEventsCli::FlowAdvisor.new(cli).run
+ InternalEventsCli::Flows::FlowAdvisor.new(cli).run
end
end
end
diff --git a/scripts/internal_events/cli/event_definer.rb b/scripts/internal_events/cli/event_definer.rb
deleted file mode 100755
index 0e5da5b35eb..00000000000
--- a/scripts/internal_events/cli/event_definer.rb
+++ /dev/null
@@ -1,280 +0,0 @@
-# frozen_string_literal: true
-
-require_relative './helpers'
-
-module InternalEventsCli
- class EventDefiner
- include Helpers
-
- SCHEMA = ::JSONSchemer.schema(Pathname('config/events/schema.json'))
- 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[feature_enabled_by_namespace_ids user] => 'Use case: For user actions attributable to multiple namespaces ' \
- '(ex - Code-Suggestions / Duo Pro)',
- %w[] => "Use case: For instance-level events without user interaction [LEAST COMMON]"
- }.freeze
-
- IDENTIFIER_FORMATTING_BUFFER = "[#{IDENTIFIER_OPTIONS.keys.map { |k| k.join(', ') }.max_by(&:length)}]".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_context
- prompt_for_url
- prompt_for_product_group
- 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 =~ NAME_REGEX && cli.global.events.map(&:action).none?(input) }
- q.modify :trim
- q.messages[:valid?] = format_warning("Invalid event name. Only lowercase/numbers/underscores allowed. " \
- "Ensure %{value} is not an existing event.")
- q.messages[:required?] = Text::EVENT_ACTION_HELP
- end
- end
-
- def prompt_for_context
- new_page!(3, 7, STEPS)
- cli.say format_prompt("EVENT CONTEXT #{counter(0, 2)}")
- prompt_for_identifiers
-
- new_page!(3, 7, STEPS) # Same "step" but increment counter
- cli.say format_prompt("EVENT CONTEXT #{counter(1, 2)}")
- prompt_for_additional_properties
- end
-
- def prompt_for_identifiers
- cli.say Text::EVENT_IDENTIFIERS_INTRO % event.action
-
- identifiers = prompt_for_array_selection(
- 'Which identifiers are available when the event occurs?',
- IDENTIFIER_OPTIONS.keys,
- per_page: IDENTIFIER_OPTIONS.length
- ) { |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_additional_properties
- cli.say Text::ADDITIONAL_PROPERTIES_INTRO
-
- available_props = [:label, :property, :value, :add_extra_prop]
-
- while available_props.any?
- disabled = format_help('(already defined)')
-
- # rubocop:disable Rails/NegateInclude -- this isn't Rails
- options = [
- { value: :none, name: 'None! Continue to next section!' },
- disableable_option(
- value: :label,
- name: 'String 1 (attribute will be named `label`)',
- disabled: disabled
- ) { !available_props.include?(:label) },
- disableable_option(
- value: :property,
- name: 'String 2 (attribute will be named `property`)',
- disabled: disabled
- ) { !available_props.include?(:property) },
- disableable_option(
- value: :value,
- name: 'Number (attribute will be named `value`)',
- disabled: disabled
- ) { !available_props.include?(:value) },
- disableable_option(
- value: :add_extra_prop,
- name: 'Add extra property (attribute will be named the input custom name)',
- disabled: format_warning('(option disabled - use label/property/value first)')
- ) do
- !((!available_props.include?(:label) &&
- !available_props.include?(:property)) ||
- !available_props.include?(:value))
- end
- ]
- # rubocop:enable Rails/NegateInclude
-
- selected_property = cli.select(
- "Which additional property do you want to add to the event?",
- options,
- help: format_help("(will reprompt for multiple)"),
- **select_opts,
- &disabled_format_callback
- )
-
- if selected_property == :none
- available_props.clear
- elsif selected_property == :add_extra_prop
- property_name = prompt_for_add_extra_properties
- property_description = prompt_for_text('Describe what the field will include:')
- assign_extra_properties(property_name, property_description)
- else
- available_props.delete(selected_property)
- property_description = prompt_for_text('Describe what the field will include:')
- assign_extra_properties(selected_property, property_description)
- end
- end
- end
-
- def assign_extra_properties(property, description = nil)
- event.additional_properties ||= {}
- event.additional_properties[property.to_s] = {
- 'description' => description || 'TODO'
- }
- end
-
- def prompt_for_add_extra_properties
- primary_props = %w[label property value]
-
- prompt_for_text('Define a name for the attribute:', **input_opts) do |q|
- q.required true
- q.validate ->(input) { input =~ NAME_REGEX && primary_props.none?(input) }
- q.modify :trim
- q.messages[:required?] = Text::ADDITIONAL_PROPERTIES_ADD_MORE_HELP
- q.messages[:valid?] = format_warning("Invalid property name. Only lowercase/numbers/underscores allowed. " \
- "Ensure %{value} is not one of `property, label, value`.")
- end
- 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_group
- new_page!(5, 7, STEPS)
-
- product_group = prompt_for_group_ownership('Which group will own the event?')
-
- event.product_group = product_group
- 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}
-
- Do you need to create a metric? Probably!
-
- Metrics are required to pull any usage data from self-managed instances or GitLab-Dedicated through Service Ping. Collected metric data can viewed in Tableau. Individual event details from GitLab.com can also be accessed through Snowflake.
-
- Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake
-
- TEXT
- end
-
- def prompt_for_next_steps
- next_step = cli.select("How would you like to proceed?", **select_opts) do |menu|
- menu.enum "."
-
- menu.choice "New Event -- define another event", :new_event
-
- choice = if File.exist?(event.file_path)
- ["Create Metric -- define a new metric using #{event.action}.yml", :add_metric]
- else
- ["Save & Create Metric -- save #{event.action}.yml and define a matching metric", :save_and_add]
- end
-
- menu.default choice[0]
- menu.choice(*choice)
-
- menu.choice "View Usage -- look at code examples for #{event.action}.yml", :view_usage
- menu.choice 'Exit', :exit
- end
-
- case next_step
- when :new_event
- InternalEventsCli::EventDefiner.new(cli).run
- 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/flow_advisor.rb b/scripts/internal_events/cli/flow_advisor.rb
deleted file mode 100644
index 5c8037aa684..00000000000
--- a/scripts/internal_events/cli/flow_advisor.rb
+++ /dev/null
@@ -1,89 +0,0 @@
-# frozen_string_literal: true
-
-require_relative './helpers'
-
-# Entrypoint for help flow, which directs the user to the
-# correct flow or documentation based on their goal
-module InternalEventsCli
- class FlowAdvisor
- include Helpers
-
- attr_reader :cli
-
- def initialize(cli)
- @cli = cli
- end
-
- def run
- 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
-
- private
-
- 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 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 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 Text::ALTERNATE_RESOURCES_NOTICE
- cli.say 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-15 min)\n")
-
- return not_ready_error('New Metric') unless cli.yes?(format_prompt('Ready to start?'))
-
- 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-10 min)\n")
-
- return not_ready_error('New Event') unless cli.yes?(format_prompt('Ready to start?'))
-
- 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 Text::FEEDBACK_NOTICE
- end
- end
-end
diff --git a/scripts/internal_events/cli/flows/event_definer.rb b/scripts/internal_events/cli/flows/event_definer.rb
new file mode 100644
index 00000000000..1a5bae24f24
--- /dev/null
+++ b/scripts/internal_events/cli/flows/event_definer.rb
@@ -0,0 +1,288 @@
+# frozen_string_literal: true
+
+require_relative '../helpers'
+
+# Entrypoint for flow to create an event definition file
+module InternalEventsCli
+ module Flows
+ class EventDefiner
+ include Helpers
+
+ SCHEMA = ::JSONSchemer.schema(Pathname('config/events/schema.json'))
+ 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[feature_enabled_by_namespace_ids user] =>
+ 'Use case: For user actions attributable to multiple namespaces (ex - Code-Suggestions / Duo Pro)',
+ %w[] =>
+ 'Use case: For instance-level events without user interaction [LEAST COMMON]'
+ }.freeze
+
+ IDENTIFIER_FORMATTING_BUFFER = "[#{IDENTIFIER_OPTIONS.keys.map { |k| k.join(', ') }.max_by(&:length)}]".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_context
+ prompt_for_url
+ prompt_for_product_group
+ 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 =~ NAME_REGEX && cli.global.events.map(&:action).none?(input) }
+ q.modify :trim
+ q.messages[:valid?] = format_warning(
+ "Invalid event name. Only lowercase/numbers/underscores allowed. " \
+ "Ensure %{value} is not an existing event.")
+ q.messages[:required?] = Text::EVENT_ACTION_HELP
+ end
+ end
+
+ def prompt_for_context
+ new_page!(3, 7, STEPS)
+ cli.say format_prompt("EVENT CONTEXT #{counter(0, 2)}")
+ prompt_for_identifiers
+
+ new_page!(3, 7, STEPS) # Same "step" but increment counter
+ cli.say format_prompt("EVENT CONTEXT #{counter(1, 2)}")
+ prompt_for_additional_properties
+ end
+
+ def prompt_for_identifiers
+ cli.say Text::EVENT_IDENTIFIERS_INTRO % event.action
+
+ identifiers = prompt_for_array_selection(
+ 'Which identifiers are available when the event occurs?',
+ IDENTIFIER_OPTIONS.keys,
+ per_page: IDENTIFIER_OPTIONS.length
+ ) { |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_additional_properties
+ cli.say Text::ADDITIONAL_PROPERTIES_INTRO
+
+ available_props = [:label, :property, :value, :add_extra_prop]
+
+ while available_props.any?
+ disabled = format_help('(already defined)')
+
+ # rubocop:disable Rails/NegateInclude -- this isn't Rails
+ options = [
+ { value: :none, name: 'None! Continue to next section!' },
+ disableable_option(
+ value: :label,
+ name: 'String 1 (attribute will be named `label`)',
+ disabled: disabled
+ ) { !available_props.include?(:label) },
+ disableable_option(
+ value: :property,
+ name: 'String 2 (attribute will be named `property`)',
+ disabled: disabled
+ ) { !available_props.include?(:property) },
+ disableable_option(
+ value: :value,
+ name: 'Number (attribute will be named `value`)',
+ disabled: disabled
+ ) { !available_props.include?(:value) },
+ disableable_option(
+ value: :add_extra_prop,
+ name: 'Add extra property (attribute will be named the input custom name)',
+ disabled: format_warning('(option disabled - use label/property/value first)')
+ ) do
+ !((!available_props.include?(:label) &&
+ !available_props.include?(:property)) ||
+ !available_props.include?(:value))
+ end
+ ]
+ # rubocop:enable Rails/NegateInclude
+
+ selected_property = cli.select(
+ "Which additional property do you want to add to the event?",
+ options,
+ help: format_help("(will reprompt for multiple)"),
+ **select_opts,
+ &disabled_format_callback
+ )
+
+ if selected_property == :none
+ available_props.clear
+ elsif selected_property == :add_extra_prop
+ property_name = prompt_for_add_extra_properties
+ property_description = prompt_for_text('Describe what the field will include:')
+ assign_extra_properties(property_name, property_description)
+ else
+ available_props.delete(selected_property)
+ property_description = prompt_for_text('Describe what the field will include:')
+ assign_extra_properties(selected_property, property_description)
+ end
+ end
+ end
+
+ def assign_extra_properties(property, description = nil)
+ event.additional_properties ||= {}
+ event.additional_properties[property.to_s] = {
+ 'description' => description || 'TODO'
+ }
+ end
+
+ def prompt_for_add_extra_properties
+ primary_props = %w[label property value]
+
+ prompt_for_text('Define a name for the attribute:', **input_opts) do |q|
+ q.required true
+ q.validate ->(input) { input =~ NAME_REGEX && primary_props.none?(input) }
+ q.modify :trim
+ q.messages[:required?] = Text::ADDITIONAL_PROPERTIES_ADD_MORE_HELP
+ q.messages[:valid?] = format_warning(
+ "Invalid property name. Only lowercase/numbers/underscores allowed. " \
+ "Ensure %{value} is not one of `property, label, value`.")
+ end
+ 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_group
+ new_page!(5, 7, STEPS)
+
+ product_group = prompt_for_group_ownership('Which group will own the event?')
+
+ event.product_group = product_group
+ 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}
+
+ Do you need to create a metric? Probably!
+
+ Metrics are required to pull any usage data from self-managed instances or GitLab-Dedicated through Service Ping. Collected metric data can viewed in Tableau. Individual event details from GitLab.com can also be accessed through Snowflake.
+
+ Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake
+
+ TEXT
+ end
+
+ def prompt_for_next_steps
+ next_step = cli.select("How would you like to proceed?", **select_opts) do |menu|
+ menu.enum "."
+
+ menu.choice "New Event -- define another event", :new_event
+
+ choice = if File.exist?(event.file_path)
+ ["Create Metric -- define a new metric using #{event.action}.yml", :add_metric]
+ else
+ ["Save & Create Metric -- save #{event.action}.yml and define a matching metric", :save_and_add]
+ end
+
+ menu.default choice[0]
+ menu.choice(*choice)
+
+ menu.choice "View Usage -- look at code examples for #{event.action}.yml", :view_usage
+ menu.choice 'Exit', :exit
+ end
+
+ case next_step
+ when :new_event
+ EventDefiner.new(cli).run
+ 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
+end
diff --git a/scripts/internal_events/cli/flows/flow_advisor.rb b/scripts/internal_events/cli/flows/flow_advisor.rb
new file mode 100755
index 00000000000..79435a08af5
--- /dev/null
+++ b/scripts/internal_events/cli/flows/flow_advisor.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require_relative '../helpers'
+
+# Entrypoint for help flow, which directs the user to the
+# correct flow or documentation based on their goal
+module InternalEventsCli
+ module Flows
+ class FlowAdvisor
+ include Helpers
+
+ attr_reader :cli
+
+ def initialize(cli)
+ @cli = cli
+ end
+
+ def run
+ 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
+
+ private
+
+ 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 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 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 Text::ALTERNATE_RESOURCES_NOTICE
+ cli.say 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-15 min)\n")
+
+ return not_ready_error('New Metric') unless cli.yes?(format_prompt('Ready to start?'))
+
+ 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-10 min)\n")
+
+ return not_ready_error('New Event') unless cli.yes?(format_prompt('Ready to start?'))
+
+ 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 Text::FEEDBACK_NOTICE
+ end
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/flows/metric_definer.rb b/scripts/internal_events/cli/flows/metric_definer.rb
new file mode 100755
index 00000000000..17ea14772d0
--- /dev/null
+++ b/scripts/internal_events/cli/flows/metric_definer.rb
@@ -0,0 +1,574 @@
+# frozen_string_literal: true
+
+require_relative '../helpers'
+require_relative '../text'
+
+# Entrypoint for flow to create an metric definition file
+module InternalEventsCli
+ module Flows
+ class MetricDefiner
+ include Helpers
+
+ SCHEMA = ::JSONSchemer.schema(Pathname('config/metrics/schema/base.json'))
+ STEPS = [
+ 'New Metric',
+ 'Type',
+ 'Events',
+ 'Scope',
+ 'Descriptions',
+ 'Copy event',
+ 'Group',
+ 'URL',
+ 'Tiers',
+ 'Save files'
+ ].freeze
+
+ NAME_REQUIREMENT_REASONS = {
+ filters: {
+ text: 'Metrics using filters are too complex for default naming.',
+ help: Text::METRIC_NAME_FILTER_HELP
+ },
+ length: {
+ text: 'The default filename will be too long.',
+ help: Text::METRIC_NAME_LENGTH_HELP
+ },
+ conflict: {
+ text: 'The default key path is already in use.',
+ help: Text::METRIC_NAME_CONFLICT_HELP
+ }
+ }.freeze
+
+ attr_reader :cli
+
+ def initialize(cli, starting_event = nil)
+ @cli = cli
+ @selected_event_paths = Array(starting_event)
+ @metrics = []
+ @selected_filters = {}
+ end
+
+ def run
+ type = prompt_for_metric_type
+ prompt_for_events(type)
+
+ return unless @selected_event_paths.any?
+
+ prompt_for_metrics
+ prompt_for_event_filters
+
+ return unless @metrics.any?
+
+ prompt_for_descriptions
+ defaults = prompt_for_copying_event_properties
+ prompt_for_product_group(defaults)
+ prompt_for_url(defaults)
+ prompt_for_tier(defaults)
+ outcomes = create_metric_files
+ prompt_for_next_steps(outcomes)
+ end
+
+ private
+
+ # ----- Memoization Helpers -----------------
+
+ def events
+ @events ||= events_by_filepath(@selected_event_paths)
+ end
+
+ def selected_events
+ @selected_events ||= events.values_at(*@selected_event_paths)
+ end
+
+ # ----- Prompts -----------------------------
+
+ 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)
+ cli.say format_info('SELECTED EVENTS')
+ cli.say selected_events_filter_options.join
+ cli.say "\n"
+
+ @metrics = cli.select(
+ 'Which metrics do you want to add?',
+ eligible_metrics,
+ **select_opts,
+ **filter_opts,
+ per_page: 20,
+ &disabled_format_callback
+ )
+
+ assign_shared_attrs(:actions, :milestone) do
+ {
+ actions: selected_events.map(&:action).sort,
+ milestone: MILESTONE
+ }
+ end
+ end
+
+ def prompt_for_event_filters
+ return if @metrics.none?(&:filters_expected?)
+
+ selected_unique_identifier = @metrics.first.identifier.value
+ event_count = selected_events.length
+ previous_inputs = {
+ 'label' => nil,
+ 'property' => nil,
+ 'value' => nil
+ }
+
+ event_filters = selected_events.dup.flat_map.with_index do |event, idx|
+ print_event_filter_header(event, idx, event_count)
+
+ next if deselect_nonfilterable_event?(event) # prompts user
+
+ filter_values = event.additional_properties&.filter_map do |property, _|
+ next if selected_unique_identifier == property
+
+ prompt_for_property_filter(
+ event.action,
+ property,
+ previous_inputs[property]
+ )
+ end
+
+ previous_inputs.merge!(@selected_filters[event.action] || {})
+
+ find_filter_permutations(event.action, filter_values)
+ end.compact
+
+ bulk_assign(filters: event_filters)
+ end
+
+ def prompt_for_descriptions
+ default_description = nil
+ default_key = nil
+
+ separate_page_per_metric = @metrics.any? do |metric|
+ name_requirement_reason(metric)
+ end
+
+ @metrics.each_with_index do |metric, idx|
+ if idx == 0 || separate_page_per_metric
+ new_page!(4, 9, STEPS)
+
+ cli.say Text::METRIC_DESCRIPTION_INTRO
+ cli.say selected_event_descriptions.join
+ end
+
+ cli.say "\n"
+ cli.say format_prompt(format_subheader(
+ 'DESCRIBING METRIC',
+ metric.technical_description,
+ idx,
+ @metrics.length
+ ))
+
+ prompt_for_description(metric, default_description).tap do |description|
+ default_description = description
+ metric.description = "#{metric.description_prefix} #{description}"
+ end
+
+ prompt_for_metric_name(metric, default_key)&.tap do |key|
+ default_key = key
+ metric.key = key
+ end
+ end
+ end
+
+ def file_saved_context_message(attributes)
+ format_prefix " ", <<~TEXT.chomp
+ - Visit #{format_info('https://metrics.gitlab.com')} to find dashboard links for this metric
+ - Metric trend dashboard: #{format_info(metric_trend_path(attributes['key_path']))}
+ TEXT
+ end
+
+ # Check existing event files for attributes to copy over
+ def prompt_for_copying_event_properties
+ shared_values = collect_values_for_shared_event_properties
+ defaults = shared_values.except(:stage, :section)
+
+ return {} if shared_values.none?
+
+ return shared_values 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
+
+ shared_values
+ end
+
+ def prompt_for_product_group(defaults)
+ assign_shared_attr(:product_group) do
+ new_page!(6, 9, STEPS)
+
+ prompt_for_group_ownership('Which group owns the metric?', defaults)
+ 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(:tiers) { |metric| [*metric.tier] }
+
+ 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(format_subheader('SAVING FILE', metric.description, idx, @metrics.length))
+ cli.say "\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.'
+ metric = @metrics.first
+
+ cli.say <<~TEXT
+ #{divider}
+ #{format_info('Done with metric definitions!')}
+
+ #{outcome}
+ #{divider}
+
+ Have you instrumented the application code to trigger the event yet? View usage examples to easily copy/paste implementation!
+
+ Want to verify the metrics? Check out the group::#{metric[:product_group]} Metrics Exploration Dashboard in Tableau
+ Note: The Metrics Exploration Dashboard data would be available ~1 week after deploy for Gitlab.com, ~1 week after next release for self-managed
+ Link: #{format_info(metric_exploration_group_path(metric[:product_group], find_stage(metric.product_group)))}
+
+ Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake
+
+ TEXT
+
+ actions = selected_events.map(&:action).join(', ')
+ next_step = cli.select("How would you like to proceed?", **select_opts) do |menu|
+ menu.enum "."
+
+ menu.choice "New Event -- define a new event", :new_event
+ menu.choice "New Metric -- define another metric for #{actions}", :new_metric_with_events
+ menu.choice "New Metric -- define another metric", :new_metric
+ choice = "View Usage -- look at code examples for event #{selected_events.first.action}"
+ menu.default choice
+ menu.choice choice, :view_usage
+ menu.choice 'Exit', :exit
+ end
+
+ case next_step
+ when :new_event
+ EventDefiner.new(cli).run
+ when :new_metric_with_events
+ MetricDefiner.new(cli, @selected_event_paths).run
+ when :new_metric
+ MetricDefiner.new(cli).run
+ when :view_usage
+ UsageViewer.new(cli, @selected_event_paths.first, selected_events.first).run
+ when :exit
+ cli.say Text::FEEDBACK_NOTICE
+ end
+ end
+
+ # ----- Prompt-specific Helpers -------------
+
+ # Helper for #prompt_for_metrics
+ def selected_events_filter_options
+ filterable_events_selected = selected_events.any? { |event| event.additional_properties&.any? }
+
+ selected_events.map do |event|
+ filters = event.additional_properties&.keys
+ filter_phrase = if filters
+ " (filterable by #{filters&.join(', ')})"
+ elsif filterable_events_selected
+ ' -- not filterable'
+ end
+
+ " - #{event.action}#{format_help(filter_phrase)}\n"
+ end
+ end
+
+ # Helper for #prompt_for_event_filters
+ def print_event_filter_header(event, idx, total)
+ cli.say "\n"
+ cli.say format_info(format_subheader('SETTING EVENT FILTERS', event.action, idx, total))
+
+ return unless event.additional_properties&.any?
+
+ event_filter_options = event.additional_properties.map do |property, attrs|
+ " #{property}: #{attrs['description']}\n"
+ end
+
+ cli.say event_filter_options.join
+ end
+
+ # Helper for #prompt_for_event_filters
+ def deselect_nonfilterable_event?(event)
+ cli.say "\n"
+
+ return false if event.additional_properties&.any?
+ return false if cli.yes?("This event is not filterable. Should it be included in the metric?", **yes_no_opts)
+
+ selected_events.delete(event)
+ bulk_assign(actions: selected_events.map(&:action).sort)
+
+ true
+ end
+
+ # Helper for #prompt_for_event_filters
+ def prompt_for_property_filter(action, property, default)
+ formatted_prop = format_info(property)
+ prompt = "Count where #{formatted_prop} equals any of (comma-sep):"
+
+ inputs = prompt_for_text(prompt, default, **input_opts) do |q|
+ if property == 'value'
+ q.convert ->(input) { input.split(',').map(&:to_i).uniq }
+ q.validate %r{^(\d|\s|,)*$}
+ q.messages[:valid?] = "Inputs for #{formatted_prop} must be numeric"
+ else
+ q.convert ->(input) { input.split(',').map(&:strip).uniq }
+ end
+ end
+
+ return unless inputs&.any?
+
+ @selected_filters[action] ||= {}
+ @selected_filters[action][property] = inputs.join(',')
+
+ inputs.map { |input| { property => input } }.uniq
+ end
+
+ # Helper for #prompt_for_event_filters
+ #
+ # Gets all the permutations of the provided property values.
+ # @param filters [Array] ex) [{ 'label' => 'red' }, { 'label' => 'blue' }, { value => 16 }]
+ # @return ex) [{ 'label' => 'red', value => 16 }, { 'label' => 'blue', value => 16 }]
+ def find_filter_permutations(action, filters)
+ # Define a filter for all events, regardless of the available props so NewMetric#events is correct
+ return [[action, {}]] unless filters&.any?
+
+ # Uses proc syntax to avoid spliting & type-checking `filters`
+ :product.to_proc.call(*filters).map do |filter|
+ [action, filter.reduce(&:merge)]
+ end
+ end
+
+ # Helper for #prompt_for_descriptions
+ def selected_event_descriptions
+ selected_events.map do |event|
+ filters = @selected_filters[event.action]
+
+ if filters&.any?
+ filter_phrase = filters.map { |k, v| "#{k}=#{v}" }.join(' ')
+ filter_phrase = format_help("(#{filter_phrase})")
+ end
+
+ " #{event.action}#{filter_phrase} - #{format_selection(event.description)}\n"
+ end
+ end
+
+ # Helper for #prompt_for_descriptions
+ def prompt_for_description(metric, default)
+ description_start = format_info("#{metric.description_prefix}...")
+
+ cli.say <<~TEXT
+
+ #{input_opts[:prefix]} How would you describe this metric to a non-technical person? #{input_required_text}
+
+ TEXT
+
+ prompt_for_text(" Finish the description: #{description_start}", default, multiline: true) do |q|
+ q.required true
+ q.modify :trim
+ q.messages[:required?] = Text::METRIC_DESCRIPTION_HELP
+ end
+ end
+
+ # Helper for #prompt_for_descriptions
+ def prompt_for_metric_name(metric, default)
+ name_reason = name_requirement_reason(metric)
+ default_name = metric.key.value
+ display_name = metric.key.value("\e[0m[REPLACE ME]\e[36m")
+ empty_name = metric.key.value('')
+
+ return unless name_reason
+
+ cli.say <<~TEXT
+
+ #{input_opts[:prefix]} #{name_reason[:text]} How should we refererence this metric? #{input_required_text}
+
+ ID: #{format_info(display_name)}
+ Filename: #{format_info(display_name)}#{format_info('.yml')}
+
+ TEXT
+
+ max_length = MAX_FILENAME_LENGTH - "#{empty_name}.yml".length
+ help_tokens = { name: default_name, count: max_length }
+
+ prompt_for_text(' Replace with: ', default, multiline: true) do |q|
+ q.required true
+ q.messages[:required?] = name_reason[:help] % help_tokens
+ q.messages[:valid?] = Text::METRIC_NAME_ERROR % help_tokens
+ q.validate ->(input) do
+ input.length <= max_length &&
+ input.match?(NAME_REGEX) &&
+ !conflicting_key_path?(metric.key.value(input))
+ end
+ end
+ end
+
+ # Helper for #prompt_for_descriptions
+ def name_requirement_reason(metric)
+ if metric.filters.assigned?
+ NAME_REQUIREMENT_REASONS[:filters]
+ elsif metric.file_name.length > MAX_FILENAME_LENGTH
+ NAME_REQUIREMENT_REASONS[:length]
+ elsif conflicting_key_path?(metric.key_path)
+ NAME_REQUIREMENT_REASONS[:conflict]
+ end
+ end
+
+ # Helper for #prompt_for_descriptions
+ def conflicting_key_path?(key_path)
+ cli.global.metrics.any? do |existing_metric|
+ existing_metric.key_path == key_path
+ end
+ end
+
+ # Helper for #prompt_for_copying_event_properties
+ 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_group] << event.product_group
+ fields[:stage] << find_stage(event.product_group)
+ fields[:section] << find_section(event.product_group)
+ fields[:distribution] << event.distributions&.sort
+ fields[:tier] << event.tiers&.sort
+ fields[:tiers] << 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
+
+ # ----- Shared Helpers ----------------------
+
+ 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
+end
diff --git a/scripts/internal_events/cli/flows/usage_viewer.rb b/scripts/internal_events/cli/flows/usage_viewer.rb
new file mode 100755
index 00000000000..1b84769a515
--- /dev/null
+++ b/scripts/internal_events/cli/flows/usage_viewer.rb
@@ -0,0 +1,438 @@
+# frozen_string_literal: true
+
+require_relative '../helpers'
+
+# Entrypoint for flow to print examples of how to trigger an
+# event in different languages & different methods of testing
+module InternalEventsCli
+ module Flows
+ class UsageViewer
+ include Helpers
+
+ PROPERTY_EXAMPLES = {
+ 'label' => "'string'",
+ 'property' => "'string'",
+ 'value' => '72',
+ 'custom_key' => 'custom_value'
+ }.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 = '1. ruby/rails')
+ choices = [
+ { name: '1. ruby/rails', value: :rails },
+ { name: '2. rspec', value: :rspec },
+ { name: '3. javascript (vue)', value: :vue },
+ { name: '4. javascript (plain)', value: :js },
+ { name: '5. vue template', value: :vue_template },
+ { name: '6. haml', value: :haml },
+ { name: '7. Manual testing in GDK', value: :gdk },
+ { name: '8. Data verification in Tableau', value: :tableau },
+ { name: '9. View examples for a different event', value: :other_event },
+ { name: '10. Exit', value: :exit }
+ ]
+
+ usage_location = cli.select(
+ 'Select a use-case to view examples for:',
+ choices,
+ **select_opts,
+ **filter_opts,
+ per_page: 10
+ ) do |menu|
+ menu.default default
+ end
+
+ case usage_location
+ when :rails
+ rails_examples
+ prompt_for_usage_location('1. ruby/rails')
+ when :rspec
+ rspec_examples
+ prompt_for_usage_location('2. rspec')
+ when :haml
+ haml_examples
+ prompt_for_usage_location('6. haml')
+ when :js
+ js_examples
+ prompt_for_usage_location('4. javascript (plain)')
+ when :vue
+ vue_examples
+ prompt_for_usage_location('3. javascript (vue)')
+ when :vue_template
+ vue_template_examples
+ prompt_for_usage_location('5. vue template')
+ when :gdk
+ gdk_examples
+ prompt_for_usage_location('7. Manual testing in GDK')
+ when :tableau
+ service_ping_dashboard_examples
+ prompt_for_usage_location('8. Data verification in Tableau')
+ when :other_event
+ self.class.new(cli).run
+ when :exit
+ cli.say(Text::FEEDBACK_NOTICE)
+ end
+ end
+
+ def rails_examples
+ identifier_args = identifiers.map do |identifier|
+ " #{identifier}: #{identifier}"
+ end
+
+ property_args = format_additional_properties do |property, value, description|
+ " #{property}: #{value}, # #{description}"
+ end
+
+ if property_args.any?
+ # remove trailing comma after last arg but keep any other commas
+ property_args.last.sub!(',', '')
+ property_arg = " additional_properties: {\n#{property_args.join("\n")}\n }"
+ end
+
+ args = ["'#{action}'", *identifier_args, property_arg].compact.join(",\n")
+ args = "\n #{args}\n" if args.lines.count > 1
+
+ cli.say format_warning <<~TEXT
+ #{divider}
+ #{format_help('# RAILS')}
+
+ include Gitlab::InternalEventsTracking
+
+ track_internal_event(#{args})
+
+ #{divider}
+ TEXT
+ end
+
+ def rspec_examples
+ identifier_args = identifiers.map do |identifier|
+ " let(:#{identifier}) { create(:#{identifier}) }\n"
+ end.join('')
+
+ property_args = format_additional_properties do |property, value|
+ " #{property}: #{value}"
+ end
+
+ if property_args.any?
+ property_arg = format_prefix ' ', <<~TEXT
+ let(:additional_properties) do
+ {
+ #{property_args.join(",\n")}
+ }
+ end
+ TEXT
+ end
+
+ args = [*identifier_args, *property_arg].join('')
+
+ cli.say format_warning <<~TEXT
+ #{divider}
+ #{format_help('# RSPEC')}
+
+ it_behaves_like 'internal event tracking' do
+ let(:event) { '#{action}' }
+ #{args}end
+
+ #{divider}
+ TEXT
+ end
+
+ def haml_examples
+ property_args = format_additional_properties do |property, value, _|
+ "event_#{property}: #{value}"
+ end
+
+ args = ["event_tracking: '#{action}'", *property_args].join(', ')
+
+ cli.say <<~TEXT
+ #{divider}
+ #{format_help('# HAML -- ON-CLICK')}
+
+ .inline-block{ #{format_warning("data: { #{args} }")} }
+ = _('Important Text')
+
+ #{divider}
+ #{format_help('# HAML -- COMPONENT ON-CLICK')}
+
+ = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { #{args} }")} })
+
+ #{divider}
+ #{format_help('# HAML -- COMPONENT ON-LOAD')}
+
+ = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking_load: true, #{args} }")} })
+
+ #{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
+ on_click_args = template_formatted_args('data-event-tracking', indent: 2)
+ on_load_args = template_formatted_args('data-event-tracking-load', indent: 2)
+
+ cli.say <<~TEXT
+ #{divider}
+ #{format_help('// VUE TEMPLATE -- ON-CLICK')}
+
+
+
+
+
+
+
+ #{divider}
+ #{format_help('// VUE TEMPLATE -- ON-LOAD')}
+
+
+
+
+
+
+
+ #{divider}
+ TEXT
+
+ cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
+ end
+
+ def js_examples
+ args = js_formatted_args(indent: 2)
+
+ cli.say <<~TEXT
+ #{divider}
+ #{format_help('// FRONTEND -- RAW JAVASCRIPT')}
+
+ #{format_warning("import { InternalEvents } from '~/tracking';")}
+
+ export const performAction = () => {
+ #{format_warning("InternalEvents.trackEvent#{args}")}
+
+ 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
+ args = js_formatted_args(indent: 6)
+
+ cli.say <<~TEXT
+ #{divider}
+ #{format_help('// VUE')}
+
+
+
+
+ Click Me
+
+
+ #{divider}
+ TEXT
+
+ cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
+ end
+
+ private
+
+ def action
+ event['action']
+ end
+
+ def identifiers
+ Array(event['identifiers']).tap do |ids|
+ # We always auto assign namespace if project is provided
+ ids.delete('namespace') if ids.include?('project')
+ end
+ end
+
+ def additional_properties
+ Array(event['additional_properties'])
+ end
+
+ def format_additional_properties
+ additional_properties.map do |property, details|
+ example_value = PROPERTY_EXAMPLES[property]
+ description = details['description'] || 'TODO'
+
+ yield(property, example_value, description)
+ end
+ end
+
+ def js_formatted_args(indent:)
+ return "('#{action}');" if additional_properties.none?
+
+ property_args = format_additional_properties do |property, value, description|
+ " #{property}: #{value}, // #{description}"
+ end
+
+ [
+ '(',
+ " '#{action}',",
+ ' {',
+ *property_args,
+ ' },',
+ ');'
+ ].join("\n#{' ' * indent}")
+ end
+
+ def service_ping_metrics_info
+ product_group = related_metrics.map(&:product_group).uniq
+
+ <<~TEXT
+ #{product_group.map { |group| "#{group}: #{format_info(metric_exploration_group_path(group, find_stage(group)))}" }.join("\n")}
+
+ #{divider}
+ #{format_help("# METRIC TRENDS -- view data for a service ping metric for #{event.action}")}
+
+ #{related_metrics.map { |metric| "#{metric.key_path}: #{format_info(metric_trend_path(metric.key_path))}" }.join("\n")}
+ TEXT
+ end
+
+ def service_ping_no_metric_info
+ <<~TEXT
+ #{format_help("# Warning: There are no metrics for #{event.action} yet.")}
+ #{event.product_group}: #{format_info(metric_exploration_group_path(event.product_group, find_stage(event.product_group)))}
+ TEXT
+ end
+
+ def template_formatted_args(data_attr, indent:)
+ return " #{data_attr}=\"#{action}\">" if additional_properties.none?
+
+ spacer = ' ' * indent
+ property_args = format_additional_properties do |property, value, _|
+ " data-event-#{property}=#{value.tr("'", '"')}"
+ end
+
+ args = [
+ '', # start args on next line
+ " #{data_attr}=\"#{action}\"",
+ *property_args
+ ].join("\n#{spacer}")
+
+ "#{format_warning(args)}\n#{spacer}>"
+ end
+
+ def related_metrics
+ cli.global.metrics.select { |metric| metric.actions&.include?(event.action) }
+ end
+
+ def service_ping_dashboard_examples
+ cli.say <<~TEXT
+ #{divider}
+ #{format_help('# GROUP DASHBOARDS -- view all service ping metrics for a specific group')}
+
+ #{related_metrics.any? ? service_ping_metrics_info : service_ping_no_metric_info}
+ #{divider}
+ Note: The metric dashboard links can also be accessed from #{format_info('https://metrics.gitlab.com/')}
+
+ Not what you're looking for? Check this doc:
+ - #{format_info('https://docs.gitlab.com/ee/development/internal_analytics/#data-discovery')}
+
+ TEXT
+ end
+
+ def gdk_examples
+ key_paths = related_metrics.map(&:key_path)
+
+ cli.say <<~TEXT
+ #{divider}
+ #{format_help('# TERMINAL -- monitor events & changes to service ping metrics as they occur')}
+
+ 1. From `gitlab/` directory, run the monitor script:
+
+ #{format_warning("bin/rails runner scripts/internal_events/monitor.rb #{event.action}")}
+
+ 2. View metric updates within the terminal
+
+ 3. [Optional] Configure gdk with snowplow micro to see individual events: https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/howto/snowplow_micro.md
+
+ #{divider}
+ #{format_help('# RAILS CONSOLE -- generate service ping payload, including most recent usage data')}
+
+ #{format_warning("require_relative 'spec/support/helpers/service_ping_helpers.rb'")}
+
+ #{format_help('# Get current value of a metric')}
+ #{
+ if key_paths.any?
+ key_paths.map { |key_path| format_warning("ServicePingHelpers.get_current_usage_metric_value('#{key_path}')") }.join("\n")
+ else
+ format_help("# Warning: There are no metrics for #{event.action} yet. When there are, replace below.\n") +
+ format_warning('ServicePingHelpers.get_current_usage_metric_value()')
+ end
+ }
+
+ #{format_help('# View entire service ping payload')}
+ #{format_warning('ServicePingHelpers.get_current_service_ping_payload')}
+ #{divider}
+ Need to test something else? Check these docs:
+ - https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/local_setup_and_debugging.html
+ - https://docs.gitlab.com/ee/development/internal_analytics/service_ping/troubleshooting.html
+ - https://docs.gitlab.com/ee/development/internal_analytics/review_guidelines.html
+
+ TEXT
+ end
+ end
+ end
+end
diff --git a/scripts/internal_events/cli/metric_definer.rb b/scripts/internal_events/cli/metric_definer.rb
deleted file mode 100755
index f8e9d3f090b..00000000000
--- a/scripts/internal_events/cli/metric_definer.rb
+++ /dev/null
@@ -1,568 +0,0 @@
-# frozen_string_literal: true
-
-require_relative './helpers'
-require_relative './text'
-
-module InternalEventsCli
- class MetricDefiner
- include Helpers
-
- SCHEMA = ::JSONSchemer.schema(Pathname('config/metrics/schema/base.json'))
- STEPS = [
- 'New Metric',
- 'Type',
- 'Events',
- 'Scope',
- 'Descriptions',
- 'Copy event',
- 'Group',
- 'URL',
- 'Tiers',
- 'Save files'
- ].freeze
-
- NAME_REQUIREMENT_REASONS = {
- filters: {
- text: 'Metrics using filters are too complex for default naming.',
- help: Text::METRIC_NAME_FILTER_HELP
- },
- length: {
- text: 'The default filename will be too long.',
- help: Text::METRIC_NAME_LENGTH_HELP
- },
- conflict: {
- text: 'The default key path is already in use.',
- help: Text::METRIC_NAME_CONFLICT_HELP
- }
- }.freeze
-
- attr_reader :cli
-
- def initialize(cli, starting_event = nil)
- @cli = cli
- @selected_event_paths = Array(starting_event)
- @metrics = []
- @selected_filters = {}
- end
-
- def run
- type = prompt_for_metric_type
- prompt_for_events(type)
-
- return unless @selected_event_paths.any?
-
- prompt_for_metrics
- prompt_for_event_filters
-
- return unless @metrics.any?
-
- prompt_for_descriptions
- defaults = prompt_for_copying_event_properties
- prompt_for_product_group(defaults)
- prompt_for_url(defaults)
- prompt_for_tier(defaults)
- outcomes = create_metric_files
- prompt_for_next_steps(outcomes)
- end
-
- private
-
- # ----- Memoization Helpers -----------------
-
- def events
- @events ||= events_by_filepath(@selected_event_paths)
- end
-
- def selected_events
- @selected_events ||= events.values_at(*@selected_event_paths)
- end
-
- # ----- Prompts -----------------------------
-
- 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)
- cli.say format_info('SELECTED EVENTS')
- cli.say selected_events_filter_options.join
- cli.say "\n"
-
- @metrics = cli.select(
- 'Which metrics do you want to add?',
- eligible_metrics,
- **select_opts,
- **filter_opts,
- per_page: 20,
- &disabled_format_callback
- )
-
- assign_shared_attrs(:actions, :milestone) do
- {
- actions: selected_events.map(&:action).sort,
- milestone: MILESTONE
- }
- end
- end
-
- def prompt_for_event_filters
- return if @metrics.none?(&:filters_expected?)
-
- selected_unique_identifier = @metrics.first.identifier.value
- event_count = selected_events.length
- previous_inputs = {
- 'label' => nil,
- 'property' => nil,
- 'value' => nil
- }
-
- event_filters = selected_events.dup.flat_map.with_index do |event, idx|
- print_event_filter_header(event, idx, event_count)
-
- next if deselect_nonfilterable_event?(event) # prompts user
-
- filter_values = event.additional_properties&.filter_map do |property, _|
- next if selected_unique_identifier == property
-
- prompt_for_property_filter(
- event.action,
- property,
- previous_inputs[property]
- )
- end
-
- previous_inputs.merge!(@selected_filters[event.action] || {})
-
- find_filter_permutations(event.action, filter_values)
- end.compact
-
- bulk_assign(filters: event_filters)
- end
-
- def prompt_for_descriptions
- default_description = nil
- default_key = nil
-
- separate_page_per_metric = @metrics.any? do |metric|
- name_requirement_reason(metric)
- end
-
- @metrics.each_with_index do |metric, idx|
- if idx == 0 || separate_page_per_metric
- new_page!(4, 9, STEPS)
-
- cli.say Text::METRIC_DESCRIPTION_INTRO
- cli.say selected_event_descriptions.join
- end
-
- cli.say "\n"
- cli.say format_prompt(format_subheader(
- 'DESCRIBING METRIC',
- metric.technical_description,
- idx,
- @metrics.length
- ))
-
- prompt_for_description(metric, default_description).tap do |description|
- default_description = description
- metric.description = "#{metric.description_prefix} #{description}"
- end
-
- prompt_for_metric_name(metric, default_key)&.tap do |key|
- default_key = key
- metric.key = key
- end
- end
- end
-
- def file_saved_context_message(attributes)
- format_prefix " ", <<~TEXT.chomp
- - Visit #{format_info('https://metrics.gitlab.com')} to find dashboard links for this metric
- - Metric trend dashboard: #{format_info(metric_trend_path(attributes['key_path']))}
- TEXT
- end
-
- # Check existing event files for attributes to copy over
- def prompt_for_copying_event_properties
- shared_values = collect_values_for_shared_event_properties
- defaults = shared_values.except(:stage, :section)
-
- return {} if shared_values.none?
-
- return shared_values 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
-
- shared_values
- end
-
- def prompt_for_product_group(defaults)
- assign_shared_attr(:product_group) do
- new_page!(6, 9, STEPS)
-
- prompt_for_group_ownership('Which group owns the metric?', defaults)
- 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(:tiers) { |metric| [*metric.tier] }
-
- 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(format_subheader('SAVING FILE', metric.description, idx, @metrics.length))
- cli.say "\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.'
- metric = @metrics.first
-
- cli.say <<~TEXT
- #{divider}
- #{format_info('Done with metric definitions!')}
-
- #{outcome}
- #{divider}
-
- Have you instrumented the application code to trigger the event yet? View usage examples to easily copy/paste implementation!
-
- Want to verify the metrics? Check out the group::#{metric[:product_group]} Metrics Exploration Dashboard in Tableau
- Note: The Metrics Exploration Dashboard data would be available ~1 week after deploy for Gitlab.com, ~1 week after next release for self-managed
- Link: #{format_info(metric_exploration_group_path(metric[:product_group], find_stage(metric.product_group)))}
-
- Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake
-
- TEXT
-
- actions = selected_events.map(&:action).join(', ')
- next_step = cli.select("How would you like to proceed?", **select_opts) do |menu|
- menu.enum "."
-
- menu.choice "New Event -- define a new event", :new_event
- menu.choice "New Metric -- define another metric for #{actions}", :new_metric_with_events
- menu.choice "New Metric -- define another metric", :new_metric
- choice = "View Usage -- look at code examples for event #{selected_events.first.action}"
- menu.default choice
- menu.choice choice, :view_usage
- menu.choice 'Exit', :exit
- end
-
- case next_step
- when :new_event
- InternalEventsCli::EventDefiner.new(cli).run
- when :new_metric_with_events
- MetricDefiner.new(cli, @selected_event_paths).run
- when :new_metric
- MetricDefiner.new(cli).run
- when :view_usage
- UsageViewer.new(cli, @selected_event_paths.first, selected_events.first).run
- when :exit
- cli.say Text::FEEDBACK_NOTICE
- end
- end
-
- # ----- Prompt-specific Helpers -------------
-
- # Helper for #prompt_for_metrics
- def selected_events_filter_options
- filterable_events_selected = selected_events.any? { |event| event.additional_properties&.any? }
-
- selected_events.map do |event|
- filters = event.additional_properties&.keys
- filter_phrase = if filters
- " (filterable by #{filters&.join(', ')})"
- elsif filterable_events_selected
- ' -- not filterable'
- end
-
- " - #{event.action}#{format_help(filter_phrase)}\n"
- end
- end
-
- # Helper for #prompt_for_event_filters
- def print_event_filter_header(event, idx, total)
- cli.say "\n"
- cli.say format_info(format_subheader('SETTING EVENT FILTERS', event.action, idx, total))
-
- return unless event.additional_properties&.any?
-
- event_filter_options = event.additional_properties.map do |property, attrs|
- " #{property}: #{attrs['description']}\n"
- end
-
- cli.say event_filter_options.join
- end
-
- # Helper for #prompt_for_event_filters
- def deselect_nonfilterable_event?(event)
- cli.say "\n"
-
- return false if event.additional_properties&.any?
- return false if cli.yes?("This event is not filterable. Should it be included in the metric?", **yes_no_opts)
-
- selected_events.delete(event)
- bulk_assign(actions: selected_events.map(&:action).sort)
-
- true
- end
-
- # Helper for #prompt_for_event_filters
- def prompt_for_property_filter(action, property, default)
- formatted_prop = format_info(property)
- prompt = "Count where #{formatted_prop} equals any of (comma-sep):"
-
- inputs = prompt_for_text(prompt, default, **input_opts) do |q|
- if property == 'value'
- q.convert ->(input) { input.split(',').map(&:to_i).uniq }
- q.validate %r{^(\d|\s|,)*$}
- q.messages[:valid?] = "Inputs for #{formatted_prop} must be numeric"
- else
- q.convert ->(input) { input.split(',').map(&:strip).uniq }
- end
- end
-
- return unless inputs&.any?
-
- @selected_filters[action] ||= {}
- @selected_filters[action][property] = inputs.join(',')
-
- inputs.map { |input| { property => input } }.uniq
- end
-
- # Helper for #prompt_for_event_filters
- #
- # Gets all the permutations of the provided property values.
- # @param filters [Array] ex) [{ 'label' => 'red' }, { 'label' => 'blue' }, { value => 16 }]
- # @return ex) [{ 'label' => 'red', value => 16 }, { 'label' => 'blue', value => 16 }]
- def find_filter_permutations(action, filters)
- # Define a filter for all events, regardless of the available props so NewMetric#events is correct
- return [[action, {}]] unless filters&.any?
-
- # Uses proc syntax to avoid spliting & type-checking `filters`
- :product.to_proc.call(*filters).map do |filter|
- [action, filter.reduce(&:merge)]
- end
- end
-
- # Helper for #prompt_for_descriptions
- def selected_event_descriptions
- selected_events.map do |event|
- filters = @selected_filters[event.action]
-
- if filters&.any?
- filter_phrase = filters.map { |k, v| "#{k}=#{v}" }.join(' ')
- filter_phrase = format_help("(#{filter_phrase})")
- end
-
- " #{event.action}#{filter_phrase} - #{format_selection(event.description)}\n"
- end
- end
-
- # Helper for #prompt_for_descriptions
- def prompt_for_description(metric, default)
- description_start = format_info("#{metric.description_prefix}...")
-
- cli.say <<~TEXT
-
- #{input_opts[:prefix]} How would you describe this metric to a non-technical person? #{input_required_text}
-
- TEXT
-
- prompt_for_text(" Finish the description: #{description_start}", default, multiline: true) do |q|
- q.required true
- q.modify :trim
- q.messages[:required?] = Text::METRIC_DESCRIPTION_HELP
- end
- end
-
- # Helper for #prompt_for_descriptions
- def prompt_for_metric_name(metric, default)
- name_reason = name_requirement_reason(metric)
- default_name = metric.key.value
- display_name = metric.key.value("\e[0m[REPLACE ME]\e[36m")
- empty_name = metric.key.value('')
-
- return unless name_reason
-
- cli.say <<~TEXT
-
- #{input_opts[:prefix]} #{name_reason[:text]} How should we refererence this metric? #{input_required_text}
-
- ID: #{format_info(display_name)}
- Filename: #{format_info(display_name)}#{format_info('.yml')}
-
- TEXT
-
- max_length = MAX_FILENAME_LENGTH - "#{empty_name}.yml".length
- help_tokens = { name: default_name, count: max_length }
-
- prompt_for_text(' Replace with: ', default, multiline: true) do |q|
- q.required true
- q.messages[:required?] = name_reason[:help] % help_tokens
- q.messages[:valid?] = Text::METRIC_NAME_ERROR % help_tokens
- q.validate ->(input) do
- input.length <= max_length &&
- input.match?(NAME_REGEX) &&
- !conflicting_key_path?(metric.key.value(input))
- end
- end
- end
-
- # Helper for #prompt_for_descriptions
- def name_requirement_reason(metric)
- if metric.filters.assigned?
- NAME_REQUIREMENT_REASONS[:filters]
- elsif metric.file_name.length > MAX_FILENAME_LENGTH
- NAME_REQUIREMENT_REASONS[:length]
- elsif conflicting_key_path?(metric.key_path)
- NAME_REQUIREMENT_REASONS[:conflict]
- end
- end
-
- # Helper for #prompt_for_descriptions
- def conflicting_key_path?(key_path)
- cli.global.metrics.any? do |existing_metric|
- existing_metric.key_path == key_path
- end
- end
-
- # Helper for #prompt_for_copying_event_properties
- 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_group] << event.product_group
- fields[:stage] << find_stage(event.product_group)
- fields[:section] << find_section(event.product_group)
- fields[:distribution] << event.distributions&.sort
- fields[:tier] << event.tiers&.sort
- fields[:tiers] << 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
-
- # ----- Shared Helpers ----------------------
-
- 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/usage_viewer.rb b/scripts/internal_events/cli/usage_viewer.rb
deleted file mode 100755
index d91fa6f3c4e..00000000000
--- a/scripts/internal_events/cli/usage_viewer.rb
+++ /dev/null
@@ -1,434 +0,0 @@
-# frozen_string_literal: true
-
-require_relative './helpers'
-
-module InternalEventsCli
- class UsageViewer
- include Helpers
-
- PROPERTY_EXAMPLES = {
- 'label' => "'string'",
- 'property' => "'string'",
- 'value' => '72',
- 'custom_key' => 'custom_value'
- }.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 = '1. ruby/rails')
- choices = [
- { name: '1. ruby/rails', value: :rails },
- { name: '2. rspec', value: :rspec },
- { name: '3. javascript (vue)', value: :vue },
- { name: '4. javascript (plain)', value: :js },
- { name: '5. vue template', value: :vue_template },
- { name: '6. haml', value: :haml },
- { name: '7. Manual testing in GDK', value: :gdk },
- { name: '8. Data verification in Tableau', value: :tableau },
- { name: '9. View examples for a different event', value: :other_event },
- { name: '10. Exit', value: :exit }
- ]
-
- usage_location = cli.select(
- 'Select a use-case to view examples for:',
- choices,
- **select_opts,
- **filter_opts,
- per_page: 10
- ) do |menu|
- menu.default default
- end
-
- case usage_location
- when :rails
- rails_examples
- prompt_for_usage_location('1. ruby/rails')
- when :rspec
- rspec_examples
- prompt_for_usage_location('2. rspec')
- when :haml
- haml_examples
- prompt_for_usage_location('6. haml')
- when :js
- js_examples
- prompt_for_usage_location('4. javascript (plain)')
- when :vue
- vue_examples
- prompt_for_usage_location('3. javascript (vue)')
- when :vue_template
- vue_template_examples
- prompt_for_usage_location('5. vue template')
- when :gdk
- gdk_examples
- prompt_for_usage_location('7. Manual testing in GDK')
- when :tableau
- service_ping_dashboard_examples
- prompt_for_usage_location('8. Data verification in Tableau')
- when :other_event
- self.class.new(cli).run
- when :exit
- cli.say(Text::FEEDBACK_NOTICE)
- end
- end
-
- def rails_examples
- identifier_args = identifiers.map do |identifier|
- " #{identifier}: #{identifier}"
- end
-
- property_args = format_additional_properties do |property, value, description|
- " #{property}: #{value}, # #{description}"
- end
-
- if property_args.any?
- # remove trailing comma after last arg but keep any other commas
- property_args.last.sub!(',', '')
- property_arg = " additional_properties: {\n#{property_args.join("\n")}\n }"
- end
-
- args = ["'#{action}'", *identifier_args, property_arg].compact.join(",\n")
- args = "\n #{args}\n" if args.lines.count > 1
-
- cli.say format_warning <<~TEXT
- #{divider}
- #{format_help('# RAILS')}
-
- include Gitlab::InternalEventsTracking
-
- track_internal_event(#{args})
-
- #{divider}
- TEXT
- end
-
- def rspec_examples
- identifier_args = identifiers.map do |identifier|
- " let(:#{identifier}) { create(:#{identifier}) }\n"
- end.join('')
-
- property_args = format_additional_properties do |property, value|
- " #{property}: #{value}"
- end
-
- if property_args.any?
- property_arg = format_prefix ' ', <<~TEXT
- let(:additional_properties) do
- {
- #{property_args.join(",\n")}
- }
- end
- TEXT
- end
-
- args = [*identifier_args, *property_arg].join('')
-
- cli.say format_warning <<~TEXT
- #{divider}
- #{format_help('# RSPEC')}
-
- it_behaves_like 'internal event tracking' do
- let(:event) { '#{action}' }
- #{args}end
-
- #{divider}
- TEXT
- end
-
- def haml_examples
- property_args = format_additional_properties do |property, value, _|
- "event_#{property}: #{value}"
- end
-
- args = ["event_tracking: '#{action}'", *property_args].join(', ')
-
- cli.say <<~TEXT
- #{divider}
- #{format_help('# HAML -- ON-CLICK')}
-
- .inline-block{ #{format_warning("data: { #{args} }")} }
- = _('Important Text')
-
- #{divider}
- #{format_help('# HAML -- COMPONENT ON-CLICK')}
-
- = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { #{args} }")} })
-
- #{divider}
- #{format_help('# HAML -- COMPONENT ON-LOAD')}
-
- = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking_load: true, #{args} }")} })
-
- #{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
- on_click_args = template_formatted_args('data-event-tracking', indent: 2)
- on_load_args = template_formatted_args('data-event-tracking-load', indent: 2)
-
- cli.say <<~TEXT
- #{divider}
- #{format_help('// VUE TEMPLATE -- ON-CLICK')}
-
-
-
-
-
-
-
- #{divider}
- #{format_help('// VUE TEMPLATE -- ON-LOAD')}
-
-
-
-
-
-
-
- #{divider}
- TEXT
-
- cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
- end
-
- def js_examples
- args = js_formatted_args(indent: 2)
-
- cli.say <<~TEXT
- #{divider}
- #{format_help('// FRONTEND -- RAW JAVASCRIPT')}
-
- #{format_warning("import { InternalEvents } from '~/tracking';")}
-
- export const performAction = () => {
- #{format_warning("InternalEvents.trackEvent#{args}")}
-
- 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
- args = js_formatted_args(indent: 6)
-
- cli.say <<~TEXT
- #{divider}
- #{format_help('// VUE')}
-
-
-
-
- Click Me
-
-
- #{divider}
- TEXT
-
- cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
- end
-
- private
-
- def action
- event['action']
- end
-
- def identifiers
- Array(event['identifiers']).tap do |ids|
- # We always auto assign namespace if project is provided
- ids.delete('namespace') if ids.include?('project')
- end
- end
-
- def additional_properties
- Array(event['additional_properties'])
- end
-
- def format_additional_properties
- additional_properties.map do |property, details|
- example_value = PROPERTY_EXAMPLES[property]
- description = details['description'] || 'TODO'
-
- yield(property, example_value, description)
- end
- end
-
- def js_formatted_args(indent:)
- return "('#{action}');" if additional_properties.none?
-
- property_args = format_additional_properties do |property, value, description|
- " #{property}: #{value}, // #{description}"
- end
-
- [
- '(',
- " '#{action}',",
- ' {',
- *property_args,
- ' },',
- ');'
- ].join("\n#{' ' * indent}")
- end
-
- def service_ping_metrics_info
- product_group = related_metrics.map(&:product_group).uniq
-
- <<~TEXT
- #{product_group.map { |group| "#{group}: #{format_info(metric_exploration_group_path(group, find_stage(group)))}" }.join("\n")}
-
- #{divider}
- #{format_help("# METRIC TRENDS -- view data for a service ping metric for #{event.action}")}
-
- #{related_metrics.map { |metric| "#{metric.key_path}: #{format_info(metric_trend_path(metric.key_path))}" }.join("\n")}
- TEXT
- end
-
- def service_ping_no_metric_info
- <<~TEXT
- #{format_help("# Warning: There are no metrics for #{event.action} yet.")}
- #{event.product_group}: #{format_info(metric_exploration_group_path(event.product_group, find_stage(event.product_group)))}
- TEXT
- end
-
- def template_formatted_args(data_attr, indent:)
- return " #{data_attr}=\"#{action}\">" if additional_properties.none?
-
- spacer = ' ' * indent
- property_args = format_additional_properties do |property, value, _|
- " data-event-#{property}=#{value.tr("'", '"')}"
- end
-
- args = [
- '', # start args on next line
- " #{data_attr}=\"#{action}\"",
- *property_args
- ].join("\n#{spacer}")
-
- "#{format_warning(args)}\n#{spacer}>"
- end
-
- def related_metrics
- cli.global.metrics.select { |metric| metric.actions&.include?(event.action) }
- end
-
- def service_ping_dashboard_examples
- cli.say <<~TEXT
- #{divider}
- #{format_help('# GROUP DASHBOARDS -- view all service ping metrics for a specific group')}
-
- #{related_metrics.any? ? service_ping_metrics_info : service_ping_no_metric_info}
- #{divider}
- Note: The metric dashboard links can also be accessed from #{format_info('https://metrics.gitlab.com/')}
-
- Not what you're looking for? Check this doc:
- - #{format_info('https://docs.gitlab.com/ee/development/internal_analytics/#data-discovery')}
-
- TEXT
- end
-
- def gdk_examples
- key_paths = related_metrics.map(&:key_path)
-
- cli.say <<~TEXT
- #{divider}
- #{format_help('# TERMINAL -- monitor events & changes to service ping metrics as they occur')}
-
- 1. From `gitlab/` directory, run the monitor script:
-
- #{format_warning("bin/rails runner scripts/internal_events/monitor.rb #{event.action}")}
-
- 2. View metric updates within the terminal
-
- 3. [Optional] Configure gdk with snowplow micro to see individual events: https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/howto/snowplow_micro.md
-
- #{divider}
- #{format_help('# RAILS CONSOLE -- generate service ping payload, including most recent usage data')}
-
- #{format_warning("require_relative 'spec/support/helpers/service_ping_helpers.rb'")}
-
- #{format_help('# Get current value of a metric')}
- #{
- if key_paths.any?
- key_paths.map { |key_path| format_warning("ServicePingHelpers.get_current_usage_metric_value('#{key_path}')") }.join("\n")
- else
- format_help("# Warning: There are no metrics for #{event.action} yet. When there are, replace below.\n") +
- format_warning('ServicePingHelpers.get_current_usage_metric_value()')
- end
- }
-
- #{format_help('# View entire service ping payload')}
- #{format_warning('ServicePingHelpers.get_current_service_ping_payload')}
- #{divider}
- Need to test something else? Check these docs:
- - https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/local_setup_and_debugging.html
- - https://docs.gitlab.com/ee/development/internal_analytics/service_ping/troubleshooting.html
- - https://docs.gitlab.com/ee/development/internal_analytics/review_guidelines.html
-
- TEXT
- end
- end
-end
diff --git a/spec/lib/gitlab/ci/config/entry/kubernetes_spec.rb b/spec/lib/gitlab/ci/config/entry/kubernetes_spec.rb
index 0ac8d01b8e4..9899502e8b7 100644
--- a/spec/lib/gitlab/ci/config/entry/kubernetes_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/kubernetes_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::Entry::Kubernetes do
+RSpec.describe Gitlab::Ci::Config::Entry::Kubernetes, feature_category: :kubernetes_management do
let(:config) { Hash(namespace: 'namespace') }
subject { described_class.new(config) }
@@ -31,6 +31,12 @@ RSpec.describe Gitlab::Ci::Config::Entry::Kubernetes do
it { is_expected.not_to be_valid }
end
+
+ context 'is empty' do
+ let(:config) { {} }
+
+ it { is_expected.not_to be_valid }
+ end
end
describe 'namespace' do
@@ -47,9 +53,19 @@ RSpec.describe Gitlab::Ci::Config::Entry::Kubernetes do
it { is_expected.not_to be_valid }
end
+ end
- context 'is not present' do
- let(:namespace) { '' }
+ describe 'agent' do
+ let(:config) { Hash(agent: agent) }
+
+ context 'is a string' do
+ let(:agent) { 'path/to/project:example-agent' }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'is a hash' do
+ let(:agent) { { key: 'agent' } }
it { is_expected.not_to be_valid }
end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index ebc45685d9a..212a3c3224a 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -1830,10 +1830,22 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
%w[succeed! drop! cancel! skip!].each do |action|
context "when the pipeline received #{action} event" do
it 'performs AutoMergeProcessWorker' do
- expect(AutoMergeProcessWorker).to receive(:perform_async).with(merge_request.id)
+ expect(AutoMergeProcessWorker).to receive(:perform_async).with({ 'pipeline_id' => pipeline.id })
pipeline.public_send(action)
end
+
+ context 'with auto_merge_process_worker_pipeline disabled' do
+ before do
+ stub_feature_flags(auto_merge_process_worker_pipeline: false)
+ end
+
+ it 'performs AutoMergeProcessWorker by passing only the merge request' do
+ expect(AutoMergeProcessWorker).to receive(:perform_async).with(merge_request.id)
+
+ pipeline.succeed!
+ end
+ end
end
end
end
@@ -1841,8 +1853,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
context 'when auto merge is not enabled in the merge request' do
let(:merge_request) { create(:merge_request) }
+ # We enqueue the job here because the check for whether or not to
+ # automatically merge happens in the worker itself.
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/167095
it 'performs AutoMergeProcessWorker' do
- expect(AutoMergeProcessWorker).not_to receive(:perform_async)
+ expect(AutoMergeProcessWorker).to receive(:perform_async).with({ 'pipeline_id' => pipeline.id })
pipeline.succeed!
end
diff --git a/spec/services/auto_merge/base_service_spec.rb b/spec/services/auto_merge/base_service_spec.rb
index 0f4fed72fdc..8022f81f290 100644
--- a/spec/services/auto_merge/base_service_spec.rb
+++ b/spec/services/auto_merge/base_service_spec.rb
@@ -76,7 +76,7 @@ RSpec.describe AutoMerge::BaseService, feature_category: :code_review_workflow d
end
it 'calls AutoMergeProcessWorker' do
- expect(AutoMergeProcessWorker).to receive(:perform_async).with(merge_request.id).once
+ expect(AutoMergeProcessWorker).to receive(:perform_async).with({ 'merge_request_id' => merge_request.id }).once
subject
end
diff --git a/spec/services/deployments/update_environment_service_spec.rb b/spec/services/deployments/update_environment_service_spec.rb
index bc6e244dc2f..97dd7431b53 100644
--- a/spec/services/deployments/update_environment_service_spec.rb
+++ b/spec/services/deployments/update_environment_service_spec.rb
@@ -3,14 +3,14 @@
require 'spec_helper'
RSpec.describe Deployments::UpdateEnvironmentService, feature_category: :continuous_delivery do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
- let(:options) { { name: environment_name } }
- let(:pipeline) do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository, developers: [user]) }
+ let_it_be(:pipeline) do
create(
:ci_pipeline,
sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0',
- project: project
+ project: project,
+ user: user
)
end
@@ -22,12 +22,14 @@ RSpec.describe Deployments::UpdateEnvironmentService, feature_category: :continu
tag: false,
environment: environment_name,
options: { environment: options },
+ user: user,
project: project)
end
let(:deployment) { job.deployment }
let(:environment) { deployment.environment }
let(:environment_name) { 'production' }
+ let(:options) { { name: environment_name } }
subject(:service) { described_class.new(deployment) }
@@ -265,6 +267,58 @@ RSpec.describe Deployments::UpdateEnvironmentService, feature_category: :continu
.to change { environment.reset.tier }.from(nil).to('other')
end
end
+
+ context 'when cluster agent is specified' do
+ let(:agent) { create(:cluster_agent, project: project) }
+
+ let(:options) { { name: environment_name, kubernetes: { agent: agent_path } } }
+
+ context 'when the agent does not exist' do
+ let(:agent_path) { "#{project.full_path}:non-existent-agent" }
+
+ it 'does not assign a cluster agent' do
+ expect { subject.execute }.not_to change { environment.cluster_agent }
+ end
+ end
+
+ context 'when the agent exists' do
+ let(:agent_path) { "#{project.full_path}:#{agent.name}" }
+
+ context 'and the user no longer exists' do
+ before do
+ job.update!(user: nil)
+ end
+
+ it 'does not assign a cluster agent' do
+ expect { subject.execute }.not_to change { environment.cluster_agent }
+ end
+ end
+
+ context 'and the user is not authorized' do
+ it 'does not assign a cluster agent' do
+ expect { subject.execute }.not_to change { environment.cluster_agent }
+ end
+ end
+
+ context 'and the user is authorized' do
+ before do
+ agent.user_access_project_authorizations.create!(project: project, config: {})
+ end
+
+ it 'assigns the cluster agent to the environment' do
+ expect { subject.execute }.to change { environment.cluster_agent }.from(nil).to(agent)
+ end
+
+ context 'when the agent path contains variables' do
+ let(:agent_path) { "$CI_PROJECT_PATH:#{agent.name}" }
+
+ it 'expands variables and assigns the cluster agent to the environment' do
+ expect { subject.execute }.to change { environment.cluster_agent }.from(nil).to(agent)
+ end
+ end
+ end
+ end
+ end
end
describe '#expanded_environment_url' do
diff --git a/spec/workers/auto_merge_process_worker_spec.rb b/spec/workers/auto_merge_process_worker_spec.rb
index f208f162ee3..7d1c09182cc 100644
--- a/spec/workers/auto_merge_process_worker_spec.rb
+++ b/spec/workers/auto_merge_process_worker_spec.rb
@@ -20,7 +20,17 @@ RSpec.describe AutoMergeProcessWorker, feature_category: :continuous_delivery do
end
end
- context 'when a pipeline is passed with auto mergeable MRs' do
+ context 'when merge request is not found' do
+ let(:args) { { 'merge_request_id' => -1 } }
+
+ it 'does not execute AutoMergeService' do
+ expect(AutoMergeService).not_to receive(:new)
+
+ subject
+ end
+ end
+
+ context 'when a pipeline is passed with auto mergeable MRs', :aggregate_failures do
let(:merge_service) { instance_double(AutoMergeService, process: true) }
let(:mwps_merge_request) { create(:merge_request, :with_head_pipeline, :merge_when_pipeline_succeeds) }
let(:mwcp_merge_request) { create(:merge_request, :with_head_pipeline, :merge_when_checks_pass) }
@@ -43,8 +53,8 @@ RSpec.describe AutoMergeProcessWorker, feature_category: :continuous_delivery do
end
end
- context 'when merge request is not found' do
- let(:args) { { 'merge_request_id' => -1 } }
+ context 'when pipeline is not found' do
+ let(:args) { { 'pipeline_id' => -1 } }
it 'does not execute AutoMergeService' do
expect(AutoMergeService).not_to receive(:new)
@@ -65,6 +75,7 @@ RSpec.describe AutoMergeProcessWorker, feature_category: :continuous_delivery do
# Integer args are deprecated as of 17.5. IDs should be passed
# as a hash with merge_request_id and pipeline_id keys.
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/497247
context 'with integer args' do
let(:args) { merge_request.id }