gitlab_internal_events_cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.tool-versions +1 -0
  4. data/Gemfile +11 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE.txt +19 -0
  7. data/README.md +164 -0
  8. data/Rakefile +10 -0
  9. data/exe/gitlab-internal-events-cli +7 -0
  10. data/gitlab_internal_events_cli.gemspec +39 -0
  11. data/lib/gitlab_internal_events_cli/cli.rb +59 -0
  12. data/lib/gitlab_internal_events_cli/configuration.rb +115 -0
  13. data/lib/gitlab_internal_events_cli/event.rb +73 -0
  14. data/lib/gitlab_internal_events_cli/flows/event_definer.rb +306 -0
  15. data/lib/gitlab_internal_events_cli/flows/flow_advisor.rb +90 -0
  16. data/lib/gitlab_internal_events_cli/flows/metric_definer.rb +468 -0
  17. data/lib/gitlab_internal_events_cli/flows/usage_viewer.rb +474 -0
  18. data/lib/gitlab_internal_events_cli/gitlab_prompt.rb +9 -0
  19. data/lib/gitlab_internal_events_cli/global_state.rb +63 -0
  20. data/lib/gitlab_internal_events_cli/helpers/cli_inputs.rb +138 -0
  21. data/lib/gitlab_internal_events_cli/helpers/event_options.rb +63 -0
  22. data/lib/gitlab_internal_events_cli/helpers/files.rb +84 -0
  23. data/lib/gitlab_internal_events_cli/helpers/formatting.rb +166 -0
  24. data/lib/gitlab_internal_events_cli/helpers/group_ownership.rb +160 -0
  25. data/lib/gitlab_internal_events_cli/helpers/metric_options.rb +253 -0
  26. data/lib/gitlab_internal_events_cli/helpers/schema_loader.rb +25 -0
  27. data/lib/gitlab_internal_events_cli/helpers/service_ping_dashboards.rb +22 -0
  28. data/lib/gitlab_internal_events_cli/helpers.rb +47 -0
  29. data/lib/gitlab_internal_events_cli/http_cache.rb +52 -0
  30. data/lib/gitlab_internal_events_cli/metric.rb +406 -0
  31. data/lib/gitlab_internal_events_cli/schema_resolver.rb +25 -0
  32. data/lib/gitlab_internal_events_cli/subflows/database_metric_definer.rb +71 -0
  33. data/lib/gitlab_internal_events_cli/subflows/event_metric_definer.rb +258 -0
  34. data/lib/gitlab_internal_events_cli/text/event_definer.rb +166 -0
  35. data/lib/gitlab_internal_events_cli/text/flow_advisor.rb +64 -0
  36. data/lib/gitlab_internal_events_cli/text/metric_definer.rb +138 -0
  37. data/lib/gitlab_internal_events_cli/time_framed_key_path.rb +18 -0
  38. data/lib/gitlab_internal_events_cli/version.rb +5 -0
  39. data/lib/gitlab_internal_events_cli.rb +36 -0
  40. metadata +170 -0
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helpers related to listing existing event definitions
4
+ module GitlabInternalEventsCli
5
+ module Helpers
6
+ module EventOptions
7
+ def get_event_options(events)
8
+ options = events.filter_map do |(path, event)|
9
+ next if duplicate_events?(event.action, events.values)
10
+
11
+ description = format_help(" - #{trim_description(event.description)}")
12
+
13
+ {
14
+ name: "#{format_event_name(event)}#{description}",
15
+ value: path
16
+ }
17
+ end
18
+
19
+ options.sort_by do |option|
20
+ category = events.dig(option[:value], 'category')
21
+ internal_events = events.dig(option[:value], 'internal_events')
22
+
23
+ event_sort_param(internal_events, category, option[:name])
24
+ end
25
+ end
26
+
27
+ def events_by_filepath(event_paths = [])
28
+ events = cli.global.events.to_h { |event| [event.file_path, event] }
29
+
30
+ return events if event_paths.none?
31
+
32
+ events.slice(*event_paths)
33
+ end
34
+
35
+ private
36
+
37
+ def trim_description(description)
38
+ return description if description.to_s.length < 50
39
+
40
+ "#{description[0, 50]}..."
41
+ end
42
+
43
+ def format_event_name(event)
44
+ if event.internal_events || event.category == 'default'
45
+ event.action
46
+ else
47
+ "#{event.category}:#{event.action}"
48
+ end
49
+ end
50
+
51
+ def event_sort_param(internal_events, category, name)
52
+ return "0#{name}" if internal_events
53
+ return "1#{name}" if category == 'default'
54
+
55
+ "2#{category}#{name}"
56
+ end
57
+
58
+ def duplicate_events?(action, events)
59
+ events.count { |event| action == event.action } > 1
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helpers related reading/writing definition files
4
+ module GitlabInternalEventsCli
5
+ module Helpers
6
+ module Files
7
+ MAX_FILENAME_LENGTH = 100
8
+
9
+ def absolute_path(filepath)
10
+ GitlabInternalEventsCli.configuration.absolute_path(filepath)
11
+ end
12
+
13
+ def prompt_to_save_file(filepath, content)
14
+ full_path = absolute_path(filepath)
15
+
16
+ cli.say <<~TEXT.chomp
17
+ #{format_info('Preparing to generate definition with these attributes:')}
18
+ #{filepath}
19
+ #{content}
20
+ TEXT
21
+
22
+ if File.exist?(full_path)
23
+ cli.error("Oh no! This file already exists!\n")
24
+
25
+ return if cli.no?(format_prompt('Overwrite file?'))
26
+
27
+ write_to_file(filepath, content, 'update')
28
+ elsif cli.yes?(format_prompt('Create file?'))
29
+ write_to_file(filepath, content, 'create')
30
+ end
31
+ end
32
+
33
+ def file_saved_message(verb, filepath)
34
+ attributes = YAML.safe_load_file(absolute_path(filepath))
35
+
36
+ format_prefix ' ', [
37
+ file_saved_success_message(verb, filepath),
38
+ file_saved_context_message(attributes),
39
+ file_saved_validations_message(attributes)
40
+ ].compact.join("\n")
41
+ end
42
+
43
+ def write_to_file(filepath, content, verb)
44
+ full_path = absolute_path(filepath)
45
+ dir = File.dirname(full_path)
46
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
47
+
48
+ File.write(full_path, content)
49
+
50
+ file_saved_message(verb, filepath).tap do |_message|
51
+ cli.global.reload_definitions
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def file_saved_success_message(verb, filepath)
58
+ "#{format_selection(verb)} #{filepath}"
59
+ end
60
+
61
+ def file_saved_context_message(_attributes)
62
+ # Override in definition class
63
+ end
64
+
65
+ def file_saved_validations_message(attributes)
66
+ schema = self.class.respond_to?(:schema) ? self.class.schema : nil
67
+ return '' unless schema
68
+
69
+ errors = schema.validate(attributes).to_a
70
+
71
+ return '' unless errors.any?
72
+
73
+ <<~TEXT
74
+
75
+ #{errors.map { |e| [format_warning('!! WARNING: '), JSONSchemer::Errors.pretty(e)].join }.join("\n")}
76
+
77
+ These errors will cause validation specs to fail and should be resolved before merging your changes.
78
+ TEXT
79
+ rescue StandardError
80
+ ''
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helpers related to visual formatting of outputs
4
+ module GitlabInternalEventsCli
5
+ module Helpers
6
+ module Formatting
7
+ DEFAULT_WINDOW_WIDTH = 100
8
+ DEFAULT_WINDOW_HEIGHT = 30
9
+
10
+ # When to format as "info":
11
+ # - When a header is needed to organize contextual
12
+ # information. These headers should always be all caps.
13
+ # - As a supplemental way to highlight the most important
14
+ # text within a menu or informational text.
15
+ # - Optionally, for URLs
16
+ def format_info(string)
17
+ pastel.cyan(string)
18
+ end
19
+
20
+ # When to format as "warning":
21
+ # - To highlight the first sentence/phrase describing a
22
+ # problem the user needs to address. Any further text
23
+ # explantion should be left unformatted.
24
+ # - To highlight an explanation of why the user cannot take
25
+ # a particular action.
26
+ def format_warning(string)
27
+ pastel.yellow(string)
28
+ end
29
+
30
+ # When to format as "selection":
31
+ # - As a supplemental way of indicating something was
32
+ # selected or the current state of an interaction.
33
+ def format_selection(string)
34
+ pastel.green(string)
35
+ end
36
+
37
+ # When to format as "help":
38
+ # - To format supplemental information on how to interact
39
+ # with prompts. This should always be in parenthesis.
40
+ # - To indicate disabled or unavailable menu options.
41
+ # - To indicate meta-information in menu options or
42
+ # informational text.
43
+ def format_help(string)
44
+ pastel.bright_black(string)
45
+ end
46
+
47
+ # When to format as "prompt":
48
+ # - When we need the user to input information. The text
49
+ # should describe the action the user should take to move
50
+ # forward, like `Input text` or `Select one`
51
+ # - As header text on multi-screen steps in a flow. Always
52
+ # include a counter when this is the case.
53
+ def format_prompt(string)
54
+ pastel.magenta(string)
55
+ end
56
+
57
+ # When to format as "error":
58
+ # - When the CLI encounters unexpected problems that may
59
+ # require broader changes by the Analytics Instrumentation
60
+ # Group or out of band configuration.
61
+ # - To highlight special characters used to symbolize that
62
+ # there was an error or that an option is not available.
63
+ def format_error(string)
64
+ pastel.red(string)
65
+ end
66
+
67
+ # Strips all existing color/text style
68
+ def clear_format(string)
69
+ pastel.strip(string)
70
+ end
71
+
72
+ # When to format as "heading":
73
+ # - At the beginning or end of complete flows, to create
74
+ # visual separation and indicate logical breakpoints.
75
+ def format_heading(string)
76
+ [divider, pastel.cyan(string), divider].join("\n")
77
+ end
78
+
79
+ # Used for grouping prompts that occur on the same screen
80
+ # or as part of the same step of a flow.
81
+ #
82
+ # Counter is exluded if total is 1.
83
+ # The subject's formatting is extended to the counter.
84
+ #
85
+ # @return [String] ex) -- EATING COOKIES (2/3): Chocolate Chip --
86
+ # @param subject [String] describes task generically ex) EATING COOKIES
87
+ # @param item [String] describes specific context ex) Chocolate Chip
88
+ # @param count [Integer] ex) 2
89
+ # @param total [Integer] ex) 3
90
+ def format_subheader(subject, item, count = 1, total = 1)
91
+ formatting_end = "\e[0m"
92
+ suffix = formatting_end if subject[-formatting_end.length..] == formatting_end
93
+
94
+ "-- #{[subject.chomp(formatting_end), counter(count, total)].compact.join(' ')}:#{suffix} #{item} --"
95
+ end
96
+
97
+ def format_prefix(prefix, string)
98
+ string.lines.map { |line| line.prepend(prefix) }.join
99
+ end
100
+
101
+ # When to use a divider:
102
+ # - As separation between whole flows or format the layout
103
+ # of a screen or the layout of CLI outputs.
104
+ # - Dividers should not be used to differentiate between
105
+ # prompts on the same screen.
106
+ def divider
107
+ '-' * window_size
108
+ end
109
+
110
+ # Prints a progress bar on the screen at the current location
111
+ # @param current_title [String] title to highlight
112
+ # @param titles [Array<String>] progression to follow;
113
+ # -> first element is expected to be a title for the entire flow
114
+ def progress_bar(current_title, titles = [])
115
+ step = titles.index(current_title)
116
+ total = titles.length - 1
117
+
118
+ raise ArgumentError, "Invalid selection #{current_title} in progress bar" unless step
119
+
120
+ status = " Step #{step} / #{total} : #{titles.join(' > ')}"
121
+ status.gsub!(current_title, format_selection(current_title))
122
+
123
+ total_length = window_size - 4
124
+ step_length = step / total.to_f * total_length
125
+
126
+ incomplete = '-' * [(total_length - step_length - 1), 0].max
127
+ complete = '=' * [(step_length - 1), 0].max
128
+
129
+ "#{status}\n|==#{complete}>#{incomplete}|\n"
130
+ end
131
+
132
+ # Formats a counter if there's anything to count
133
+ #
134
+ # @return [String, nil] ex) "(3/4)""
135
+ def counter(idx, total)
136
+ "(#{idx + 1}/#{total})" if total > 1
137
+ end
138
+
139
+ private
140
+
141
+ def pastel
142
+ @pastel ||= Pastel.new
143
+ end
144
+
145
+ def window_size
146
+ Integer(fetch_window_size)
147
+ rescue StandardError
148
+ DEFAULT_WINDOW_WIDTH
149
+ end
150
+
151
+ def window_height
152
+ Integer(fetch_window_height)
153
+ rescue StandardError
154
+ DEFAULT_WINDOW_HEIGHT
155
+ end
156
+
157
+ def fetch_window_size
158
+ `tput cols`
159
+ end
160
+
161
+ def fetch_window_height
162
+ `tput lines`
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helpers related to Stage/Section/Group ownership
4
+ module GitlabInternalEventsCli
5
+ module Helpers
6
+ module GroupOwnership
7
+ def known_categories
8
+ @known_categories ||= load_known_categories
9
+ end
10
+
11
+ def prompt_for_group_ownership(message, defaults = {})
12
+ if available_groups.any?
13
+ prompt_for_ownership_from_ssot(message, defaults)
14
+ else
15
+ prompt_for_ownership_manually(message, defaults)
16
+ end
17
+ end
18
+
19
+ def prompt_for_feature_categories(message, groups, default = nil)
20
+ choices = category_choices(groups)
21
+ valid_defaults = Array(default).compact & known_categories
22
+
23
+ categories = cli.multi_select(
24
+ message,
25
+ choices,
26
+ **multiselect_opts,
27
+ **filter_opts(header_size: 10)
28
+ ) do |menu|
29
+ menu.default(*valid_defaults) if valid_defaults.any?
30
+ format_disabled_options_as_dividers(menu)
31
+ end
32
+
33
+ categories.sort if categories.any?
34
+ end
35
+
36
+ def find_stage(group)
37
+ available_groups[group]&.fetch(:stage)
38
+ end
39
+
40
+ def find_section(group)
41
+ available_groups[group]&.fetch(:section)
42
+ end
43
+
44
+ def find_categories(group)
45
+ available_groups[group]&.fetch(:categories)
46
+ end
47
+
48
+ private
49
+
50
+ def load_known_categories
51
+ # Try local file first, fall back to remote
52
+ path = GitlabInternalEventsCli.configuration.absolute_path(
53
+ GitlabInternalEventsCli.configuration.feature_categories_path
54
+ )
55
+ return YAML.load_file(path) if File.exist?(path)
56
+
57
+ # Fall back to fetching from remote
58
+ fetch_remote_categories
59
+ rescue StandardError
60
+ []
61
+ end
62
+
63
+ def fetch_remote_categories
64
+ url = GitlabInternalEventsCli.configuration.feature_categories_url
65
+ response = HttpCache.get(url)
66
+ return [] unless response
67
+
68
+ YAML.safe_load(response)
69
+ rescue StandardError
70
+ []
71
+ end
72
+
73
+ def prompt_for_ownership_from_ssot(prompt, defaults)
74
+ sorted_defaults = defaults.values_at(:section, :stage, :product_group)
75
+ group = sorted_defaults.last
76
+ default = sorted_defaults.compact.join(':') # compact because not all groups have a section
77
+
78
+ cli.select(prompt, group_choices, **select_opts, **filter_opts) do |menu|
79
+ if group
80
+ if available_groups[group]
81
+ # We have a complete group selection -> set as default in menu
82
+ menu.default(default)
83
+ else
84
+ cli.error format_error(">>> Failed to find group matching #{group}. Select another.\n")
85
+ end
86
+ elsif default
87
+ # We have a section and/or stage in common
88
+ menu.instance_variable_set(:@filter, default.chars)
89
+ end
90
+ end
91
+ end
92
+
93
+ def prompt_for_ownership_manually(message, defaults)
94
+ prompt_for_text(message, defaults[:product_group])
95
+ end
96
+
97
+ # @return Array[<Hash - matches #prompt_for_ownership_manually output format>]
98
+ def group_choices
99
+ available_groups.map do |group, ownership|
100
+ {
101
+ name: ownership.values_at(:section, :stage, :group).compact.join(':'),
102
+ value: group
103
+ }
104
+ end
105
+ end
106
+
107
+ # Returns the list of product category options for use in
108
+ # select menu. Prioritizes categories from related groups.
109
+ def category_choices(groups)
110
+ options = []
111
+
112
+ # List likeliest categories for group as the first options
113
+ Array(groups).compact.uniq.each do |group|
114
+ divider = select_option_divider("Categories for #{group}")
115
+ categories = (find_categories(group) || []) & known_categories
116
+
117
+ options.push(divider, *categories.sort) if categories.any?
118
+ end
119
+
120
+ divider = select_option_divider('All categories')
121
+ none_option = { name: 'N/A (this definition does not correspond to any product categories)', value: nil }
122
+
123
+ options.push(divider) if options.any?
124
+ options.concat(known_categories)
125
+ options.push(none_option)
126
+ options.uniq
127
+ end
128
+
129
+ # Output looks like:
130
+ # {
131
+ # "import" => { stage: "manage", section: "dev", group: "import" },
132
+ # ...
133
+ # }
134
+ def available_groups
135
+ return @available_groups if defined?(@available_groups)
136
+
137
+ response = HttpCache.get(GitlabInternalEventsCli.configuration.stages_url)
138
+ return @available_groups = {} unless response
139
+
140
+ data = YAML.safe_load(response)
141
+
142
+ @available_groups = data['stages'].flat_map do |stage_name, stage_data|
143
+ stage_data['groups'].map do |group_name, group_data|
144
+ [
145
+ group_name,
146
+ {
147
+ group: group_name,
148
+ stage: stage_name,
149
+ section: stage_data['section'],
150
+ categories: group_data['categories']
151
+ }
152
+ ]
153
+ end
154
+ end.to_h
155
+ rescue StandardError
156
+ @available_groups = {}
157
+ end
158
+ end
159
+ end
160
+ end