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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Metanorma
6
+ module Release
7
+ module CacheStore
8
+ def get(key)
9
+ raise NotImplementedError, "#{self.class} must implement #get"
10
+ end
11
+
12
+ def set(key, value)
13
+ raise NotImplementedError, "#{self.class} must implement #set"
14
+ end
15
+
16
+ def delete(key)
17
+ raise NotImplementedError, "#{self.class} must implement #delete"
18
+ end
19
+
20
+ def clear
21
+ raise NotImplementedError, "#{self.class} must implement #clear"
22
+ end
23
+
24
+ def keys
25
+ raise NotImplementedError, "#{self.class} must implement #keys"
26
+ end
27
+ end
28
+
29
+ class FileCacheStore
30
+ include CacheStore
31
+
32
+ def initialize(directory)
33
+ @directory = directory
34
+ end
35
+
36
+ def get(key)
37
+ path = file_path(key)
38
+ return nil unless File.exist?(path)
39
+
40
+ File.read(path)
41
+ end
42
+
43
+ def set(key, value)
44
+ FileUtils.mkdir_p(@directory)
45
+ File.write(file_path(key), value)
46
+ end
47
+
48
+ def delete(key)
49
+ path = file_path(key)
50
+ File.delete(path) if File.exist?(path)
51
+ end
52
+
53
+ def clear
54
+ return unless Dir.exist?(@directory)
55
+
56
+ Dir.glob(File.join(@directory, '*')).each do |f|
57
+ File.delete(f) if File.file?(f)
58
+ end
59
+ end
60
+
61
+ def keys
62
+ return [] unless Dir.exist?(@directory)
63
+
64
+ Dir.glob(File.join(@directory, '*')).select { |f| File.file?(f) }
65
+ .map { |f| File.basename(f) }
66
+ end
67
+
68
+ private
69
+
70
+ def file_path(key)
71
+ sanitized = key.gsub(/[^a-zA-Z0-9._-]/, '_')
72
+ File.join(@directory, sanitized)
73
+ end
74
+ end
75
+
76
+ class NullCacheStore
77
+ include CacheStore
78
+
79
+ def get(_key) = nil
80
+ def set(_key, _value) = nil
81
+ def delete(_key) = nil
82
+ def clear = nil
83
+ def keys = []
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ class ContentHashChangeDetector
6
+ include ChangeDetector
7
+
8
+ def initialize(previous_releases:)
9
+ @previous_releases = previous_releases
10
+ end
11
+
12
+ def detect(metadata, tag, force: false)
13
+ current = ContentHash.of_directory(metadata.output_dir, base: metadata.file_base_name)
14
+ previous = @previous_releases[tag.to_s]
15
+ changed = force || previous.nil? || !current.eql?(previous)
16
+ ChangeResult.new(changed?: changed, current_hash: current, previous_hash: previous)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ class Channel
6
+ def self.parse(channel_string)
7
+ parts = channel_string.to_s.strip.split('/', 2)
8
+ if ChannelAudience.values.include?(parts[0])
9
+ new(audience: parts[0], category: parts[1] || 'default')
10
+ else
11
+ new(audience: ChannelAudience::PUBLIC, category: parts[0])
12
+ end
13
+ end
14
+
15
+ def self.parse_list(strings)
16
+ (strings || []).map { |s| parse(s) }
17
+ end
18
+
19
+ def self.public(category)
20
+ new(audience: ChannelAudience::PUBLIC, category: category)
21
+ end
22
+
23
+ def self.members(category)
24
+ new(audience: ChannelAudience::MEMBERS, category: category)
25
+ end
26
+
27
+ def self.internal(category)
28
+ new(audience: ChannelAudience::INTERNAL, category: category)
29
+ end
30
+
31
+ attr_reader :audience, :category
32
+
33
+ def initialize(audience:, category:)
34
+ @audience = audience
35
+ @category = category
36
+ freeze
37
+ end
38
+
39
+ def to_s
40
+ "#{audience}/#{category}"
41
+ end
42
+
43
+ def public?
44
+ audience == ChannelAudience::PUBLIC
45
+ end
46
+
47
+ def members?
48
+ audience == ChannelAudience::MEMBERS
49
+ end
50
+
51
+ def matches?(filter_channels)
52
+ filter_channels.any? { |c| eql?(Channel.parse(c)) }
53
+ end
54
+
55
+ def eql?(other)
56
+ other.is_a?(self.class) && audience == other.audience && category == other.category
57
+ end
58
+
59
+ def hash
60
+ [audience, category].hash
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module ChannelAudience
6
+ PUBLIC = 'public'
7
+ MEMBERS = 'members'
8
+ INTERNAL = 'internal'
9
+
10
+ ALL = [PUBLIC, MEMBERS, INTERNAL].freeze
11
+
12
+ def self.values
13
+ ALL
14
+ end
15
+
16
+ def self.from_string(raw)
17
+ normalized = raw.to_s.downcase.strip
18
+ return normalized if ALL.include?(normalized)
19
+
20
+ raise ArgumentError, "Unknown audience: #{raw.inspect}. Expected one of: #{ALL.join(', ')}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Metanorma
6
+ module Release
7
+ class ChannelConfig
8
+ def self.from_yaml(yaml_string)
9
+ data = YAML.safe_load(yaml_string, permitted_classes: [Symbol])
10
+ raise ArgumentError, 'Invalid channel config YAML' unless data.is_a?(Hash)
11
+
12
+ registry = ChannelRegistry.from_yaml(yaml_string)
13
+ defaults = data['defaults'] || {}
14
+ default_visibility = defaults['visibility'] || 'public'
15
+ default_channels = parse_channels(defaults['channels'])
16
+
17
+ new(registry: registry, default_visibility: default_visibility,
18
+ default_channels: default_channels)
19
+ end
20
+
21
+ def self.from_file(path)
22
+ if File.directory?(path)
23
+ channels_yml = File.join(path, 'channels.yml')
24
+ raise ArgumentError, "Channel config file not found: #{path}" unless File.exist?(channels_yml)
25
+
26
+ return from_file(channels_yml)
27
+ end
28
+
29
+ raise ArgumentError, "Channel config file not found: #{path}" unless File.exist?(path)
30
+
31
+ from_yaml(File.read(path))
32
+ end
33
+
34
+ def self.empty
35
+ new(registry: ChannelRegistry.all_allowed,
36
+ default_visibility: 'public', default_channels: [])
37
+ end
38
+
39
+ def initialize(registry:, default_visibility:, default_channels:)
40
+ @registry = registry
41
+ @default_visibility = default_visibility
42
+ @default_channels = default_channels.freeze
43
+ freeze
44
+ end
45
+
46
+ attr_reader :registry, :default_visibility, :default_channels
47
+
48
+ def self.parse_channels(list)
49
+ return [] unless list
50
+
51
+ list.map { |c| Channel.parse(c.to_s) }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ class ChannelFilter
6
+ def initialize(channels)
7
+ @channels = channels.map { |c| Channel.parse(c) }
8
+ @all = @channels.empty?
9
+ end
10
+
11
+ def matches?(release_metadata)
12
+ return true if @all
13
+
14
+ release_channels = (release_metadata['channels'] || []).map { |c| Channel.parse(c) }
15
+ release_channels.any? { |rc| @channels.any? { |fc| fc.eql?(rc) } }
16
+ end
17
+
18
+ def overlaps?(manifest_channels)
19
+ return true if @all
20
+
21
+ parsed = manifest_channels.map { |c| Channel.parse(c) }
22
+ parsed.any? { |mc| @channels.any? { |fc| fc.eql?(mc) } }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Metanorma
6
+ module Release
7
+ class DocumentReleasePolicy
8
+ def self.from_defaults(visibility, channels)
9
+ ch = build_channels(visibility, channels)
10
+ is_released = visibility != 'private' || !ch.empty?
11
+ new(release: is_released, channels: ch, stage_allow_list: nil)
12
+ end
13
+
14
+ def self.from_entry(entry)
15
+ ch = build_channels(entry.visibility, entry.channels)
16
+ new(release: true, channels: ch, stage_allow_list: entry.stages_set)
17
+ end
18
+
19
+ def self.not_released
20
+ new(release: false, channels: [].freeze, stage_allow_list: nil)
21
+ end
22
+
23
+ def initialize(release:, channels:, stage_allow_list:)
24
+ @release = release
25
+ @channels = channels.freeze
26
+ @stage_allow_list = stage_allow_list
27
+ freeze
28
+ end
29
+
30
+ def release?
31
+ @release
32
+ end
33
+
34
+ attr_reader :channels, :stage_allow_list
35
+
36
+ def self.build_channels(visibility, explicit_channels)
37
+ return explicit_channels if explicit_channels && !explicit_channels.empty?
38
+
39
+ case visibility
40
+ when 'public' then [Channel.public('default')]
41
+ when 'members' then [Channel.members('default')]
42
+ else [].freeze
43
+ end
44
+ end
45
+ end
46
+
47
+ class ManifestEntry
48
+ attr_reader :source, :pattern, :visibility, :channels, :stages
49
+
50
+ def initialize(source:, pattern:, visibility:, channels:, stages:)
51
+ @source = source
52
+ @pattern = pattern
53
+ @visibility = visibility
54
+ @channels = channels
55
+ @stages = stages
56
+ freeze
57
+ end
58
+
59
+ def match_priority
60
+ return 100 if source
61
+ return 50 + pattern.to_s.length if pattern
62
+
63
+ 0
64
+ end
65
+
66
+ def stages_set
67
+ return nil if stages.nil? || stages.empty?
68
+
69
+ Set.new(stages.map(&:downcase))
70
+ end
71
+ end
72
+
73
+ class ChannelManifest
74
+ def self.parse(yaml_hash)
75
+ defaults = yaml_hash['defaults'] || {}
76
+ default_visibility = defaults['visibility'] || 'public'
77
+ default_channels = parse_channels(defaults['channels'])
78
+ entries = parse_entries(yaml_hash['documents'] || [])
79
+ config_source = yaml_hash['config']
80
+
81
+ new(entries: entries, default_visibility: default_visibility,
82
+ default_channels: default_channels, explicit: true, config_source: config_source)
83
+ end
84
+
85
+ def self.from_yaml(yaml_string)
86
+ yaml = YAML.safe_load(yaml_string, permitted_classes: [Symbol])
87
+ raise ArgumentError, 'Manifest YAML is empty' unless yaml.is_a?(Hash)
88
+
89
+ parse(yaml)
90
+ end
91
+
92
+ def self.from_file(path)
93
+ raise ArgumentError, "Manifest file not found: #{path}" unless File.exist?(path)
94
+
95
+ from_yaml(File.read(path))
96
+ end
97
+
98
+ def self.all_public
99
+ new(entries: [], default_visibility: 'public',
100
+ default_channels: [Channel.public('default')], explicit: false, config_source: nil)
101
+ end
102
+
103
+ def self.all_private
104
+ new(entries: [], default_visibility: 'private',
105
+ default_channels: [], explicit: false, config_source: nil)
106
+ end
107
+
108
+ def initialize(entries:, default_visibility:, default_channels:, explicit:, config_source: nil)
109
+ @entries = entries
110
+ @default_visibility = default_visibility
111
+ @default_channels = default_channels.freeze
112
+ @explicit = explicit
113
+ @config_source = config_source
114
+ freeze
115
+ end
116
+
117
+ def resolve(document)
118
+ return default_policy unless @explicit
119
+
120
+ entry = find_best_match(document)
121
+ return DocumentReleasePolicy.from_defaults(@default_visibility, @default_channels) unless entry
122
+
123
+ DocumentReleasePolicy.from_entry(entry)
124
+ end
125
+
126
+ def list_all
127
+ @entries
128
+ end
129
+
130
+ def all_channels
131
+ (@default_channels + @entries.flat_map(&:channels)).uniq
132
+ end
133
+
134
+ def explicit?
135
+ @explicit
136
+ end
137
+
138
+ attr_reader :config_source
139
+
140
+ private
141
+
142
+ def default_policy
143
+ DocumentReleasePolicy.from_defaults(@default_visibility, @default_channels)
144
+ end
145
+
146
+ def find_best_match(document)
147
+ source = extract_source(document)
148
+ matches = @entries.select { |e| entry_matches?(e, source) }
149
+ return nil if matches.empty?
150
+
151
+ matches.max_by(&:match_priority)
152
+ end
153
+
154
+ def entry_matches?(entry, source)
155
+ return false unless source
156
+ return true if entry.source && entry.source == source
157
+ return true if entry.pattern && File.fnmatch?(entry.pattern, source)
158
+
159
+ false
160
+ end
161
+
162
+ def extract_source(document)
163
+ document['source_path']
164
+ end
165
+
166
+ def self.parse_channels(channel_list)
167
+ return [] unless channel_list
168
+
169
+ channel_list.map { |c| Channel.parse(c.to_s) }
170
+ end
171
+
172
+ def self.parse_entries(documents)
173
+ documents.map do |doc|
174
+ validate_entry!(doc)
175
+ ManifestEntry.new(
176
+ source: doc['source'],
177
+ pattern: doc['pattern'],
178
+ visibility: doc['visibility'],
179
+ channels: parse_channels(doc['channels']),
180
+ stages: doc['stages']
181
+ )
182
+ end
183
+ end
184
+
185
+ def self.validate_entry!(doc)
186
+ return unless doc['source']&.include?('..')
187
+
188
+ raise ArgumentError, "Path traversal detected in manifest source: #{doc['source']}"
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Metanorma
6
+ module Release
7
+ class ChannelRegistry
8
+ def self.from_yaml(yaml_string)
9
+ data = YAML.safe_load(yaml_string, permitted_classes: [Symbol])
10
+ raise ArgumentError, 'Invalid channel registry YAML' unless data.is_a?(Hash)
11
+
12
+ channels = parse_channel_list(data['channels'])
13
+ new(channels: channels)
14
+ end
15
+
16
+ def self.from_file(path)
17
+ raise ArgumentError, "Channel registry file not found: #{path}" unless File.exist?(path)
18
+
19
+ from_yaml(File.read(path))
20
+ end
21
+
22
+ def self.all_allowed
23
+ new(channels: [])
24
+ end
25
+
26
+ def initialize(channels:)
27
+ @channels = channels.freeze
28
+ @channel_set = channels.to_set
29
+ freeze
30
+ end
31
+
32
+ def valid?(channel)
33
+ return true if @channels.empty?
34
+
35
+ @channel_set.include?(channel)
36
+ end
37
+
38
+ def include?(channel_or_string)
39
+ valid?(Channel.parse(channel_or_string.to_s))
40
+ end
41
+
42
+ attr_reader :channels
43
+
44
+ def empty?
45
+ @channels.empty?
46
+ end
47
+
48
+ def self.parse_channel_list(list)
49
+ return [] unless list
50
+
51
+ list.filter_map do |entry|
52
+ case entry
53
+ when Hash then Channel.parse(entry['name'].to_s)
54
+ when String then Channel.parse(entry)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Metanorma
6
+ module Release
7
+ module CLI
8
+ module_function
9
+
10
+ def run(argv)
11
+ command = argv.shift
12
+ case command
13
+ when 'package' then run_package(argv)
14
+ when 'publish' then run_publish(argv)
15
+ when 'aggregate' then run_aggregate(argv)
16
+ when nil
17
+ warn 'Usage: mn-release <package|publish|aggregate> [options]'
18
+ exit 2
19
+ else
20
+ warn "Unknown command: #{command}"
21
+ exit 2
22
+ end
23
+ end
24
+
25
+ def run_package(argv)
26
+ config = parse_package_args(argv)
27
+ result = PackageCommand.new(config).call
28
+ print_package_result(result, config.dest)
29
+ exit(result.failed.empty? ? 0 : 1)
30
+ end
31
+
32
+ def run_publish(argv)
33
+ config = parse_publish_args(argv)
34
+ result = PublishCommand.new(config).call
35
+ print_publish_result(result)
36
+ exit(result.failed.empty? ? 0 : 1)
37
+ end
38
+
39
+ def run_aggregate(argv)
40
+ config = parse_aggregate_args(argv)
41
+ result = AggregateCommand.new(config).call
42
+ print_aggregate_result(result)
43
+ if config.min_documents.positive? && result.documents.length < config.min_documents
44
+ warn "Error: Found #{result.documents.length} documents, minimum is #{config.min_documents}"
45
+ exit 1
46
+ end
47
+ exit(result.failed_repos.empty? ? 0 : 1)
48
+ end
49
+
50
+ class << self
51
+ private
52
+
53
+ def parse_package_args(argv)
54
+ options = { output_dir: '_site', dest: 'dist', manifest: 'metanorma.release.yml',
55
+ config_source: nil }
56
+ OptionParser.new do |opts|
57
+ opts.banner = 'Usage: mn-release package [options]'
58
+ opts.on('--output-dir DIR', 'Compiled docs directory') { |v| options[:output_dir] = v }
59
+ opts.on('--dest DIR', 'Destination for packages') { |v| options[:dest] = v }
60
+ opts.on('--manifest FILE', 'Release manifest file') { |v| options[:manifest] = v }
61
+ opts.on('--config SOURCE', 'Channel config (file path or platform ref)') { |v| options[:config_source] = v }
62
+ end.parse!(argv)
63
+ PackageCommand::Config.new(**options)
64
+ end
65
+
66
+ def parse_publish_args(argv)
67
+ options = { output_dir: '_site', platform: 'github', manifest: 'metanorma.release.yml',
68
+ force: false, force_replace: [], channels: nil, concurrency: 4, token: nil,
69
+ config_source: nil }
70
+ OptionParser.new do |opts|
71
+ opts.banner = 'Usage: mn-release publish [options]'
72
+ opts.on('--platform NAME', 'github|local') { |v| options[:platform] = v }
73
+ opts.on('--output-dir DIR', 'Compiled docs directory') { |v| options[:output_dir] = v }
74
+ opts.on('--manifest FILE', 'Release manifest file') { |v| options[:manifest] = v }
75
+ opts.on('--force', 'Force release even if unchanged') { |v| options[:force] = v }
76
+ opts.on('--force-replace PAT', 'Glob patterns for force-replace') { |v| options[:force_replace] << v }
77
+ opts.on('--channels CHANS', 'Override channels (comma-separated)') { |v| options[:channels] = v.split(',') }
78
+ opts.on('--concurrency N', Integer) { |v| options[:concurrency] = v }
79
+ opts.on('--token TOKEN', 'Platform auth token') { |v| options[:token] = v }
80
+ opts.on('--config SOURCE', 'Channel config (file path or platform ref)') { |v| options[:config_source] = v }
81
+ end.parse!(argv)
82
+ PublishCommand::Config.new(**options)
83
+ end
84
+
85
+ def parse_aggregate_args(argv)
86
+ options = { source: 'github', organizations: [], topic: 'metanorma-release',
87
+ repos: nil, channels: [], stages: [], output_dir: '_site/cc',
88
+ file_routing: 'by-document', cache_dir: nil,
89
+ include_drafts: false, concurrency: 4, min_documents: 0, token: nil }
90
+ OptionParser.new do |opts|
91
+ opts.banner = 'Usage: mn-release aggregate [options]'
92
+ opts.on('--source SOURCE', 'github|local:PATH') { |v| options[:source] = v }
93
+ opts.on('--organizations ORGS', 'Comma-separated org list') { |v| options[:organizations] = v.split(',') }
94
+ opts.on('--topic TOPIC', 'Repository topic') { |v| options[:topic] = v }
95
+ opts.on('--repos REPOS', 'Explicit repo list (comma-separated)') { |v| options[:repos] = v.split(',') }
96
+ opts.on('--channels CHANS', 'Filter channels (comma-separated)') { |v| options[:channels] = v.split(',') }
97
+ opts.on('--stages STAGES', 'Filter stages (comma-separated)') { |v| options[:stages] = v.split(',') }
98
+ opts.on('--output-dir DIR', 'Output directory') { |v| options[:output_dir] = v }
99
+ opts.on('--file-routing MODE', 'by-document|flat|by-format') { |v| options[:file_routing] = v }
100
+ opts.on('--cache-dir DIR', 'Cache directory') { |v| options[:cache_dir] = v }
101
+ opts.on('--[no-]include-drafts', 'Include draft releases') { |v| options[:include_drafts] = v }
102
+ opts.on('--concurrency N', Integer) { |v| options[:concurrency] = v }
103
+ opts.on('--min-documents N', Integer) { |v| options[:min_documents] = v }
104
+ opts.on('--token TOKEN', 'Platform auth token') { |v| options[:token] = v }
105
+ end.parse!(argv)
106
+ AggregateCommand::Config.new(**options)
107
+ end
108
+
109
+ def print_package_result(result, dest)
110
+ released = result.released
111
+ puts "Packaged #{released.length} documents → #{dest}/"
112
+ released.each { |doc| puts " #{doc.id}" }
113
+ end
114
+
115
+ def print_publish_result(result)
116
+ puts "Released #{result.released.length}, skipped #{result.skipped.length}, failed #{result.failed.length}"
117
+ result.released.each { |doc| puts " RELEASED: #{doc.id} (#{doc.version.tag_component})" }
118
+ result.skipped.each { |doc| puts " SKIPPED: #{doc.id} (unchanged)" }
119
+ result.failed.each { |f| puts " FAILED: #{f[:document].id} - #{f[:error]}" }
120
+ end
121
+
122
+ def print_aggregate_result(result)
123
+ puts "Aggregated #{result.documents.length} documents from #{result.repo_count} repos"
124
+ puts "Index: #{result.channels_found.join(', ')}" unless result.channels_found.empty?
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end