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,937 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+ require 'fileutils'
6
+ require_relative '../releasehx'
7
+
8
+ module ReleaseHx
9
+ class CLI < Thor
10
+ def self.exit_on_failure?
11
+ true
12
+ end
13
+
14
+ # =======================================
15
+ # OVERRIDE .start to handle no-arguments
16
+ # and default subcommand behavior
17
+ # =======================================
18
+ def self.start original_args = ARGV, config = {}
19
+ # If user gave no arguments at all, or only gave --help, allow that through
20
+ if original_args.empty? || (original_args.length == 1 && original_args.first =~ /^--?h(elp)?$/)
21
+ show_usage_snippet
22
+ return
23
+ elsif original_args.length == 1 && original_args.first =~ /^--man(page)?$/
24
+ show_manpage
25
+ return
26
+ elsif original_args.length == 1 && original_args.first =~ /^--version$/
27
+ puts ReleaseHx::VERSION
28
+ return
29
+ else
30
+ first = original_args[0]
31
+ original_args.unshift 'default' unless first.start_with?('-') || all_tasks.key?(first)
32
+ end
33
+
34
+ super
35
+ end
36
+
37
+ default_task :default
38
+
39
+ desc 'VERSION|PATH [options]', ReleaseHx.attrs['tagline']
40
+ method_option :md, type: :string, lazy_default: '',
41
+ desc: ReleaseHx.attrs['cli_option_message_md']
42
+ method_option :adoc, type: :string, aliases: ['--ad'], lazy_default: '',
43
+ desc: ReleaseHx.attrs['cli_option_message_adoc']
44
+ method_option :yaml, type: :string, aliases: ['--yml'], lazy_default: '',
45
+ desc: ReleaseHx.attrs['cli_option_message_yaml']
46
+ method_option :html, type: :string, lazy_default: '',
47
+ desc: ReleaseHx.attrs['cli_option_message_html']
48
+ method_option :pdf, type: :string, lazy_default: '',
49
+ desc: ReleaseHx.attrs['cli_option_message_pdf']
50
+ method_option :output_dir, type: :string, lazy_default: '',
51
+ desc: ReleaseHx.attrs['cli_option_message_output_dir']
52
+ method_option :api_data, type: :string, banner: 'PATH',
53
+ desc: ReleaseHx.attrs['cli_option_message_api_data']
54
+ method_option :config, type: :string, banner: 'PATH',
55
+ desc: ReleaseHx.attrs['cli_option_message_config']
56
+ method_option :mapping, type: :string, banner: 'PATH',
57
+ desc: ReleaseHx.attrs['cli_option_message_mapping']
58
+ method_option :payload, type: :string, lazy_default: '',
59
+ desc: ReleaseHx.attrs['cli_option_message_payload']
60
+ method_option :fetch, type: :boolean,
61
+ desc: ReleaseHx.attrs['cli_option_message_fetch']
62
+ method_option :append, type: :boolean,
63
+ desc: ReleaseHx.attrs['cli_option_message_append']
64
+ method_option :over, type: :boolean, aliases: ['--force'],
65
+ desc: ReleaseHx.attrs['cli_option_message_over']
66
+ method_option :check, type: :boolean, aliases: ['--scan'],
67
+ desc: ReleaseHx.attrs['cli_option_message_check']
68
+ method_option :emptynotes, type: :string, aliases: ['--empty', '-e'], lazy_default: '',
69
+ desc: ReleaseHx.attrs['cli_option_message_emptynotes']
70
+ method_option :internal, type: :boolean,
71
+ desc: ReleaseHx.attrs['cli_option_message_internal']
72
+ method_option :wrap, type: :boolean, default: nil,
73
+ desc: ReleaseHx.attrs['cli_option_message_wrap']
74
+ method_option :frontmatter, type: :boolean, default: nil, desc: ReleaseHx.attrs['cli_option_message_frontmatter']
75
+ method_option :verbose, type: :boolean,
76
+ desc: ReleaseHx.attrs['cli_option_message_verbose']
77
+ method_option :debug, type: :boolean,
78
+ desc: ReleaseHx.attrs['cli_option_message_debug']
79
+ method_option :debug_dump, type: :boolean, desc: ReleaseHx.attrs['cli_option_message_debug_dump']
80
+ method_option :quiet, type: :boolean,
81
+ desc: ReleaseHx.attrs['cli_option_message_quiet']
82
+
83
+ # FIXME: This method is overly complex and handles too many concerns.
84
+ # It should be broken down into smaller methods, each handling a specific
85
+ # CLI action or workflow. A major refactor is planned for post-0.1.0.
86
+ def default source_arg
87
+ setup_logger
88
+ ReleaseHx.logger.debug "Starting ReleaseHx with version/source: #{source_arg}"
89
+ load_and_configure_settings(ReleaseHx.attrs['app_default_config_path'])
90
+
91
+ if options[:debug]
92
+ begin
93
+ config_dump = SgymlHelpers.deep_stringify_safe(@settings).to_yaml
94
+ ReleaseHx.logger.debug "Operative config settings:\n#{config_dump}"
95
+ rescue StandardError => e
96
+ require 'pp' # pretty print
97
+ ReleaseHx.logger.debug "Rescued config PP dump:\n#{PP.pp(@settings, +'')}"
98
+ raise e
99
+ end
100
+ end
101
+
102
+ source_arg_type = version_or_file(source_arg)
103
+ if source_arg_type == :invalid
104
+ raise Thor::Error, <<~ERRTXT
105
+ ERROR: Invalid file extension for source file: #{source_arg}
106
+ Valid draft file types are: #{ReleaseHx.attrs['draft_source_file_types']}
107
+ Valid extensions are: #{ReleaseHx.attrs['draft_source_extensions']}
108
+ ERRTXT
109
+ end
110
+
111
+ ReleaseHx.logger.info "Source type: #{source_arg_type}"
112
+
113
+ if options[:verbose] && @settings['origin']
114
+ ReleaseHx.logger.debug "✓ Source configured: #{@settings['origin']['source']}"
115
+ end
116
+
117
+ if options[:check]
118
+ if source_arg_type == :file
119
+ raise Thor::Error,
120
+ 'ERROR: Scan operations require a version number as the first argument.'
121
+ end
122
+
123
+ perform_scan(source_arg)
124
+ return
125
+ end
126
+
127
+ if options[:api_data] && (options[:api_data].nil? || options[:api_data].empty?)
128
+ raise Thor::Error, 'Must specify a PATH for --api-data. E.g. --api-data cached-1-1-1.json'
129
+ end
130
+
131
+ if options[:api_data] && !File.exist?(options[:api_data])
132
+ raise Thor::Error, "API data file not found: #{options[:api_data]}"
133
+ end
134
+
135
+ if options[:api_data]
136
+ ReleaseHx.logger.debug "✓ Using cached API data: #{options[:api_data]}"
137
+ elsif options[:fetch]
138
+ ReleaseHx.logger.info "✓ Will fetch fresh data from #{@settings['origin']['source']} API" if options[:verbose]
139
+ end
140
+
141
+ if options[:api_data] && options[:fetch]
142
+ ReleaseHx.logger.warn 'Warning: --fetch ignored when --api-data is specified'
143
+ end
144
+
145
+ if [options[:adoc], options[:md], options[:yaml]].compact.size > 1
146
+ raise Thor::Error, 'ERROR: Only one of --adoc, --md, or --yaml (or aliases) may be specified.'
147
+ end
148
+
149
+ if options[:append]
150
+ perform_append(source_arg)
151
+ return
152
+ end
153
+
154
+ determine_operations(source_arg)
155
+ end
156
+
157
+ private
158
+
159
+ def load_and_configure_settings default_config_path
160
+ if options[:config] && !File.exist?(options[:config])
161
+ raise Thor::Error, "ERROR: Configuration declared but not found: #{options[:config]}"
162
+ end
163
+
164
+ config_path = options[:config] || default_config_path
165
+ if options[:config]
166
+ ReleaseHx.logger.info "Using specified config file: #{config_path}"
167
+ elsif options[:verbose]
168
+ ReleaseHx.logger.info "Using default config file: #{config_path}"
169
+ end
170
+
171
+ ReleaseHx.logger.info 'Loading configuration...' if options[:verbose]
172
+ @settings = ReleaseHx::Configuration.load(config_path).settings
173
+
174
+ merge_cli_flags_into_settings
175
+
176
+ apply_cli_overrides
177
+
178
+ return unless options[:debug]
179
+
180
+ begin
181
+ config_dump = SgymlHelpers.deep_stringify_safe(@settings).to_yaml
182
+ ReleaseHx.logger.debug "Operative config settings:\n#{config_dump}"
183
+ rescue StandardError => e
184
+ require 'pp' # pretty print
185
+ ReleaseHx.logger.debug "Rescued config PP dump:\n#{PP.pp(@settings, +'')}"
186
+ raise e
187
+ end
188
+ end
189
+
190
+ def setup_logger
191
+ # Ensure the global logger references the same object we configured
192
+ log = ReleaseHx.logger
193
+
194
+ log.level = if options[:debug_dump]
195
+ ReleaseHx::DUMP # lowest level, includes all output
196
+ elsif options[:debug]
197
+ Logger::DEBUG
198
+ elsif options[:quiet]
199
+ Logger::ERROR
200
+ else # default or :verbose
201
+ Logger::INFO
202
+ end
203
+
204
+ # Log the logging level change for verbose output
205
+ return unless options[:verbose]
206
+
207
+ level_name = case log.level
208
+ when ReleaseHx::DUMP then 'DUMP'
209
+ when Logger::DEBUG then 'DEBUG'
210
+ when Logger::INFO then 'INFO'
211
+ when Logger::WARN then 'WARN'
212
+ when Logger::ERROR then 'ERROR'
213
+ else 'UNKNOWN'
214
+ end
215
+ log.info "Logging level set to #{level_name}"
216
+ end
217
+
218
+ class << self
219
+ private
220
+
221
+ def show_usage_snippet
222
+ usage_text = ReleaseHx.read_built_snippet(:helpscreen)
223
+ puts "\nVersion: #{ReleaseHx::VERSION}"
224
+ puts usage_text
225
+ puts
226
+ end
227
+
228
+ def show_manpage
229
+ gem_manfile = File.expand_path('../../build/docs/releasehx.1', __dir__)
230
+
231
+ unless File.exist?(gem_manfile)
232
+ warn "Man file not found in gem: #{gem_manfile}"
233
+ return
234
+ end
235
+ display_manpage_locally(gem_manfile)
236
+ end
237
+
238
+ def display_manpage_locally manfile
239
+ # FIXME: Eventually we want to provide a proper man page for Unix environments
240
+ tmp_dir = File.join(Dir.home, '.rhxtmp')
241
+ FileUtils.mkdir_p(tmp_dir)
242
+
243
+ tmp_path = File.join(tmp_dir, 'releasehx.1')
244
+ FileUtils.cp(manfile, tmp_path)
245
+
246
+ # Use -l to display local file
247
+ system("man -l '#{tmp_path}'")
248
+
249
+ FileUtils.rm_f(tmp_path)
250
+ end
251
+ end
252
+
253
+ # Determine whether first argument is a version # or a proper file path
254
+ def version_or_file source_arg
255
+ extensions = ReleaseHx.attrs['draft_source_extensions'].split(', ')
256
+ if extensions.any? { |ext| source_arg.end_with?(ext) }
257
+ :file
258
+ elsif source_arg.end_with?('.html', '.pdf')
259
+ :invalid
260
+ else
261
+ :version
262
+ end
263
+ end
264
+
265
+ def perform_scan version
266
+ ReleaseHx.logger.info("Scanning for missing release notes in version #{version}")
267
+
268
+ # Use the same source determination logic as main operations
269
+ source_type = determine_payload_type(version)
270
+
271
+ case source_type
272
+ when :yaml
273
+ # For scan operations, we need the version code, not a file path
274
+ raise Thor::Error, 'ERROR: Scan operations require a version number, not a YAML file path.'
275
+ when :json
276
+ if options[:api_data]
277
+ payload = load_json_issues(options[:api_data])
278
+ else
279
+ raise Thor::Error,
280
+ 'ERROR: JSON source specified but no --api-data file provided. ' \
281
+ 'Use --api-data PATH to specify a JSON file.'
282
+ end
283
+ when :api
284
+ # Get configured source type for API operations
285
+ configured_source_type = @settings.dig('origin', 'source')
286
+ case configured_source_type
287
+ when 'jira', 'github', 'gitlab'
288
+ payload = fetch_issues_from_api(version)
289
+ else
290
+ raise Thor::Error,
291
+ "ERROR: API origin requires origin.source to be 'jira', 'github', or 'gitlab', " \
292
+ "but got '#{configured_source_type}'"
293
+ end
294
+ else
295
+ raise Thor::Error, "ERROR: Scanning not supported for source type '#{source_type}'"
296
+ end
297
+
298
+ mapping = load_mapping
299
+ release_code = basename_for_output(version)
300
+ release_date = Date.today
301
+ issues_count = payload.is_a?(Array) ? payload.size : payload['issues']&.size || payload.size
302
+
303
+ emptynotes_arg = options[:emptynotes]
304
+
305
+ # Interpret CLI override for --empty/-e
306
+ if emptynotes_arg && !emptynotes_arg.empty?
307
+ if %w[skip empty dump ai].include?(emptynotes_arg)
308
+ # override setting
309
+ @settings['rhyml']['empty_notes'] = emptynotes_arg
310
+ else
311
+ # Toggle logic
312
+ current = @settings.dig('rhyml', 'empty_notes') || 'skip'
313
+ @settings['rhyml']['empty_notes'] = current == 'skip' ? 'empty' : 'skip'
314
+ end
315
+ elsif emptynotes_arg && emptynotes_arg.empty?
316
+ # Flag used without value; toggle logic
317
+ current = @settings.dig('rhyml', 'empty_notes') || 'skip'
318
+ @settings['rhyml']['empty_notes'] = current == 'skip' ? 'empty' : 'skip'
319
+ end
320
+
321
+ require_relative 'ops/check_ops'
322
+
323
+ release = ReleaseHx::DraftOps.from_payload(
324
+ payload: payload,
325
+ config: @settings,
326
+ mapping: mapping,
327
+ release_code: release_code,
328
+ release_date: release_date,
329
+ scan: true)
330
+
331
+ ReleaseHx::CheckOps.print_check_summary(release, issues_count, payload, @settings, mapping)
332
+ nil
333
+ end
334
+
335
+ def perform_append source_arg
336
+ ReleaseHx.logger.info('Appending new issues to existing YAML draft')
337
+
338
+ # Determine the existing YAML file path
339
+ yaml_file_path = resolve_yaml_file_path(source_arg)
340
+
341
+ unless File.exist?(yaml_file_path)
342
+ raise Thor::Error, "ERROR: YAML file not found: #{yaml_file_path}. Cannot append without existing file."
343
+ end
344
+
345
+ # Load the YAML to get the actual version code from the 'code' field
346
+ yaml_data = SchemaGraphy::Loader.load_yaml_with_tags(yaml_file_path)
347
+ version_code = yaml_data['code']
348
+
349
+ raise Thor::Error, "ERROR: No 'code' field found in YAML file: #{yaml_file_path}" unless version_code
350
+
351
+ ReleaseHx.logger.debug "Using version code from YAML: #{version_code}"
352
+
353
+ source_type = determine_payload_type(version_code)
354
+ payload = fetch_or_load_issues(version_code, source_type)
355
+
356
+ # Delegate to DraftOps for the append logic
357
+ new_changes_count = ReleaseHx::DraftOps.append_changes(
358
+ yaml_file_path: yaml_file_path,
359
+ version_code: version_code,
360
+ config: @settings,
361
+ mapping: load_mapping,
362
+ source_type: source_type,
363
+ payload: payload,
364
+ force: options[:over])
365
+
366
+ if new_changes_count.positive?
367
+ ReleaseHx.logger.info "Successfully appended #{new_changes_count} changes to #{yaml_file_path}"
368
+ else
369
+ ReleaseHx.logger.info 'No new changes found to append.'
370
+ end
371
+ end
372
+
373
+ def resolve_yaml_file_path source_arg
374
+ if version_or_file(source_arg) == :file
375
+ # Direct file path provided
376
+ unless source_arg.end_with?('.yml', '.yaml', '.rhyml')
377
+ raise Thor::Error, "ERROR: --append requires a YAML file. Got: #{source_arg}"
378
+ end
379
+
380
+ source_arg
381
+ else
382
+ # Version code; look for existing draft first
383
+ existing = find_existing_drafts(source_arg)
384
+ if existing && existing[:format] == :yml
385
+ existing[:path]
386
+ else
387
+ # Use the same path resolution logic as draft generation
388
+ resolve_draft_path(:yaml, source_arg)
389
+ end
390
+ end
391
+ end
392
+
393
+ # IMPORTANT: This method is currently not used
394
+ def check_for_existing_draft version
395
+ # Implementation pending the draft handling refactor
396
+ end
397
+
398
+ def determine_operations source_arg
399
+ operation_mode = determine_mode(source_arg)
400
+
401
+ case operation_mode
402
+ when :draft_only
403
+ ReleaseHx.logger.info('Generating draft only.')
404
+ generate_draft(source_arg)
405
+ when :enrich_only
406
+ ReleaseHx.logger.info('Rendering document/s only.')
407
+ enrich_docs(source_arg, source_arg)
408
+ when :draft_and_enrich
409
+ ReleaseHx.logger.info('Generating draft and enriched docs.')
410
+ generate_draft(source_arg)
411
+ enrich_docs(source_arg, source_arg)
412
+ else
413
+ raise Thor::Error, 'ERROR: Could not determine valid operation mode.'
414
+ end
415
+ end
416
+
417
+ def determine_mode _source_arg
418
+ return :enrich_only if enrich_requested? && !draft_requested?
419
+ if options[:fetch] && !draft_requested? && !enrich_requested?
420
+ raise Thor::Error, 'ERROR: You must specify a draft or enrich format when using --fetch.'
421
+ end
422
+ return :draft_and_enrich if options[:fetch] && enrich_requested?
423
+ return :draft_and_enrich if draft_requested? && enrich_requested?
424
+ return :draft_only if options[:fetch] || draft_requested?
425
+
426
+ raise Thor::Error, 'ERROR: You must specify a draft or enrich format.'
427
+ end
428
+
429
+ # Returns true if any draft format flags exist
430
+ def draft_requested?
431
+ !options[:md].nil? || !options[:adoc].nil? || !options[:yaml].nil?
432
+ end
433
+
434
+ # Returns true if any enrich format flags exist
435
+ def enrich_requested?
436
+ !options[:html].nil? || !options[:pdf].nil?
437
+ end
438
+
439
+ def determine_input_type version_arg
440
+ return { type: :file, format: file_format(version_arg), path: version_arg } if File.file?(version_arg)
441
+
442
+ # It's a version number. Try resolving a usable draft.
443
+ existing = find_existing_drafts(version_arg)
444
+ return { type: :draft, format: existing[:format], path: existing[:path] } if existing
445
+
446
+ { type: :api, format: :api, path: version_arg }
447
+ end
448
+
449
+ def basename_for_output source_arg
450
+ if version_or_file(source_arg) == :file
451
+ File.basename(source_arg, File.extname(source_arg))
452
+ else
453
+ source_arg
454
+ end
455
+ end
456
+
457
+ def find_existing_drafts version
458
+ base = File.join(@settings['paths']['drafts_dir'], version)
459
+
460
+ %w[adoc md yml].each do |ext|
461
+ path = "#{base}.#{ext}"
462
+ return { format: ext.to_sym, path: path } if File.exist?(path)
463
+ end
464
+
465
+ nil
466
+ end
467
+
468
+ def generate_draft source_arg
469
+ version = basename_for_output(source_arg) # used only for filenames and template context
470
+ release = create_rhyml_from_source(source_arg, version)
471
+
472
+ fmt = draft_format_requested
473
+ outpath = resolve_draft_path(fmt, version)
474
+
475
+ if File.exist?(outpath) && !options[:over]
476
+ ReleaseHx.logger.warn "File exists: #{outpath}. Use --force to overwrite."
477
+ return
478
+ end
479
+
480
+ ReleaseHx::DraftOps.draft_output(
481
+ release: release,
482
+ config: @settings,
483
+ format: fmt,
484
+ outpath: outpath)
485
+ end
486
+
487
+ def resolve_draft_path flag, version
488
+ user_path = options[flag]
489
+ if user_path && !user_path.empty?
490
+ ReleaseHx.logger.info "✓ Custom output path for #{flag}: #{user_path}" if options[:verbose]
491
+ return user_path
492
+ end
493
+
494
+ format = case flag
495
+ when :yaml then :yaml
496
+ when :md then :md
497
+ when :adoc then :adoc
498
+ else flag
499
+ end
500
+
501
+ ext = ReleaseHx.format_extension(format, @settings)
502
+
503
+ template_obj = @settings.dig('paths', 'draft_filename')
504
+ filename = Sourcerer::Templating::Engines.render(
505
+ template_obj, 'liquid',
506
+ { 'version' => version, 'format_ext' => ext })
507
+
508
+ # Get output_dir and drafts_dir, making drafts_dir relative to output_dir
509
+ output_dir = @settings.dig('paths', 'output_dir')
510
+ drafts_dir = @settings.dig('paths', 'drafts_dir')
511
+
512
+ # Construct full path: output_dir/drafts_dir/filename
513
+ File.join(output_dir, drafts_dir, filename.strip)
514
+ end
515
+
516
+ def draft_format_requested
517
+ return :yaml if options[:yaml]
518
+ return :md if options[:md]
519
+ return :adoc if options[:adoc]
520
+
521
+ nil
522
+ end
523
+
524
+ def derive_release_code arg
525
+ # If it's already a version code (no path separator or file extension), return as-is
526
+ return arg unless arg.include?('/') || arg.end_with?('.yaml', '.yml', '.json', '.adoc', '.md')
527
+
528
+ # Otherwise, it's a file path or filename; extract basename without extension
529
+ File.basename(arg, File.extname(arg))
530
+ end
531
+
532
+ def load_mapping
533
+ if options[:mapping]
534
+ path = options[:mapping]
535
+ raise Thor::Error, "Mapping file not found: #{path}" unless File.exist?(path)
536
+
537
+ ReleaseHx.logger.info "✓ Using custom mapping file: #{path}" if options[:verbose]
538
+ SchemaGraphy::Loader.load_yaml_with_tags(path)
539
+ else
540
+ origin_source = @settings.dig('origin', 'source') || 'default'
541
+ local_dir = @settings.dig('paths', 'mappings_dir') || '_mappings'
542
+
543
+ # Try both .yaml and .yml extensions for local mappings
544
+ local_paths = [
545
+ File.join(local_dir, "#{origin_source}.yaml"),
546
+ File.join(local_dir, "#{origin_source}.yml")
547
+ ]
548
+
549
+ local_paths.each do |local_path|
550
+ return SchemaGraphy::Loader.load_yaml_with_tags(local_path) if File.exist?(local_path)
551
+ end
552
+
553
+ # Fallback to built-in mapping shipped with gem
554
+ gem_root = File.expand_path('../..', __dir__) # adjust if needed
555
+ built_in_paths = [
556
+ File.join(gem_root, 'lib/releasehx/rhyml/mappings', "#{origin_source}.yaml"),
557
+ File.join(gem_root, 'lib/releasehx/rhyml/mappings', "#{origin_source}.yml")
558
+ ]
559
+
560
+ built_in_paths.each do |built_in_path|
561
+ if File.exist?(built_in_path)
562
+ ReleaseHx.logger.debug "Found mapping at: #{built_in_path}"
563
+ return SchemaGraphy::Loader.load_yaml_with_tags(built_in_path)
564
+ end
565
+ end
566
+
567
+ # Better error message showing what was tried
568
+ tried_paths = local_paths + built_in_paths
569
+ raise Thor::Error, <<~ERRMSG
570
+ No mapping file found for source '#{origin_source}'.
571
+
572
+ Searched in these locations:
573
+ #{tried_paths.map { |p| " - #{p}" }.join("\n")}
574
+
575
+ Solutions:
576
+ 1. Create a mapping file in #{local_dir}/#{origin_source}.yaml
577
+ 2. Use --mapping to specify a custom mapping file path
578
+ 3. Check that config.origin.source is set correctly (currently: '#{origin_source}')
579
+ ERRMSG
580
+ end
581
+ end
582
+
583
+ def determine_payload_type identifier
584
+ return :yaml if identifier.end_with?('.yml', '.yaml')
585
+ return :json if options[:api_data]
586
+
587
+ :api
588
+ end
589
+
590
+ def render_template template_path, context
591
+ engine = Tilt.new(template_path)
592
+ engine.render(Object.new, context)
593
+ end
594
+
595
+ def fetch_or_load_issues identifier, source_type
596
+ if source_type == :yaml
597
+ load_yaml_issues(identifier)
598
+ elsif source_type == :json
599
+ load_json_issues(options[:api_data])
600
+ else
601
+ fetch_issues_from_api(identifier)
602
+ end
603
+ end
604
+
605
+ def generate_requested_outputs version, issues, source_type
606
+ if options[:yaml]
607
+ output_yaml(version, issues)
608
+ elsif options[:md]
609
+ output_markdown(version, issues, source_type)
610
+ elsif options[:adoc]
611
+ output_asciidoc(version, issues, source_type)
612
+ end
613
+
614
+ enrich_docs(source_arg, version)
615
+ end
616
+
617
+ def enrich_docs source_path, version
618
+ input_info = determine_input_type(source_path)
619
+
620
+ case input_info[:type]
621
+ when :file, :draft
622
+ enrich_from_draft_file(input_info[:path], version)
623
+ when :api
624
+ enrich_direct_from_source(source_path, version)
625
+ else
626
+ ReleaseHx.logger.error('Unable to determine input type for enrichment.')
627
+ end
628
+ end
629
+
630
+ def enrich_from_draft_file file_path, version
631
+ unless File.exist?(file_path)
632
+ ReleaseHx.logger.warn("Draft file not found: #{file_path}")
633
+ return
634
+ end
635
+
636
+ apply_enrich_modes_to_config
637
+
638
+ if options[:html]
639
+ html_out = resolve_enrich_path(:html, version)
640
+ ReleaseHx::EnrichOps.enrich_from_file(
641
+ file_path,
642
+ format: :html,
643
+ config: @settings,
644
+ outpath: html_out,
645
+ force: options[:over])
646
+ ReleaseHx.logger.info("HTML processed: #{html_out}")
647
+ end
648
+
649
+ return unless options[:pdf]
650
+
651
+ pdf_out = resolve_enrich_path(:pdf, version)
652
+ ReleaseHx::EnrichOps.enrich_from_file(
653
+ file_path,
654
+ format: :pdf,
655
+ config: @settings,
656
+ outpath: pdf_out,
657
+ force: options[:over])
658
+ ReleaseHx.logger.info("PDF processed: #{pdf_out}")
659
+ end
660
+
661
+ # Take from API/JSON source to RHYML object, then enrich
662
+ def enrich_direct_from_source source_path, version
663
+ unless options[:fetch]
664
+ ReleaseHx.logger.warn("No draft found for enriching version #{version}. Use --fetch to generate from source.")
665
+ return
666
+ end
667
+
668
+ ReleaseHx.logger.info('Creating RHYML object from source for direct enrichment.')
669
+ release = create_rhyml_from_source(source_path, version)
670
+
671
+ apply_enrich_modes_to_config
672
+
673
+ if options[:html]
674
+ html_out = resolve_enrich_path(:html, version)
675
+ ReleaseHx::EnrichOps.enrich_from_rhyml(
676
+ release: release, format: :html, outpath: html_out, config: @settings,
677
+ force: options[:over])
678
+ ReleaseHx.logger.info("HTML processed: #{html_out}")
679
+ end
680
+
681
+ return unless options[:pdf]
682
+
683
+ pdf_out = resolve_enrich_path(:pdf, version)
684
+ ReleaseHx::EnrichOps.enrich_from_rhyml(
685
+ release: release, format: :pdf, outpath: pdf_out, config: @settings,
686
+ force: options[:over])
687
+ ReleaseHx.logger.info("PDF processed: #{pdf_out}")
688
+ end
689
+
690
+ def create_rhyml_from_source source_path, version
691
+ # Determine source type from configuration, not just file extension
692
+ configured_source_type = @settings.dig('origin', 'source') || 'json'
693
+
694
+ # Handle different source types based on configuration
695
+ case configured_source_type
696
+ when 'rhyml'
697
+ # Load RHYML data directly from YAML file
698
+ rhyml_data = SchemaGraphy::Loader.load_yaml_with_tags(source_path)
699
+ release_data = rhyml_data['releases'] ? rhyml_data['releases'].first : rhyml_data
700
+
701
+ # Convert hash keys to keyword arguments for Release constructor
702
+ ReleaseHx::RHYML::Release.new(
703
+ code: release_data['code'] || version,
704
+ date: release_data['date'],
705
+ hash: release_data['hash'],
706
+ memo: release_data['memo'],
707
+ changes: release_data['changes'] || [])
708
+ when 'json'
709
+ # For json type, only use local files (never API calls)
710
+ if options[:api_data]
711
+ issues = load_json_issues(options[:api_data])
712
+ else
713
+ raise Thor::Error,
714
+ "ERROR: origin.source is 'json' but no --api-data file provided. " \
715
+ 'Use --api-data PATH to specify a JSON file.'
716
+ end
717
+
718
+ ReleaseHx::DraftOps.from_payload(
719
+ payload: issues,
720
+ config: @settings,
721
+ mapping: load_mapping,
722
+ release_code: version)
723
+ when 'jira', 'github', 'gitlab'
724
+ # For API sources, use cached data if provided, otherwise fetch from API
725
+ issues = if options[:api_data]
726
+ load_json_issues(options[:api_data])
727
+ else
728
+ fetch_issues_from_api(version)
729
+ end
730
+ ReleaseHx::DraftOps.from_payload(
731
+ payload: issues,
732
+ config: @settings,
733
+ mapping: load_mapping,
734
+ release_code: version)
735
+ else
736
+ raise Thor::Error,
737
+ "ERROR: Unsupported source type '#{configured_source_type}'. " \
738
+ 'Must be one of: json, rhyml, jira, github, gitlab'
739
+ end
740
+ end
741
+
742
+ def apply_enrich_modes_to_config
743
+ # Apply CLI flags to config for template enrichment
744
+ @settings['modes'] ||= {}
745
+
746
+ unless options[:wrap].nil?
747
+ @settings['modes']['wrapped'] = options[:wrap]
748
+ ReleaseHx.logger.info "✓ Changed HTML wrapping to: #{options[:wrap]}" if options[:verbose]
749
+ end
750
+
751
+ return if options[:frontmatter].nil?
752
+
753
+ @settings['modes']['html_frontmatter'] = options[:frontmatter]
754
+ ReleaseHx.logger.info "✓ Changed frontmatter inclusion to: #{options[:frontmatter]}" if options[:verbose]
755
+ end
756
+
757
+ def resolve_enrich_path flag, version
758
+ # Check if user provided a custom path
759
+ user_path = options[flag]
760
+ if user_path && !user_path.empty?
761
+ ReleaseHx.logger.info "✓ Custom output path for #{flag}: #{user_path}" if options[:verbose]
762
+ return user_path
763
+ end
764
+
765
+ ext = case flag
766
+ when :pdf then 'pdf'
767
+ when :html then 'html'
768
+ else raise ArgumentError, "Unknown enrich format: #{flag}"
769
+ end
770
+
771
+ template_obj = @settings.dig('paths', 'enrich_filename')
772
+ context = { 'version' => version, 'format_ext' => ext }
773
+
774
+ # Use the existing templating system to render the filename
775
+ filename = if template_obj.respond_to?(:render)
776
+ template_obj.render(context)
777
+ elsif template_obj.is_a?(String)
778
+ # It's a plain string, compile and render with Liquid
779
+ compiled = Liquid::Template.parse(template_obj)
780
+ compiled.render(context)
781
+ else
782
+ template_obj.to_s
783
+ end
784
+
785
+ output_dir = @settings.dig('paths', 'output_dir')
786
+ enrich_dir = @settings.dig('paths', 'enrich_dir')
787
+
788
+ File.join(output_dir, enrich_dir, filename.strip)
789
+ end
790
+
791
+ # IMPORTANT: This method is currently not used (identified by debride)
792
+ # Similar to find_existing_drafts but with simpler path handling
793
+ # Will be replaced as part of the draft handling refactor
794
+ def find_existing_draft version
795
+ ["#{version}.adoc", "#{version}.md", "#{version}.yml"].find { |f| File.exist?(f) }
796
+ end
797
+
798
+ def output_yaml path, _issues
799
+ yaml_content = "---\n#YAML Draft" # placeholder
800
+ safe_write(path, yaml_content)
801
+ end
802
+
803
+ def output_markdown path, _issues, _source_type
804
+ markdown_content = "# Markdown Draft\n\n" # placeholder
805
+ safe_write(path, markdown_content)
806
+ end
807
+
808
+ def output_asciidoc path, _issues, _source_type
809
+ content = "= AsciiDoc Draft\n\n" # placeholder
810
+ safe_write(path, content)
811
+ end
812
+
813
+ def safe_write filename, content
814
+ ReleaseHx.logger.debug("Attempting to write file #{filename}")
815
+ if File.exist?(filename) && !options[:over]
816
+ ReleaseHx.logger.warn("#{filename} already exists. Use --force to overwrite.")
817
+ else
818
+ File.write(filename, content)
819
+ ReleaseHx.logger.info("Draft written: #{filename}")
820
+ end
821
+ end
822
+
823
+ def load_yaml_issues path
824
+ ReleaseHx.logger.info("Loading YAML issues from #{path}")
825
+ SchemaGraphy::Loader.load_yaml_with_tags(path)
826
+ end
827
+
828
+ def load_json_issues path
829
+ ReleaseHx.logger.info("Loading JSON issues from #{path}")
830
+ payload = JSON.parse(File.read(path))
831
+
832
+ # Extract issues array if client config specifies root_issues_path: "issues"
833
+ # This matches what the API client does when fetching live data
834
+ origin_source = @settings.dig('origin', 'source')
835
+ if origin_source && payload.is_a?(Hash) && payload.key?('issues')
836
+ client_path = find_api_client_config(origin_source)
837
+ if client_path
838
+ client_def = SchemaGraphy::Loader.load_yaml_with_tags(client_path)
839
+ if client_def['root_issues_path'] == 'issues'
840
+ ReleaseHx.logger.debug "Extracting 'issues' array from payload"
841
+ return payload['issues']
842
+ end
843
+ end
844
+ end
845
+
846
+ payload
847
+ end
848
+
849
+ def find_api_client_config origin_source
850
+ local_dir = @settings.dig('paths', 'api_clients_dir') || '_apis'
851
+ local_paths = [
852
+ File.join(local_dir, "#{origin_source}.yaml"),
853
+ File.join(local_dir, "#{origin_source}.yml")
854
+ ]
855
+
856
+ local_paths.each do |local_path|
857
+ return local_path if File.exist?(local_path)
858
+ end
859
+
860
+ # Check built-in clients
861
+ gem_root = File.expand_path('../..', __dir__)
862
+ builtin_paths = [
863
+ File.join(gem_root, 'lib/releasehx/rest/clients', "#{origin_source}.yaml"),
864
+ File.join(gem_root, 'lib/releasehx/rest/clients', "#{origin_source}.yml")
865
+ ]
866
+
867
+ builtin_paths.each do |builtin_path|
868
+ return builtin_path if File.exist?(builtin_path)
869
+ end
870
+
871
+ nil
872
+ end
873
+
874
+ def fetch_issues_from_api version
875
+ ReleaseHx.logger.info("Fetching issues for version #{version} from API using config")
876
+
877
+ client = ReleaseHx::REST::YamlClient.new(@settings, version)
878
+ results = client.fetch_all
879
+
880
+ if options[:payload]
881
+ ReleaseHx.logger.info '✓ Will save API payload to file' if options[:verbose]
882
+ save_api_payload(results, options[:payload])
883
+ else
884
+ ReleaseHx.logger.info("Fetched #{results.size} issues from API for version #{version}")
885
+ end
886
+
887
+ results
888
+ end
889
+
890
+ def save_api_payload results, custom_path = nil
891
+ if custom_path && !custom_path.empty?
892
+ payload_file = custom_path
893
+ payload_dir = File.dirname(payload_file)
894
+ FileUtils.mkdir_p(payload_dir)
895
+ else # Use default path when --payload used without value
896
+ payload_dir = @settings.dig('paths', 'payloads_dir') || 'payloads'
897
+ FileUtils.mkdir_p(payload_dir)
898
+ origin_source_name = @settings.dig('origin', 'source') || 'unknown'
899
+ payload_file = File.join(payload_dir, "#{origin_source_name}.json")
900
+ end
901
+
902
+ File.write(payload_file, JSON.pretty_generate(results))
903
+ ReleaseHx.logger.info("API payload saved to #{payload_file}")
904
+ end
905
+
906
+ def file_format file_path
907
+ ext = File.extname(file_path).downcase
908
+ case ext
909
+ when '.yml', '.yaml' then :yaml
910
+ when '.md' then :markdown
911
+ when '.adoc' then :asciidoc
912
+ else :unknown
913
+ end
914
+ end
915
+
916
+ def merge_cli_flags_into_settings
917
+ @settings['cli_flags'] = {
918
+ 'force' => options[:over],
919
+ 'fetch' => options[:fetch],
920
+ 'verbose' => options[:verbose],
921
+ 'debug' => options[:debug],
922
+ 'internal' => options[:internal],
923
+ 'wrap' => options[:wrap],
924
+ 'frontmatter' => options[:frontmatter],
925
+ 'empty_notes' => options[:emptynotes]
926
+ }.compact
927
+ end
928
+
929
+ def apply_cli_overrides
930
+ return unless options[:output_dir] && !options[:output_dir].empty?
931
+
932
+ @settings['paths'] ||= {}
933
+ @settings['paths']['output_dir'] = options[:output_dir]
934
+ ReleaseHx.logger.info "✓ Changed output directory to: #{options[:output_dir]}"
935
+ end
936
+ end
937
+ end