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,466 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "archsight/import"
6
+ require_relative "registry"
7
+ require_relative "handler"
8
+ require_relative "progress"
9
+ require_relative "concurrent_progress"
10
+ require_relative "shared_file_writer"
11
+
12
+ # Executes imports in dependency order with concurrent processing
13
+ #
14
+ # The executor:
15
+ # 1. Loads all Import resources from the database
16
+ # 2. Finds pending imports whose dependencies are satisfied
17
+ # 3. Executes ready imports concurrently (up to max_concurrent)
18
+ # 4. Reloads database once after batch completes to discover new imports
19
+ # 5. Repeats until no pending imports remain
20
+ # 6. Stops immediately on first error
21
+ class Archsight::Import::Executor
22
+ MAX_CONCURRENT = 20
23
+
24
+ # Duration parsing patterns for cache time
25
+ DURATION_PATTERNS = {
26
+ /^(\d+)s$/ => 1,
27
+ /^(\d+)m$/ => 60,
28
+ /^(\d+)h$/ => 3600,
29
+ /^(\d+)d$/ => 86_400
30
+ }.freeze
31
+
32
+ attr_reader :database, :resources_dir, :verbose, :filter, :force
33
+
34
+ # @param database [Archsight::Database] Database instance
35
+ # @param resources_dir [String] Root resources directory
36
+ # @param verbose [Boolean] Whether to print verbose debug output
37
+ # @param max_concurrent [Integer] Maximum concurrent imports (default: 20)
38
+ # @param output [IO] Output stream for progress (default: $stdout)
39
+ # @param filter [String, nil] Regex pattern to match import names
40
+ # @param force [Boolean] Whether to bypass cache and re-run all imports
41
+ def initialize(database:, resources_dir:, verbose: false, max_concurrent: MAX_CONCURRENT, output: $stdout, filter: nil, force: false)
42
+ @database = database
43
+ @resources_dir = resources_dir
44
+ @verbose = verbose
45
+ @max_concurrent = max_concurrent
46
+ @output = output
47
+ @filter = filter
48
+ @filter_regex = Regexp.new(filter, Regexp::IGNORECASE) if filter
49
+ @force = force
50
+ @executed_this_run = Set.new
51
+ @iteration = 0
52
+ @mutex = Mutex.new
53
+ end
54
+
55
+ # Run all pending imports
56
+ # @raise [DeadlockError] if pending imports have unsatisfied dependencies
57
+ # @raise [ImportError] if an import fails
58
+ def run!
59
+ @total_executed = 0
60
+ @total_cached = 0
61
+ @failed_imports = {}
62
+ @first_error = nil
63
+ @interrupted = false
64
+ @concurrent_progress = Archsight::Import::ConcurrentProgress.new(max_slots: @max_concurrent, output: @output)
65
+ @shared_writer = Archsight::Import::SharedFileWriter.new
66
+
67
+ # Set up graceful shutdown on Ctrl-C
68
+ setup_signal_handlers
69
+
70
+ # Calculate total imports for overall progress
71
+ reload_database_quietly!
72
+ total = count_all_enabled_imports
73
+ @concurrent_progress.total = total if total.positive?
74
+
75
+ # Track if we need to reload (skip first iteration since we just reloaded)
76
+ need_reload = false
77
+
78
+ loop do
79
+ break if @interrupted
80
+
81
+ @iteration += 1
82
+ log "=== Iteration #{@iteration} ==="
83
+
84
+ # Only reload if previous batch executed imports (might have generated new Import resources)
85
+ reload_and_update_total! if need_reload
86
+
87
+ # Get all pending imports
88
+ pending = pending_imports
89
+
90
+ if pending.empty?
91
+ log "No pending imports. Done."
92
+ break
93
+ end
94
+
95
+ log "Found #{pending.size} pending import(s)"
96
+
97
+ # Find imports whose dependencies are satisfied
98
+ ready = pending.select { |imp| dependencies_satisfied?(imp) }
99
+
100
+ if ready.empty?
101
+ unsatisfied = pending.map(&:name).join(", ")
102
+ raise Archsight::Import::DeadlockError, "Deadlock: pending imports have unsatisfied dependencies: #{unsatisfied}"
103
+ end
104
+
105
+ # Sort by priority (lower first), then by name for determinism
106
+ ready.sort_by! { |imp| [imp.annotations["import/priority"].to_i, imp.name] }
107
+
108
+ # Execute all ready imports at the same priority level concurrently
109
+ current_priority = ready.first.annotations["import/priority"].to_i
110
+ batch = ready.select { |imp| imp.annotations["import/priority"].to_i == current_priority }
111
+
112
+ executed_before = @total_executed
113
+ execute_batch_concurrently(batch)
114
+
115
+ # Close shared files before potential database reload so new content is visible
116
+ @shared_writer.close_all
117
+
118
+ # Only reload next iteration if imports were actually executed (not just cached)
119
+ need_reload = @total_executed > executed_before
120
+
121
+ # Stop on first error
122
+ raise Archsight::Import::ImportError, "Import #{@first_error[:name]} failed: #{@first_error[:message]}" if @first_error
123
+ end
124
+
125
+ @shared_writer.close_all
126
+ finish_message = build_finish_message
127
+ @concurrent_progress.finish(finish_message) if @total_executed.positive? || @total_cached.positive?
128
+
129
+ # Raise InterruptedError if we were interrupted, so CLI can handle it
130
+ raise Archsight::Import::InterruptedError, "Import interrupted by user" if @interrupted
131
+ ensure
132
+ restore_signal_handlers
133
+ end
134
+
135
+ def build_finish_message
136
+ parts = []
137
+ parts << "#{@total_executed} executed" if @total_executed.positive?
138
+ parts << "#{@total_cached} cached" if @total_cached.positive?
139
+ "Completed: #{parts.join(", ")}"
140
+ end
141
+
142
+ # Show execution plan without running imports
143
+ # @return [Array<Archsight::Resources::Import>] Imports in execution order
144
+ def execution_plan
145
+ reload_database_quietly!
146
+
147
+ # Collect all imports
148
+ all_imports = database.instances_by_kind("Import")&.values || []
149
+
150
+ # Apply filter if specified
151
+ filtered_imports = all_imports.select { |imp| import_matches_filter?(imp) }
152
+
153
+ # Topological sort
154
+ sorted = topological_sort(filtered_imports)
155
+
156
+ @output.puts "Filter: #{@filter}" if @filter
157
+
158
+ sorted.each_with_index do |imp, idx|
159
+ enabled = import_enabled?(imp)
160
+ deps = import_dependency_names(imp)
161
+ deps_str = deps.empty? ? "(no dependencies)" : "depends on: #{deps.join(", ")}"
162
+ enabled_str = enabled ? "" : " [DISABLED]"
163
+ @output.puts " #{idx + 1}. #{imp.name}#{enabled_str} #{deps_str}"
164
+ end
165
+
166
+ sorted
167
+ end
168
+
169
+ private
170
+
171
+ # Set up signal handlers for graceful shutdown
172
+ def setup_signal_handlers
173
+ @original_int_handler = Signal.trap("INT") do
174
+ if @interrupted
175
+ # Second Ctrl-C: force exit
176
+ # Use trap-safe method (no mutex) since we're in signal context
177
+ @concurrent_progress&.finish_from_trap("Force quit")
178
+ @shared_writer&.close_all
179
+ exit(130)
180
+ else
181
+ # First Ctrl-C: graceful shutdown
182
+ @interrupted = true
183
+ # Use trap-safe method (no mutex) since we're in signal context
184
+ @concurrent_progress&.interrupt_from_trap("Shutting down gracefully (Ctrl-C again to force quit)...")
185
+ end
186
+ end
187
+ end
188
+
189
+ # Restore original signal handlers
190
+ def restore_signal_handlers
191
+ Signal.trap("INT", @original_int_handler || "DEFAULT")
192
+ end
193
+
194
+ # Count all enabled imports (for overall progress display)
195
+ def count_all_enabled_imports
196
+ imports = database.instances_by_kind("Import")&.values || []
197
+ imports.count { |imp| import_enabled?(imp) && import_matches_filter?(imp) }
198
+ end
199
+
200
+ # Get all pending imports (enabled=true, not yet executed this run, matches filter)
201
+ def pending_imports
202
+ imports = database.instances_by_kind("Import")&.values || []
203
+
204
+ @mutex.synchronize do
205
+ imports.select do |imp|
206
+ import_enabled?(imp) && import_matches_filter?(imp) && !@executed_this_run.include?(imp.name)
207
+ end
208
+ end
209
+ end
210
+
211
+ # Check if import is enabled and has a handler
212
+ def import_enabled?(import)
213
+ import.annotations["import/enabled"] != "false" && import.annotations["import/handler"]
214
+ end
215
+
216
+ # Check if import matches the filter pattern (or is a dependency of a matching import)
217
+ def import_matches_filter?(import)
218
+ return true unless @filter_regex
219
+
220
+ # Build the set of imports to run on first call (includes filtered + their dependencies)
221
+ @imports_to_run ||= compute_imports_to_run
222
+ @imports_to_run.include?(import.name)
223
+ end
224
+
225
+ # Compute the full set of imports to run: filtered imports + all their dependencies
226
+ def compute_imports_to_run
227
+ all_imports = database.instances_by_kind("Import")&.values || []
228
+ imports_by_name = all_imports.to_h { |imp| [imp.name, imp] }
229
+
230
+ # Start with imports matching the filter
231
+ matching = all_imports.select { |imp| @filter_regex.match?(imp.name) }.map(&:name)
232
+ to_run = Set.new(matching)
233
+
234
+ # Recursively add dependencies
235
+ queue = matching.dup
236
+ while (name = queue.shift)
237
+ import = imports_by_name[name]
238
+ next unless import
239
+
240
+ dep_names = import_dependency_names(import)
241
+ dep_names.each do |dep_name|
242
+ unless to_run.include?(dep_name)
243
+ to_run.add(dep_name)
244
+ queue << dep_name
245
+ end
246
+ end
247
+ end
248
+
249
+ to_run
250
+ end
251
+
252
+ # Check if all dependencies of an import are satisfied (executed successfully this run)
253
+ def dependencies_satisfied?(import)
254
+ dep_names = import_dependency_names(import)
255
+ return true if dep_names.empty?
256
+
257
+ @mutex.synchronize do
258
+ dep_names.all? do |dep_name|
259
+ @executed_this_run.include?(dep_name) && !@failed_imports.key?(dep_name)
260
+ end
261
+ end
262
+ end
263
+
264
+ # Get dependency names for an import by finding parents that generate this import
265
+ # Dependencies are derived from the inverse of `generates` - if Import A generates Import B,
266
+ # then B depends on A
267
+ def import_dependency_names(import)
268
+ # Find all imports that have this import in their generates.imports
269
+ all_imports = database.instances_by_kind("Import")&.values || []
270
+ parent_imports = all_imports.select do |parent|
271
+ generated = parent.relations(:generates, :imports)
272
+ generated&.any? { |gen| (gen.is_a?(String) ? gen : gen.name) == import.name }
273
+ end
274
+ parent_imports.map(&:name)
275
+ end
276
+
277
+ # Reload database without verbose output
278
+ def reload_database_quietly!
279
+ original_verbose = database.verbose
280
+ database.verbose = false
281
+ database.reload!
282
+ # Reset filter cache since new imports may have been discovered
283
+ @imports_to_run = nil if @filter_regex
284
+ ensure
285
+ database.verbose = original_verbose
286
+ end
287
+
288
+ # Reload database and update progress total for newly discovered imports
289
+ def reload_and_update_total!
290
+ reload_database_quietly!
291
+ new_total = count_all_enabled_imports
292
+ @concurrent_progress.update_total(new_total) if new_total.positive?
293
+ end
294
+
295
+ # Execute a batch of imports concurrently
296
+ def execute_batch_concurrently(batch)
297
+ threads = []
298
+
299
+ batch.each do |import|
300
+ # Mark as executed (thread-safe)
301
+ @mutex.synchronize { @executed_this_run.add(import.name) }
302
+
303
+ threads << Thread.new(import) do |imp|
304
+ # Acquire a slot (blocks if all slots are in use)
305
+ slot_progress = @concurrent_progress.acquire_slot(imp.name)
306
+
307
+ begin
308
+ result = execute_single_import(imp, slot_progress)
309
+ if result == :cached
310
+ slot_progress.complete("Cached")
311
+ else
312
+ @mutex.synchronize { @total_executed += 1 }
313
+ slot_progress.complete("Done")
314
+ end
315
+ rescue StandardError => e
316
+ @mutex.synchronize do
317
+ @failed_imports[imp.name] = e.message
318
+ @first_error ||= { name: imp.name, message: e.message }
319
+ end
320
+ slot_progress.error(e.message)
321
+ ensure
322
+ # Update overall progress and release slot
323
+ @concurrent_progress.increment_completed
324
+ slot_progress.release
325
+ end
326
+ end
327
+ end
328
+
329
+ # Wait for all threads to complete
330
+ threads.each(&:join)
331
+ end
332
+
333
+ # Execute a single import (called from thread)
334
+ # @return [Symbol] :executed, :cached, or raises on error
335
+ def execute_single_import(import, import_progress)
336
+ # Check if import is still fresh based on generated/at and import/cacheTime
337
+ if import_fresh?(import)
338
+ @mutex.synchronize { @total_cached += 1 }
339
+ return :cached
340
+ end
341
+
342
+ # Execute the import
343
+ handler_class = Archsight::Import::Registry.handler_for(import)
344
+ handler = handler_class.new(
345
+ import,
346
+ database: database,
347
+ resources_dir: resources_dir,
348
+ progress: import_progress,
349
+ shared_writer: @shared_writer
350
+ )
351
+ handler.execute
352
+ :executed
353
+ end
354
+
355
+ # Check if import is still fresh (cached)
356
+ # Uses generated/at annotation and import/cacheTime to determine freshness
357
+ # Also validates that output file exists (if specified) and source file hasn't changed
358
+ def import_fresh?(import)
359
+ # Honor force flag - always re-run when forced
360
+ return false if @force
361
+
362
+ cache_time = import.annotations["import/cacheTime"]
363
+ return false if cache_time.nil? || cache_time.empty? || cache_time == "never"
364
+
365
+ # Check if output file exists - if not, cache is invalid
366
+ output_path = import.annotations["import/outputPath"]
367
+ if output_path && !output_path.empty?
368
+ full_path = File.join(resources_dir, output_path)
369
+ return false unless File.exist?(full_path)
370
+ end
371
+
372
+ generated_at = import.annotations["generated/at"]
373
+ return false if generated_at.nil? || generated_at.empty?
374
+
375
+ ttl_seconds = parse_duration(cache_time)
376
+ return false unless ttl_seconds
377
+
378
+ last_run = Time.parse(generated_at)
379
+
380
+ # Check if import configuration has changed since last generation
381
+ # Compare stored config hash against current config hash
382
+ stored_hash = import.annotations["generated/configHash"]
383
+ if stored_hash
384
+ current_hash = compute_config_hash(import)
385
+ return false if current_hash != stored_hash
386
+ end
387
+
388
+ Time.now < (last_run + ttl_seconds)
389
+ rescue ArgumentError
390
+ # Invalid time format
391
+ false
392
+ end
393
+
394
+ def parse_duration(str)
395
+ return nil if str.nil?
396
+
397
+ DURATION_PATTERNS.each do |pattern, multiplier|
398
+ match = str.match(pattern)
399
+ return match[1].to_i * multiplier if match
400
+ end
401
+
402
+ nil
403
+ end
404
+
405
+ # Compute a hash of the import's configuration for cache invalidation
406
+ # Includes handler and all import/config/* annotations
407
+ def compute_config_hash(import)
408
+ config_data = {
409
+ handler: import.annotations["import/handler"],
410
+ config: import.annotations.select { |k, _| k.start_with?("import/config/") }.sort.to_h
411
+ }
412
+ Digest::SHA256.hexdigest(config_data.to_json)[0, 16]
413
+ end
414
+
415
+ # Check if an import failed during this run
416
+ def failed?(import)
417
+ @mutex.synchronize { @failed_imports&.key?(import.name) }
418
+ end
419
+
420
+ # Topological sort of imports by dependencies
421
+ def topological_sort(imports)
422
+ sorted = []
423
+ visited = Set.new
424
+ temp_visited = Set.new
425
+
426
+ imports_by_name = imports.to_h { |imp| [imp.name, imp] }
427
+
428
+ visit = lambda do |import|
429
+ return if visited.include?(import.name)
430
+
431
+ raise Archsight::Import::DeadlockError, "Circular dependency detected involving: #{import.name}" if temp_visited.include?(import.name)
432
+
433
+ temp_visited.add(import.name)
434
+
435
+ # Visit dependencies first (derived from generates relation)
436
+ dep_names = import_dependency_names(import)
437
+ dep_names.each do |dep_name|
438
+ dep = imports_by_name[dep_name]
439
+ visit.call(dep) if dep
440
+ end
441
+
442
+ temp_visited.delete(import.name)
443
+ visited.add(import.name)
444
+ sorted << import
445
+ end
446
+
447
+ imports.each { |imp| visit.call(imp) }
448
+ sorted
449
+ end
450
+
451
+ def log(msg)
452
+ # Suppress verbose output in TTY mode as it would disrupt slot-based progress display
453
+ return if @concurrent_progress&.tty?
454
+
455
+ @output.puts msg if verbose
456
+ end
457
+ end
458
+
459
+ # Error raised when imports have circular or unsatisfied dependencies
460
+ class Archsight::Import::DeadlockError < StandardError; end
461
+
462
+ # Error raised when an import fails
463
+ class Archsight::Import::ImportError < StandardError; end
464
+
465
+ # Error raised when import is interrupted by user (Ctrl-C)
466
+ class Archsight::Import::InterruptedError < StandardError; end