Verified Commit 9f50c39d authored by Hercules Merscher's avatar Hercules Merscher 🌴
Browse files

feat: Labkit::CoveredExperience::Registry

parent 5a9a26eb
Loading
Loading
Loading
Loading
+0 −7
Original line number Diff line number Diff line
@@ -5,12 +5,6 @@
  "description": "Schema for GitLab Covered Experience files",
  "type": "object",
  "properties": {
    "covered_experience_id": {
      "type": "string",
      "pattern": "^[a-z0-9_]+$",
      "minLength": 1,
      "description": "Unique identifier for the covered experience"
    },
    "description": {
      "type": "string",
      "minLength": 1,
@@ -33,7 +27,6 @@
    }
  },
  "required": [
    "covered_experience_id",
    "description",
    "feature_category",
    "urgency"
+0 −1
Original line number Diff line number Diff line
# yaml-language-server: $schema=./schema.json
covered_experience_id: "merge_request_creation"
description: "Creating a new merge request in a project"
feature_category: "source_code_management"
urgency: "sync_fast"
+6 −66
Original line number Diff line number Diff line
@@ -3,81 +3,21 @@
require 'yaml'
require 'pathname'
require 'json-schema'
require "forwardable"
require 'labkit/covered_experience/registry'

module Labkit
  # Module for loading and managing Covered Experiences.
  module CoveredExperience
    class << self
      SCHEMA_PATH = File.expand_path('../../config/covered_experiences/schema.json', __dir__)
      extend Forwardable

      # Initialize the registry by loading YAML files from the specified directory
      #
      # @param dir [String, Pathname] Directory path containing YAML file definitions
      #   Defaults to 'config/covered_experiences' relative to the calling application's root
      # @return [Hash] Registry of covered experiences keyed by covered_experience_id
      def init_registry(dir: nil)
        @registry = {}
        dir_path = resolve_directory_path(dir)

        return @registry unless directory_exists?(dir_path)

        load_yaml_files(dir_path)
        @registry
      end

      # Get the current registry
      #
      # @return [Hash] Current registry of covered experiences
      def registry
        @registry ||= {}
      end
      def_delegators :registry, :init_registry

      private

      # Resolve the directory path, using default if none provided.
      # Expands relative paths from the current working directory.
      #
      # @param directory [String, Pathname, nil] Custom directory path
      # @return [Pathname] Resolved directory path
      def resolve_directory_path(directory)
        directory ||= File.join("config", "covered_experiences")
        Pathname.new(Dir.pwd).join(directory)
      end

      def directory_exists?(directory_path)
        directory_path.exist? && directory_path.directory? && directory_path.readable?
      end

      def load_yaml_files(directory_path)
        directory_path
          .glob('*.yml')
          .each { |f| load_yaml_file(f) }
      end

      def load_yaml_file(file_path)
        content = YAML.safe_load(file_path.read)

        return unless content.is_a?(Hash)

        if JSON::Validator.validate(schema, content)
          covered_experience_id = content['covered_experience_id']
          @registry[covered_experience_id] = content.dup.freeze
        else
          warn("Invalid schema for #{file_path}")
        end
      rescue Psych::SyntaxError => e
        warn("Invalid YAML file #{file_path}: #{e.message}")
      rescue StandardError => e
        warn("Unexpected error processing #{file_path}: #{e.message}")
      end

      def schema
        @schema ||= JSON.parse(File.read(SCHEMA_PATH))
      end

      def warn(_message)
        # TODO: hook logger here to warn about errors
        # without causing the library to break code running it
      def registry
        Registry
      end
    end
  end
+35 −5
Original line number Diff line number Diff line
@@ -4,20 +4,50 @@ This module covers the definition for Covered Experiences, as described in the [

## Usage

```yaml
# create a new covered experience file in the registry directory, e.g. config/covered_experiences/merge_request_creation.yaml
Create a new covered experience file in the registry directory, e.g. config/covered_experiences/merge_request_creation.yaml

The basename of the file will be taken as the covered_experience_id.

The schema header is optional, but if you're using VSCode (or any other editor with support), you can get them validated
instantaneously in the editor via a [JSON schema plugin](https://marketplace.visualstudio.com/items?itemName=remcohaszing.schemastore).

```yaml
# yaml-language-server: $schema=https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby/-/raw/master/config/covered_experiences/schema.json
covered_experience_id: "merge_request_creation"
description: "Creating a new merge request in a project"
feature_category: "source_code_management"
urgency: "sync_fast"
```

**Feature category**

https://docs.gitlab.com/development/feature_categorization/#feature-categorization.

**Urgency**

| Threshold    | Description                                                                                                                                                      | Examples                                                                       | Value |
|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|-------|
| `sync_fast`  | A user is awaiting a synchronous response which needs to be returned before they can continue with their action                                                  | A full-page render                                                             | 2s    |
| `sync_slow`  | A user is awaiting a synchronous response which needs to be returned before they can continue with their action, but which the user may accept a slower response | Displaying a full-text search response while displaying an amusement animation | 5s    |
| `async_fast` | An async process which may block a user from continuing with their user journey                                                                                  | MR diff update after git push                                                  | 15s   |
| `async_slow` | An async process which will not block a user and will not be immediately noticed as being slow                                                                   | Notification following an assignment                                           | 5m    |


Initializing the registry:

```ruby
# initialize the registry (automatically loads from config/covered_experiences relative to the project's root)
Labkit::CoveredExperience.init_registry
registry = Labkit::CoveredExperience.init_registry

# initialize the registry providing a custom directory
Labkit::CoveredExperience.init_registry(dir: 'config/awesome_covered_experiences')
registry = Labkit::CoveredExperience.init_registry(dir: 'config/awesome_covered_experiences')

# the invalid file definitions can be retrieved for debugging
# they are silently ignored during load
registry.invalid_definitions
# {"testing_sample" => "The property '#/' did not contain a required property of 'feature_category' in schema 2cc4aef6-0b0d-551f-b89b-62d868b55944"}
```

If this step is skipped, the covered experiences will be lazily loaded from the default directory.

It's recommended to call `init_registry` upfront, so we can catch issues with directory not accessible
at development time.
+133 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

require 'yaml'
require 'pathname'
require 'json-schema'
require "forwardable"

module Labkit
  module CoveredExperience
    class Registry
      DirectoryNotReadable = Class.new(StandardError)

      SCHEMA_PATH = File.expand_path('../../../config/covered_experiences/schema.json', __dir__)

      class << self
        extend Forwardable

        def_delegator :registry, :has_key?, :has_covered_experience?
        def_delegator :registry, :empty?

        # Initialize the registry by loading YAML files from the specified directory
        #
        # @param dir [String, Pathname] Directory path containing YAML file definitions
        #   Defaults to 'config/covered_experiences' relative to the calling application's root
        # @return [Hash] Registry of covered experiences keyed by covered_experience_id
        def init_registry(dir: nil)
          reset

          # assigning dir to an instance variable to lazy load files on first access
          @init_dir = dir

          # eagerly checking directory existence to fail fast during development
          raise(DirectoryNotReadable) unless directory_exists?(directory_path)

          self
        end

        # Retrieve a definition from the registry
        #
        # @param covered_experience_id [String, Symbol] Covered experience identifier
        # @return [Hash, nil] Definition hash if present, otherwise nil
        def [](covered_experience_id)
          registry[covered_experience_id.to_s]
        end

        # Retrieve invalid definitions (ignored because they didn't pass the schema validation)
        #
        # @return [Hash] Invalid definitions keyed by covered_experience_id
        def invalid_definitions
          registry && @invalid_definitions
        end

        private

        # Lazy-load the registry of covered experience definitions.
        # This will load all YAML files from the default directory,
        # on first access and memoize the registry for subsequent access.
        #
        # If .init_registry is called with a dir, it will clear the memoized
        # registry and load the new directory's definitions.
        #
        # @return [Hash] The memoized registry of covered experience definitions
        def registry
          @registry ||= load_definitions
        end

        # Resetting will force the registry to be re-evaluated by clearing the memoized vars
        # if .init_registry is called again.
        def reset
          @registry = nil
          @invalid_definitions = nil
        end

        def load_definitions
          @registry = {}
          @invalid_definitions = {}

          load_yaml_files(directory_path)

          @registry.freeze
        end

        # Resolve the directory path, using default if @init_dir not provided.
        # Expands relative paths from the current working directory.
        #
        # @return [Pathname] Resolved directory path
        def directory_path
          dir = @init_dir || File.join("config", "covered_experiences")
          Pathname.new(Dir.pwd).join(dir)
        end

        def directory_exists?(directory_path)
          directory_path.exist? && directory_path.directory? && directory_path.readable?
        end

        def load_yaml_files(directory_path)
          directory_path
            .glob('*.yml')
            .each { |f| load_yaml_file(f) }
        end

        def load_yaml_file(file_path)
          content = YAML.safe_load(file_path.read)

          return unless content.is_a?(Hash)

          covered_experience_id = file_path.basename('.yml').to_s
          errors = JSON::Validator.fully_validate(schema, content)

          if errors.empty?
            @registry[covered_experience_id] = content.dup.freeze
          else
            @invalid_definitions[covered_experience_id] = errors.join(', ')
            warn("Invalid schema for #{file_path}")
          end
        rescue Psych::SyntaxError => e
          warn("Invalid YAML file #{file_path}: #{e.message}")
        rescue StandardError => e
          warn("Unexpected error processing #{file_path}: #{e.message}")
        end

        def schema
          @schema ||= JSON.parse(File.read(SCHEMA_PATH))
        end

        def warn(_message)
          # TODO: hook logger here to warn about errors
          # without causing the library to break code running it
        end
      end
    end
  end
end
Loading