feature_map 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a0dcdb071ddc3f13c6b54a523bcc537f863b257acf9a11df10efff8e7f5c6fe
4
- data.tar.gz: 6267a5894214c5a5e09c318e09a2e7e38be8db2ff57e3b2f921a122e0bb2efaa
3
+ metadata.gz: 6a939e8112b0e1f3fd05c92db1292c6b5fd8bb7551b06818c8269c81e620ab28
4
+ data.tar.gz: 377d23e954d590917ec165f882538a79082dfda2e44a111b0f25f771a6430bc5
5
5
  SHA512:
6
- metadata.gz: 8139590d22a89de94fed09f0542b9fd4be48ced297955490c8d042462e47fd41930077ebc4509ef708b1b376725c46f34e584388e7fb579c78c64182fa32c51d
7
- data.tar.gz: 5d878b6b3ba44784bba640cf7206b5caec0fee43668dffb009a06f0daf460805bad3f0a52f82fa3c21af859af9c7e574f22cb74d71609d5c98bd3d5113d6fbfe
6
+ metadata.gz: 78846256b085c0706f05f2a845fa2890486cab9a45603ef65d32f4bec53a5a777f80c7c457de51d50aa3bd732d9834f328442b39b5fa8b03c5e0633f0ebb4444
7
+ data.tar.gz: 2dc8a225029e728c61cd963d3007b568d3f95851279f91ba1af605ad99e4a450b99ecba2301dc22d183275d6c1f1871525b8f651cc84abda93c2587d2eb368ca
@@ -20,6 +20,12 @@ module FeatureMap
20
20
  test_pyramid!(argv)
21
21
  elsif command == 'additional_metrics'
22
22
  additional_metrics!(argv)
23
+ elsif command == 'github_pr_annotation'
24
+ github_pr_annotation!(argv)
25
+ elsif command == 'slack_release_log'
26
+ slack_release_log!(argv)
27
+ elsif command == 'slack_team_notifications'
28
+ slack_team_notifications!(argv)
23
29
  elsif command == 'for_file'
24
30
  for_file(argv)
25
31
  elsif command == 'for_feature'
@@ -33,6 +39,9 @@ module FeatureMap
33
39
  docs - generates feature documentation
34
40
  for_feature - find assignment information for a feature
35
41
  for_file - find feature assignment for a single file
42
+ github_pr_annotation - comments the features and metrics affected by a pull request and labels it
43
+ slack_release_log - posts the changes since the previous release to the release log thread in Slack
44
+ slack_team_notifications - posts each team's affected features for a release to the team's Slack channel
36
45
  test_coverage - generates per-feature test coverage statistics
37
46
  test_pyramid - generates per-feature test pyramid (unit, integration, regression) statistics
38
47
  additional_metrics - generates additional metrics per-feature (e.g. health score)
@@ -267,6 +276,48 @@ module FeatureMap
267
276
  FeatureMap.generate_additional_metrics!
268
277
  end
269
278
 
279
+ def self.github_pr_annotation!(argv)
280
+ parser = OptionParser.new do |opts|
281
+ opts.banner = 'Usage: bin/featuremap github_pr_annotation'
282
+
283
+ opts.on('--help', 'Shows this prompt') do
284
+ puts opts
285
+ exit
286
+ end
287
+ end
288
+ parser.parse!(argv)
289
+
290
+ FeatureMap.github_pr_annotation!
291
+ end
292
+
293
+ def self.slack_release_log!(argv)
294
+ parser = OptionParser.new do |opts|
295
+ opts.banner = 'Usage: bin/featuremap slack_release_log'
296
+
297
+ opts.on('--help', 'Shows this prompt') do
298
+ puts opts
299
+ exit
300
+ end
301
+ end
302
+ parser.parse!(argv)
303
+
304
+ FeatureMap.slack_release_log!
305
+ end
306
+
307
+ def self.slack_team_notifications!(argv)
308
+ parser = OptionParser.new do |opts|
309
+ opts.banner = 'Usage: bin/featuremap slack_team_notifications'
310
+
311
+ opts.on('--help', 'Shows this prompt') do
312
+ puts opts
313
+ exit
314
+ end
315
+ end
316
+ parser.parse!(argv)
317
+
318
+ FeatureMap.slack_team_notifications!
319
+ end
320
+
270
321
  # For now, this just returns feature assignment
271
322
  # Later, this could also return feature assignment errors about that file.
272
323
  def self.for_file(argv)
@@ -1,6 +1,13 @@
1
1
  # @feature Core Library
2
+ require 'erb'
3
+
2
4
  module FeatureMap
3
5
  class Configuration
6
+ GITHUB_PR_ANNOTATION_SECTION = 'github_pr_annotation'.freeze
7
+ SLACK_RELEASE_LOG_SECTION = 'slack_release_log'.freeze
8
+ SLACK_TEAM_NOTIFICATIONS_SECTION = 'slack_team_notifications'.freeze
9
+ COMMAND_SECTIONS = [GITHUB_PR_ANNOTATION_SECTION, SLACK_RELEASE_LOG_SECTION, SLACK_TEAM_NOTIFICATIONS_SECTION].freeze
10
+
4
11
  attr_reader :assigned_globs
5
12
  attr_reader :unassigned_globs
6
13
  attr_reader :unbuilt_gems_path
@@ -13,6 +20,7 @@ module FeatureMap
13
20
  attr_reader :repository
14
21
  attr_reader :documentation_site
15
22
  attr_reader :documentation_site_url
23
+ attr_reader :command_sections
16
24
 
17
25
  def initialize(
18
26
  assigned_globs: nil,
@@ -26,7 +34,8 @@ module FeatureMap
26
34
  code_cov: nil,
27
35
  repository: nil,
28
36
  documentation_site: nil,
29
- documentation_site_url: nil
37
+ documentation_site_url: nil,
38
+ command_sections: nil
30
39
  )
31
40
  @assigned_globs = assigned_globs
32
41
  @unassigned_globs = unassigned_globs
@@ -40,10 +49,13 @@ module FeatureMap
40
49
  @repository = repository
41
50
  @documentation_site = documentation_site
42
51
  @documentation_site_url = documentation_site_url
52
+ @command_sections = command_sections || {}
43
53
  end
44
54
 
45
55
  def self.fetch
46
- config_hash = YAML.load_file('.feature_map/config.yml')
56
+ full_hash = YAML.load(ERB.new(File.read('.feature_map/config.yml')).result) || {}
57
+ command_sections = full_hash.slice(*COMMAND_SECTIONS)
58
+ config_hash = full_hash.except(*COMMAND_SECTIONS)
47
59
 
48
60
  if config_hash.key?('require')
49
61
  config_hash['require'].each do |require_directive|
@@ -62,7 +74,8 @@ module FeatureMap
62
74
  code_cov: config_hash.fetch('code_cov', {}),
63
75
  repository: config_hash.fetch('repository', {}),
64
76
  documentation_site: config_hash.fetch('documentation_site', {}),
65
- documentation_site_url: config_hash.fetch('documentation_site_url', nil)
77
+ documentation_site_url: config_hash.fetch('documentation_site_url', nil),
78
+ command_sections: command_sections
66
79
  )
67
80
  end
68
81
  end
@@ -0,0 +1,94 @@
1
+ # @feature Core Library
2
+ # frozen_string_literal: true
3
+
4
+ module FeatureMap
5
+ module Private
6
+ module ConfiguredCommands
7
+ def self.github_pr_annotation
8
+ run(Configuration::GITHUB_PR_ANNOTATION_SECTION) do |settings|
9
+ require 'octokit'
10
+ PullRequestAnnotator.new(
11
+ required(settings, Configuration::GITHUB_PR_ANNOTATION_SECTION, 'github_token'),
12
+ full_repo_name,
13
+ required(settings, Configuration::GITHUB_PR_ANNOTATION_SECTION, 'pr_number'),
14
+ settings['base_metrics_path'].to_s,
15
+ doc_site_base_url
16
+ ).announce
17
+ end
18
+ end
19
+
20
+ def self.slack_release_log
21
+ run(Configuration::SLACK_RELEASE_LOG_SECTION) do |settings|
22
+ require 'octokit'
23
+ require 'slack-ruby-client'
24
+ ReleaseLogPublisher.new(
25
+ required(settings, Configuration::SLACK_RELEASE_LOG_SECTION, 'github_token'),
26
+ full_repo_name,
27
+ display_name(settings),
28
+ doc_site_base_url,
29
+ main_branch,
30
+ settings['slack_api_token'].to_s,
31
+ settings['slack_app_user_id'].to_s,
32
+ settings['slack_channel'].to_s,
33
+ settings['slack_thread_parent_ts'].to_s
34
+ ).publish
35
+ end
36
+ end
37
+
38
+ def self.slack_team_notifications
39
+ run(Configuration::SLACK_TEAM_NOTIFICATIONS_SECTION) do |settings|
40
+ require 'octokit'
41
+ require 'faraday'
42
+ TeamReleaseNotifier.new(
43
+ required(settings, Configuration::SLACK_TEAM_NOTIFICATIONS_SECTION, 'release_ref'),
44
+ required(settings, Configuration::SLACK_TEAM_NOTIFICATIONS_SECTION, 'github_token'),
45
+ full_repo_name,
46
+ display_name(settings),
47
+ (settings['teams'] || {}).transform_values(&:to_s)
48
+ ).notify
49
+ end
50
+ end
51
+
52
+ def self.run(section_name)
53
+ settings = Private.configuration.command_sections[section_name]
54
+ unless settings
55
+ puts "'#{section_name}' is not configured in .feature_map/config.yml. Skipping."
56
+ return
57
+ end
58
+
59
+ yield settings
60
+ end
61
+
62
+ def self.required(settings, section_name, key)
63
+ value = settings[key].to_s
64
+ raise "'#{section_name}.#{key}' must be set in .feature_map/config.yml" if value.empty?
65
+
66
+ value
67
+ end
68
+
69
+ def self.display_name(settings)
70
+ settings['display_name'].to_s.empty? ? full_repo_name.split('/').last : settings['display_name'].to_s
71
+ end
72
+
73
+ def self.full_repo_name
74
+ repository_url = Private.configuration.repository['url'].to_s
75
+ raise "'repository.url' must be set in .feature_map/config.yml" if repository_url.empty?
76
+
77
+ URI.parse(repository_url).path.delete_prefix('/').delete_suffix('.git')
78
+ end
79
+
80
+ def self.main_branch
81
+ Private.configuration.repository['main_branch'].to_s.empty? ? 'main' : Private.configuration.repository['main_branch']
82
+ end
83
+
84
+ def self.doc_site_base_url
85
+ url = Private.configuration.documentation_site_url.to_s
86
+ raise "'documentation_site_url' must be set in .feature_map/config.yml" if url.empty?
87
+
88
+ "#{url}#/"
89
+ end
90
+
91
+ private_class_method :run, :required, :display_name, :full_repo_name, :main_branch, :doc_site_base_url
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,294 @@
1
+ # @feature Core Library
2
+ # frozen_string_literal: true
3
+
4
+ module FeatureMap
5
+ module Private
6
+ class PullRequestAnnotator
7
+ METRICS_FILE_PATH = '.feature_map/metrics.yml'
8
+ COMMENT_MARKER = '<!-- FEATURE_ANALYSIS_COMMENT -->'
9
+ FEATURE_LABEL_COLOR = '0366d6'
10
+ FEATURE_METRIC_LABELS = {
11
+ 'abc_size' => 'ABC Size',
12
+ 'lines_of_code' => 'Lines of Code',
13
+ 'cyclomatic_complexity' => 'Cyclomatic Complexity'
14
+ }.freeze
15
+
16
+ def initialize(github_token, full_repo_name, pr_number, base_branch_metrics_path, doc_site_base_url)
17
+ @client = Octokit::Client.new(access_token: github_token, auto_paginate: true)
18
+ @full_repo_name = full_repo_name
19
+ @pr_number = pr_number
20
+ @doc_site_base_url = doc_site_base_url
21
+ @base_branch_metrics_path = base_branch_metrics_path
22
+ end
23
+
24
+ def announce
25
+ unless File.exist?(@base_branch_metrics_path)
26
+ puts "Base metrics not found at '#{@base_branch_metrics_path}'. Skipping feature announcement."
27
+ return
28
+ end
29
+
30
+ changed_files = lookup_changed_files
31
+ affected_features = identify_affected_features(changed_files)
32
+ feature_metrics = gather_feature_metrics(affected_features)
33
+ update_or_create_comment(affected_features, feature_metrics)
34
+ update_labels(affected_features.keys)
35
+ end
36
+
37
+ private
38
+
39
+ def lookup_changed_files
40
+ files = @client.pull_request_files(@full_repo_name, @pr_number)
41
+ changed_files = files.map(&:filename)
42
+
43
+ puts "\nFinal changed files: #{changed_files.inspect}"
44
+ changed_files
45
+ rescue StandardError => e
46
+ puts "Error fetching PR files: #{e.message}"
47
+ []
48
+ end
49
+
50
+ def identify_affected_features(changed_files)
51
+ affected = {}
52
+
53
+ changed_files.each do |file|
54
+ feature = FeatureMap.for_file(file)
55
+ next unless feature
56
+
57
+ affected[feature] ||= []
58
+ affected[feature] << file
59
+ end
60
+
61
+ puts "All Affected Features: #{affected.keys}"
62
+
63
+ affected
64
+ end
65
+
66
+ def gather_feature_metrics(affected_features)
67
+ base_branch_metrics = load_base_branch_feature_metrics
68
+ pr_branch_metrics = load_pr_branch_feature_metrics
69
+
70
+ metrics = {}
71
+
72
+ affected_features.each_key do |feature|
73
+ next if !pr_branch_metrics[feature.name] || !base_branch_metrics[feature.name]
74
+
75
+ feature_metrics = {}
76
+ FEATURE_METRIC_LABELS.each_key do |metric|
77
+ pr_branch_value = pr_branch_metrics[feature.name][metric] || 0
78
+ base_branch_value = base_branch_metrics[feature.name][metric] || 0
79
+ feature_metrics[metric] = { 'base_branch' => base_branch_value, 'pr_branch' => pr_branch_value }
80
+ end
81
+ metrics[feature.name] = feature_metrics
82
+ end
83
+
84
+ puts "\nMetrics for Affected Features: #{metrics}"
85
+
86
+ metrics
87
+ end
88
+
89
+ def load_base_branch_feature_metrics
90
+ YAML.load_file(@base_branch_metrics_path)['features']
91
+ rescue StandardError => e
92
+ puts "Error loading #{@base_branch_metrics_path}: #{e.class} - #{e.message}"
93
+ {}
94
+ end
95
+
96
+ def load_pr_branch_feature_metrics
97
+ YAML.load_file(METRICS_FILE_PATH)['features']
98
+ rescue StandardError => e
99
+ puts "Error loading #{METRICS_FILE_PATH}: #{e.class} - #{e.message}"
100
+ {}
101
+ end
102
+
103
+ def build_feature_message(affected_features, feature_metrics)
104
+ message = []
105
+ message << COMMENT_MARKER
106
+
107
+ return nil if affected_features.empty?
108
+
109
+ message << "## 📢 Features Affected by this PR\n"
110
+ message.concat(build_feature_sections(affected_features, feature_metrics))
111
+
112
+ message.join("\n")
113
+ end
114
+
115
+ def build_feature_sections(affected_features, feature_metrics)
116
+ sections = []
117
+ affected_features
118
+ .sort_by { |feature, _files| feature.name }
119
+ .each_with_index do |(feature, files), index|
120
+ sections << build_feature_section(
121
+ feature.name,
122
+ files,
123
+ feature_metrics,
124
+ is_last: index == affected_features.length - 1
125
+ )
126
+ end
127
+ sections
128
+ end
129
+
130
+ def build_feature_section(feature_name, files, feature_metrics, is_last: false)
131
+ section = []
132
+ puts "\nBuilding section for feature: '#{feature_name}'"
133
+ definition = FeatureMap::CodeFeatures.find(feature_name)
134
+ puts "Found definition: #{definition.inspect}"
135
+
136
+ docs_url = "#{@doc_site_base_url}#{URI::DEFAULT_PARSER.escape(feature_name)}"
137
+
138
+ section << "\n"
139
+ section << "#{feature_name} - [View Documentation →](#{docs_url})\n"
140
+
141
+ section << '<details>'
142
+ section << "<summary>#{files.length} file(s) changed</summary>\n"
143
+ section << "\n<br>\n"
144
+
145
+ section << "**Description:** #{definition.raw_hash['description']}\n" if definition&.raw_hash&.dig('description')
146
+
147
+ section << '**Changed Files:**'
148
+ files.sort.each do |file|
149
+ section << "- `#{file}`"
150
+ end
151
+
152
+ section << "\n<br>\n"
153
+
154
+ section.concat(build_feature_metrics_table(feature_name, feature_metrics))
155
+
156
+ section << "\n</details>\n"
157
+ section << "\n<hr>\n" unless is_last
158
+ section.join("\n")
159
+ end
160
+
161
+ def build_feature_metrics_table(feature_name, feature_metrics)
162
+ return ['*No metrics found for this feature.*'] unless feature_metrics[feature_name]
163
+
164
+ table = []
165
+ table << '| Metric | Initial Value | Change in Value (pct) | Final Value |'
166
+ table << '|---|:---:|:---:|:---:|'
167
+
168
+ feature_metrics[feature_name].each do |metric_name, values|
169
+ metric_label = FEATURE_METRIC_LABELS[metric_name] || metric_name.gsub('_', ' ').capitalize
170
+ initial_value = values['base_branch'] || 0
171
+ ending_value = values['pr_branch'] || 0
172
+ absolute_change = (ending_value - initial_value).round(2)
173
+ percentage_change = (absolute_change.to_f / initial_value * 100).round(2)
174
+
175
+ table << "| #{metric_label} | #{initial_value} | #{absolute_change} (#{percentage_change}%) | #{ending_value} |"
176
+ end
177
+
178
+ table
179
+ end
180
+
181
+ def find_existing_comment
182
+ comments = @client.issue_comments(@full_repo_name, @pr_number)
183
+ comments.find { |comment| comment.body.start_with?(COMMENT_MARKER) }
184
+ end
185
+
186
+ def update_or_create_comment(affected_features, feature_metrics)
187
+ message = build_feature_message(affected_features, feature_metrics)
188
+
189
+ existing_comment = find_existing_comment
190
+ if existing_comment
191
+ if message
192
+ update_comment(existing_comment, message)
193
+ puts 'Comment updated'
194
+ else
195
+ delete_comment(existing_comment)
196
+ puts 'No features found; Comment removed'
197
+ end
198
+ elsif message
199
+ add_comment(message)
200
+ puts 'Comment created'
201
+ else
202
+ puts 'No features found; Comment skipped'
203
+ end
204
+ end
205
+
206
+ def update_labels(features)
207
+ current_labels = lookup_current_labels
208
+ feature_labels = current_labels.select do |label|
209
+ label.start_with?(FeatureMap::CodeFeatures::Feature::LABEL_PREFIX)
210
+ end
211
+ affected_feature_labels = features.map(&:label)
212
+
213
+ puts "\nLabel Management:"
214
+ puts " Current feature labels: #{feature_labels.inspect}"
215
+ puts " Features to be labeled: #{features.map(&:name).inspect}"
216
+
217
+ remove_feature_labels(feature_labels, affected_feature_labels)
218
+
219
+ add_feature_labels(feature_labels, affected_feature_labels)
220
+ end
221
+
222
+ def remove_feature_labels(feature_labels, affected_feature_labels)
223
+ feature_labels
224
+ .reject { |existing_label| affected_feature_labels.include?(existing_label) }
225
+ .each do |existing_label|
226
+ puts " Removing: '#{existing_label}' (feature '#{existing_label}' no longer changed)"
227
+ remove_label(existing_label)
228
+ end
229
+ end
230
+
231
+ def add_feature_labels(feature_labels, affected_feature_labels)
232
+ affected_feature_labels.each do |label_name|
233
+ if feature_labels.include?(label_name)
234
+ puts " Keeping: '#{label_name}'"
235
+ else
236
+ puts " Adding: '#{label_name}'"
237
+ ensure_label_exists(label_name)
238
+ add_label(label_name)
239
+ end
240
+ end
241
+ end
242
+
243
+ def lookup_current_labels
244
+ issue = @client.issue(@full_repo_name, @pr_number)
245
+ issue.labels.map(&:name)
246
+ rescue StandardError => e
247
+ puts "Error fetching current labels: #{e.class} - #{e.message}"
248
+ []
249
+ end
250
+
251
+ def ensure_label_exists(label_name)
252
+ @client.label(@full_repo_name, label_name)
253
+ rescue Octokit::NotFound
254
+ create_label(label_name)
255
+ end
256
+
257
+ def add_comment(message)
258
+ @client.add_comment(@full_repo_name, @pr_number, message)
259
+ rescue StandardError => e
260
+ puts "Error adding comment: #{e.class} - #{e.message}"
261
+ end
262
+
263
+ def update_comment(existing_comment, message)
264
+ @client.update_comment(@full_repo_name, existing_comment.id, message)
265
+ rescue StandardError => e
266
+ puts "Error updating comment: #{e.class} - #{e.message}"
267
+ end
268
+
269
+ def delete_comment(existing_comment)
270
+ @client.delete_comment(@full_repo_name, existing_comment.id)
271
+ rescue StandardError => e
272
+ puts "Error deleting comment: #{e.class} - #{e.message}"
273
+ end
274
+
275
+ def create_label(label_name)
276
+ @client.add_label(@full_repo_name, label_name, FEATURE_LABEL_COLOR)
277
+ rescue StandardError => e
278
+ puts "Error creating label: #{e.class} - #{e.message}"
279
+ end
280
+
281
+ def add_label(label_name)
282
+ @client.add_labels_to_an_issue(@full_repo_name, @pr_number, [label_name])
283
+ rescue StandardError => e
284
+ puts "Error adding label: #{e.class} - #{e.message}"
285
+ end
286
+
287
+ def remove_label(label_name)
288
+ @client.remove_label(@full_repo_name, @pr_number, label_name)
289
+ rescue StandardError => e
290
+ puts "Error removing label: #{e.class} - #{e.message}"
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,301 @@
1
+ # @feature Core Library
2
+ # frozen_string_literal: true
3
+
4
+ module FeatureMap
5
+ module Private
6
+ class ReleaseLogPublisher
7
+ MAX_COMMITS_PER_RELEASE = 100
8
+ MAX_RELEASE_LOG_MESSAGE_AGE_IN_SECONDS = (24 * 60 * 60)
9
+
10
+ MAX_FEATURES_PER_COMMIT = 5
11
+ MAX_RELEASE_OUTLINE_BULLETS = 47
12
+
13
+ attr_reader :github_client, :full_repo_name, :display_name, :main_branch, :slack_api_token, :slack_app_user_id, :slack_channel,
14
+ :slack_thread_parent_ts, :doc_site_base_url
15
+
16
+ def initialize(github_token, full_repo_name, display_name, doc_site_base_url, main_branch, slack_api_token, slack_app_user_id, slack_channel, slack_thread_parent_ts)
17
+ @github_client = Octokit::Client.new(access_token: github_token)
18
+ @full_repo_name = full_repo_name
19
+ @display_name = display_name
20
+ @main_branch = main_branch
21
+ @slack_api_token = slack_api_token
22
+ @slack_app_user_id = slack_app_user_id
23
+ @slack_channel = slack_channel
24
+ @slack_thread_parent_ts = slack_thread_parent_ts
25
+ @doc_site_base_url = doc_site_base_url
26
+ end
27
+
28
+ def publish
29
+ previous_release_tag = find_previous_release_tag
30
+
31
+ commits = commits_since_release(previous_release_tag)
32
+ if commits.empty?
33
+ puts "No pending commits found.\n"
34
+ return
35
+ end
36
+
37
+ puts "Commits since last release:\n#{commits}\n\n"
38
+
39
+ features_by_commit = identify_features_for_commits(commits)
40
+
41
+ current_commit_sha = commits.first.sha
42
+ slack_message_blocks = build_release_log_message(features_by_commit, previous_release_tag, current_commit_sha)
43
+ post_release_log(slack_message_blocks)
44
+ end
45
+
46
+ private
47
+
48
+ def commits_since_release(release_tag)
49
+ commit_logs_since(release_tag).map do |commit_log|
50
+ commit_info = commit_log.split
51
+
52
+ sha = commit_info.shift
53
+ pull_request_number = commit_info.pop[2..-2] if commit_info.last.match?(/^\(#[0-9]+\)$/i)
54
+ description = commit_info.join(' ')
55
+ files = `git show --pretty="" --name-only #{sha}`.split("\n")
56
+
57
+ FeatureMap::Commit.new(sha:, description:, pull_request_number:, files:)
58
+ end
59
+ end
60
+
61
+ def commit_logs_since(release_tag)
62
+ load_repo_history
63
+ fetch_tag(release_tag)
64
+
65
+ commit_logs_command = "git log --oneline --decorate-refs-exclude=refs/tags \"#{release_tag}\"..\"origin/#{main_branch}\""
66
+ puts "Running '#{commit_logs_command}' to identify commits since release #{release_tag}\n"
67
+ commit_logs_in_current_release = `#{commit_logs_command}`
68
+ commit_logs_in_current_release.split("\n")
69
+ end
70
+
71
+ def find_previous_release_tag
72
+ releases = github_client.releases(full_repo_name)
73
+ published_releases = releases.reject { |release| release.draft || release.prerelease }
74
+
75
+ return published_releases.first.tag_name unless published_releases.empty?
76
+
77
+ raise 'No prior release found.'
78
+ end
79
+
80
+ def load_repo_history
81
+ `git fetch --depth #{MAX_COMMITS_PER_RELEASE} origin #{main_branch}`
82
+ end
83
+
84
+ def fetch_tag(tag_name)
85
+ command = "git fetch -n origin refs/tags/#{tag_name}:refs/tags/#{tag_name}"
86
+
87
+ puts "Running '#{command}' to fetch tag #{tag_name}\n"
88
+ `#{command}`
89
+ end
90
+
91
+ def identify_features_for_commits(commits)
92
+ commits.to_h do |commit|
93
+ [commit, commit.files.map { |file| FeatureMap.for_file(file) }.compact.uniq]
94
+ end
95
+ end
96
+
97
+ def build_release_log_message(features_by_commit, release_tag, current_commit_sha)
98
+ if features_by_commit.empty?
99
+ puts "No changes identified since previous release. Skipping notification.\n"
100
+ return
101
+ end
102
+
103
+ compare_url = github_compare_url(release_tag, current_commit_sha)
104
+ message_blocks = build_header_blocks(compare_url, current_commit_sha) +
105
+ build_release_outline(features_by_commit, compare_url)
106
+ puts "Release Log Message Content:\n#{message_blocks.to_json}\n\n"
107
+
108
+ message_blocks
109
+ end
110
+
111
+ def post_release_log(message_blocks)
112
+ required_configurations = [
113
+ { value: slack_api_token, label: 'Slack API token' },
114
+ { value: slack_channel, label: 'target Slack channel' },
115
+ { value: slack_thread_parent_ts, label: 'parent Slack message timestamp' }
116
+ ]
117
+ return unless required_configurations.all? do |required_config|
118
+ if required_config[:value].nil? || required_config[:value].empty?
119
+ puts "No #{required_config[:label]} configured. Skipping notification delivery.\n\n"
120
+ next false
121
+ end
122
+ true
123
+ end
124
+
125
+ puts 'Verifying Slack Client authentication...'
126
+ slack_client.auth_test
127
+
128
+ puts "Posting release log message to thread in #{slack_channel}..."
129
+ slack_client.chat_postMessage(channel: slack_channel, blocks: message_blocks, thread_ts: slack_thread_parent_ts)
130
+
131
+ remove_outdated_release_log_messages
132
+ end
133
+
134
+ def remove_outdated_release_log_messages
135
+ if slack_app_user_id.nil? || slack_app_user_id.empty?
136
+ puts "No Slack user ID configured for the Slack app. Skipping past release log cleanup.\n\n"
137
+ return
138
+ end
139
+
140
+ puts "Fetching release log messages for this repo from thread in #{slack_channel}..."
141
+ thread_messages_response = slack_client.conversations_replies(channel: slack_channel, ts: slack_thread_parent_ts)
142
+
143
+ unless thread_messages_response['ok']
144
+ puts "Outdated release log message retrieval failed: #{thread_messages_response.to_json}"
145
+ raise 'Failed to retrieve outdated release log messages.'
146
+ end
147
+
148
+ messages_to_delete = thread_messages_response['messages'].filter { |message| outdated_message?(message) }
149
+
150
+ puts "Found #{messages_to_delete.length} outdated messages in the release log thread for this repo to be deleted."
151
+ messages_to_delete.each do |message_data|
152
+ delete_outdated_message(message_data)
153
+ end
154
+
155
+ puts 'All outdated messages deleted.'
156
+ end
157
+
158
+ def delete_outdated_message(message_data)
159
+ slack_client.chat_delete(channel: slack_channel, ts: message_data['ts'])
160
+ rescue Slack::Web::Api::Errors::MessageNotFound => e
161
+ puts "Message #{message_data['ts']} was already deleted: #{e.message}"
162
+ end
163
+
164
+ def outdated_message?(slack_message)
165
+ !slack_message['user'].nil? &&
166
+ !slack_message['ts'].nil? &&
167
+ slack_message['user'] == slack_app_user_id &&
168
+ slack_message['ts'].split('.').first.to_i < oldest_relevant_message_ts
169
+ end
170
+
171
+ def oldest_relevant_message_ts
172
+ @oldest_relevant_message_ts ||= (Time.now - MAX_RELEASE_LOG_MESSAGE_AGE_IN_SECONDS).to_i
173
+ end
174
+
175
+ def github_compare_url(release_tag, current_commit_sha)
176
+ "https://github.com/#{full_repo_name}/compare/#{release_tag}...#{current_commit_sha}"
177
+ end
178
+
179
+ def build_header_blocks(compare_url, current_commit_sha)
180
+ [
181
+ {
182
+ type: 'header',
183
+ text: {
184
+ type: 'plain_text',
185
+ text: ":shipit: #{display_name} Changes"
186
+ }
187
+ },
188
+ {
189
+ type: 'section',
190
+ text: {
191
+ type: 'mrkdwn',
192
+ text: "Current Commit: <#{compare_url}|#{current_commit_sha}>\n"
193
+ }
194
+ }
195
+ ]
196
+ end
197
+
198
+ def build_release_outline(features_by_commit, compare_url)
199
+ return [] if features_by_commit.empty?
200
+
201
+ bullets_by_commit = features_by_commit.map do |commit, features|
202
+ [build_commit_bullet(commit)] + build_feature_sub_bullets(features)
203
+ end
204
+
205
+ return [build_rich_text_block(bullets_by_commit.flatten)] if bullets_by_commit.sum(&:length) <= MAX_RELEASE_OUTLINE_BULLETS
206
+
207
+ included_bullets = fit_commit_bullets(bullets_by_commit, MAX_RELEASE_OUTLINE_BULLETS - 1)
208
+ omitted_commit_count = bullets_by_commit.length - included_bullets.length
209
+
210
+ [
211
+ build_rich_text_block(included_bullets.flatten),
212
+ build_truncation_notice_block(omitted_commit_count, compare_url)
213
+ ]
214
+ end
215
+
216
+ def fit_commit_bullets(bullets_by_commit, bullet_budget)
217
+ bullet_count = 0
218
+ bullets_by_commit.take_while do |commit_bullets|
219
+ bullet_count += commit_bullets.length
220
+ bullet_count <= bullet_budget
221
+ end
222
+ end
223
+
224
+ def build_rich_text_block(bullets)
225
+ {
226
+ type: 'rich_text',
227
+ elements: bullets
228
+ }
229
+ end
230
+
231
+ def build_truncation_notice_block(omitted_commit_count, compare_url)
232
+ commits_label = omitted_commit_count == 1 ? 'commit' : 'commits'
233
+
234
+ {
235
+ type: 'section',
236
+ text: {
237
+ type: 'mrkdwn',
238
+ text: ":warning: Message truncated to fit Slack's limits. #{omitted_commit_count} older #{commits_label} " \
239
+ "not shown. <#{compare_url}|View the full comparison on GitHub>"
240
+ }
241
+ }
242
+ end
243
+
244
+ def build_commit_bullet(commit)
245
+ commit_description_details = [{ type: 'text', text: commit.description }]
246
+
247
+ if commit.pull_request_number
248
+ commit_description_details << { type: 'text', text: ' (' }
249
+ commit_description_details << { type: 'link', url: "https://github.com/#{full_repo_name}/pull/#{commit.pull_request_number}", text: "##{commit.pull_request_number}" }
250
+ commit_description_details << { type: 'text', text: ')' }
251
+ end
252
+
253
+ slack_list_item(0, commit_description_details)
254
+ end
255
+
256
+ def build_feature_sub_bullets(features)
257
+ return [slack_list_item(1, [{ type: 'text', text: 'No Feature' }])] if features.empty?
258
+
259
+ features
260
+ .first(MAX_FEATURES_PER_COMMIT)
261
+ .map { |feature| build_feature_sub_bullet(feature) }
262
+ .tap do |feature_bullets|
263
+ additional_features_count = features.length - MAX_FEATURES_PER_COMMIT
264
+ next unless additional_features_count.positive?
265
+
266
+ features_label = additional_features_count > 1 ? 'features' : 'feature'
267
+ feature_bullets << slack_list_item(1,
268
+ [{ type: 'text',
269
+ text: "And #{additional_features_count} other #{features_label}..." }])
270
+ end
271
+ end
272
+
273
+ def build_feature_sub_bullet(feature)
274
+ feature_description_details = [{ type: 'text', text: "#{feature.name} (" }]
275
+ doc_site_url = "#{doc_site_base_url}#{URI::DEFAULT_PARSER.escape(feature.name)}"
276
+ feature_description_details << { type: 'link', url: doc_site_url, text: 'View Documentation' }
277
+ feature_description_details << { type: 'text', text: ')' }
278
+
279
+ slack_list_item(1, feature_description_details)
280
+ end
281
+
282
+ def slack_list_item(indent, elements)
283
+ {
284
+ type: 'rich_text_list',
285
+ style: 'bullet',
286
+ indent:,
287
+ elements: [
288
+ {
289
+ type: 'rich_text_section',
290
+ elements:
291
+ }
292
+ ]
293
+ }
294
+ end
295
+
296
+ def slack_client
297
+ @slack_client ||= Slack::Web::Client.new(token: slack_api_token)
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,118 @@
1
+ # @feature Core Library
2
+ # frozen_string_literal: true
3
+
4
+ module FeatureMap
5
+ module Private
6
+ class TeamReleaseNotifier
7
+ MAX_COMMITS_PER_RELEASE = 100
8
+
9
+ attr_reader :release_tag, :github_client, :full_repo_name, :display_name, :team_slack_webhooks
10
+
11
+ def initialize(release_ref, github_token, full_repo_name, display_name, team_slack_webhooks)
12
+ unless release_ref.start_with?('refs/tags/')
13
+ raise "Release commit reference must be a tag (i.e. start with 'refs/tags/'). Received value: #{release_ref}"
14
+ end
15
+
16
+ @release_tag = release_ref[10..]
17
+ @github_client = Octokit::Client.new(access_token: github_token)
18
+ @full_repo_name = full_repo_name
19
+ @display_name = display_name
20
+ @team_slack_webhooks = team_slack_webhooks
21
+ end
22
+
23
+ def notify
24
+ commits = lookup_commits_in_release
25
+ puts "Commits in release #{release_tag}:\n#{commits}\n\n"
26
+
27
+ grouped_commits = FeatureMap.group_commits(commits)
28
+
29
+ deliver_release_notifications(grouped_commits)
30
+ end
31
+
32
+ private
33
+
34
+ def lookup_commits_in_release
35
+ commit_logs_in_current_release.map do |commit_log|
36
+ commit_info = commit_log.split
37
+
38
+ sha = commit_info.shift
39
+ pull_request_number = commit_info.pop[2..-2] if commit_info.last.match?(/^\(#[0-9]+\)$/i)
40
+ description = commit_info.join(' ')
41
+ files = `git show --pretty="" --name-only #{sha}`.split("\n")
42
+
43
+ FeatureMap::Commit.new(sha:, description:, pull_request_number:, files:)
44
+ end
45
+ end
46
+
47
+ def commit_logs_in_current_release
48
+ previous_release_tag = lookup_previous_release_tag
49
+
50
+ load_repo_history
51
+ fetch_tag(previous_release_tag)
52
+ fetch_tag(release_tag)
53
+
54
+ command = "git log --oneline --decorate-refs-exclude=refs/tags \"#{previous_release_tag}\"..\"#{release_tag}\""
55
+ puts "Running '#{command}' to identify commits in release #{release_tag}\n"
56
+ commit_logs_in_current_release = `#{command}`
57
+ commit_logs_in_current_release.split("\n")
58
+ end
59
+
60
+ def lookup_previous_release_tag
61
+ releases = github_client.releases(full_repo_name)
62
+ published_releases = releases.reject { |release| release.draft || release.prerelease }
63
+
64
+ published_releases.each_with_index do |release, index|
65
+ return published_releases[index + 1].tag_name if release.tag_name == release_tag
66
+ end
67
+
68
+ raise "No prior release found. Current release tag: #{release_tag}"
69
+ end
70
+
71
+ def load_repo_history
72
+ `git fetch --depth #{MAX_COMMITS_PER_RELEASE} origin #{release_tag}`
73
+ end
74
+
75
+ def fetch_tag(tag_name)
76
+ command = "git fetch -n origin refs/tags/#{tag_name}:refs/tags/#{tag_name}"
77
+
78
+ puts "Running '#{command}' to fetch tag #{tag_name}\n"
79
+ `#{command}`
80
+ end
81
+
82
+ def deliver_release_notifications(grouped_commits)
83
+ team_slack_webhooks.each do |team_name, slack_webhook|
84
+ if !grouped_commits[team_name] || grouped_commits[team_name].empty?
85
+ puts "No features for the #{team_name} Team impacted by release #{release_tag}. Skipping notification.\n"
86
+ next
87
+ end
88
+
89
+ message_blocks = FeatureMap.generate_release_notification(grouped_commits[team_name])
90
+
91
+ message_blocks = [header_block] + message_blocks
92
+ puts "Full Message Content for #{team_name} Team:\n#{message_blocks.to_json}\n"
93
+ if slack_webhook.nil? || slack_webhook.empty?
94
+ puts "No Slack webhook configured for team #{team_name}. Skipping notification delivery.\n\n"
95
+ next
96
+ end
97
+
98
+ post_team_notification(slack_webhook, message_blocks)
99
+ puts "Delivered release notification to team #{team_name}\n\n"
100
+ end
101
+ end
102
+
103
+ def header_block
104
+ {
105
+ type: 'header',
106
+ text: {
107
+ type: 'plain_text',
108
+ text: ":shipit: #{release_tag} Released in #{display_name}"
109
+ }
110
+ }
111
+ end
112
+
113
+ def post_team_notification(slack_webhook, message_blocks)
114
+ Faraday.post(slack_webhook, { blocks: message_blocks }.to_json, { 'Content-Type' => 'application/json' })
115
+ end
116
+ end
117
+ end
118
+ end
@@ -35,9 +35,15 @@ require 'feature_map/private/assignment_mappers/feature_globs'
35
35
  require 'feature_map/private/assignment_mappers/directory_assignment'
36
36
  require 'feature_map/private/assignment_mappers/feature_definition_assignment'
37
37
  require 'feature_map/private/release_notification_builder'
38
+ require 'feature_map/private/pull_request_annotator'
39
+ require 'feature_map/private/release_log_publisher'
40
+ require 'feature_map/private/team_release_notifier'
41
+ require 'feature_map/private/configured_commands'
38
42
 
39
43
  module FeatureMap
40
44
  module Private
45
+ DOCUMENTATION_SITE_CONFIGURATION_KEYS = %w[documentation_site repository].freeze
46
+
41
47
  def self.apply_assignments!(assignments_file_path)
42
48
  assignments = CSV.read(assignments_file_path)
43
49
  AssignmentApplicator.apply_assignments!(assignments)
@@ -102,7 +108,7 @@ module FeatureMap
102
108
  feature_test_coverage,
103
109
  feature_test_pyramid,
104
110
  feature_additional_metrics,
105
- configuration.raw_hash,
111
+ configuration.raw_hash.slice(*DOCUMENTATION_SITE_CONFIGURATION_KEYS),
106
112
  git_ref || configuration.repository['main_branch']
107
113
  )
108
114
  end
data/lib/feature_map.rb CHANGED
@@ -227,6 +227,18 @@ module FeatureMap
227
227
  Mapper.all.each(&:bust_caches!)
228
228
  end
229
229
 
230
+ def self.github_pr_annotation!
231
+ Private::ConfiguredCommands.github_pr_annotation
232
+ end
233
+
234
+ def self.slack_release_log!
235
+ Private::ConfiguredCommands.slack_release_log
236
+ end
237
+
238
+ def self.slack_team_notifications!
239
+ Private::ConfiguredCommands.slack_team_notifications
240
+ end
241
+
230
242
  def self.configuration
231
243
  Private.configuration
232
244
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feature_map
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Beyond Finance
@@ -51,6 +51,26 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '2.7'
54
+ - !ruby/object:Gem::Dependency
55
+ name: octokit
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '9'
61
+ - - "<"
62
+ - !ruby/object:Gem::Version
63
+ version: '11'
64
+ type: :runtime
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '9'
71
+ - - "<"
72
+ - !ruby/object:Gem::Version
73
+ version: '11'
54
74
  - !ruby/object:Gem::Dependency
55
75
  name: packs-specification
56
76
  requirement: !ruby/object:Gem::Requirement
@@ -93,6 +113,26 @@ dependencies:
93
113
  - - "~>"
94
114
  - !ruby/object:Gem::Version
95
115
  version: '1.0'
116
+ - !ruby/object:Gem::Dependency
117
+ name: slack-ruby-client
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '2'
123
+ - - "<"
124
+ - !ruby/object:Gem::Version
125
+ version: '4'
126
+ type: :runtime
127
+ prerelease: false
128
+ version_requirements: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '2'
133
+ - - "<"
134
+ - !ruby/object:Gem::Version
135
+ version: '4'
96
136
  - !ruby/object:Gem::Dependency
97
137
  name: uri
98
138
  requirement: !ruby/object:Gem::Requirement
@@ -228,6 +268,7 @@ files:
228
268
  - lib/feature_map/private/assignment_mappers/file_annotations.rb
229
269
  - lib/feature_map/private/assignments_file.rb
230
270
  - lib/feature_map/private/code_cov.rb
271
+ - lib/feature_map/private/configured_commands.rb
231
272
  - lib/feature_map/private/cyclomatic_complexity_calculator.rb
232
273
  - lib/feature_map/private/docs/index.html
233
274
  - lib/feature_map/private/documentation_site.rb
@@ -240,8 +281,11 @@ files:
240
281
  - lib/feature_map/private/lines_of_code_calculator.rb
241
282
  - lib/feature_map/private/metrics_file.rb
242
283
  - lib/feature_map/private/percentile_metrics_calculator.rb
284
+ - lib/feature_map/private/pull_request_annotator.rb
285
+ - lib/feature_map/private/release_log_publisher.rb
243
286
  - lib/feature_map/private/release_notification_builder.rb
244
287
  - lib/feature_map/private/simple_cov_resultsets.rb
288
+ - lib/feature_map/private/team_release_notifier.rb
245
289
  - lib/feature_map/private/test_coverage_file.rb
246
290
  - lib/feature_map/private/test_pyramid/jest_mapper.rb
247
291
  - lib/feature_map/private/test_pyramid/mapper.rb