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 +4 -4
- data/lib/feature_map/cli.rb +51 -0
- data/lib/feature_map/configuration.rb +16 -3
- data/lib/feature_map/private/configured_commands.rb +94 -0
- data/lib/feature_map/private/pull_request_annotator.rb +294 -0
- data/lib/feature_map/private/release_log_publisher.rb +301 -0
- data/lib/feature_map/private/team_release_notifier.rb +118 -0
- data/lib/feature_map/private.rb +7 -1
- data/lib/feature_map.rb +12 -0
- metadata +45 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6a939e8112b0e1f3fd05c92db1292c6b5fd8bb7551b06818c8269c81e620ab28
|
|
4
|
+
data.tar.gz: 377d23e954d590917ec165f882538a79082dfda2e44a111b0f25f771a6430bc5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 78846256b085c0706f05f2a845fa2890486cab9a45603ef65d32f4bec53a5a777f80c7c457de51d50aa3bd732d9834f328442b39b5fa8b03c5e0633f0ebb4444
|
|
7
|
+
data.tar.gz: 2dc8a225029e728c61cd963d3007b568d3f95851279f91ba1af605ad99e4a450b99ecba2301dc22d183275d6c1f1871525b8f651cc84abda93c2587d2eb368ca
|
data/lib/feature_map/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/feature_map/private.rb
CHANGED
|
@@ -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.
|
|
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
|