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.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/README.adoc +2915 -0
  3. data/bin/releasehx +7 -0
  4. data/bin/rhx +7 -0
  5. data/bin/rhx-mcp +7 -0
  6. data/bin/sourcerer +32 -0
  7. data/build/docs/CNAME +1 -0
  8. data/build/docs/Gemfile.lock +95 -0
  9. data/build/docs/_config.yml +36 -0
  10. data/build/docs/config-reference.adoc +4104 -0
  11. data/build/docs/config-reference.json +1546 -0
  12. data/build/docs/index.adoc +2915 -0
  13. data/build/docs/landing.adoc +21 -0
  14. data/build/docs/manpage.adoc +68 -0
  15. data/build/docs/releasehx.1 +281 -0
  16. data/build/docs/releasehx_readme.html +367 -0
  17. data/build/docs/sample-config.adoc +9 -0
  18. data/build/docs/sample-config.yml +251 -0
  19. data/build/docs/schemagraphy_readme.html +0 -0
  20. data/build/docs/sourcerer_readme.html +46 -0
  21. data/build/snippets/helpscreen.txt +29 -0
  22. data/lib/docopslab/mcp/asset_packager.rb +30 -0
  23. data/lib/docopslab/mcp/manifest.rb +67 -0
  24. data/lib/docopslab/mcp/resource_pack.rb +46 -0
  25. data/lib/docopslab/mcp/server.rb +92 -0
  26. data/lib/docopslab/mcp.rb +6 -0
  27. data/lib/releasehx/cli.rb +937 -0
  28. data/lib/releasehx/configuration.rb +215 -0
  29. data/lib/releasehx/generated.rb +17 -0
  30. data/lib/releasehx/helpers.rb +58 -0
  31. data/lib/releasehx/mcp/asset_packager.rb +21 -0
  32. data/lib/releasehx/mcp/assets/agent-config-guide.md +178 -0
  33. data/lib/releasehx/mcp/assets/config-def.yml +1426 -0
  34. data/lib/releasehx/mcp/assets/config-reference.adoc +4104 -0
  35. data/lib/releasehx/mcp/assets/config-reference.json +1546 -0
  36. data/lib/releasehx/mcp/assets/sample-config.yml +251 -0
  37. data/lib/releasehx/mcp/manifest.rb +18 -0
  38. data/lib/releasehx/mcp/resource_pack.rb +26 -0
  39. data/lib/releasehx/mcp/server.rb +57 -0
  40. data/lib/releasehx/mcp.rb +7 -0
  41. data/lib/releasehx/ops/check_ops.rb +136 -0
  42. data/lib/releasehx/ops/draft_ops.rb +173 -0
  43. data/lib/releasehx/ops/enrich_ops.rb +221 -0
  44. data/lib/releasehx/ops/template_ops.rb +61 -0
  45. data/lib/releasehx/ops/write_ops.rb +124 -0
  46. data/lib/releasehx/rest/clients/github.yml +46 -0
  47. data/lib/releasehx/rest/clients/gitlab.yml +31 -0
  48. data/lib/releasehx/rest/clients/jira.yml +31 -0
  49. data/lib/releasehx/rest/yaml_client.rb +418 -0
  50. data/lib/releasehx/rhyml/adapter.rb +740 -0
  51. data/lib/releasehx/rhyml/change.rb +167 -0
  52. data/lib/releasehx/rhyml/liquid.rb +13 -0
  53. data/lib/releasehx/rhyml/loaders.rb +37 -0
  54. data/lib/releasehx/rhyml/mappings/github.yaml +60 -0
  55. data/lib/releasehx/rhyml/mappings/gitlab.yaml +73 -0
  56. data/lib/releasehx/rhyml/mappings/jira.yaml +29 -0
  57. data/lib/releasehx/rhyml/mappings/verb_past_tenses.yml +98 -0
  58. data/lib/releasehx/rhyml/release.rb +144 -0
  59. data/lib/releasehx/rhyml.rb +15 -0
  60. data/lib/releasehx/sgyml/helpers.rb +45 -0
  61. data/lib/releasehx/transforms/adf_to_markdown.rb +307 -0
  62. data/lib/releasehx/version.rb +7 -0
  63. data/lib/releasehx.rb +69 -0
  64. data/lib/schemagraphy/attribute_resolver.rb +48 -0
  65. data/lib/schemagraphy/cfgyml/definition.rb +90 -0
  66. data/lib/schemagraphy/cfgyml/doc_builder.rb +52 -0
  67. data/lib/schemagraphy/cfgyml/path_reference.rb +24 -0
  68. data/lib/schemagraphy/data_query/json_pointer.rb +42 -0
  69. data/lib/schemagraphy/loader.rb +59 -0
  70. data/lib/schemagraphy/regexp_utils.rb +215 -0
  71. data/lib/schemagraphy/safe_expression.rb +189 -0
  72. data/lib/schemagraphy/schema_utils.rb +124 -0
  73. data/lib/schemagraphy/tag_utils.rb +32 -0
  74. data/lib/schemagraphy/templating.rb +104 -0
  75. data/lib/schemagraphy.rb +17 -0
  76. data/lib/sourcerer/builder.rb +120 -0
  77. data/lib/sourcerer/jekyll/bootstrapper.rb +78 -0
  78. data/lib/sourcerer/jekyll/liquid/file_system.rb +74 -0
  79. data/lib/sourcerer/jekyll/liquid/filters.rb +215 -0
  80. data/lib/sourcerer/jekyll/liquid/tags.rb +44 -0
  81. data/lib/sourcerer/jekyll/monkeypatches.rb +73 -0
  82. data/lib/sourcerer/jekyll.rb +26 -0
  83. data/lib/sourcerer/plaintext_converter.rb +75 -0
  84. data/lib/sourcerer/templating.rb +190 -0
  85. data/lib/sourcerer.rb +322 -0
  86. data/specs/data/api-client-schema.yaml +160 -0
  87. data/specs/data/config-def.yml +1426 -0
  88. data/specs/data/mcp-manifest.yml +50 -0
  89. data/specs/data/rhyml-mapping-schema.yaml +410 -0
  90. data/specs/data/rhyml-schema.yaml +152 -0
  91. 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