releasehx 0.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 +7 -0
- data/README.adoc +2915 -0
- data/bin/releasehx +7 -0
- data/bin/rhx +7 -0
- data/bin/rhx-mcp +7 -0
- data/bin/sourcerer +32 -0
- data/build/docs/CNAME +1 -0
- data/build/docs/Gemfile.lock +95 -0
- data/build/docs/_config.yml +36 -0
- data/build/docs/config-reference.adoc +4104 -0
- data/build/docs/config-reference.json +1546 -0
- data/build/docs/index.adoc +2915 -0
- data/build/docs/landing.adoc +21 -0
- data/build/docs/manpage.adoc +68 -0
- data/build/docs/releasehx.1 +281 -0
- data/build/docs/releasehx_readme.html +367 -0
- data/build/docs/sample-config.adoc +9 -0
- data/build/docs/sample-config.yml +251 -0
- data/build/docs/schemagraphy_readme.html +0 -0
- data/build/docs/sourcerer_readme.html +46 -0
- data/build/snippets/helpscreen.txt +29 -0
- data/lib/docopslab/mcp/asset_packager.rb +30 -0
- data/lib/docopslab/mcp/manifest.rb +67 -0
- data/lib/docopslab/mcp/resource_pack.rb +46 -0
- data/lib/docopslab/mcp/server.rb +92 -0
- data/lib/docopslab/mcp.rb +6 -0
- data/lib/releasehx/cli.rb +937 -0
- data/lib/releasehx/configuration.rb +215 -0
- data/lib/releasehx/generated.rb +17 -0
- data/lib/releasehx/helpers.rb +58 -0
- data/lib/releasehx/mcp/asset_packager.rb +21 -0
- data/lib/releasehx/mcp/assets/agent-config-guide.md +178 -0
- data/lib/releasehx/mcp/assets/config-def.yml +1426 -0
- data/lib/releasehx/mcp/assets/config-reference.adoc +4104 -0
- data/lib/releasehx/mcp/assets/config-reference.json +1546 -0
- data/lib/releasehx/mcp/assets/sample-config.yml +251 -0
- data/lib/releasehx/mcp/manifest.rb +18 -0
- data/lib/releasehx/mcp/resource_pack.rb +26 -0
- data/lib/releasehx/mcp/server.rb +57 -0
- data/lib/releasehx/mcp.rb +7 -0
- data/lib/releasehx/ops/check_ops.rb +136 -0
- data/lib/releasehx/ops/draft_ops.rb +173 -0
- data/lib/releasehx/ops/enrich_ops.rb +221 -0
- data/lib/releasehx/ops/template_ops.rb +61 -0
- data/lib/releasehx/ops/write_ops.rb +124 -0
- data/lib/releasehx/rest/clients/github.yml +46 -0
- data/lib/releasehx/rest/clients/gitlab.yml +31 -0
- data/lib/releasehx/rest/clients/jira.yml +31 -0
- data/lib/releasehx/rest/yaml_client.rb +418 -0
- data/lib/releasehx/rhyml/adapter.rb +740 -0
- data/lib/releasehx/rhyml/change.rb +167 -0
- data/lib/releasehx/rhyml/liquid.rb +13 -0
- data/lib/releasehx/rhyml/loaders.rb +37 -0
- data/lib/releasehx/rhyml/mappings/github.yaml +60 -0
- data/lib/releasehx/rhyml/mappings/gitlab.yaml +73 -0
- data/lib/releasehx/rhyml/mappings/jira.yaml +29 -0
- data/lib/releasehx/rhyml/mappings/verb_past_tenses.yml +98 -0
- data/lib/releasehx/rhyml/release.rb +144 -0
- data/lib/releasehx/rhyml.rb +15 -0
- data/lib/releasehx/sgyml/helpers.rb +45 -0
- data/lib/releasehx/transforms/adf_to_markdown.rb +307 -0
- data/lib/releasehx/version.rb +7 -0
- data/lib/releasehx.rb +69 -0
- data/lib/schemagraphy/attribute_resolver.rb +48 -0
- data/lib/schemagraphy/cfgyml/definition.rb +90 -0
- data/lib/schemagraphy/cfgyml/doc_builder.rb +52 -0
- data/lib/schemagraphy/cfgyml/path_reference.rb +24 -0
- data/lib/schemagraphy/data_query/json_pointer.rb +42 -0
- data/lib/schemagraphy/loader.rb +59 -0
- data/lib/schemagraphy/regexp_utils.rb +215 -0
- data/lib/schemagraphy/safe_expression.rb +189 -0
- data/lib/schemagraphy/schema_utils.rb +124 -0
- data/lib/schemagraphy/tag_utils.rb +32 -0
- data/lib/schemagraphy/templating.rb +104 -0
- data/lib/schemagraphy.rb +17 -0
- data/lib/sourcerer/builder.rb +120 -0
- data/lib/sourcerer/jekyll/bootstrapper.rb +78 -0
- data/lib/sourcerer/jekyll/liquid/file_system.rb +74 -0
- data/lib/sourcerer/jekyll/liquid/filters.rb +215 -0
- data/lib/sourcerer/jekyll/liquid/tags.rb +44 -0
- data/lib/sourcerer/jekyll/monkeypatches.rb +73 -0
- data/lib/sourcerer/jekyll.rb +26 -0
- data/lib/sourcerer/plaintext_converter.rb +75 -0
- data/lib/sourcerer/templating.rb +190 -0
- data/lib/sourcerer.rb +322 -0
- data/specs/data/api-client-schema.yaml +160 -0
- data/specs/data/config-def.yml +1426 -0
- data/specs/data/mcp-manifest.yml +50 -0
- data/specs/data/rhyml-mapping-schema.yaml +410 -0
- data/specs/data/rhyml-schema.yaml +152 -0
- metadata +376 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jsonpath'
|
|
4
|
+
require 'jmespath'
|
|
5
|
+
require 'liquid'
|
|
6
|
+
require 'erb'
|
|
7
|
+
require 'yaml'
|
|
8
|
+
require 'json'
|
|
9
|
+
require_relative '../../schemagraphy'
|
|
10
|
+
require_relative '../../schemagraphy/safe_expression'
|
|
11
|
+
require_relative '../../sourcerer/jekyll'
|
|
12
|
+
|
|
13
|
+
module ReleaseHx
|
|
14
|
+
module RHYML
|
|
15
|
+
# Transforms raw API payloads into structured RHYML Release objects.
|
|
16
|
+
#
|
|
17
|
+
# Uses RHYML mapping definitions to extract, transform, and normalize
|
|
18
|
+
# data from various issue management systems into a consistent Release structure.
|
|
19
|
+
class Adapter
|
|
20
|
+
SCHEMA_PATH = File.expand_path('../../../specs/data/rhyml-mapping-schema.yaml', __dir__)
|
|
21
|
+
MAPPING_SCHEMA = SchemaGraphy::Loader.load_yaml_with_tags(SCHEMA_PATH)['$schema']
|
|
22
|
+
SKIP_KEYS = %w[$meta $config changes_array_path].freeze
|
|
23
|
+
|
|
24
|
+
# Initializes a new adapter instance with mapping configuration and runtime config.
|
|
25
|
+
#
|
|
26
|
+
# @param mapping [Hash] The RHYML mapping definition containing field transformations.
|
|
27
|
+
# @param config [Hash] Application runtime configuration.
|
|
28
|
+
def initialize mapping:, config:
|
|
29
|
+
@mapping = mapping
|
|
30
|
+
@config = config
|
|
31
|
+
@defaults = load_defaults
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Transforms a raw data payload into a Release object with mapped changes.
|
|
35
|
+
#
|
|
36
|
+
# @param payload [Hash] The raw data payload to transform (ex: GitHub API response).
|
|
37
|
+
# @param release_code [String] The release identifier/version code.
|
|
38
|
+
# @param release_date [String, nil] Optional release date.
|
|
39
|
+
# @param release_hash [String, nil] Optional git commit hash for the release.
|
|
40
|
+
# @param release_memo [String, nil] Optional release memo/description.
|
|
41
|
+
# @param scan [Boolean] Whether to enable detailed debug logging (default: false).
|
|
42
|
+
# @return [Release] A Release object containing the mapped changes.
|
|
43
|
+
def to_release payload, release_code:, release_date: nil, release_hash: nil, release_memo: nil, scan: false
|
|
44
|
+
ReleaseHx.logger.debug "Adapter.to_release called (scan = #{scan})"
|
|
45
|
+
array_path = mapping['changes_array_path']
|
|
46
|
+
raw_items = resolve_path(array_path, payload)
|
|
47
|
+
|
|
48
|
+
if raw_items.nil?
|
|
49
|
+
payload_info = payload.is_a?(Hash) ? payload.keys.inspect : payload.class.name
|
|
50
|
+
ReleaseHx.logger.error "Failed to extract items from path '#{array_path}'. Payload: #{payload_info}"
|
|
51
|
+
raw_items = []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ReleaseHx.logger.debug "Extracted raw_items (#{raw_items.size}) from path '#{array_path}'"
|
|
55
|
+
ReleaseHx.logger.dump "First raw item: #{raw_items.first.inspect}"
|
|
56
|
+
|
|
57
|
+
release = Release.new(
|
|
58
|
+
code: release_code,
|
|
59
|
+
date: release_date,
|
|
60
|
+
hash: release_hash,
|
|
61
|
+
memo: release_memo,
|
|
62
|
+
changes: [])
|
|
63
|
+
|
|
64
|
+
ReleaseHx.logger.debug "Mapping #{raw_items.size} raw items..."
|
|
65
|
+
|
|
66
|
+
changes = raw_items.map { |raw| transform_change(raw, release: release, scan: scan) }.compact
|
|
67
|
+
|
|
68
|
+
if changes.empty?
|
|
69
|
+
ReleaseHx.logger.warn(
|
|
70
|
+
'All mapped changes were nil after transformation. ' \
|
|
71
|
+
"No changes attached to release #{release_code}.")
|
|
72
|
+
else
|
|
73
|
+
with_notes = changes.count { |c| c.note.to_s.strip != '' }
|
|
74
|
+
ReleaseHx.logger.info(
|
|
75
|
+
"Transformed #{changes.size} changes for release #{release_code} (#{with_notes} with notes)")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
ReleaseHx.logger.debug "Adding #{changes.size} changes to release" if scan
|
|
79
|
+
ReleaseHx.logger.debug "First change keys: #{changes.first.to_h.keys.inspect}" if scan && changes.any?
|
|
80
|
+
release.instance_variable_set(:@changes, changes)
|
|
81
|
+
|
|
82
|
+
release
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
attr_reader :mapping, :config, :defaults
|
|
88
|
+
|
|
89
|
+
# @api private
|
|
90
|
+
# Loads default configuration values from the RHYML mapping schema.
|
|
91
|
+
# Provides fallback values for path language and template language.
|
|
92
|
+
#
|
|
93
|
+
# @return [Hash] A hash containing default configuration values.
|
|
94
|
+
def load_defaults
|
|
95
|
+
{
|
|
96
|
+
'path_lang' => SchemaGraphy::SchemaUtils.default_for(MAPPING_SCHEMA, '$config.path_lang') || 'jmespath',
|
|
97
|
+
'tplt_lang' => SchemaGraphy::SchemaUtils.default_for(MAPPING_SCHEMA, '$config.tplt_lang') || 'liquid'
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @api private
|
|
102
|
+
# Transforms a single raw item into a Change object after mapping and post-processing.
|
|
103
|
+
#
|
|
104
|
+
# @param raw [Hash] The raw data item to transform.
|
|
105
|
+
# @param release [Release] The parent release object.
|
|
106
|
+
# @param scan [Boolean] Whether to enable detailed debug logging (default: false).
|
|
107
|
+
# @return [Change, nil] A Change object, or nil if the change should be skipped.
|
|
108
|
+
def transform_change raw, release:, scan: false
|
|
109
|
+
mapped = map_single_change(raw, release: release)
|
|
110
|
+
ReleaseHx.logger.dump "map_single_change returned: #{mapped.class} - #{mapped.inspect}"
|
|
111
|
+
|
|
112
|
+
shaped = postprocess(mapped, scan: scan)
|
|
113
|
+
|
|
114
|
+
ReleaseHx.logger.dump "Mapped: #{mapped.inspect}"
|
|
115
|
+
ReleaseHx.logger.dump "Postprocessed: #{shaped.inspect}"
|
|
116
|
+
|
|
117
|
+
if shaped.nil?
|
|
118
|
+
ReleaseHx.logger.debug "Change dropped after postprocess: #{mapped.inspect}"
|
|
119
|
+
return nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
Change.new(shaped, release: release)
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
ReleaseHx.logger.warn "Change transform error: #{e.class}: #{e.message}"
|
|
125
|
+
ReleaseHx.logger.debug e.backtrace.join("\n")
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# FIXME: This method's complexity is high and handles multiple responsibilities.
|
|
130
|
+
# Should be refactored into smaller, focused methods for better maintainability.
|
|
131
|
+
# A comprehensive refactor is planned for post-0.1.0 releases.
|
|
132
|
+
def map_single_change raw, release:
|
|
133
|
+
result = {}
|
|
134
|
+
path_lang = mapping.dig('$config', 'path_lang') || defaults['path_lang']
|
|
135
|
+
context = { 'config' => @config }
|
|
136
|
+
|
|
137
|
+
ReleaseHx.logger.dump "map_single_change starting, result class: #{result.class}"
|
|
138
|
+
|
|
139
|
+
mapping.each do |key, defn|
|
|
140
|
+
next if SKIP_KEYS.include?(key) || key.start_with?('_') || defn.nil?
|
|
141
|
+
|
|
142
|
+
ReleaseHx.logger.debug "Processing mapping field: #{key}"
|
|
143
|
+
|
|
144
|
+
# STEP 1: Render Path Expression if templated
|
|
145
|
+
path_expr = render_if_templated(defn['path'], context, key, 'path')
|
|
146
|
+
|
|
147
|
+
# STEP 2: Extract value via path (JMESPath or JSONPath)
|
|
148
|
+
extracted_value = extract_value(raw, path_expr, path_lang)
|
|
149
|
+
|
|
150
|
+
# STEP 3: Apply transformations
|
|
151
|
+
current_value = if defn['ruby']
|
|
152
|
+
apply_ruby_transform(extracted_value, defn, key)
|
|
153
|
+
elsif defn['tplt']
|
|
154
|
+
apply_template_transform(extracted_value, defn, key)
|
|
155
|
+
else
|
|
156
|
+
extracted_value
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
result[key] = apply_pasterization(key, current_value)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# STEP 4: Generate chid if a template is provided
|
|
163
|
+
generate_chid!(result, release)
|
|
164
|
+
|
|
165
|
+
# Attach raw payload for downstream logic (e.g., placeholder note)
|
|
166
|
+
result['raw'] = raw
|
|
167
|
+
|
|
168
|
+
ReleaseHx.logger.dump "map_single_change ending, result: #{result.inspect} (class: #{result.class})"
|
|
169
|
+
result
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# @api private
|
|
173
|
+
# Generates a change ID (chid) using the configured template.
|
|
174
|
+
# Updates the result hash in-place with the generated chid.
|
|
175
|
+
#
|
|
176
|
+
# @param result [Hash] The mapped change data hash to update.
|
|
177
|
+
# @param release [Release] The parent release object for context.
|
|
178
|
+
# @return [void]
|
|
179
|
+
def generate_chid! result, release
|
|
180
|
+
chid_template = @config.dig('rhyml', 'chid')
|
|
181
|
+
return unless chid_template
|
|
182
|
+
|
|
183
|
+
ctx = {
|
|
184
|
+
'change' => result,
|
|
185
|
+
'release' => {
|
|
186
|
+
'code' => release.code,
|
|
187
|
+
'date' => release.date,
|
|
188
|
+
'hash' => release.hash
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
initialize_liquid_filters
|
|
193
|
+
|
|
194
|
+
mapped_chid = if chid_template.respond_to?(:templated?) && chid_template.respond_to?(:render)
|
|
195
|
+
chid_template.render(ctx)
|
|
196
|
+
else
|
|
197
|
+
template = Liquid::Template.parse(chid_template.to_s)
|
|
198
|
+
template.render(
|
|
199
|
+
ctx,
|
|
200
|
+
filters: [::ReleaseHx::RHYML::RHYMLFilters,
|
|
201
|
+
::Sourcerer::Jekyll::Liquid::Filters])
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
result['chid'] = mapped_chid.strip unless mapped_chid.to_s.strip.empty?
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# @api private
|
|
208
|
+
# Applies Ruby code transformation to a value using SchemaGraphy's safe transformation.
|
|
209
|
+
#
|
|
210
|
+
# @param value [Object] The value to transform.
|
|
211
|
+
# @param defn [Hash] The field definition containing the Ruby transformation code.
|
|
212
|
+
# @param key [String] The field key being processed (for error reporting).
|
|
213
|
+
# @return [Object] The transformed value, or original value if transformation fails.
|
|
214
|
+
def apply_ruby_transform value, defn, key
|
|
215
|
+
transformer = SchemaGraphy::SafeTransform.new
|
|
216
|
+
transformer.add_context('path', value)
|
|
217
|
+
transformer.add_context('config', @config)
|
|
218
|
+
transformer.transform(defn['ruby'])
|
|
219
|
+
rescue StandardError => e
|
|
220
|
+
ReleaseHx.logger.error "Ruby execution error for '#{key}': #{e.message}"
|
|
221
|
+
ReleaseHx.logger.debug "Context: path=#{value.inspect}, config=#{@config.inspect}"
|
|
222
|
+
value # Return original value on error
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @api private
|
|
226
|
+
# Applies template transformation to a value using the configured template engine.
|
|
227
|
+
#
|
|
228
|
+
# @param value [Object] The value to transform.
|
|
229
|
+
# @param defn [Hash] The field definition containing the template.
|
|
230
|
+
# @param key [String] The field key being processed (for error reporting).
|
|
231
|
+
# @return [Object] The transformed value.
|
|
232
|
+
def apply_template_transform value, defn, key
|
|
233
|
+
context = { 'path' => value }
|
|
234
|
+
render_if_templated(defn['tplt'], context, key, 'tplt')
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# @api private
|
|
238
|
+
# Applies pasterization (past tense conversion) to field values if configured.
|
|
239
|
+
# Converts verbs to past tense for fields like 'head' and 'summ'.
|
|
240
|
+
#
|
|
241
|
+
# @param key [String] The field key being processed.
|
|
242
|
+
# @param value [String] The value to potentially pasterize.
|
|
243
|
+
# @return [String] The pasterized value or original value if not configured.
|
|
244
|
+
def apply_pasterization key, value
|
|
245
|
+
return value unless (key == 'head' && @config.dig('rhyml', 'pasterize_head')) ||
|
|
246
|
+
(key == 'summ' && @config.dig('rhyml', 'pasterize_summ'))
|
|
247
|
+
|
|
248
|
+
ReleaseHx::RHYML.pasterize(value)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @api private
|
|
252
|
+
# Renders a templated field string using the configured template engine.
|
|
253
|
+
#
|
|
254
|
+
# @param template_def [String, Hash] The template definition to render.
|
|
255
|
+
# @param context [Hash] The context variables for template rendering.
|
|
256
|
+
# @param key [String] The field key being processed (for error reporting).
|
|
257
|
+
# @param field_type [String] The type of field being rendered (for error reporting).
|
|
258
|
+
# @return [String] The rendered template result or original value if not templated.
|
|
259
|
+
def render_if_templated template_def, context, key, field_type
|
|
260
|
+
return template_def unless template_def.is_a?(String) || (template_def.is_a?(Hash) && template_def['value'])
|
|
261
|
+
|
|
262
|
+
engine = defaults['tplt_lang'] || 'liquid'
|
|
263
|
+
raw_tpl = template_def.is_a?(Hash) && template_def['__tag__'] ? template_def['value'] : template_def
|
|
264
|
+
|
|
265
|
+
case engine
|
|
266
|
+
when 'liquid'
|
|
267
|
+
initialize_liquid_filters
|
|
268
|
+
template = ::Liquid::Template.parse(raw_tpl)
|
|
269
|
+
template.render(context)
|
|
270
|
+
when 'erb'
|
|
271
|
+
compiled = ERB.new(raw_tpl)
|
|
272
|
+
compiled.result_with_hash(context)
|
|
273
|
+
else
|
|
274
|
+
raise "Unsupported template engine: #{engine}"
|
|
275
|
+
end
|
|
276
|
+
rescue StandardError => e
|
|
277
|
+
raise "Error rendering '#{field_type}' template for '#{key}': #{e.message}"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @api private
|
|
281
|
+
# Extracts a value from data using a path expression and the specified path language.
|
|
282
|
+
#
|
|
283
|
+
# @param data [Hash] The data structure to extract from.
|
|
284
|
+
# @param path [String] The path expression to evaluate.
|
|
285
|
+
# @param lang [String] The path language to use ('jmespath' or 'jsonpath').
|
|
286
|
+
# @return [Object, nil] The extracted value or nil if extraction fails.
|
|
287
|
+
def extract_value data, path, lang
|
|
288
|
+
return nil unless path.is_a?(String) && !path.empty?
|
|
289
|
+
|
|
290
|
+
case lang.downcase
|
|
291
|
+
when 'jmespath'
|
|
292
|
+
JMESPath.search(path, data)
|
|
293
|
+
when 'jsonpath'
|
|
294
|
+
JsonPath.new(path).on(data)
|
|
295
|
+
else
|
|
296
|
+
raise "Unsupported path interpreter: #{lang}"
|
|
297
|
+
end
|
|
298
|
+
rescue StandardError => e
|
|
299
|
+
ReleaseHx.logger.error "Path extraction error (#{lang}): '#{path}' – #{e.message}"
|
|
300
|
+
nil
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# @api private
|
|
304
|
+
# Resolves a path expression against data using the configured or overridden path language.
|
|
305
|
+
#
|
|
306
|
+
# @param expr [String] The path expression to resolve.
|
|
307
|
+
# @param data [Hash] The data structure to extract from.
|
|
308
|
+
# @param override_lang [String, nil] Optional language override for path resolution.
|
|
309
|
+
# @return [Object, nil] The resolved value.
|
|
310
|
+
def resolve_path expr, data, override_lang = nil
|
|
311
|
+
engine = (override_lang || mapping.dig('$config', 'path_lang') || defaults['path_lang']).downcase
|
|
312
|
+
extract_value(data, expr, engine)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# @api private
|
|
316
|
+
# Post-processes mapped change data by extracting notes/heads and applying filtering logic.
|
|
317
|
+
#
|
|
318
|
+
# @param data [Hash] The mapped change data to post-process.
|
|
319
|
+
# @param scan [Boolean] Whether to enable detailed debug logging (default: false).
|
|
320
|
+
# @return [Hash, nil] The post-processed data or nil if the change should be skipped.
|
|
321
|
+
def postprocess data, scan: false
|
|
322
|
+
ReleaseHx.logger.debug "Entering postprocess with scan=#{scan}"
|
|
323
|
+
ReleaseHx.logger.dump "Data before compact: #{data.inspect}"
|
|
324
|
+
|
|
325
|
+
data.compact!
|
|
326
|
+
extract_note_and_head!(data)
|
|
327
|
+
|
|
328
|
+
# Save original tags before filtering for display
|
|
329
|
+
original_tags = data['tags'].dup if data['tags']
|
|
330
|
+
data['tags'] = process_tags(data['tags'], data['note'])
|
|
331
|
+
|
|
332
|
+
# Handle placeholder notes based on raw tags
|
|
333
|
+
handle_placeholder_notes!(data, original_tags)
|
|
334
|
+
|
|
335
|
+
ReleaseHx.logger.debug "Evaluating skip logic for: #{data['tick']}" if scan
|
|
336
|
+
|
|
337
|
+
skip_change?(data, scan: scan) ? nil : data
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# @api private
|
|
341
|
+
# Extracts note and head content from change data using configured patterns.
|
|
342
|
+
#
|
|
343
|
+
# @param data [Hash] The change data to extract from.
|
|
344
|
+
# @return [void]
|
|
345
|
+
def extract_note_and_head! data
|
|
346
|
+
sources = SchemaGraphy::TagUtils.detag(@config['conversions']) || {}
|
|
347
|
+
templates = SchemaGraphy::TagUtils.detag(@config['rhyml']) || {}
|
|
348
|
+
|
|
349
|
+
note_pattern = sources['note_pattern'] || templates['note_pattern']
|
|
350
|
+
head_pattern = sources['head_pattern'] || templates['head_pattern']
|
|
351
|
+
head_source = sources['head_source']
|
|
352
|
+
note_source = sources['note_source']
|
|
353
|
+
|
|
354
|
+
extract_note!(data, note_source, note_pattern)
|
|
355
|
+
extract_head!(data, head_source, head_pattern)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# @api private
|
|
359
|
+
# Extracts note content from issue body using a configured regex pattern.
|
|
360
|
+
# Also handles ADF (Atlassian Document Format) transformation to Markdown.
|
|
361
|
+
#
|
|
362
|
+
# @param data [Hash] The change data containing the note to extract from.
|
|
363
|
+
# @param note_source [String] The source field specification for note extraction.
|
|
364
|
+
# @param note_pattern [String] The regex pattern for extracting notes.
|
|
365
|
+
# @return [void]
|
|
366
|
+
def extract_note! data, note_source, note_pattern
|
|
367
|
+
# Keep original content if no match or error
|
|
368
|
+
original_content = data['note']
|
|
369
|
+
return unless original_content
|
|
370
|
+
|
|
371
|
+
# STEP 1: Transform ADF to Markdown if applicable
|
|
372
|
+
if original_content.is_a?(Hash) && ReleaseHx::Transforms::AdfToMarkdown.adf?(original_content)
|
|
373
|
+
ReleaseHx.logger.debug 'Detected ADF format in note field, converting to Markdown'
|
|
374
|
+
|
|
375
|
+
begin
|
|
376
|
+
# Get section heading from config (only used for description-based extraction)
|
|
377
|
+
section_heading = @config.dig('sources', 'note_heading')
|
|
378
|
+
adf_to_convert = original_content
|
|
379
|
+
|
|
380
|
+
# Only extract section if we have a heading configured (description-based notes)
|
|
381
|
+
# For custom fields, convert the entire ADF content
|
|
382
|
+
if section_heading && !section_heading.empty?
|
|
383
|
+
ReleaseHx.logger.debug "Extracting '#{section_heading}' section from ADF"
|
|
384
|
+
adf_to_convert = ReleaseHx::Transforms::AdfToMarkdown.extract_section(
|
|
385
|
+
original_content,
|
|
386
|
+
heading: section_heading)
|
|
387
|
+
else
|
|
388
|
+
ReleaseHx.logger.debug 'No note_heading configured, converting entire ADF content'
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Convert to Markdown
|
|
392
|
+
markdown_note = ReleaseHx::Transforms::AdfToMarkdown.convert(adf_to_convert)
|
|
393
|
+
|
|
394
|
+
# Update data with Markdown version
|
|
395
|
+
data['note'] = markdown_note
|
|
396
|
+
data['note_fmt'] = 'md' # Track format for template routing
|
|
397
|
+
|
|
398
|
+
ReleaseHx.logger.debug "ADF converted to Markdown (#{markdown_note.length} chars)"
|
|
399
|
+
original_content = markdown_note # Update for subsequent processing
|
|
400
|
+
rescue StandardError => e
|
|
401
|
+
ReleaseHx.logger.warn "ADF conversion error: #{e.message}"
|
|
402
|
+
ReleaseHx.logger.debug e.backtrace.join("\n")
|
|
403
|
+
# Fall back to original content on error
|
|
404
|
+
data['note'] = original_content.to_s
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# STEP 2: Apply regex pattern extraction if configured
|
|
409
|
+
return unless note_source =~ /issue_body/i && original_content.is_a?(String) && note_pattern.is_a?(String)
|
|
410
|
+
|
|
411
|
+
ReleaseHx.logger.debug "Extracting note using pattern: #{note_pattern}"
|
|
412
|
+
ReleaseHx.logger.debug "Original content: #{original_content}"
|
|
413
|
+
|
|
414
|
+
begin
|
|
415
|
+
# Apply sensible default flag 'm' (multiline/dotall in Ruby) when no flags provided
|
|
416
|
+
pattern_info = SchemaGraphy::RegexpUtils.parse_pattern(note_pattern, 'm')
|
|
417
|
+
ReleaseHx.logger.debug "Parsed pattern: #{pattern_info.inspect}"
|
|
418
|
+
|
|
419
|
+
extracted_note = SchemaGraphy::RegexpUtils.extract_capture(
|
|
420
|
+
original_content,
|
|
421
|
+
pattern_info,
|
|
422
|
+
'note')
|
|
423
|
+
|
|
424
|
+
# Only update if we got a match
|
|
425
|
+
data['note'] = extracted_note.strip if extracted_note
|
|
426
|
+
rescue RegexpError => e
|
|
427
|
+
ReleaseHx.logger.warn "Invalid note_pattern '#{note_pattern}': #{e.message}"
|
|
428
|
+
data['note'] = original_content # Restore original on error
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# @api private
|
|
433
|
+
# Extracts head content from release note content using a configured regex pattern.
|
|
434
|
+
# Uses dual-strategy logic for pattern matching against blocks and individual lines.
|
|
435
|
+
#
|
|
436
|
+
# @param data [Hash] The change data containing the note to extract head from.
|
|
437
|
+
# @param head_source [String] The source field specification for head extraction.
|
|
438
|
+
# @param head_pattern [String] The regex pattern for extracting heads.
|
|
439
|
+
# @return [void]
|
|
440
|
+
def extract_head! data, head_source, head_pattern
|
|
441
|
+
return unless head_source =~ /release_note_heading/i && data['note'] && head_pattern.is_a?(String)
|
|
442
|
+
|
|
443
|
+
ReleaseHx.logger.debug "Extracting head using pattern: #{head_pattern}"
|
|
444
|
+
ReleaseHx.logger.debug "Note content: #{data['note']}"
|
|
445
|
+
|
|
446
|
+
begin
|
|
447
|
+
pattern_info = SchemaGraphy::RegexpUtils.parse_pattern(head_pattern, 'm')
|
|
448
|
+
note_content = data['note']
|
|
449
|
+
|
|
450
|
+
extracted_head, matched_segment = extract_head_from_block(note_content, pattern_info)
|
|
451
|
+
extracted_head, matched_segment = extract_head_from_lines(note_content, pattern_info) unless extracted_head
|
|
452
|
+
|
|
453
|
+
if extracted_head
|
|
454
|
+
data['head'] = extracted_head.strip
|
|
455
|
+
data['note'] = note_content.sub(matched_segment, '').strip if matched_segment
|
|
456
|
+
end
|
|
457
|
+
rescue RegexpError => e
|
|
458
|
+
ReleaseHx.logger.warn "Invalid head_pattern '#{head_pattern}': #{e.message}"
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# @api private
|
|
463
|
+
# Extracts head content from a block of text using regex pattern matching.
|
|
464
|
+
#
|
|
465
|
+
# @param note_content [String] The note content to search within.
|
|
466
|
+
# @param pattern_info [Hash] The parsed regex pattern information.
|
|
467
|
+
# @return [Array<String, String>, nil] Array containing extracted head and matched segment, or nil.
|
|
468
|
+
def extract_head_from_block note_content, pattern_info
|
|
469
|
+
extracted_head = SchemaGraphy::RegexpUtils.extract_capture(note_content, pattern_info, 'head')
|
|
470
|
+
return nil unless extracted_head
|
|
471
|
+
|
|
472
|
+
# Find the exact matched segment to remove
|
|
473
|
+
re = Regexp.new(pattern_info[:regexp].source, pattern_info[:regexp].options | Regexp::MULTILINE)
|
|
474
|
+
match_data = note_content.match(re)
|
|
475
|
+
matched_segment = match_data ? match_data[0] : nil
|
|
476
|
+
|
|
477
|
+
[extracted_head, matched_segment]
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# @api private
|
|
481
|
+
# Extracts head content from individual lines using regex pattern matching.
|
|
482
|
+
#
|
|
483
|
+
# @param note_content [String] The note content to search within.
|
|
484
|
+
# @param pattern_info [Hash] The parsed regex pattern information.
|
|
485
|
+
# @return [Array<String, String>, nil] Array containing extracted head and matched line, or nil.
|
|
486
|
+
def extract_head_from_lines note_content, pattern_info
|
|
487
|
+
re = pattern_info[:regexp]
|
|
488
|
+
note_content.each_line do |line|
|
|
489
|
+
next unless (m = line.match(re))
|
|
490
|
+
|
|
491
|
+
extracted_head = if m.names.include?('head')
|
|
492
|
+
m[:head]
|
|
493
|
+
elsif m.captures.any?
|
|
494
|
+
m.captures.first
|
|
495
|
+
else
|
|
496
|
+
m[0]
|
|
497
|
+
end
|
|
498
|
+
return [extracted_head, line] # Return head and the matched line
|
|
499
|
+
end
|
|
500
|
+
nil # No match found
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# @api private
|
|
504
|
+
# Handles placeholder note generation for changes that need release notes.
|
|
505
|
+
#
|
|
506
|
+
# @param data [Hash] The change data to potentially update with placeholder notes.
|
|
507
|
+
# @param original_tags [Array<String>] The original tag list before processing.
|
|
508
|
+
# @return [void]
|
|
509
|
+
def handle_placeholder_notes! data, original_tags
|
|
510
|
+
raw_tags = extract_raw_tags(data, original_tags)
|
|
511
|
+
tag_config = @config['tags'] || {}
|
|
512
|
+
rn_slugs = get_release_note_needed_slugs(tag_config)
|
|
513
|
+
empty_note_policy = @config.dig('rhyml', 'empty_notes') || 'skip'
|
|
514
|
+
empty_notes_content = @config.dig('rhyml', 'empty_notes_content') || 'RELEASE NOTE NEEDED'
|
|
515
|
+
|
|
516
|
+
note_is_empty = data['note'].to_s.strip == '' || data['note'].nil?
|
|
517
|
+
return unless empty_note_policy == 'empty' && raw_tags.intersect?(rn_slugs) && note_is_empty
|
|
518
|
+
|
|
519
|
+
data['note'] = empty_notes_content
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# @api private
|
|
523
|
+
# Extracts raw tag data from various sources in the original payload.
|
|
524
|
+
#
|
|
525
|
+
# @param data [Hash] The change data containing raw payload information.
|
|
526
|
+
# @param original_tags [Array<String>] The original tag list as fallback.
|
|
527
|
+
# @return [Array<String>] Array of raw tag strings in lowercase.
|
|
528
|
+
def extract_raw_tags data, original_tags
|
|
529
|
+
raw_tags = nil
|
|
530
|
+
if data['raw'].is_a?(Hash)
|
|
531
|
+
if data['raw'].key?('labels')
|
|
532
|
+
raw_tags = Array(data['raw']['labels']).map { |l| l.is_a?(Hash) ? l['name'] : l.to_s }
|
|
533
|
+
end
|
|
534
|
+
raw_tags ||= Array(data['raw']['tags']).map(&:to_s) if data['raw'].key?('tags')
|
|
535
|
+
end
|
|
536
|
+
raw_tags ||= Array(original_tags)
|
|
537
|
+
raw_tags.map(&:downcase)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# @api private
|
|
541
|
+
# Gets slug variations for the 'release_note_needed' tag from tag configuration.
|
|
542
|
+
#
|
|
543
|
+
# @param tag_config [Hash] The tag configuration containing slug mappings.
|
|
544
|
+
# @return [Array<String>] Array of slug strings that indicate a release note is needed.
|
|
545
|
+
def get_release_note_needed_slugs tag_config
|
|
546
|
+
rn_tag_key = 'release_note_needed'
|
|
547
|
+
rn_slug = tag_config.dig(rn_tag_key, 'slug')&.downcase || rn_tag_key.downcase
|
|
548
|
+
rn_slugs = [rn_slug, 'needs:note'] # Always include 'needs:note' for safety
|
|
549
|
+
if respond_to?(:tag_slug_map)
|
|
550
|
+
rn_slugs += tag_slug_map.keys.map(&:downcase).select { |slug| tag_slug_map[slug] == rn_tag_key }
|
|
551
|
+
end
|
|
552
|
+
rn_slugs
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# @api private
|
|
556
|
+
# Processes and maps tags from various formats (arrays, checkbox text) into standardized tag names.
|
|
557
|
+
#
|
|
558
|
+
# @param tags [Array, String] The raw tags to process (array of labels or checkbox text).
|
|
559
|
+
# @param _note [String] The note content (unused parameter for potential future use).
|
|
560
|
+
# @return [Array<String>] Array of processed and mapped tag names.
|
|
561
|
+
def process_tags tags, _note
|
|
562
|
+
# Map and filter tags, but keep all mapped tags for inclusion logic.
|
|
563
|
+
# Only drop tags that are marked for display dropping, not inclusion filtering.
|
|
564
|
+
all_tags = []
|
|
565
|
+
|
|
566
|
+
if tags.is_a?(Array)
|
|
567
|
+
# Handle label-based tags (GitHub labels, GitLab labels, etc.)
|
|
568
|
+
all_tags = tags.map(&:to_s).map(&:downcase)
|
|
569
|
+
elsif tags.is_a?(String)
|
|
570
|
+
# Handle text-based checkbox tags from raw description/body content
|
|
571
|
+
all_tags = tags.scan(/^- \[x\] (\w[\w-]{1,25})/im).flatten.map(&:downcase)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Map tags through slug map and compact to remove unmapped tags
|
|
575
|
+
mapped_tags = all_tags
|
|
576
|
+
.uniq
|
|
577
|
+
.map { |slug| tag_slug_map[slug] }
|
|
578
|
+
.compact
|
|
579
|
+
|
|
580
|
+
# Store all mapped tags (including droppable ones) for inclusion logic
|
|
581
|
+
@all_mapped_tags = mapped_tags
|
|
582
|
+
|
|
583
|
+
# Return only non-droppable tags for display
|
|
584
|
+
mapped_tags.reject { |tag| @config.dig('tags', tag, 'drop') == true }
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# @api private
|
|
588
|
+
# Determines whether a change should be skipped based on filtering rules.
|
|
589
|
+
# Uses complex conditional logic for tag inclusion/exclusion and note requirements.
|
|
590
|
+
#
|
|
591
|
+
# @param data [Hash] The change data to evaluate.
|
|
592
|
+
# @param scan [Boolean] Whether to enable detailed debug logging (default: false).
|
|
593
|
+
# @return [Boolean] True if the change should be skipped, false otherwise.
|
|
594
|
+
def skip_change? data, scan: false
|
|
595
|
+
all_tags = Array(@all_mapped_tags || data['tags']).map(&:downcase)
|
|
596
|
+
|
|
597
|
+
if excluded_by_tag?(all_tags)
|
|
598
|
+
ReleaseHx.logger.debug 'Skipping change due to excluded tag' if scan
|
|
599
|
+
return true
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
return false if note_present?(data, scan: scan)
|
|
603
|
+
return false if included_by_tag?(all_tags, scan: scan)
|
|
604
|
+
|
|
605
|
+
if missing_required_note?(all_tags)
|
|
606
|
+
ReleaseHx.logger.debug 'Skipping change due to missing required note' if scan
|
|
607
|
+
return true
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
if keep_for_empty_policy?
|
|
611
|
+
ReleaseHx.logger.debug 'Keeping change due to empty_notes policy' if scan
|
|
612
|
+
return false
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
ReleaseHx.logger.debug 'Skipping change by default (no note or include tag)' if scan
|
|
616
|
+
true
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# @api private
|
|
620
|
+
# Checks if tags contain any that are marked for exclusion.
|
|
621
|
+
#
|
|
622
|
+
# @param tags [Array<String>] The tags to check for exclusion.
|
|
623
|
+
# @return [Boolean] True if any tag is marked for exclusion.
|
|
624
|
+
def excluded_by_tag? tags
|
|
625
|
+
tags.intersect?(Array(@config.dig('tags', '_exclude')))
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# @api private
|
|
629
|
+
# Checks if a note is present and non-empty in the change data.
|
|
630
|
+
#
|
|
631
|
+
# @param data [Hash] The change data to check for note presence.
|
|
632
|
+
# @param scan [Boolean] Whether to enable detailed debug logging (default: false).
|
|
633
|
+
# @return [Boolean] True if a note is present and non-empty.
|
|
634
|
+
def note_present? data, scan: false
|
|
635
|
+
present = data['note'].to_s.strip != ''
|
|
636
|
+
ReleaseHx.logger.debug 'Keeping change due to note present' if present && scan
|
|
637
|
+
present
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# @api private
|
|
641
|
+
# Checks if tags contain any that are marked for inclusion.
|
|
642
|
+
#
|
|
643
|
+
# @param tags [Array<String>] The tags to check for inclusion.
|
|
644
|
+
# @param scan [Boolean] Whether to enable detailed debug logging (default: false).
|
|
645
|
+
# @return [Boolean] True if any tag is marked for inclusion.
|
|
646
|
+
def included_by_tag? tags, scan: false
|
|
647
|
+
include_tags = Array(@config.dig('tags', '_include'))
|
|
648
|
+
overlap = tags & include_tags
|
|
649
|
+
if overlap.any?
|
|
650
|
+
ReleaseHx.logger.debug "Keeping change due to tag in _include: #{overlap.inspect}" if scan
|
|
651
|
+
return true
|
|
652
|
+
end
|
|
653
|
+
false
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# @api private
|
|
657
|
+
# Checks if tags indicate a missing required note when empty notes policy is 'skip'.
|
|
658
|
+
#
|
|
659
|
+
# @param tags [Array<String>] The tags to check for required note indicators.
|
|
660
|
+
# @return [Boolean] True if a required note is missing.
|
|
661
|
+
def missing_required_note? tags
|
|
662
|
+
return false unless @config.dig('rhyml', 'empty_notes') == 'skip'
|
|
663
|
+
|
|
664
|
+
rn_slugs = get_release_note_needed_slugs(@config['tags'] || {})
|
|
665
|
+
tags.intersect?(rn_slugs)
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# @api private
|
|
669
|
+
# Checks if the configuration allows keeping changes with empty notes.
|
|
670
|
+
#
|
|
671
|
+
# @return [Boolean] True if empty notes policy is set to 'empty'.
|
|
672
|
+
def keep_for_empty_policy?
|
|
673
|
+
@config.dig('rhyml', 'empty_notes') == 'empty'
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# @api private
|
|
677
|
+
# Creates and caches a mapping of tag slugs to their canonical tag names.
|
|
678
|
+
#
|
|
679
|
+
# @return [Hash] A hash mapping slug strings to canonical tag names.
|
|
680
|
+
def tag_slug_map
|
|
681
|
+
@tag_slug_map ||= begin
|
|
682
|
+
tag_defs = @config['tags'] || {}
|
|
683
|
+
tag_defs.each_with_object({}) do |(key, value), memo|
|
|
684
|
+
next if %w[_include _exclude].include?(key)
|
|
685
|
+
|
|
686
|
+
slug = value.is_a?(Hash) ? (value['slug'] || key) : key
|
|
687
|
+
memo[slug] = key
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# @api private
|
|
693
|
+
# Initializes Liquid templating filters and runtime environment.
|
|
694
|
+
# Ensures filters are registered only once to avoid duplicate registrations.
|
|
695
|
+
#
|
|
696
|
+
# @return [void]
|
|
697
|
+
def initialize_liquid_filters
|
|
698
|
+
return if defined?(@__liquid_ready) && @__liquid_ready
|
|
699
|
+
|
|
700
|
+
::Sourcerer::Jekyll.initialize_liquid_runtime
|
|
701
|
+
::Liquid::Template.register_filter(::ReleaseHx::RHYML::RHYMLFilters)
|
|
702
|
+
|
|
703
|
+
@__liquid_ready = true
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# Loads verb past tense mappings from YAML file for pasterization.
|
|
708
|
+
#
|
|
709
|
+
# @return [Hash] A hash mapping present tense verbs to past tense forms.
|
|
710
|
+
def self.verb_past_tenses
|
|
711
|
+
@verb_past_tenses ||= begin
|
|
712
|
+
yaml_path = File.expand_path('mappings/verb_past_tenses.yml', __dir__)
|
|
713
|
+
YAML.load_file(yaml_path)
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Converts verbs in input text to past tense using the verb mapping dictionary.
|
|
718
|
+
# Preserves original casing (uppercase, capitalized, lowercase) of the words.
|
|
719
|
+
#
|
|
720
|
+
# @param input [String] The text to pasterize.
|
|
721
|
+
# @return [String] The text with verbs converted to past tense, or original if nil/empty.
|
|
722
|
+
def self.pasterize input
|
|
723
|
+
return input if input.nil? || input.empty?
|
|
724
|
+
|
|
725
|
+
input.gsub(/\b(\w+)\b/) do |word|
|
|
726
|
+
replacement = verb_past_tenses[word.downcase]
|
|
727
|
+
next word unless replacement
|
|
728
|
+
|
|
729
|
+
# Preserve casing
|
|
730
|
+
if word == word.upcase
|
|
731
|
+
replacement.upcase
|
|
732
|
+
elsif word == word.capitalize
|
|
733
|
+
replacement.capitalize
|
|
734
|
+
else
|
|
735
|
+
replacement
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
end
|