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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ class AggregateCommand
6
+ Config = Struct.new(
7
+ :source, :organizations, :topic, :repos, :repo_pattern, :local_path,
8
+ :channels, :stages, :output_dir, :file_routing, :cache_dir,
9
+ :include_drafts, :concurrency, :min_documents, :token, :zip,
10
+ keyword_init: true
11
+ )
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ end
16
+
17
+ def call
18
+ result = run_aggregation
19
+ enrich(result) if result.documents.any?
20
+ zip_output if @config.zip
21
+ result
22
+ end
23
+
24
+ private
25
+
26
+ def run_aggregation
27
+ adapters = PlatformFactory.build_aggregation_adapters(
28
+ source: @config.local_path ? "local:#{@config.local_path}" : @config.source,
29
+ organizations: @config.organizations,
30
+ topic: @config.topic,
31
+ repos: @config.repos,
32
+ token: @config.token
33
+ )
34
+
35
+ channel_filter = ChannelFilter.new(
36
+ channels: Channel.parse_list(@config.channels)
37
+ )
38
+ stage_filter = StageFilter.new(@config.stages || [])
39
+ routing = FileRoutingFactory.from_name(@config.file_routing)
40
+ asset_processor = AssetProcessor.new(
41
+ output_dir: @config.output_dir,
42
+ routing: routing,
43
+ canonicalize: true
44
+ )
45
+ delta_state = build_delta_state
46
+
47
+ deps = AggregationPipeline::Dependencies.new(
48
+ discoverer: adapters[:discoverer],
49
+ fetcher: adapters[:fetcher],
50
+ manifest_reader: adapters[:manifest_reader],
51
+ channel_filter: channel_filter,
52
+ stage_filter: stage_filter,
53
+ asset_processor: asset_processor,
54
+ delta_state: delta_state
55
+ )
56
+
57
+ config = AggregationPipeline::Config.new(
58
+ organizations: @config.organizations,
59
+ channels: @config.channels,
60
+ topic: @config.topic,
61
+ concurrency: @config.concurrency,
62
+ include_drafts: @config.include_drafts,
63
+ fail_on_error: false
64
+ )
65
+
66
+ AggregationPipeline.new(deps).run(config, @config.output_dir)
67
+ end
68
+
69
+ def build_delta_state
70
+ return NullDeltaState.new unless @config.cache_dir
71
+
72
+ DeltaState.new(
73
+ cache_store: FileCacheStore.new(@config.cache_dir),
74
+ output_dir: @config.output_dir
75
+ )
76
+ end
77
+
78
+ def enrich(result)
79
+ index = DocumentIndex.from_documents(
80
+ result.documents,
81
+ parameters: IndexParameters.new(
82
+ organizations: @config.organizations,
83
+ channels: @config.channels || [],
84
+ topic: @config.topic,
85
+ repo_count: result.repo_count
86
+ )
87
+ )
88
+ enricher = RelatonEnricher.new
89
+ enrich_result = enricher.enrich(index, @config.output_dir)
90
+ return unless enrich_result
91
+
92
+ stamp_primary_identifiers(enrich_result.documents)
93
+ rescue LoadError
94
+ warn ' (relaton gem not available — bibliography skipped)'
95
+ end
96
+
97
+ def stamp_primary_identifiers(documents)
98
+ documents.map do |doc|
99
+ next doc unless doc['bibliographic']
100
+
101
+ ids = doc['bibliographic']['docidentifier']
102
+ next doc unless ids&.any?
103
+
104
+ primary = ids.find { |di| di['primary'] == true } || ids.first
105
+ doc.merge('primary_identifier' => primary['content'])
106
+ end
107
+ end
108
+
109
+ def zip_output
110
+ require 'zip'
111
+
112
+ dir = @config.output_dir
113
+ zip_path = "#{dir}.zip"
114
+
115
+ Zip::File.open(zip_path, Zip::File::CREATE) do |zipfile|
116
+ Dir.glob("#{dir}/**/*").each do |file|
117
+ next if File.directory?(file)
118
+
119
+ entry_name = file.sub("#{File.dirname(dir)}/", '')
120
+ zipfile.add(entry_name, file)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ class PackageCommand
6
+ Config = Struct.new(
7
+ :output_dir, :dest, :manifest, :config_source,
8
+ keyword_init: true
9
+ )
10
+
11
+ include ConfigResolver
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ end
16
+
17
+ def call
18
+ manifest = load_manifest(@config.manifest)
19
+ channel_config = resolve_channel_config(@config.config_source, manifest)
20
+
21
+ deps = ReleasePipeline::Dependencies.new(
22
+ extractor: RxlExtractor.new,
23
+ filters: [],
24
+ change_detector: ContentHashChangeDetector.new(previous_releases: {}),
25
+ packager: ZipPackager.new,
26
+ publisher: PlatformFactory.build_publisher('null', {}),
27
+ naming_registry: NamingRegistry.default_registry,
28
+ manifest: manifest,
29
+ channel_override: nil,
30
+ channel_config: channel_config
31
+ )
32
+
33
+ pipeline_config = ReleasePipeline::Config.new(
34
+ output_dir: @config.output_dir,
35
+ manifest_path: @config.manifest,
36
+ force: false,
37
+ force_replace_patterns: nil,
38
+ concurrency: 4,
39
+ default_visibility: 'public'
40
+ )
41
+
42
+ ReleasePipeline.new(deps).run(pipeline_config)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ class PublishCommand
6
+ Config = Struct.new(
7
+ :output_dir, :platform, :manifest, :force,
8
+ :force_replace, :channels, :concurrency, :token, :config_source,
9
+ keyword_init: true
10
+ )
11
+
12
+ include ConfigResolver
13
+
14
+ def initialize(config)
15
+ @config = config
16
+ end
17
+
18
+ def call
19
+ manifest = load_manifest(@config.manifest)
20
+ channel_config = resolve_channel_config(@config.config_source, manifest)
21
+
22
+ options = { token: @config.token }
23
+ publisher = PlatformFactory.build_publisher(@config.platform, options)
24
+ channel_override = Channel.parse_list(@config.channels) if @config.channels
25
+
26
+ deps = ReleasePipeline::Dependencies.new(
27
+ extractor: RxlExtractor.new,
28
+ filters: [],
29
+ change_detector: ContentHashChangeDetector.new(previous_releases: {}),
30
+ packager: ZipPackager.new,
31
+ publisher: publisher,
32
+ naming_registry: NamingRegistry.default_registry,
33
+ manifest: manifest,
34
+ channel_override: channel_override,
35
+ channel_config: channel_config
36
+ )
37
+
38
+ pipeline_config = ReleasePipeline::Config.new(
39
+ output_dir: @config.output_dir,
40
+ manifest_path: @config.manifest,
41
+ force: @config.force,
42
+ force_replace_patterns: @config.force_replace && !@config.force_replace.empty? ? @config.force_replace : nil,
43
+ concurrency: @config.concurrency,
44
+ default_visibility: 'public'
45
+ )
46
+
47
+ ReleasePipeline.new(deps).run(pipeline_config)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module ConfigFetcher
6
+ def fetch(source)
7
+ raise NotImplementedError, "#{self.class} must implement #fetch"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ class ConfigLocator
6
+ CONFIG_FILES = ['.metanorma.yml', '.metanorma.yaml'].freeze
7
+ CONFIG_DIRS = ['.metanorma'].freeze
8
+
9
+ def self.find(start_dir = Dir.pwd)
10
+ new.find(start_dir)
11
+ end
12
+
13
+ def find(start_dir)
14
+ dir = File.expand_path(start_dir)
15
+ loop do
16
+ CONFIG_FILES.each do |name|
17
+ path = File.join(dir, name)
18
+ return ChannelConfig.from_file(path) if File.exist?(path)
19
+ end
20
+
21
+ CONFIG_DIRS.each do |name|
22
+ path = File.join(dir, name)
23
+ next unless File.directory?(path)
24
+
25
+ channels = File.join(path, 'channels.yml')
26
+ return ChannelConfig.from_file(channels) if File.exist?(channels)
27
+ end
28
+
29
+ parent = File.dirname(dir)
30
+ return nil if parent == dir
31
+
32
+ dir = parent
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module ConfigResolver
6
+ def resolve_channel_config(cli_source, manifest)
7
+ return fetch_config(cli_source) if cli_source
8
+ return fetch_config(manifest.config_source) if manifest&.config_source
9
+
10
+ found = ConfigLocator.find
11
+ return found if found
12
+
13
+ ChannelConfig.empty
14
+ end
15
+
16
+ def load_manifest(path)
17
+ return nil unless path && File.exist?(path)
18
+
19
+ ChannelManifest.from_file(path)
20
+ end
21
+
22
+ private
23
+
24
+ def fetch_config(source)
25
+ if source.start_with?('local:')
26
+ Platform::Local::ConfigFetcher.new.fetch(source)
27
+ elsif source.include?('/')
28
+ Platform::Local::ConfigFetcher.new.fetch("local:#{source}")
29
+ else
30
+ require 'octokit'
31
+ client = PlatformFactory.build_github_client(nil)
32
+ Platform::GitHub::ConfigFetcher.new(client: client).fetch(source)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Metanorma
6
+ module Release
7
+ class ContentHash
8
+ def self.from_hex(hex_string)
9
+ new(hex_string.to_s)
10
+ end
11
+
12
+ def self.of_content(data)
13
+ new(Digest::SHA256.hexdigest(data))
14
+ end
15
+
16
+ def self.of_file(path)
17
+ new(Digest::SHA256.file(path).hexdigest)
18
+ end
19
+
20
+ def self.of_files(paths)
21
+ sorted = paths.sort
22
+ digest = Digest::SHA256.new
23
+ sorted.each { |p| digest << File.binread(p) }
24
+ new(digest.hexdigest)
25
+ end
26
+
27
+ def self.of_directory(directory, base: nil)
28
+ pattern = base ? File.join(directory, "#{base}.*") : File.join(directory, '**', '*')
29
+ files = Dir.glob(pattern).reject { |f| File.directory?(f) || f.end_with?('.zip') }
30
+ of_files(files)
31
+ end
32
+
33
+ def initialize(hex)
34
+ @hex = hex
35
+ freeze
36
+ end
37
+
38
+ def to_s
39
+ @hex
40
+ end
41
+
42
+ def eql?(other)
43
+ other.is_a?(self.class) && @hex == other.to_s
44
+ end
45
+
46
+ def hash
47
+ @hex.hash
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Metanorma
6
+ module Release
7
+ class DeltaState
8
+ def initialize(cache_store:, output_dir:)
9
+ @cache = cache_store
10
+ @output_dir = output_dir
11
+ @state = empty_state
12
+ end
13
+
14
+ def load
15
+ raw = @cache.get('delta_state')
16
+ return if raw.nil?
17
+
18
+ @state = JSON.parse(raw)
19
+ rescue JSON::ParserError
20
+ @state = empty_state
21
+ end
22
+
23
+ def save
24
+ @cache.set('delta_state', JSON.generate(@state))
25
+ end
26
+
27
+ def etag(repo_key)
28
+ repo_state(repo_key)['etag']
29
+ end
30
+
31
+ def set_etag(repo_key, etag_value)
32
+ repo = ensure_repo(repo_key)
33
+ repo['etag'] = etag_value
34
+ end
35
+
36
+ def processed?(repo_key, tag, content_hash)
37
+ releases = repo_state(repo_key)['releases']
38
+ return false unless releases.key?(tag)
39
+ return false if content_hash.nil?
40
+
41
+ releases[tag]['content_hash'] == content_hash.to_s
42
+ end
43
+
44
+ def release_files(repo_key, tag)
45
+ releases = repo_state(repo_key)['releases']
46
+ return [] unless releases.key?(tag)
47
+
48
+ releases[tag]['files'] || []
49
+ end
50
+
51
+ def mark_processed(repo_key, tag, content_hash, files)
52
+ repo = ensure_repo(repo_key)
53
+ repo['releases'][tag] = {
54
+ 'content_hash' => content_hash.to_s,
55
+ 'files' => files
56
+ }
57
+ end
58
+
59
+ def cleanup_stale(repo_key, current_tags)
60
+ repo = repo_state(repo_key)
61
+ releases = repo['releases']
62
+ removed = 0
63
+
64
+ releases.each do |tag, entry|
65
+ next if current_tags.include?(tag)
66
+
67
+ (entry['files'] || []).each do |file|
68
+ path = File.join(@output_dir, file)
69
+ if File.exist?(path)
70
+ File.delete(path)
71
+ removed += 1
72
+ end
73
+ end
74
+ releases.delete(tag)
75
+ end
76
+
77
+ removed
78
+ end
79
+
80
+ private
81
+
82
+ def empty_state
83
+ { 'last_run' => Time.now.utc.iso8601, 'repos' => {} }
84
+ end
85
+
86
+ def repo_state(repo_key)
87
+ @state['repos'][repo_key] || { 'etag' => nil, 'releases' => {} }
88
+ end
89
+
90
+ def ensure_repo(repo_key)
91
+ @state['repos'][repo_key] ||= { 'etag' => nil, 'releases' => {} }
92
+ end
93
+ end
94
+
95
+ class NullDeltaState
96
+ def initialize; end
97
+
98
+ def load; end
99
+ def save; end
100
+ def etag(_repo_key) = nil
101
+ def set_etag(_repo_key, _etag) = nil
102
+ def processed?(*_args) = false
103
+ def release_files(*_args) = []
104
+ def mark_processed(*_args); end
105
+ def cleanup_stale(*_args) = 0
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ class DocumentId
6
+ def self.from_raw(raw_identifier)
7
+ normalized = raw_identifier.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/^-|-$/, '')
8
+ raise ArgumentError, 'Document ID cannot be empty' if normalized.empty?
9
+
10
+ new(normalized)
11
+ end
12
+
13
+ def self.from_normalized(value)
14
+ raise ArgumentError, 'Document ID cannot be empty' if value.nil? || value.strip.empty?
15
+
16
+ new(value.to_s.strip)
17
+ end
18
+
19
+ def initialize(value)
20
+ @value = value
21
+ freeze
22
+ end
23
+
24
+ def to_s
25
+ @value
26
+ end
27
+
28
+ def tag_prefix
29
+ @value
30
+ end
31
+
32
+ def file_name
33
+ @value
34
+ end
35
+
36
+ def eql?(other)
37
+ other.is_a?(self.class) && @value == other.to_s
38
+ end
39
+
40
+ def hash
41
+ @value.hash
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Metanorma
6
+ module Release
7
+ DocumentFile = Struct.new(:name, :path, keyword_init: true) do
8
+ def extension
9
+ File.extname(name).delete_prefix('.')
10
+ end
11
+ end
12
+
13
+ DocumentSource = Struct.new(
14
+ :owner, :repo, :tag, :release_url, :release_date,
15
+ keyword_init: true
16
+ ) do
17
+ def repo_key
18
+ "#{owner}/#{repo}"
19
+ end
20
+ end
21
+
22
+ IndexParameters = Struct.new(
23
+ :organizations, :channels, :topic, :repo_count,
24
+ keyword_init: true
25
+ )
26
+
27
+ IndexSummary = Struct.new(
28
+ :repo_count, :document_count, :channels_found,
29
+ keyword_init: true
30
+ )
31
+
32
+ class AggregatedDocument
33
+ def self.from_h(hash)
34
+ files = (hash['files'] || []).map do |f|
35
+ DocumentFile.new(name: f['name'], path: f['path'])
36
+ end
37
+ source_data = hash['source'] || {}
38
+ source = DocumentSource.new(
39
+ owner: source_data['owner'], repo: source_data['repo'],
40
+ tag: source_data['tag'], release_url: source_data['releaseUrl'],
41
+ release_date: source_data['releaseDate']
42
+ )
43
+ new(
44
+ id: hash['id'], title: hash['title'], edition: hash['edition'],
45
+ stage: hash['stage'], doctype: hash.fetch('doctype', ''),
46
+ channels: hash['channels'] || [], formats: hash['formats'] || [],
47
+ flavor: hash['flavor'], content_hash: hash['contentHash'],
48
+ source: source, files: files
49
+ )
50
+ end
51
+
52
+ attr_reader :id, :title, :edition, :stage, :doctype, :channels,
53
+ :formats, :flavor, :content_hash, :source, :files
54
+
55
+ def initialize(id:, title:, edition:, stage:, doctype:, channels:,
56
+ formats:, flavor:, content_hash:, source:, files:)
57
+ @id = id
58
+ @title = title
59
+ @edition = edition
60
+ @stage = stage
61
+ @doctype = doctype
62
+ @channels = channels.freeze
63
+ @formats = formats.freeze
64
+ @flavor = flavor
65
+ @content_hash = content_hash
66
+ @source = source
67
+ @files = files.freeze
68
+ freeze
69
+ end
70
+
71
+ def to_h
72
+ {
73
+ 'id' => id, 'title' => title, 'edition' => edition,
74
+ 'stage' => stage, 'doctype' => doctype,
75
+ 'channels' => channels, 'formats' => formats,
76
+ 'flavor' => flavor, 'contentHash' => content_hash,
77
+ 'source' => {
78
+ 'owner' => source.owner, 'repo' => source.repo,
79
+ 'tag' => source.tag, 'releaseUrl' => source.release_url,
80
+ 'releaseDate' => source.release_date
81
+ },
82
+ 'files' => files.map { |f| { 'name' => f.name, 'path' => f.path } }
83
+ }
84
+ end
85
+ end
86
+
87
+ class DocumentIndex
88
+ SCHEMA_VERSION = 1
89
+
90
+ SchemaError = Class.new(StandardError)
91
+
92
+ def self.from_json(json_string)
93
+ data = JSON.parse(json_string)
94
+ validate!(data)
95
+ new(
96
+ documents: (data['documents'] || []).map { |d| AggregatedDocument.from_h(d) },
97
+ parameters: IndexParameters.new(
98
+ organizations: data.dig('parameters', 'organizations') || [],
99
+ channels: data.dig('parameters', 'channels') || [],
100
+ topic: data.dig('parameters', 'topic'),
101
+ repo_count: data.dig('parameters', 'repoCount') || 0
102
+ ),
103
+ generated_at: data['generatedAt']
104
+ )
105
+ end
106
+
107
+ def self.from_documents(documents, parameters:)
108
+ new(documents: documents, parameters: parameters)
109
+ end
110
+
111
+ def initialize(documents:, parameters:, generated_at: nil)
112
+ @documents = documents.freeze
113
+ @parameters = parameters
114
+ @generated_at = generated_at || Time.now.utc.iso8601
115
+ freeze
116
+ end
117
+
118
+ attr_reader :documents, :parameters
119
+
120
+ def summary
121
+ IndexSummary.new(
122
+ repo_count: @parameters.repo_count,
123
+ document_count: @documents.length,
124
+ channels_found: channels
125
+ )
126
+ end
127
+
128
+ def channels
129
+ @documents.flat_map(&:channels).uniq.sort
130
+ end
131
+
132
+ def document_count
133
+ @documents.length
134
+ end
135
+
136
+ def empty?
137
+ @documents.empty?
138
+ end
139
+
140
+ def to_h
141
+ {
142
+ 'version' => SCHEMA_VERSION,
143
+ 'generatedAt' => @generated_at,
144
+ 'parameters' => {
145
+ 'organizations' => @parameters.organizations,
146
+ 'channels' => @parameters.channels,
147
+ 'topic' => @parameters.topic,
148
+ 'repoCount' => @parameters.repo_count
149
+ },
150
+ 'summary' => {
151
+ 'repoCount' => summary.repo_count,
152
+ 'documentCount' => summary.document_count,
153
+ 'channelsFound' => summary.channels_found
154
+ },
155
+ 'documents' => @documents.map(&:to_h)
156
+ }
157
+ end
158
+
159
+ def to_json(*_args)
160
+ JSON.generate(to_h)
161
+ end
162
+
163
+ def write(path)
164
+ FileUtils.mkdir_p(File.dirname(path))
165
+ File.write(path, to_json)
166
+ end
167
+
168
+ def self.validate!(data)
169
+ raise SchemaError, "Missing 'version' field" unless data.key?('version')
170
+ unless data['version'] == SCHEMA_VERSION
171
+ raise SchemaError,
172
+ "Unsupported schema version: #{data['version']}. Expected #{SCHEMA_VERSION}"
173
+ end
174
+ raise SchemaError, "Missing 'documents' field" unless data.key?('documents')
175
+
176
+ data['documents'].each do |doc|
177
+ raise SchemaError, "Document missing required field 'id'" unless doc.key?('id')
178
+ raise SchemaError, "Document missing required field 'title'" unless doc.key?('title')
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end