archsight 0.1.2 → 0.1.3

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -5
  3. data/lib/archsight/analysis/executor.rb +112 -0
  4. data/lib/archsight/analysis/result.rb +174 -0
  5. data/lib/archsight/analysis/sandbox.rb +319 -0
  6. data/lib/archsight/analysis.rb +11 -0
  7. data/lib/archsight/annotations/architecture_annotations.rb +2 -2
  8. data/lib/archsight/cli.rb +163 -0
  9. data/lib/archsight/database.rb +6 -2
  10. data/lib/archsight/helpers/analysis_renderer.rb +83 -0
  11. data/lib/archsight/helpers/formatting.rb +95 -0
  12. data/lib/archsight/helpers.rb +20 -4
  13. data/lib/archsight/import/concurrent_progress.rb +341 -0
  14. data/lib/archsight/import/executor.rb +466 -0
  15. data/lib/archsight/import/git_analytics.rb +626 -0
  16. data/lib/archsight/import/handler.rb +263 -0
  17. data/lib/archsight/import/handlers/github.rb +161 -0
  18. data/lib/archsight/import/handlers/gitlab.rb +202 -0
  19. data/lib/archsight/import/handlers/jira_base.rb +189 -0
  20. data/lib/archsight/import/handlers/jira_discover.rb +161 -0
  21. data/lib/archsight/import/handlers/jira_metrics.rb +179 -0
  22. data/lib/archsight/import/handlers/openapi_schema_parser.rb +279 -0
  23. data/lib/archsight/import/handlers/repository.rb +439 -0
  24. data/lib/archsight/import/handlers/rest_api.rb +293 -0
  25. data/lib/archsight/import/handlers/rest_api_index.rb +183 -0
  26. data/lib/archsight/import/progress.rb +91 -0
  27. data/lib/archsight/import/registry.rb +54 -0
  28. data/lib/archsight/import/shared_file_writer.rb +67 -0
  29. data/lib/archsight/import/team_matcher.rb +195 -0
  30. data/lib/archsight/import.rb +14 -0
  31. data/lib/archsight/resources/analysis.rb +91 -0
  32. data/lib/archsight/resources/application_component.rb +2 -2
  33. data/lib/archsight/resources/application_service.rb +12 -12
  34. data/lib/archsight/resources/business_product.rb +12 -12
  35. data/lib/archsight/resources/data_object.rb +1 -1
  36. data/lib/archsight/resources/import.rb +79 -0
  37. data/lib/archsight/resources/technology_artifact.rb +23 -2
  38. data/lib/archsight/version.rb +1 -1
  39. data/lib/archsight/web/api/docs.rb +17 -0
  40. data/lib/archsight/web/api/json_helpers.rb +164 -0
  41. data/lib/archsight/web/api/openapi/spec.yaml +500 -0
  42. data/lib/archsight/web/api/routes.rb +101 -0
  43. data/lib/archsight/web/application.rb +66 -43
  44. data/lib/archsight/web/doc/import.md +458 -0
  45. data/lib/archsight/web/doc/index.md.erb +1 -0
  46. data/lib/archsight/web/public/css/artifact.css +10 -0
  47. data/lib/archsight/web/public/css/graph.css +14 -0
  48. data/lib/archsight/web/public/css/instance.css +489 -0
  49. data/lib/archsight/web/views/api_docs.erb +19 -0
  50. data/lib/archsight/web/views/partials/artifact/_project_estimate.haml +14 -8
  51. data/lib/archsight/web/views/partials/instance/_analysis_detail.haml +74 -0
  52. data/lib/archsight/web/views/partials/instance/_analysis_result.haml +64 -0
  53. data/lib/archsight/web/views/partials/instance/_detail.haml +7 -3
  54. data/lib/archsight/web/views/partials/instance/_import_detail.haml +87 -0
  55. data/lib/archsight/web/views/partials/instance/_relations.haml +4 -4
  56. data/lib/archsight/web/views/partials/layout/_content.haml +4 -0
  57. data/lib/archsight/web/views/partials/layout/_navigation.haml +6 -5
  58. metadata +78 -1
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "net/http"
5
+ require "yaml"
6
+ require "uri"
7
+ require "openssl"
8
+ require_relative "../handler"
9
+ require_relative "../registry"
10
+ require_relative "openapi_schema_parser"
11
+
12
+ # REST API handler - downloads OpenAPI spec and generates ApplicationInterface and DataObject resources
13
+ #
14
+ # Configuration (passed from rest_api_index handler):
15
+ # import/config/name - API name (e.g., "compute")
16
+ # import/config/version - API version (e.g., "6.0")
17
+ # import/config/visibility - API visibility (e.g., "private")
18
+ # import/config/specUrl - Full URL to OpenAPI spec (http, https, or file://)
19
+ # import/config/htmlUrl - Full URL to HTML documentation (optional)
20
+ # import/config/gate - Release gate (e.g., "GA", "BETA")
21
+ # import/config/interfaceOutputPath - Output path for ApplicationInterface resources
22
+ # import/config/dataObjectOutputPath - Output path for DataObject resources
23
+ #
24
+ # Output:
25
+ # - ApplicationInterface resource with annotations
26
+ # - DataObject resources extracted from OpenAPI schemas
27
+ class Archsight::Import::Handlers::RestApi < Archsight::Import::Handler
28
+ def execute
29
+ @name = config("name")
30
+ raise "Missing required config: name" unless @name
31
+
32
+ @version = config("version", default: "1.0")
33
+ @visibility = config("visibility", default: "private")
34
+ @spec_url = config("specUrl")
35
+ raise "Missing required config: specUrl" unless @spec_url
36
+
37
+ @html_url = config("htmlUrl")
38
+ @gate = config("gate", default: "GA")
39
+
40
+ @interface_output_path = config("interfaceOutputPath")
41
+ @data_object_output_path = config("dataObjectOutputPath")
42
+
43
+ # Download and parse OpenAPI spec
44
+ progress.update("Downloading OpenAPI spec for #{@name}")
45
+ openapi_doc = fetch_openapi_spec(@spec_url)
46
+
47
+ # Parse schemas and generate DataObjects first (needed for interface relations)
48
+ progress.update("Extracting DataObjects from #{@name} schemas")
49
+ parser = Archsight::Import::Handlers::OpenAPISchemaParser.new(openapi_doc)
50
+ parsed_objects = parser.parse
51
+ data_objects = generate_data_objects(parsed_objects)
52
+
53
+ # Generate ApplicationInterface with references to DataObjects
54
+ progress.update("Generating ApplicationInterface for #{@name}")
55
+ data_object_names = data_objects.map { |obj| obj.dig("metadata", "name") }
56
+ interface_resource = generate_interface(openapi_doc, data_object_names: data_object_names)
57
+
58
+ # Write outputs
59
+ if @interface_output_path
60
+ write_yaml(YAML.dump(interface_resource), filename: nil, sort_key: "#{@name}-interface")
61
+ else
62
+ write_yaml(YAML.dump(interface_resource), filename: "#{safe_api_name}-interface.yaml")
63
+ end
64
+
65
+ if data_objects.any?
66
+ data_yaml = data_objects.map { |r| YAML.dump(r) }.join("\n")
67
+ if @data_object_output_path
68
+ # Use shared output path - write each object with a sort key
69
+ data_objects.each do |obj|
70
+ write_data_object(obj)
71
+ end
72
+ else
73
+ write_yaml(data_yaml, filename: "#{safe_api_name}-data-objects.yaml")
74
+ end
75
+ end
76
+
77
+ progress.update("Generated #{data_objects.size} DataObjects for #{@name}")
78
+
79
+ write_generates_meta
80
+ end
81
+
82
+ private
83
+
84
+ def safe_api_name
85
+ @name.gsub(/[^a-zA-Z0-9_-]/, "_").downcase
86
+ end
87
+
88
+ def fetch_openapi_spec(url)
89
+ uri = URI(url)
90
+
91
+ case uri.scheme
92
+ when "file"
93
+ fetch_file_spec(uri)
94
+ when "http", "https"
95
+ fetch_http_spec(uri)
96
+ else
97
+ raise "Unsupported URL scheme: #{uri.scheme}. Use http://, https://, or file://"
98
+ end
99
+ end
100
+
101
+ def fetch_file_spec(uri)
102
+ # Handle file:// URLs - the host part may be treated as part of the path
103
+ # e.g., file://lib/path becomes host=lib, path=/path
104
+ # We need to reconstruct: host + path
105
+ path = if uri.host && uri.host != "localhost" && !uri.host.empty?
106
+ "#{uri.host}#{uri.path}"
107
+ else
108
+ uri.path
109
+ end
110
+
111
+ raise "File not found: #{path}" unless File.exist?(path)
112
+
113
+ content = File.read(path)
114
+ parse_spec_content(content)
115
+ end
116
+
117
+ def fetch_http_spec(uri)
118
+ http = Net::HTTP.new(uri.host, uri.port)
119
+ http.use_ssl = uri.scheme == "https"
120
+ http.open_timeout = 30
121
+ http.read_timeout = 60
122
+
123
+ request = Net::HTTP::Get.new(uri)
124
+ request["Accept"] = "application/yaml, application/json"
125
+
126
+ response = http.request(request)
127
+
128
+ case response
129
+ when Net::HTTPSuccess
130
+ parse_spec_content(response.body)
131
+ when Net::HTTPRedirection
132
+ # Follow redirect
133
+ fetch_openapi_spec(response["location"])
134
+ else
135
+ raise "Failed to fetch OpenAPI spec from #{uri}: #{response.code} #{response.message}"
136
+ end
137
+ end
138
+
139
+ def parse_spec_content(content)
140
+ # Try YAML first, then JSON
141
+ YAML.safe_load(content, permitted_classes: [Date, Time])
142
+ rescue Psych::SyntaxError
143
+ JSON.parse(content)
144
+ end
145
+
146
+ def generate_interface(openapi_doc, data_object_names: [])
147
+ info = openapi_doc["info"] || {}
148
+ title = info["title"] || @name.capitalize
149
+ description = info["description"] || ""
150
+ openapi_version = openapi_doc["openapi"] || "3.0.0"
151
+
152
+ # Detect technologies from spec
153
+ tags = detect_technologies(openapi_doc)
154
+
155
+ # Build resource name
156
+ interface_name = build_interface_name
157
+
158
+ annotations = {
159
+ "architecture/title" => title,
160
+ "architecture/description" => description.strip,
161
+ "architecture/openapi" => openapi_version,
162
+ "architecture/version" => @version,
163
+ "architecture/status" => @gate,
164
+ "architecture/visibility" => @visibility,
165
+ "architecture/tags" => tags.join(","),
166
+ "architecture/encoding" => "json"
167
+ }
168
+ annotations["architecture/documentation"] = @html_url if @html_url
169
+
170
+ # Build spec with technology relations and data objects
171
+ spec = {
172
+ "servedBy" => {
173
+ "technologyComponents" => build_technology_components(tags)
174
+ }
175
+ }
176
+
177
+ # Add relation to DataObjects if any were generated
178
+ spec["serves"] = { "dataObjects" => data_object_names } if data_object_names.any?
179
+
180
+ resource_yaml(
181
+ kind: "ApplicationInterface",
182
+ name: interface_name,
183
+ annotations: annotations,
184
+ spec: spec
185
+ )
186
+ end
187
+
188
+ def build_interface_name
189
+ visibility_prefix = @visibility.split("-").map(&:capitalize).join
190
+ api_name = @name.split(/[-_]/).map(&:capitalize).join
191
+ # Handle version that may already have "v" prefix (e.g., "v1" or "1.0")
192
+ version_str = @version.split(".").first
193
+ version_str = version_str.sub(/^v/i, "") # Remove leading v if present
194
+ "#{visibility_prefix}:#{api_name}:v#{version_str}:RestAPI"
195
+ end
196
+
197
+ def detect_technologies(openapi_doc)
198
+ tags = %w[https rest]
199
+
200
+ # Check security schemes
201
+ security_schemes = openapi_doc.dig("components", "securitySchemes") || {}
202
+
203
+ security_schemes.each_value do |scheme|
204
+ case scheme["type"]
205
+ when "http"
206
+ case scheme["scheme"]&.downcase
207
+ when "bearer"
208
+ tags << "jwt" if scheme["bearerFormat"]&.downcase == "jwt"
209
+ tags << "bearer" unless tags.include?("jwt")
210
+ when "basic"
211
+ tags << "basic-auth"
212
+ end
213
+ when "apiKey"
214
+ tags << "api-key"
215
+ when "oauth2"
216
+ tags << "oauth2"
217
+ when "openIdConnect"
218
+ tags << "oidc"
219
+ end
220
+ end
221
+
222
+ # Check for specific headers or patterns
223
+ tags << "auth" if openapi_doc["paths"]&.to_s&.include?("Authorization") && tags.none? { |t| t.include?("auth") || t == "jwt" || t == "bearer" }
224
+
225
+ tags.uniq
226
+ end
227
+
228
+ def build_technology_components(tags)
229
+ components = ["HTTPS:REST"]
230
+
231
+ tags.each do |tag|
232
+ case tag
233
+ when "jwt"
234
+ components << "AUTH:JWT"
235
+ when "basic-auth"
236
+ components << "AUTH:Basic"
237
+ when "api-key"
238
+ components << "AUTH:APIKey"
239
+ when "oauth2"
240
+ components << "AUTH:OAuth2"
241
+ when "oidc"
242
+ components << "AUTH:OIDC"
243
+ end
244
+ end
245
+
246
+ components.uniq
247
+ end
248
+
249
+ def generate_data_objects(parsed_objects)
250
+ app_name = @name.split(/[-_]/).map(&:capitalize).join
251
+
252
+ parsed_objects.map do |normalized_name, info|
253
+ object_name = "#{app_name}:#{normalized_name}"
254
+
255
+ # Build description with field documentation
256
+ description_parts = []
257
+ description_parts << info["description"] if info["description"]
258
+
259
+ if info["properties"]&.any?
260
+ field_docs = Archsight::Import::Handlers::OpenAPISchemaParser.generate_field_docs(info["properties"])
261
+ description_parts << field_docs unless field_docs.empty?
262
+ end
263
+
264
+ annotations = {
265
+ "data/application" => app_name,
266
+ "data/visibility" => @visibility,
267
+ "architecture/description" => description_parts.join("\n\n"),
268
+ "generated/variants" => info["original_names"].join(", ")
269
+ }
270
+
271
+ resource_yaml(
272
+ kind: "DataObject",
273
+ name: object_name,
274
+ annotations: annotations,
275
+ spec: {}
276
+ )
277
+ end
278
+ end
279
+
280
+ def write_data_object(obj)
281
+ # Override the output path for data objects
282
+ original_output_path = import_resource.annotations["import/outputPath"]
283
+ import_resource.annotations["import/outputPath"] = @data_object_output_path
284
+
285
+ name = obj.dig("metadata", "name")
286
+ write_yaml(YAML.dump(obj), sort_key: name)
287
+
288
+ # Restore original output path
289
+ import_resource.annotations["import/outputPath"] = original_output_path
290
+ end
291
+ end
292
+
293
+ Archsight::Import::Registry.register("rest-api", Archsight::Import::Handlers::RestApi)
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "net/http"
5
+ require "json"
6
+ require "uri"
7
+ require "openssl"
8
+ require_relative "../handler"
9
+ require_relative "../registry"
10
+
11
+ # REST API Index handler - fetches API index and generates child Import resources
12
+ #
13
+ # Configuration:
14
+ # import/config/indexUrl - URL to fetch API index JSON (required)
15
+ # import/config/baseUrl - Base URL for spec files (optional, derived from indexUrl)
16
+ # import/config/interfaceOutputPath - Shared output for ApplicationInterface resources
17
+ # import/config/dataObjectOutputPath - Shared output for DataObject resources
18
+ # import/config/skipVisibility - Comma-separated visibilities to skip (e.g., "public-preview")
19
+ # import/config/childCacheTime - Cache time for generated child imports (e.g., "1h", "30m")
20
+ #
21
+ # Output:
22
+ # Generates Import:RestApi:* resources for each API in the index
23
+ #
24
+ # Expected index format (object with "pages" array):
25
+ # {
26
+ # "pages": [
27
+ # {
28
+ # "name": "compute",
29
+ # "version": "v1",
30
+ # "visibility": "public",
31
+ # "spec": "/rest-api/public-compute-v1.yaml",
32
+ # "redoc": "/rest-api/docs/compute/v1/",
33
+ # "gate": "General-Availability"
34
+ # },
35
+ # ...
36
+ # ]
37
+ # }
38
+ class Archsight::Import::Handlers::RestApiIndex < Archsight::Import::Handler
39
+ def execute
40
+ @index_url = config("indexUrl")
41
+ raise "Missing required config: indexUrl" unless @index_url
42
+
43
+ @base_url = config("baseUrl") || derive_base_url(@index_url)
44
+ @interface_output_path = config("interfaceOutputPath")
45
+ @data_object_output_path = config("dataObjectOutputPath")
46
+ @skip_visibilities = (config("skipVisibility") || "").split(",").map(&:strip).reject(&:empty?)
47
+ @child_cache_time = config("childCacheTime")
48
+
49
+ # Fetch API index
50
+ progress.update("Fetching API index from #{@index_url}")
51
+ apis = fetch_index
52
+
53
+ if apis.empty?
54
+ progress.warn("No APIs found in index")
55
+ return
56
+ end
57
+
58
+ # Filter APIs by visibility
59
+ original_count = apis.size
60
+ apis = filter_apis(apis)
61
+ progress.update("Filtered to #{apis.size} APIs (skipped #{original_count - apis.size} by visibility)") if apis.size < original_count
62
+
63
+ # Generate child imports
64
+ progress.update("Generating #{apis.size} import resources")
65
+ generate_api_imports(apis)
66
+
67
+ write_generates_meta
68
+ end
69
+
70
+ private
71
+
72
+ def derive_base_url(url)
73
+ uri = URI(url)
74
+ "#{uri.scheme}://#{uri.host}"
75
+ end
76
+
77
+ def fetch_index
78
+ uri = URI(@index_url)
79
+ http = Net::HTTP.new(uri.host, uri.port)
80
+ http.use_ssl = uri.scheme == "https"
81
+ http.open_timeout = 30
82
+ http.read_timeout = 60
83
+
84
+ request = Net::HTTP::Get.new(uri)
85
+ request["Accept"] = "application/json"
86
+
87
+ response = http.request(request)
88
+
89
+ case response
90
+ when Net::HTTPSuccess
91
+ data = JSON.parse(response.body)
92
+ # Handle both array format and object with "pages" key
93
+ data.is_a?(Array) ? data : (data["pages"] || [])
94
+ when Net::HTTPRedirection
95
+ # Follow redirect
96
+ @index_url = response["location"]
97
+ fetch_index
98
+ when Net::HTTPUnauthorized
99
+ raise "API index error: 401 Unauthorized - Check credentials"
100
+ when Net::HTTPForbidden
101
+ raise "API index error: 403 Forbidden - Access denied"
102
+ when Net::HTTPNotFound
103
+ raise "API index error: 404 Not Found - Index not found at #{@index_url}"
104
+ else
105
+ raise "API index error: #{response.code} #{response.message}"
106
+ end
107
+ end
108
+
109
+ def filter_apis(apis)
110
+ return apis if @skip_visibilities.empty?
111
+
112
+ apis.reject do |api|
113
+ visibility = api["visibility"]&.downcase
114
+ @skip_visibilities.any? { |skip| visibility == skip.downcase }
115
+ end
116
+ end
117
+
118
+ def generate_api_imports(apis)
119
+ yaml_documents = apis.map do |api|
120
+ api_name = api["name"]
121
+ api_version = api["version"] || "v1"
122
+
123
+ # Build full URLs from base URL and paths
124
+ # Support both "spec"/"redoc" (new format) and "specPath"/"redocPath" (legacy)
125
+ spec_path = api["spec"] || api["specPath"]
126
+ redoc_path = api["redoc"] || api["redocPath"]
127
+ spec_url = build_full_url(spec_path)
128
+ html_url = redoc_path ? build_full_url(redoc_path) : nil
129
+
130
+ # Derive visibility from spec path if not explicitly provided
131
+ visibility = api["visibility"] || derive_visibility_from_path(spec_path)
132
+
133
+ # Include visibility in import name to distinguish public/private versions of same API
134
+ import_name = "Import:RestApi:#{visibility}:#{api_name}:#{api_version}"
135
+ child_config = {
136
+ "name" => api_name,
137
+ "version" => api["version"] || "1.0",
138
+ "visibility" => visibility,
139
+ "specUrl" => spec_url,
140
+ "gate" => api["gate"] || "GA"
141
+ }
142
+ child_config["htmlUrl"] = html_url if html_url
143
+
144
+ # Build annotations for child import
145
+ child_annotations = {}
146
+ child_annotations["import/outputPath"] = @interface_output_path if @interface_output_path
147
+ child_annotations["import/cacheTime"] = @child_cache_time if @child_cache_time
148
+ child_annotations["import/config/interfaceOutputPath"] = @interface_output_path if @interface_output_path
149
+ child_annotations["import/config/dataObjectOutputPath"] = @data_object_output_path if @data_object_output_path
150
+
151
+ import_yaml(
152
+ name: import_name,
153
+ handler: "rest-api",
154
+ config: child_config,
155
+ annotations: child_annotations
156
+ )
157
+ end
158
+
159
+ # Add self-marker with generated/at for caching
160
+ yaml_documents << self_marker
161
+
162
+ # Write all imports to a single file
163
+ yaml_content = yaml_documents.map { |doc| YAML.dump(doc) }.join("\n")
164
+ write_yaml(yaml_content)
165
+ end
166
+
167
+ def build_full_url(path)
168
+ return path if path.start_with?("http://", "https://", "file://")
169
+
170
+ "#{@base_url}#{path}"
171
+ end
172
+
173
+ # Derive visibility from spec path patterns like "/rest-api/public-compute-v1.yaml"
174
+ # or "/docs/public/compute/v1/"
175
+ def derive_visibility_from_path(path)
176
+ return "public" if path&.match?(/\bpublic\b/i)
177
+ return "private" if path&.match?(/\bprivate\b/i)
178
+
179
+ "public" # Default to public for APIs without explicit visibility marker
180
+ end
181
+ end
182
+
183
+ Archsight::Import::Registry.register("rest-api-index", Archsight::Import::Handlers::RestApiIndex)
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Progress reporter for import operations
4
+ #
5
+ # In TTY mode: updates a single line with \r
6
+ # In non-TTY mode (CI): prints each update on a new line
7
+ class Archsight::Import::Progress
8
+ def initialize(output: $stdout)
9
+ @output = output
10
+ @tty = output.respond_to?(:tty?) && output.tty?
11
+ @last_line_length = 0
12
+ @current_context = nil
13
+ end
14
+
15
+ def tty?
16
+ @tty
17
+ end
18
+
19
+ # Set the current context (e.g., "Import:GitLab")
20
+ def context=(name)
21
+ @current_context = name
22
+ end
23
+
24
+ # Report progress with optional sub-progress
25
+ # Examples:
26
+ # update("Fetching projects...")
27
+ # update("Cloning", current: 5, total: 100)
28
+ def update(message, current: nil, total: nil)
29
+ line = build_line(message, current, total)
30
+
31
+ if @tty
32
+ # Clear previous line and write new one
33
+ clear = "\r#{" " * @last_line_length}\r"
34
+ @output.print "#{clear}#{line}"
35
+ @output.flush
36
+ @last_line_length = line.length
37
+ else
38
+ @output.puts line
39
+ end
40
+ end
41
+
42
+ # Complete the current operation (moves to new line in TTY mode)
43
+ def complete(message = nil)
44
+ if message
45
+ line = build_line(message)
46
+ if @tty
47
+ clear = "\r#{" " * @last_line_length}\r"
48
+ @output.puts "#{clear}#{line}"
49
+ else
50
+ @output.puts line
51
+ end
52
+ elsif @tty
53
+ @output.puts
54
+ end
55
+ @last_line_length = 0
56
+ end
57
+
58
+ # Report an error
59
+ def error(message)
60
+ complete if @tty && @last_line_length.positive?
61
+ @output.puts " Error: #{message}"
62
+ end
63
+
64
+ # Report a warning
65
+ def warn(message)
66
+ if @tty && @last_line_length.positive?
67
+ # Save current line, print warning, restore
68
+ @output.puts
69
+ @output.puts " Warning: #{message}"
70
+ @last_line_length = 0
71
+ else
72
+ @output.puts " Warning: #{message}"
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def build_line(message, current = nil, total = nil)
79
+ parts = []
80
+ parts << @current_context if @current_context
81
+ parts << progress_indicator(current, total) if current && total
82
+ parts << message
83
+
84
+ parts.join(" - ")
85
+ end
86
+
87
+ def progress_indicator(current, total)
88
+ percentage = ((current.to_f / total) * 100).round
89
+ "[#{current}/#{total} #{percentage}%]"
90
+ end
91
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "archsight/import"
4
+
5
+ # Registry of import handlers
6
+ #
7
+ # Handlers register themselves with a name that matches the import/handler annotation.
8
+ # The executor looks up handlers by name to instantiate and execute them.
9
+ module Archsight::Import::Registry
10
+ @handlers = {}
11
+
12
+ class << self
13
+ # Register a handler class with a name
14
+ # @param name [String, Symbol] Handler name (matches import/handler annotation)
15
+ # @param handler_class [Class] Handler class that extends Handler
16
+ def register(name, handler_class)
17
+ @handlers[name.to_s] = handler_class
18
+ end
19
+
20
+ # Look up a handler class by name
21
+ # @param name [String] Handler name
22
+ # @return [Class, nil] Handler class or nil if not found
23
+ def [](name)
24
+ @handlers[name.to_s]
25
+ end
26
+
27
+ # Get handler class for an import resource
28
+ # @param import_resource [Archsight::Resources::Import] Import resource
29
+ # @return [Class] Handler class
30
+ # @raise [UnknownHandlerError] if handler is not registered
31
+ def handler_for(import_resource)
32
+ handler_name = import_resource.annotations["import/handler"]
33
+ handler_class = self[handler_name]
34
+
35
+ raise Archsight::Import::UnknownHandlerError, "Unknown import handler: #{handler_name}" unless handler_class
36
+
37
+ handler_class
38
+ end
39
+
40
+ # List all registered handler names
41
+ # @return [Array<String>] Handler names
42
+ def handlers
43
+ @handlers.keys
44
+ end
45
+
46
+ # Clear all registered handlers (for testing)
47
+ def clear!
48
+ @handlers = {}
49
+ end
50
+ end
51
+ end
52
+
53
+ # Error raised when an unknown handler is requested
54
+ class Archsight::Import::UnknownHandlerError < StandardError; end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ # Thread-safe file writer for concurrent import handlers
6
+ #
7
+ # Manages shared output files that multiple handlers can write to.
8
+ # Content is buffered in memory and sorted by key when close_all is called.
9
+ #
10
+ # @example
11
+ # writer = SharedFileWriter.new
12
+ # writer.append_yaml("/path/to/output.yaml", yaml_content, sort_key: "Repo:name")
13
+ # writer.close_all # Sorts and writes buffered content
14
+ class Archsight::Import::SharedFileWriter
15
+ def initialize
16
+ @mutex = Mutex.new
17
+ @files = {}
18
+ end
19
+
20
+ # Append YAML content to a file (thread-safe, buffered)
21
+ # Content is sorted by sort_key when close_all is called
22
+ #
23
+ # @param path [String] Full path to the output file
24
+ # @param content [String] YAML content to append
25
+ # @param sort_key [String, nil] Key for sorting (nil keys go last)
26
+ def append_yaml(path, content, sort_key: nil)
27
+ @mutex.synchronize do
28
+ @files[path] ||= { entries: [], lock: Mutex.new }
29
+ end
30
+
31
+ entry = @files[path]
32
+ entry[:lock].synchronize do
33
+ entry[:entries] << { key: sort_key, content: content }
34
+ end
35
+ end
36
+
37
+ # Close all files - sorts and writes buffered content
38
+ def close_all
39
+ @mutex.synchronize do
40
+ @files.each do |path, entry|
41
+ write_sorted_file(path, entry[:entries])
42
+ end
43
+ @files.clear
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def write_sorted_file(path, entries)
50
+ return if entries.empty?
51
+
52
+ FileUtils.mkdir_p(File.dirname(path))
53
+
54
+ # Sort by key (nil keys go last)
55
+ sorted = entries.sort_by { |e| e[:key] || "\xFF" }
56
+
57
+ File.open(path, "w") do |file|
58
+ sorted.each_with_index do |entry, idx|
59
+ content = entry[:content]
60
+ # Add document separator if not first and content doesn't have one
61
+ file.write("---\n") if idx.positive? && !content.start_with?("---")
62
+ file.write(content)
63
+ file.write("\n") unless content.end_with?("\n")
64
+ end
65
+ end
66
+ end
67
+ end