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,418 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'faraday'
5
+ require 'base64'
6
+ require 'liquid'
7
+ require 'erb'
8
+ require 'fileutils'
9
+ require 'digest'
10
+
11
+ module ReleaseHx
12
+ module REST
13
+ class YamlClient
14
+ attr_reader :raw_response
15
+
16
+ def initialize config, version = nil
17
+ @version = version
18
+ @config = config
19
+ @origin_cfg = config['origin'] || {}
20
+ @origin_source = @origin_cfg['source']
21
+ @vars = build_scope
22
+ @client_def = load_render_client_def
23
+ @resolved_values = {}
24
+ @cache_config = config.dig('paths', 'cache') || {}
25
+ @raw_response = nil
26
+ normalize_fields!
27
+ setup_connection
28
+ perform_resolutions!
29
+ end
30
+
31
+ def fetch_all
32
+ # Check for cached response first (unless force fetch is requested)
33
+ if cache_enabled? && !force_fetch_requested? && (cached_data = cached_response)
34
+ ReleaseHx.logger.info "Using cached API response (#{cached_data.size} items) from #{@origin_source}"
35
+ return cached_data
36
+ end
37
+
38
+ # Fetch fresh data from API
39
+ results = fetch_fresh_data
40
+
41
+ # Save to cache if caching is enabled
42
+ save_to_cache(results) if cache_enabled?
43
+
44
+ ReleaseHx.logger.info "Fetched #{results.size} items from #{@origin_source} API"
45
+ results
46
+ end
47
+
48
+ def fetch_fresh_data
49
+ results = []
50
+
51
+ if pagination?
52
+ page_param = pagination['param']
53
+ page_size_key = pagination['page_size_param']
54
+ page_size_val = pagination['page_size']
55
+
56
+ current_index = 0
57
+ loop_count = 0
58
+ max_pages = pagination['max_pages'] || 100
59
+
60
+ loop do
61
+ query = query_params.merge(
62
+ {
63
+ page_param => current_index,
64
+ page_size_key => page_size_val
65
+ })
66
+
67
+ # Report to logger debug the API URL and query params
68
+ ReleaseHx.logger.debug "Fetching from: #{@href} with query: #{query.inspect}"
69
+
70
+ resp = @conn.get(@href, query, @headers)
71
+ body = resp.body
72
+ raise "HTTP Error #{resp.status}" unless resp.success?
73
+
74
+ # Save raw response from first page for payload export
75
+ @raw_response = body if loop_count.zero?
76
+
77
+ issues = extract_issues_from_response(body)
78
+ results.concat(Array(issues))
79
+
80
+ break if issues.nil? || issues.size.to_i < page_size_val
81
+
82
+ current_index += page_size_val
83
+ loop_count += 1
84
+ break if loop_count >= max_pages
85
+ end
86
+ else
87
+ resp = @conn.get(@href, query_params, @headers)
88
+ raise "HTTP Error #{resp.status}" unless resp.success?
89
+
90
+ # Save raw response before extraction
91
+ @raw_response = resp.body
92
+
93
+ issues = extract_issues_from_response(resp.body)
94
+ results = Array(issues)
95
+ end
96
+
97
+ results
98
+ end
99
+
100
+ private
101
+
102
+ def extract_issues_from_response body
103
+ root_path = @client_def['root_issues_path']
104
+ if root_path && !root_path.empty? && root_path != '.'
105
+ body[root_path]
106
+ else
107
+ body
108
+ end
109
+ end
110
+
111
+ def build_scope
112
+ {
113
+ 'origin' => @origin_cfg.merge('version' => @version),
114
+ 'env' => ENV.to_h
115
+ }
116
+ end
117
+
118
+ def load_render_client_def
119
+ user_dir = @config.dig('paths', 'api_clients_dir') || '_apis'
120
+ user_file = File.join(user_dir, "#{@origin_source}.yaml")
121
+ builtin_file = File.expand_path("clients/#{@origin_source}.yml", __dir__)
122
+ path = File.exist?(user_file) ? user_file : builtin_file
123
+ raise "Missing client config for API: #{@origin_source}" unless File.exist?(path)
124
+
125
+ raw = File.read(path)
126
+ # Load raw YAML first, then selectively render templated fields
127
+ YAML.safe_load(raw)
128
+ end
129
+
130
+ def normalize_fields!
131
+ @href = @origin_cfg['href'] || render_field(@client_def['href'])
132
+ # Use client auth if main config auth is incomplete (missing mode/header/format)
133
+ main_auth = @origin_cfg['auth'] || {}
134
+ client_auth = @client_def['auth'] || {}
135
+ has_required = main_auth['mode'] && main_auth['header'] && (main_auth['format'] || main_auth['key_env'])
136
+ @auth = has_required ? main_auth : client_auth
137
+ @headers = build_headers
138
+ @query_string = @origin_cfg['string'] || @client_def['query_string']
139
+ end
140
+
141
+ def pagination
142
+ @origin_cfg['pagination'] || @client_def['pagination']
143
+ end
144
+
145
+ def pagination?
146
+ !!pagination
147
+ end
148
+
149
+ def render_field val
150
+ context = build_scope['origin'].merge('env' => build_scope['env'])
151
+ render_if_templated(val, context)
152
+ end
153
+
154
+ def render_if_templated template_def, context
155
+ return template_def unless template_def.is_a?(String) && template_def.include?('{{')
156
+
157
+ # Use Liquid templating directly like RHYML adapter
158
+ template = ::Liquid::Template.parse(template_def)
159
+ template.render(context)
160
+ rescue StandardError => e
161
+ ReleaseHx.logger.error "Error rendering template '#{template_def}': #{e.message}"
162
+ template_def # Return original on error
163
+ end
164
+
165
+ def build_headers
166
+ return {} unless @auth['mode'] && @auth['header'] && @auth['format']
167
+
168
+ value = render_field(@auth['format'])
169
+ {
170
+ @auth['header'] => value
171
+ }
172
+ end
173
+
174
+ def query_params
175
+ base = {}
176
+
177
+ # Prefer structured query_params over legacy query_string
178
+ if @client_def['query_params']
179
+ # New structured approach
180
+ query_type = @client_def['query_type'] || 'key_value'
181
+
182
+ case query_type.to_s.downcase
183
+ when 'jql'
184
+ # Jira JQL; render the whole query_params as a single JQL string
185
+ base['jql'] = render_structured_params(@client_def['query_params'])
186
+ else
187
+ # GitHub/GitLab style; render each param individually
188
+ @client_def['query_params'].each do |key, value|
189
+ base[key] = render_field_with_resolutions(value)
190
+ end
191
+ end
192
+ elsif @query_string
193
+ # Legacy query_string approach (backward compatibility)
194
+ rendered_query = render_field_with_resolutions(@query_string)
195
+ query_type = @client_def['query_type'] || detect_query_type(rendered_query)
196
+
197
+ case query_type.to_s.downcase
198
+ when 'jql'
199
+ base['jql'] = rendered_query
200
+ when 'key_value'
201
+ base.merge!(parse_query_string_to_hash(rendered_query))
202
+ when 'query_string'
203
+ base['string'] = rendered_query
204
+ else
205
+ base.merge!(smart_parse_query_params(rendered_query))
206
+ end
207
+ end
208
+
209
+ # Merge any additional params from config
210
+ base.merge!(@origin_cfg['params'] || {})
211
+ base
212
+ end
213
+
214
+ def detect_query_type query_str
215
+ # Smart detection based on query string characteristics
216
+ if query_str.include?('&') && query_str.include?('=')
217
+ # Contains & and = - likely key_value format
218
+ 'key_value'
219
+ elsif query_str.match?(/\b(AND|OR|IN|NOT|ORDER BY|WHERE)\b/i)
220
+ # Contains SQL/JQL keywords; likely JQL
221
+ 'jql'
222
+ else
223
+ # Default to query_string for safety
224
+ 'query_string'
225
+ end
226
+ end
227
+
228
+ def parse_query_string_to_hash query_str
229
+ # Parse "key=value&key2=value2" into hash
230
+ result = {}
231
+ query_str.split('&').each do |pair|
232
+ key, value = pair.split('=', 2)
233
+ result[key] = value if key && value
234
+ end
235
+ result
236
+ end
237
+
238
+ def smart_parse_query_params query_str
239
+ # Smart fallback parsing
240
+ if query_str.include?('&') && query_str.include?('=')
241
+ # Looks like key-value pairs
242
+ parse_query_string_to_hash(query_str)
243
+ elsif query_str.match?(/\b(AND|OR|IN|NOT)\b/i)
244
+ # Single string; could be JQL or raw query
245
+ # Looks like JQL
246
+ { 'jql' => query_str }
247
+ else
248
+ # Raw query string
249
+ { 'string' => query_str }
250
+ end
251
+ end
252
+
253
+ def perform_resolutions!
254
+ resolutions = @client_def['resolutions'] || {}
255
+ return if resolutions.empty?
256
+
257
+ ReleaseHx.logger.debug "Performing #{resolutions.keys.size} resolutions: #{resolutions.keys.join(', ')}"
258
+
259
+ resolutions.each do |name, config|
260
+ @resolved_values[name] = resolve_entity(config)
261
+ ReleaseHx.logger.debug "Resolved #{name}: #{@resolved_values[name]}"
262
+ end
263
+ end
264
+
265
+ def resolve_entity config
266
+ # Build resolution endpoint URL with proper context
267
+ context = build_scope['origin'].merge('env' => build_scope['env'])
268
+ endpoint = render_if_templated(config['endpoint'], context)
269
+ base_url = @href.split('/repos/').first # Extract base API URL
270
+ resolution_url = "#{base_url}#{endpoint}"
271
+
272
+ ReleaseHx.logger.debug "Resolving entity from: #{resolution_url}"
273
+
274
+ # Fetch resolution data
275
+ resp = @conn.get(resolution_url, {}, @headers)
276
+ raise "Resolution HTTP Error #{resp.status} for #{endpoint}" unless resp.success?
277
+
278
+ entities = Array(resp.body)
279
+ match_value = render_if_templated(config['match_value'], context)
280
+ lookup_field = config['lookup_field']
281
+ return_field = config['return_field']
282
+
283
+ ReleaseHx.logger.debug "Looking for #{lookup_field}='#{match_value}' in #{entities.size} entities"
284
+
285
+ # Find matching entity
286
+ matching_entity = entities.find { |entity| entity[lookup_field] == match_value }
287
+
288
+ if matching_entity
289
+ result = matching_entity[return_field]
290
+ ReleaseHx.logger.debug "Found match: #{lookup_field}='#{match_value}' -> #{return_field}='#{result}'"
291
+ result
292
+ else
293
+ available = entities.map { |e| e[lookup_field] }.compact.join(', ')
294
+ raise "No entity found with #{lookup_field}='#{match_value}'. Available: #{available}"
295
+ end
296
+ end
297
+
298
+ def render_field_with_resolutions val
299
+ # Build context with resolved values for templating
300
+ context = build_scope['origin'].merge('env' => build_scope['env']).merge(@resolved_values)
301
+ ReleaseHx.logger.debug "Template context for '#{val}': #{context.keys} (resolved: #{@resolved_values})"
302
+ result = render_if_templated(val, context)
303
+ ReleaseHx.logger.debug "Template result: '#{result}'"
304
+ result
305
+ end
306
+
307
+ def render_structured_params params_hash
308
+ # For JQL; combine all params into a single query string
309
+ rendered_parts = params_hash.map do |key, value|
310
+ rendered_value = render_field_with_resolutions(value)
311
+ "#{key}=#{rendered_value}"
312
+ end
313
+ rendered_parts.join(' AND ')
314
+ end
315
+
316
+ def setup_connection
317
+ require 'faraday/follow_redirects'
318
+ @conn = Faraday.new do |f|
319
+ f.request :url_encoded
320
+ f.response :json, parser_options: { symbolize_names: false }
321
+ f.use Faraday::FollowRedirects::Middleware
322
+ f.adapter Faraday.default_adapter
323
+ end
324
+ end
325
+
326
+ # Cache management methods
327
+ def cache_enabled?
328
+ @cache_config['enabled']
329
+ end
330
+
331
+ def force_fetch_requested?
332
+ # Check if CLI options force fresh fetch (--force or --fetch flags)
333
+ # These are passed through the config under cli_flags
334
+ @config.dig('cli_flags', 'force') || @config.dig('cli_flags', 'fetch')
335
+ end
336
+
337
+ def cache_dir
338
+ @cache_config['dir']
339
+ end
340
+
341
+ def cache_ttl_hours
342
+ @cache_config['ttl_hours']
343
+ end
344
+
345
+ def cache_file_path
346
+ # Create structured cache path: cache_dir/api_from/version/payload.json
347
+ version_part = @version || 'default'
348
+ cache_subdir = File.join(cache_dir, @source_type, version_part)
349
+ File.join(cache_subdir, 'payload.json')
350
+ end
351
+
352
+ def cached_response
353
+ cache_path = cache_file_path
354
+ return nil unless File.exist?(cache_path)
355
+
356
+ # Check if cache is still valid (within TTL)
357
+ cache_age_hours = (Time.now - File.mtime(cache_path)) / 3600.0
358
+ if cache_age_hours > cache_ttl_hours
359
+ ReleaseHx.logger.debug "Cache expired (#{cache_age_hours.round(1)}h old, TTL: #{cache_ttl_hours}h)"
360
+ return nil
361
+ end
362
+
363
+ ReleaseHx.logger.debug "Using cache from #{cache_path} (#{cache_age_hours.round(1)}h old)"
364
+
365
+ begin
366
+ cached_content = File.read(cache_path)
367
+ JSON.parse(cached_content)
368
+ rescue StandardError => e
369
+ ReleaseHx.logger.warn "Failed to read cache file #{cache_path}: #{e.message}"
370
+ nil
371
+ end
372
+ end
373
+
374
+ def save_to_cache api_response
375
+ cache_path = cache_file_path
376
+ cache_subdir = File.dirname(cache_path)
377
+
378
+ begin
379
+ # Create cache directory structure
380
+ FileUtils.mkdir_p(cache_subdir)
381
+
382
+ # Write API response to cache file
383
+ File.write(cache_path, JSON.pretty_generate(api_response))
384
+ ReleaseHx.logger.debug "Saved API response to cache: #{cache_path}"
385
+
386
+ # Handle .gitignore if requested
387
+ handle_gitignore_prompt if @cache_config['prompt_gitignore']
388
+ rescue StandardError => e
389
+ ReleaseHx.logger.warn "Failed to save cache to #{cache_path}: #{e.message}"
390
+ end
391
+ end
392
+
393
+ def handle_gitignore_prompt
394
+ gitignore_path = '.gitignore'
395
+ cache_dir_pattern = "/#{cache_dir.gsub(%r{^\.?/}, '')}"
396
+
397
+ # Check if .gitignore already contains our cache directory
398
+ if File.exist?(gitignore_path)
399
+ gitignore_content = File.read(gitignore_path)
400
+ return if gitignore_content.include?(cache_dir_pattern)
401
+ end
402
+
403
+ # Prompt user to add cache directory to .gitignore
404
+ # For now, we'll just add it automatically since we can't prompt in non-interactive mode
405
+ begin
406
+ File.open(gitignore_path, 'a') do |f|
407
+ f.puts
408
+ f.puts '# ReleaseHx API cache'
409
+ f.puts cache_dir_pattern
410
+ end
411
+ ReleaseHx.logger.info "Added #{cache_dir_pattern} to .gitignore"
412
+ rescue StandardError => e
413
+ ReleaseHx.logger.warn "Could not update .gitignore: #{e.message}"
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end