metanorma-release 0.2.1

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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +1 -0
  4. data/.rubocop_todo.yml +504 -0
  5. data/CHANGELOG.md +15 -0
  6. data/PROMPT.md +282 -0
  7. data/README.adoc +430 -0
  8. data/Rakefile +8 -0
  9. data/exe/mn-release +6 -0
  10. data/lib/metanorma/release/aggregation_interfaces.rb +33 -0
  11. data/lib/metanorma/release/aggregation_pipeline.rb +155 -0
  12. data/lib/metanorma/release/asset_processor.rb +58 -0
  13. data/lib/metanorma/release/cache_store.rb +86 -0
  14. data/lib/metanorma/release/change_detector.rb +20 -0
  15. data/lib/metanorma/release/channel.rb +64 -0
  16. data/lib/metanorma/release/channel_audience.rb +24 -0
  17. data/lib/metanorma/release/channel_config.rb +55 -0
  18. data/lib/metanorma/release/channel_filter.rb +26 -0
  19. data/lib/metanorma/release/channel_manifest.rb +192 -0
  20. data/lib/metanorma/release/channel_registry.rb +60 -0
  21. data/lib/metanorma/release/cli.rb +129 -0
  22. data/lib/metanorma/release/commands/aggregate.rb +126 -0
  23. data/lib/metanorma/release/commands/package.rb +46 -0
  24. data/lib/metanorma/release/commands/publish.rb +51 -0
  25. data/lib/metanorma/release/config_fetcher.rb +11 -0
  26. data/lib/metanorma/release/config_locator.rb +37 -0
  27. data/lib/metanorma/release/config_resolver.rb +37 -0
  28. data/lib/metanorma/release/content_hash.rb +51 -0
  29. data/lib/metanorma/release/delta_state.rb +108 -0
  30. data/lib/metanorma/release/document_id.rb +45 -0
  31. data/lib/metanorma/release/document_index.rb +183 -0
  32. data/lib/metanorma/release/document_metadata.rb +39 -0
  33. data/lib/metanorma/release/document_stage.rb +86 -0
  34. data/lib/metanorma/release/document_type.rb +55 -0
  35. data/lib/metanorma/release/document_version.rb +50 -0
  36. data/lib/metanorma/release/file_routing.rb +51 -0
  37. data/lib/metanorma/release/interfaces.rb +47 -0
  38. data/lib/metanorma/release/naming_strategy.rb +158 -0
  39. data/lib/metanorma/release/platform/github/config_fetcher.rb +40 -0
  40. data/lib/metanorma/release/platform/github/manifest_reader.rb +32 -0
  41. data/lib/metanorma/release/platform/github/publisher.rb +73 -0
  42. data/lib/metanorma/release/platform/github/release_fetcher.rb +52 -0
  43. data/lib/metanorma/release/platform/github/topic_discoverer.rb +29 -0
  44. data/lib/metanorma/release/platform/github.rb +25 -0
  45. data/lib/metanorma/release/platform/local/config_fetcher.rb +20 -0
  46. data/lib/metanorma/release/platform/local/directory_discoverer.rb +26 -0
  47. data/lib/metanorma/release/platform/local/fetcher.rb +76 -0
  48. data/lib/metanorma/release/platform/local/publisher.rb +44 -0
  49. data/lib/metanorma/release/platform/local.rb +14 -0
  50. data/lib/metanorma/release/platform/null/publisher.rb +17 -0
  51. data/lib/metanorma/release/platform/null.rb +11 -0
  52. data/lib/metanorma/release/platform.rb +11 -0
  53. data/lib/metanorma/release/platform_factory.rb +78 -0
  54. data/lib/metanorma/release/rake_tasks.rb +71 -0
  55. data/lib/metanorma/release/relaton_enricher.rb +138 -0
  56. data/lib/metanorma/release/release_metadata.rb +79 -0
  57. data/lib/metanorma/release/release_pipeline.rb +115 -0
  58. data/lib/metanorma/release/release_tag.rb +49 -0
  59. data/lib/metanorma/release/repo_ref.rb +34 -0
  60. data/lib/metanorma/release/rxl_extractor.rb +115 -0
  61. data/lib/metanorma/release/stage_filter.rb +18 -0
  62. data/lib/metanorma/release/version.rb +7 -0
  63. data/lib/metanorma/release/zip_packager.rb +37 -0
  64. data/lib/metanorma/release.rb +116 -0
  65. metadata +156 -0
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module Platform
6
+ module GitHub
7
+ class TopicDiscoverer
8
+ include Metanorma::Release::RepoDiscoverer
9
+
10
+ def initialize(client:, organizations:, topic:)
11
+ @client = client
12
+ @organizations = organizations
13
+ @topic = topic
14
+ end
15
+
16
+ def discover
17
+ @organizations.flat_map do |org|
18
+ query = "topic:#{@topic} org:#{org}"
19
+ results = @client.search_repositories(query)
20
+ results[:items].map do |repo|
21
+ RepoRef.new(owner: org, repo: repo[:name])
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'octokit'
5
+ rescue LoadError
6
+ raise LoadError, "The octokit gem is required for GitHub adapters. Add `gem 'octokit'` to your Gemfile."
7
+ end
8
+
9
+ module Metanorma
10
+ module Release
11
+ module Platform
12
+ module GitHub
13
+ autoload :Publisher, 'metanorma/release/platform/github/publisher'
14
+ autoload :TopicDiscoverer, 'metanorma/release/platform/github/topic_discoverer'
15
+ autoload :ReleaseFetcher, 'metanorma/release/platform/github/release_fetcher'
16
+ autoload :ManifestReader, 'metanorma/release/platform/github/manifest_reader'
17
+ autoload :ConfigFetcher, 'metanorma/release/platform/github/config_fetcher'
18
+
19
+ def self.cache_store(cache_dir:)
20
+ FileCacheStore.new(cache_dir)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module Platform
6
+ module Local
7
+ class ConfigFetcher
8
+ include Metanorma::Release::ConfigFetcher
9
+
10
+ def fetch(source)
11
+ path = source.sub(/\Alocal:/, '')
12
+ return nil unless File.exist?(path)
13
+
14
+ ChannelConfig.from_file(path)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module Platform
6
+ module Local
7
+ class DirectoryDiscoverer
8
+ include Metanorma::Release::RepoDiscoverer
9
+
10
+ def initialize(base_path:)
11
+ @base_path = base_path
12
+ end
13
+
14
+ def discover
15
+ return [] unless Dir.exist?(@base_path)
16
+
17
+ Dir.children(@base_path).filter_map do |entry|
18
+ full = File.join(@base_path, entry)
19
+ RepoRef.new(owner: 'local', repo: entry) if File.directory?(full)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Metanorma
6
+ module Release
7
+ module Platform
8
+ module Local
9
+ LocalRelease = Struct.new(:tag_name, :body, :prerelease, :draft,
10
+ :html_url, :published_at, :created_at,
11
+ :assets, keyword_init: true)
12
+ LocalAsset = Struct.new(:name, :browser_download_url, :size, :data,
13
+ keyword_init: true)
14
+
15
+ class Fetcher
16
+ include Metanorma::Release::ReleaseFetcher
17
+
18
+ def initialize(base_path:)
19
+ @base_path = base_path
20
+ end
21
+
22
+ def fetch(repo, etag: nil)
23
+ dir = File.join(@base_path, repo.repo)
24
+ return FetchResult.new(releases: [], etag: nil, unchanged?: false) unless Dir.exist?(dir)
25
+
26
+ releases = Dir.glob(File.join(dir, '*.meta.json')).filter_map do |meta_path|
27
+ build_release(dir, meta_path)
28
+ end
29
+
30
+ FetchResult.new(releases: releases, etag: nil, unchanged?: false)
31
+ end
32
+
33
+ private
34
+
35
+ def build_release(dir, meta_path)
36
+ data = JSON.parse(File.read(meta_path))
37
+ base = File.basename(meta_path, '.meta.json')
38
+ zip_path = File.join(dir, "#{base}.zip")
39
+
40
+ unless File.exist?(zip_path)
41
+ warn "Warning: Missing zip for #{meta_path}, skipping"
42
+ return nil
43
+ end
44
+
45
+ metadata = ReleaseMetadata.new(data)
46
+ asset = LocalAsset.new(
47
+ name: "#{base}.zip",
48
+ browser_download_url: "file://#{File.expand_path(zip_path)}",
49
+ size: File.size(zip_path),
50
+ data: File.binread(zip_path)
51
+ )
52
+
53
+ LocalRelease.new(
54
+ tag_name: "#{data['id']}/#{data.fetch('edition', '1')}",
55
+ body: metadata.to_release_body,
56
+ prerelease: prerelease?(data),
57
+ draft: false,
58
+ html_url: "file://#{File.expand_path(dir)}",
59
+ published_at: File.mtime(zip_path).iso8601,
60
+ created_at: File.mtime(zip_path).iso8601,
61
+ assets: [asset]
62
+ )
63
+ rescue JSON::ParserError
64
+ warn "Warning: Invalid metadata JSON in #{meta_path}, skipping"
65
+ nil
66
+ end
67
+
68
+ def prerelease?(data)
69
+ stage = data['stage'].to_s
70
+ %w[working-draft committee-draft draft-standard final-draft].include?(stage)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module Metanorma
7
+ module Release
8
+ module Platform
9
+ module Local
10
+ class Publisher
11
+ include Metanorma::Release::Publisher
12
+
13
+ def initialize(output_dir:)
14
+ @output_dir = output_dir
15
+ end
16
+
17
+ def publish(tag, artifact, metadata, channels:, force_replace: false)
18
+ FileUtils.mkdir_p(@output_dir)
19
+
20
+ zip_dest = File.join(@output_dir, artifact.asset_name)
21
+ meta_dest = File.join(@output_dir, meta_file_name(artifact.asset_name))
22
+
23
+ if force_replace
24
+ File.delete(zip_dest) if File.exist?(zip_dest)
25
+ File.delete(meta_dest) if File.exist?(meta_dest)
26
+ end
27
+
28
+ FileUtils.cp(artifact.zip_path, zip_dest)
29
+ File.write(meta_dest, metadata.to_json)
30
+
31
+ PublishResult.new(tag: tag.to_s, url: "file://#{File.expand_path(zip_dest)}", created?: true)
32
+ end
33
+
34
+ private
35
+
36
+ def meta_file_name(asset_name)
37
+ base = asset_name.sub(/\.zip$/, '')
38
+ "#{base}.meta.json"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module Platform
6
+ module Local
7
+ autoload :Publisher, 'metanorma/release/platform/local/publisher'
8
+ autoload :DirectoryDiscoverer, 'metanorma/release/platform/local/directory_discoverer'
9
+ autoload :Fetcher, 'metanorma/release/platform/local/fetcher'
10
+ autoload :ConfigFetcher, 'metanorma/release/platform/local/config_fetcher'
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module Platform
6
+ module Null
7
+ class Publisher
8
+ include Metanorma::Release::Publisher
9
+
10
+ def publish(tag, _artifact, _metadata, channels:, force_replace: false)
11
+ PublishResult.new(tag: tag.to_s, url: 'null://', created?: true)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module Platform
6
+ module Null
7
+ autoload :Publisher, 'metanorma/release/platform/null/publisher'
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module Platform
6
+ autoload :GitHub, 'metanorma/release/platform/github'
7
+ autoload :Local, 'metanorma/release/platform/local'
8
+ autoload :Null, 'metanorma/release/platform/null'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module PlatformFactory
6
+ PUBLISHER_REGISTRY = {
7
+ 'null' => ->(_opts) { Platform::Null::Publisher.new },
8
+ 'local' => ->(opts) { Platform::Local::Publisher.new(output_dir: opts[:output_dir]) }
9
+ }.freeze
10
+
11
+ AGGREGATION_REGISTRY = {
12
+ 'local' => lambda { |opts, _token|
13
+ path = opts[:source].sub('local:', '')
14
+ {
15
+ discoverer: Platform::Local::DirectoryDiscoverer.new(base_path: path),
16
+ fetcher: Platform::Local::Fetcher.new(base_path: path)
17
+ }
18
+ }
19
+ }.freeze
20
+
21
+ def self.build_publisher(platform, options)
22
+ factory = PUBLISHER_REGISTRY[platform]
23
+ raise ArgumentError, "Unknown platform: #{platform}. Available: #{PUBLISHER_REGISTRY.keys.join(', ')}" unless factory
24
+
25
+ factory.call(options)
26
+ end
27
+
28
+ def self.build_aggregation_adapters(options)
29
+ source = options[:source]
30
+ if source.start_with?('local:')
31
+ adapters = AGGREGATION_REGISTRY['local'].call(options, options[:token])
32
+ adapters[:manifest_reader] = NullManifestReader.new
33
+ return adapters
34
+ end
35
+
36
+ require 'octokit'
37
+ client = build_github_client(options[:token])
38
+
39
+ discoverer = if options[:repos]
40
+ repos = options[:repos].map { |r| RepoRef.from_string(r) }
41
+ StaticDiscoverer.new(repos: repos)
42
+ else
43
+ Platform::GitHub::TopicDiscoverer.new(
44
+ client: client, organizations: options[:organizations], topic: options[:topic]
45
+ )
46
+ end
47
+
48
+ {
49
+ discoverer: discoverer,
50
+ fetcher: Platform::GitHub::ReleaseFetcher.new(client: client),
51
+ manifest_reader: Platform::GitHub::ManifestReader.new(client: client)
52
+ }
53
+ end
54
+
55
+ def self.build_github_client(token)
56
+ require 'octokit'
57
+ token ? Octokit::Client.new(access_token: token) : Octokit::Client.new
58
+ end
59
+
60
+ class StaticDiscoverer
61
+ include RepoDiscoverer
62
+
63
+ def initialize(repos:)
64
+ @repos = repos
65
+ end
66
+
67
+ def discover
68
+ @repos
69
+ end
70
+ end
71
+
72
+ class NullManifestReader
73
+ include Metanorma::Release::ManifestReader
74
+ def read(_repo) = nil
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require 'ostruct'
5
+
6
+ module Metanorma
7
+ module Release
8
+ class RakeTasks
9
+ include Rake::DSL
10
+
11
+ def self.install(&block)
12
+ new(&block).install
13
+ end
14
+
15
+ def initialize(&block)
16
+ @config = OpenStruct.new(
17
+ output_dir: '_site',
18
+ manifest: 'metanorma.release.yml',
19
+ platform: 'github',
20
+ concurrency: 4,
21
+ dest: 'dist',
22
+ source: 'github',
23
+ organizations: [],
24
+ topic: 'metanorma-release'
25
+ )
26
+ block&.call(@config)
27
+ end
28
+
29
+ def install
30
+ install_package_task
31
+ install_publish_task
32
+ install_aggregate_task
33
+ end
34
+
35
+ private
36
+
37
+ def install_package_task
38
+ desc 'Package compiled documents'
39
+ task :"mn:package" do
40
+ argv = ['--output-dir', @config.output_dir,
41
+ '--dest', @config.dest,
42
+ '--manifest', @config.manifest]
43
+ CLI.run_package(argv)
44
+ end
45
+ end
46
+
47
+ def install_publish_task
48
+ desc 'Package and publish documents'
49
+ task :"mn:publish" do
50
+ argv = ['--platform', @config.platform,
51
+ '--output-dir', @config.output_dir,
52
+ '--manifest', @config.manifest,
53
+ '--concurrency', @config.concurrency.to_s]
54
+ CLI.run_publish(argv)
55
+ end
56
+ end
57
+
58
+ def install_aggregate_task
59
+ desc 'Aggregate released documents'
60
+ task :"mn:aggregate" do
61
+ argv = ['--source', @config.source,
62
+ '--output-dir', @config.output_dir,
63
+ '--concurrency', @config.concurrency.to_s]
64
+ argv += ['--organizations', @config.organizations.join(',')] if @config.organizations.any?
65
+ argv += ['--topic', @config.topic] if @config.topic
66
+ CLI.run_aggregate(argv)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'relaton/bib'
4
+ require 'json'
5
+ require 'yaml'
6
+ require 'fileutils'
7
+
8
+ module Metanorma
9
+ module Release
10
+ class RelatonEnricher
11
+ EnrichResult = Struct.new(
12
+ :item_count, :output_dir, :documents,
13
+ keyword_init: true
14
+ )
15
+
16
+ @flavor_registry = {}
17
+
18
+ class << self
19
+ def register_flavor(name, &loader)
20
+ @flavor_registry[name.to_s] = loader
21
+ end
22
+
23
+ attr_reader :flavor_registry
24
+ end
25
+
26
+ register_flavor('calconnect') do
27
+ require 'relaton/calconnect'
28
+ Relaton::Calconnect::Item
29
+ end
30
+ register_flavor('cc') do
31
+ require 'relaton/calconnect'
32
+ Relaton::Calconnect::Item
33
+ end
34
+ register_flavor('iso') do
35
+ require 'relaton/iso'
36
+ Relaton::Iso::Item
37
+ end
38
+ register_flavor('iec') do
39
+ require 'relaton/iec'
40
+ Relaton::Iec::BibliographicItem
41
+ end
42
+ register_flavor('ogc') do
43
+ require 'relaton/ogc'
44
+ Relaton::Ogc::BibliographicItem
45
+ end
46
+ register_flavor('ietf') do
47
+ require 'relaton/ietf'
48
+ Relaton::Ietf::BibliographicItem
49
+ end
50
+ register_flavor('bipm') do
51
+ require 'relaton/bipm'
52
+ Relaton::Bipm::BibliographicItem
53
+ end
54
+ register_flavor('itu') do
55
+ require 'relaton/itu'
56
+ Relaton::Itu::BibliographicItem
57
+ end
58
+ register_flavor('nist') do
59
+ require 'relaton/nist'
60
+ Relaton::Nist::BibliographicItem
61
+ end
62
+ register_flavor('un') do
63
+ require 'relaton/un'
64
+ Relaton::Un::BibliographicItem
65
+ end
66
+ register_flavor('bsi') do
67
+ require 'relaton/bsi'
68
+ Relaton::Bsi::BibliographicItem
69
+ end
70
+ register_flavor('ribose') do
71
+ require 'relaton/ribose'
72
+ Relaton::Ribose::Item
73
+ end
74
+
75
+ def initialize(flavor: nil, registry_name: 'Document Registry')
76
+ @flavor = flavor
77
+ @registry_name = registry_name
78
+ end
79
+
80
+ def enrich(document_index, output_dir, bib_dir: 'relaton')
81
+ return nil if document_index.empty?
82
+
83
+ flavor = resolve_flavor(document_index)
84
+ klass = resolve_class(flavor)
85
+ documents = enrich_documents(document_index, output_dir, klass)
86
+ return nil if documents.empty?
87
+
88
+ dest = File.join(output_dir, bib_dir)
89
+ write_index(documents, dest)
90
+
91
+ EnrichResult.new(item_count: documents.length, output_dir: dest,
92
+ documents: documents)
93
+ rescue LoadError
94
+ warn ' (relaton gem not available — bibliography skipped)'
95
+ nil
96
+ end
97
+
98
+ private
99
+
100
+ def resolve_flavor(document_index)
101
+ @flavor || document_index.documents.first&.flavor
102
+ end
103
+
104
+ def resolve_class(flavor)
105
+ loader = self.class.flavor_registry[flavor.to_s]
106
+ return loader.call if loader
107
+
108
+ Relaton::Bib::Item
109
+ rescue LoadError
110
+ warn " (relaton-#{flavor} gem not available — using base Relaton::Bib::Item)"
111
+ Relaton::Bib::Item
112
+ end
113
+
114
+ def enrich_documents(document_index, output_dir, klass)
115
+ document_index.documents.map do |doc|
116
+ rxl = doc.files.find { |f| f.extension == 'rxl' }
117
+ path = rxl && File.join(output_dir, rxl.path)
118
+
119
+ bib = (klass.from_xml(File.read(path)) if path && File.exist?(path))
120
+
121
+ enriched = doc.to_h
122
+ enriched['bibliographic'] = bib.to_h if bib
123
+ enriched
124
+ rescue StandardError => e
125
+ warn " Skip #{File.basename(path)}: #{e.message}"
126
+ doc.to_h
127
+ end
128
+ end
129
+
130
+ def write_index(documents, dest)
131
+ FileUtils.mkdir_p(dest)
132
+ index = { 'root' => { 'title' => @registry_name, 'items' => documents } }
133
+ File.write(File.join(dest, 'index.json'), JSON.pretty_generate(index))
134
+ File.write(File.join(dest, 'index.yaml'), YAML.dump(index))
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Metanorma
6
+ module Release
7
+ class ReleaseMetadata
8
+ SCHEMA_VERSION = 1
9
+
10
+ def self.from_document(metadata, channels:)
11
+ data = {
12
+ 'version' => SCHEMA_VERSION,
13
+ 'id' => metadata.id.to_s,
14
+ 'title' => metadata.title,
15
+ 'edition' => metadata.version.edition,
16
+ 'stage' => metadata.version.stage.to_s,
17
+ 'doctype' => metadata.doctype.to_s,
18
+ 'revdate' => metadata.revdate,
19
+ 'formats' => metadata.formats,
20
+ 'channels' => channels.map(&:to_s),
21
+ 'flavor' => metadata.flavor,
22
+ 'sourcePath' => metadata.source_path
23
+ }
24
+ new(data)
25
+ end
26
+
27
+ def self.from_json(json_string)
28
+ data = JSON.parse(json_string)
29
+ raise ArgumentError, 'Missing required field: id' unless data['id']
30
+ raise ArgumentError, 'Missing required field: title' unless data['title']
31
+
32
+ new(data)
33
+ end
34
+
35
+ def self.from_release_body(body)
36
+ return nil if body.nil? || body.empty?
37
+
38
+ match = body.match(/<!--\s*mn-release-metadata\s*\n(.*?)\n-->/m)
39
+ return nil unless match
40
+
41
+ json_str = match[1]
42
+ begin
43
+ from_json(json_str)
44
+ rescue JSON::ParserError
45
+ nil
46
+ end
47
+ end
48
+
49
+ def initialize(data)
50
+ @data = data
51
+ freeze
52
+ end
53
+
54
+ def to_json(*_args)
55
+ JSON.generate(@data)
56
+ end
57
+
58
+ def to_release_body
59
+ json_str = JSON.generate(@data)
60
+ "<!-- mn-release-metadata\n#{json_str}\n-->"
61
+ end
62
+
63
+ def to_h
64
+ @data.dup
65
+ end
66
+
67
+ def id = @data['id']
68
+ def title = @data['title']
69
+ def edition = @data['edition']
70
+ def stage = @data['stage']
71
+ def doctype = @data['doctype']
72
+ def revdate = @data['revdate']
73
+ def formats = @data['formats'] || []
74
+ def channels = @data['channels'] || []
75
+ def flavor = @data['flavor']
76
+ def source_path = @data['sourcePath']
77
+ end
78
+ end
79
+ end