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,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "yaml"
7
+ require "archsight/import"
8
+ require_relative "progress"
9
+
10
+ # Base class for import handlers
11
+ #
12
+ # Subclasses must implement the #execute method to perform the actual import.
13
+ # Use the helper methods to read configuration, validate environment, and write output.
14
+ class Archsight::Import::Handler
15
+ attr_reader :import_resource, :database, :resources_dir, :progress, :shared_writer
16
+
17
+ # @param import_resource [Archsight::Resources::Import] The import resource to execute
18
+ # @param database [Archsight::Database] The database instance
19
+ # @param resources_dir [String] Root resources directory
20
+ # @param progress [Archsight::Import::Progress] Progress reporter
21
+ # @param shared_writer [Archsight::Import::SharedFileWriter] Thread-safe file writer for concurrent output
22
+ def initialize(import_resource, database:, resources_dir:, progress: nil, shared_writer: nil)
23
+ @import_resource = import_resource
24
+ @database = database
25
+ @resources_dir = resources_dir
26
+ @progress = progress || Archsight::Import::Progress.new
27
+ @shared_writer = shared_writer
28
+ @tracked_resources = []
29
+ end
30
+
31
+ # Execute the import. Must be implemented by subclasses.
32
+ # @raise [NotImplementedError] if not overridden
33
+ def execute
34
+ raise NotImplementedError, "#{self.class}#execute must be implemented"
35
+ end
36
+
37
+ # Get a configuration value from import/config/* annotations
38
+ # @param key [String] Configuration key (without the import/config/ prefix)
39
+ # @param default [Object, nil] Default value if not set
40
+ # @return [String, nil] The configuration value
41
+ def config(key, default: nil)
42
+ import_resource.annotations["import/config/#{key}"] || default
43
+ end
44
+
45
+ # Get all configuration values as a hash
46
+ # @return [Hash] Configuration key-value pairs
47
+ def config_all
48
+ import_resource.annotations.each_with_object({}) do |(key, value), hash|
49
+ next unless key.start_with?("import/config/")
50
+
51
+ config_key = key.sub("import/config/", "")
52
+ hash[config_key] = value
53
+ end
54
+ end
55
+
56
+ # Compute a hash of the import's configuration for cache invalidation
57
+ # @return [String] 16-character hex hash
58
+ def compute_config_hash
59
+ config_data = {
60
+ handler: import_resource.annotations["import/handler"],
61
+ config: import_resource.annotations.select { |k, _| k.start_with?("import/config/") }.sort.to_h
62
+ }
63
+ Digest::SHA256.hexdigest(config_data.to_json)[0, 16]
64
+ end
65
+
66
+ # Generate a marker Import for this handler with generated/at timestamp and config hash
67
+ # Used for caching - call at end of execute() to persist the execution timestamp
68
+ # @return [Hash] Import resource hash ready for YAML serialization
69
+ def self_marker
70
+ {
71
+ "apiVersion" => "architecture/v1alpha1",
72
+ "kind" => "Import",
73
+ "metadata" => {
74
+ "name" => import_resource.name,
75
+ "annotations" => {
76
+ "generated/at" => Time.now.utc.iso8601,
77
+ "generated/configHash" => compute_config_hash
78
+ }
79
+ },
80
+ "spec" => {}
81
+ }
82
+ end
83
+
84
+ # Write YAML content to the output path
85
+ # @param content [String] YAML content to write
86
+ # @param filename [String, nil] Output filename (overrides import/outputPath filename)
87
+ # @return [String] Path to the written file
88
+ #
89
+ # Output location is determined by import/outputPath annotation:
90
+ # - Relative to resources_dir (e.g., "generated/repositories.yaml")
91
+ # - If filename parameter is provided, it replaces the filename from outputPath
92
+ # - If no outputPath, falls back to resources_dir/generated with import name as filename
93
+ #
94
+ # When shared_writer is available, uses thread-safe append for concurrent writes.
95
+ # @param content [String] YAML content to write
96
+ # @param filename [String, nil] Output filename (overrides import/outputPath filename)
97
+ # @param sort_key [String, nil] Key for sorting in shared files (default: import name)
98
+ def write_yaml(content, filename: nil, sort_key: nil)
99
+ output_path = import_resource.annotations["import/outputPath"]
100
+
101
+ full_path = if output_path
102
+ base = File.join(resources_dir, output_path)
103
+ if filename
104
+ # Replace filename portion with provided filename
105
+ File.join(File.dirname(base), filename)
106
+ else
107
+ base
108
+ end
109
+ else
110
+ # Fallback to resources_dir/generated
111
+ File.join(resources_dir, "generated", filename || "#{safe_filename(import_resource.name)}.yaml")
112
+ end
113
+
114
+ if @shared_writer
115
+ # Use thread-safe shared writer for concurrent execution
116
+ # Default sort key is import name for stable output ordering
117
+ key = sort_key || import_resource.name
118
+ @shared_writer.append_yaml(full_path, content, sort_key: key)
119
+ else
120
+ # Direct write for non-concurrent mode
121
+ FileUtils.mkdir_p(File.dirname(full_path))
122
+ File.write(full_path, content)
123
+ end
124
+
125
+ full_path
126
+ end
127
+
128
+ # Generate a resource YAML hash with standard metadata
129
+ # @param kind [String] Resource kind (e.g., "TechnologyArtifact")
130
+ # @param name [String] Resource name
131
+ # @param annotations [Hash] Resource annotations
132
+ # @param spec [Hash] Resource spec (relations)
133
+ # @return [Hash] Resource hash ready for YAML serialization
134
+ def resource_yaml(kind:, name:, annotations: {}, spec: {})
135
+ @tracked_resources << { kind: kind, name: name }
136
+ {
137
+ "apiVersion" => "architecture/v1alpha1",
138
+ "kind" => kind,
139
+ "metadata" => {
140
+ "name" => name,
141
+ "annotations" => annotations.merge(
142
+ "generated/script" => import_resource.name,
143
+ "generated/at" => Time.now.utc.iso8601
144
+ )
145
+ },
146
+ "spec" => spec
147
+ }
148
+ end
149
+
150
+ # Generate an Import resource YAML hash for child imports
151
+ # @param name [String] Import resource name
152
+ # @param handler [String] Handler name
153
+ # @param config [Hash] Configuration annotations (added as import/config/*)
154
+ # @param annotations [Hash] Additional annotations (added directly)
155
+ # @return [Hash] Import resource hash ready for YAML serialization
156
+ #
157
+ # NOTE: generated/at is NOT set here - it's only set by the self_marker when
158
+ # the child import actually executes. This ensures caching works correctly
159
+ # regardless of file loading order.
160
+ #
161
+ # Dependencies are not specified here - they are derived from the `generates`
162
+ # relation on the parent import (tracked via write_generates_meta).
163
+ def import_yaml(name:, handler:, config: {}, annotations: {})
164
+ @tracked_resources << { kind: "Import", name: name }
165
+ all_annotations = {
166
+ "import/handler" => handler,
167
+ "generated/script" => import_resource.name
168
+ }
169
+
170
+ # Add direct annotations (e.g., import/outputPath)
171
+ all_annotations.merge!(annotations)
172
+
173
+ # Add config annotations with prefix
174
+ config.each do |key, value|
175
+ all_annotations["import/config/#{key}"] = value.to_s
176
+ end
177
+
178
+ {
179
+ "apiVersion" => "architecture/v1alpha1",
180
+ "kind" => "Import",
181
+ "metadata" => {
182
+ "name" => name,
183
+ "annotations" => all_annotations
184
+ },
185
+ "spec" => {}
186
+ }
187
+ end
188
+
189
+ # Convert multiple resource hashes to YAML string with document separators
190
+ # @param resources [Array<Hash>] Array of resource hashes
191
+ # @return [String] YAML string with --- separators
192
+ def resources_to_yaml(resources)
193
+ resources.map { |r| YAML.dump(r) }.join
194
+ end
195
+
196
+ # Write generates meta record for this Import
197
+ # Call at end of execute() to persist tracking of generated resources
198
+ # Appends to the output file rather than overwriting
199
+ def write_generates_meta
200
+ return if @tracked_resources.empty?
201
+
202
+ meta = generates_meta_record(@tracked_resources)
203
+ output_path = import_resource.annotations["import/outputPath"]
204
+
205
+ full_path = if output_path
206
+ File.join(resources_dir, output_path)
207
+ else
208
+ File.join(resources_dir, "generated", "#{safe_filename(import_resource.name)}.yaml")
209
+ end
210
+
211
+ if @shared_writer
212
+ @shared_writer.append_yaml(full_path, YAML.dump(meta), sort_key: "#{import_resource.name}:generates")
213
+ else
214
+ # Append to existing file
215
+ File.open(full_path, "a") { |f| f.write(YAML.dump(meta)) }
216
+ end
217
+ end
218
+
219
+ private
220
+
221
+ # Create meta record for Import with generates relations
222
+ # @param resources [Array<Hash>] Array of tracked resources with :kind and :name
223
+ # @return [Hash] Import resource hash with generates spec
224
+ def generates_meta_record(resources)
225
+ grouped = resources.group_by { |r| r[:kind] }
226
+
227
+ generates_spec = {}
228
+ grouped.each do |kind, items|
229
+ kind_key = kind_to_relation_key(kind)
230
+ generates_spec[kind_key] = items.map { |r| r[:name] }
231
+ end
232
+
233
+ {
234
+ "apiVersion" => "architecture/v1alpha1",
235
+ "kind" => "Import",
236
+ "metadata" => { "name" => import_resource.name },
237
+ "spec" => { "generates" => generates_spec }
238
+ }
239
+ end
240
+
241
+ # Convert resource kind to relation key
242
+ # @param kind [String] Resource kind (e.g., "TechnologyArtifact")
243
+ # @return [String] Relation key (e.g., "technologyArtifacts")
244
+ def kind_to_relation_key(kind)
245
+ case kind
246
+ when "TechnologyArtifact" then "technologyArtifacts"
247
+ when "ApplicationInterface" then "applicationInterfaces"
248
+ when "DataObject" then "dataObjects"
249
+ when "Import" then "imports"
250
+ when "BusinessActor" then "businessActors"
251
+ else
252
+ # Fallback: convert CamelCase to camelCase plural
253
+ "#{kind[0].downcase}#{kind[1..]}s"
254
+ end
255
+ end
256
+
257
+ # Convert a name to a safe filename
258
+ # @param name [String] Resource name
259
+ # @return [String] Safe filename
260
+ def safe_filename(name)
261
+ name.gsub(/[^a-zA-Z0-9_-]/, "_")
262
+ end
263
+ end
@@ -0,0 +1,161 @@
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
+ # GitHub handler - lists repositories from a GitHub organization and generates child Import resources
12
+ #
13
+ # Configuration:
14
+ # import/config/org - GitHub organization name
15
+ # import/config/repoOutputPath - Output path for repository handler results (e.g., "generated/repositories.yaml")
16
+ # import/config/childCacheTime - Cache time for generated child imports (e.g., "1h", "30m")
17
+ #
18
+ # Environment:
19
+ # GITHUB_TOKEN - GitHub Personal Access Token (required)
20
+ # Create at: https://github.com/settings/tokens
21
+ # Required scopes: repo (private repos) or public_repo (public only)
22
+ #
23
+ # Output:
24
+ # Generates Import:Repo:* resources for each repository
25
+ # The repository handler will clone/sync the actual git repositories
26
+ class Archsight::Import::Handlers::Github < Archsight::Import::Handler
27
+ PER_PAGE = 100
28
+
29
+ def execute
30
+ @org = config("org")
31
+ raise "Missing required config: org" unless @org
32
+
33
+ @token = ENV.fetch("GITHUB_TOKEN", nil)
34
+ raise "Missing required environment variable: GITHUB_TOKEN" unless @token
35
+
36
+ @repo_output_path = config("repoOutputPath")
37
+ @child_cache_time = config("childCacheTime")
38
+ @target_dir = File.join(Dir.home, ".cache", "archsight", "git", "github", @org)
39
+
40
+ # Fetch all repositories with pagination
41
+ progress.update("Fetching repositories from #{@org}")
42
+ repos = fetch_all_repos
43
+
44
+ if repos.empty?
45
+ progress.warn("No repositories found in #{@org}")
46
+ return
47
+ end
48
+
49
+ # Generate Import resources for each repository
50
+ progress.update("Generating #{repos.size} import resources")
51
+ generate_repository_imports(repos)
52
+
53
+ write_generates_meta
54
+ end
55
+
56
+ private
57
+
58
+ def fetch_all_repos
59
+ all_repos = []
60
+ page = 1
61
+
62
+ loop do
63
+ progress.update("Fetching repositories (page #{page})")
64
+ batch = fetch_repos_page(page)
65
+ break if batch.empty?
66
+
67
+ all_repos.concat(batch)
68
+ progress.update("Fetched #{all_repos.size} repositories")
69
+ break if batch.size < PER_PAGE
70
+
71
+ page += 1
72
+ end
73
+
74
+ all_repos
75
+ end
76
+
77
+ def fetch_repos_page(page)
78
+ data = make_request("/orgs/#{@org}/repos", per_page: PER_PAGE, page: page)
79
+
80
+ # Transform API response to match expected format
81
+ data.map do |repo|
82
+ {
83
+ "name" => repo["name"],
84
+ "isArchived" => repo["archived"],
85
+ "visibility" => repo["visibility"],
86
+ "sshUrl" => repo["ssh_url"],
87
+ "url" => repo["html_url"]
88
+ }
89
+ end
90
+ end
91
+
92
+ def make_request(path, params = {})
93
+ uri = URI("https://api.github.com#{path}")
94
+ uri.query = URI.encode_www_form(params) unless params.empty?
95
+
96
+ http = Net::HTTP.new(uri.host, uri.port)
97
+ http.use_ssl = true
98
+
99
+ request = Net::HTTP::Get.new(uri)
100
+ request["Authorization"] = "Bearer #{@token}"
101
+ request["Accept"] = "application/vnd.github+json"
102
+ request["X-GitHub-Api-Version"] = "2022-11-28"
103
+
104
+ response = http.request(request)
105
+ handle_response(response)
106
+ end
107
+
108
+ def handle_response(response)
109
+ case response
110
+ when Net::HTTPSuccess
111
+ JSON.parse(response.body)
112
+ when Net::HTTPUnauthorized
113
+ raise "GitHub API error: 401 Unauthorized - Invalid or expired GITHUB_TOKEN"
114
+ when Net::HTTPForbidden
115
+ if response["X-RateLimit-Remaining"] == "0"
116
+ reset_time = Time.at(response["X-RateLimit-Reset"].to_i)
117
+ raise "GitHub API error: 403 Rate limit exceeded. Resets at #{reset_time}"
118
+ end
119
+ raise "GitHub API error: 403 Forbidden - Check token permissions"
120
+ when Net::HTTPNotFound
121
+ raise "GitHub API error: 404 Not Found - Organization '#{@org}' not found or not accessible"
122
+ else
123
+ raise "GitHub API error: #{response.code} #{response.message}"
124
+ end
125
+ end
126
+
127
+ def generate_repository_imports(repos)
128
+ yaml_documents = repos.map do |repo|
129
+ repo_name = repo["name"]
130
+ repo_path = File.join(@target_dir, repo_name)
131
+ visibility = (repo["visibility"] || "private").downcase
132
+ git_url = repo["sshUrl"] || repo["url"]
133
+
134
+ # Build annotations for child import
135
+ child_annotations = {}
136
+ child_annotations["import/outputPath"] = @repo_output_path if @repo_output_path
137
+ child_annotations["import/cacheTime"] = @child_cache_time if @child_cache_time
138
+
139
+ import_yaml(
140
+ name: "Import:Repo:github:#{@org}:#{repo_name}",
141
+ handler: "repository",
142
+ config: {
143
+ "path" => repo_path,
144
+ "gitUrl" => git_url,
145
+ "archived" => repo["isArchived"].to_s,
146
+ "visibility" => visibility == "public" ? "open-source" : "internal"
147
+ },
148
+ annotations: child_annotations
149
+ )
150
+ end
151
+
152
+ # Add self-marker with generated/at for caching
153
+ yaml_documents << self_marker
154
+
155
+ # Write all imports to a single file with --- separators
156
+ yaml_content = yaml_documents.map { |doc| YAML.dump(doc) }.join("\n")
157
+ write_yaml(yaml_content)
158
+ end
159
+ end
160
+
161
+ Archsight::Import::Registry.register("github", Archsight::Import::Handlers::Github)
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "fileutils"
5
+ require "net/http"
6
+ require "json"
7
+ require "uri"
8
+ require "openssl"
9
+ require_relative "../handler"
10
+ require_relative "../registry"
11
+
12
+ # GitLab handler - lists repositories from a GitLab instance and generates child Import resources
13
+ #
14
+ # Configuration:
15
+ # import/config/host - GitLab host (e.g., gitlab.company.com)
16
+ # import/config/exploreGroups - If "true", explore all visible groups (default: false)
17
+ # import/config/perPage - API pagination page size (default: 100)
18
+ # import/config/verifySSL - If "false", disable SSL verification (default: true)
19
+ # import/config/sslFingerprint - SSL certificate fingerprint for pinning (SHA256, colon-separated hex)
20
+ # import/config/repoOutputPath - Output path for repository handler results (e.g., "generated/repositories.yaml")
21
+ # import/config/childCacheTime - Cache time for generated child imports (e.g., "1h", "30m")
22
+ #
23
+ # Environment:
24
+ # GITLAB_TOKEN - GitLab personal access token (required)
25
+ #
26
+ # Output:
27
+ # Generates Import:Repo:* resources for each repository
28
+ # The repository handler will clone/sync the actual git repositories (via SSH)
29
+ class Archsight::Import::Handlers::Gitlab < Archsight::Import::Handler
30
+ def execute
31
+ @host = config("host")
32
+ raise "Missing required config: host" unless @host
33
+
34
+ @token = ENV.fetch("GITLAB_TOKEN", nil)
35
+ raise "Missing required environment variable: GITLAB_TOKEN" unless @token
36
+
37
+ @repo_output_path = config("repoOutputPath")
38
+ @child_cache_time = config("childCacheTime")
39
+
40
+ @target_dir = File.join(Dir.home, ".cache", "archsight", "git", "gitlab")
41
+ @explore_groups = config("exploreGroups") == "true"
42
+ @per_page = config("perPage", default: "100").to_i
43
+ @verify_ssl = config("verifySSL") != "false"
44
+ @ssl_fingerprint = config("sslFingerprint")
45
+
46
+ # Fetch all projects
47
+ progress.update("Fetching projects from #{@host}")
48
+ projects = fetch_all_projects
49
+
50
+ if projects.empty?
51
+ progress.warn("No projects found on GitLab")
52
+ return
53
+ end
54
+
55
+ # Generate Import resources for each repository
56
+ progress.update("Generating #{projects.size} import resources")
57
+ generate_repository_imports(projects)
58
+
59
+ write_generates_meta
60
+ end
61
+
62
+ private
63
+
64
+ def api_endpoint
65
+ "https://#{@host}/api/v4"
66
+ end
67
+
68
+ def make_request(path, params = {})
69
+ uri = URI("#{api_endpoint}#{path}")
70
+ uri.query = URI.encode_www_form(params) unless params.empty?
71
+
72
+ http = Net::HTTP.new(uri.host, uri.port)
73
+ http.use_ssl = true
74
+
75
+ # Configure SSL verification
76
+ if @ssl_fingerprint
77
+ # Use certificate pinning - disable default verification, we verify fingerprint manually
78
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
79
+ http.verify_callback = lambda do |_preverify_ok, cert_store|
80
+ # Get the peer certificate from the chain
81
+ cert = cert_store.chain&.first
82
+ return true unless cert # Allow if no cert (will fail later)
83
+
84
+ # Compute SHA256 fingerprint
85
+ fingerprint = OpenSSL::Digest::SHA256.new(cert.to_der).to_s.upcase.scan(/../).join(":")
86
+ expected = @ssl_fingerprint.upcase
87
+
88
+ raise OpenSSL::SSL::SSLError, "Certificate fingerprint mismatch! Expected: #{expected}, Got: #{fingerprint}" if fingerprint != expected
89
+
90
+ true
91
+ end
92
+ else
93
+ http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
94
+ end
95
+
96
+ request = Net::HTTP::Get.new(uri)
97
+ request["PRIVATE-TOKEN"] = @token
98
+
99
+ response = http.request(request)
100
+
101
+ raise "GitLab API error: #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
102
+
103
+ JSON.parse(response.body)
104
+ end
105
+
106
+ def fetch_all_groups
107
+ groups = []
108
+ page = 1
109
+
110
+ loop do
111
+ progress.update("Fetching groups (page #{page})")
112
+ params = { per_page: @per_page, page: page }
113
+ params[:all_available] = true if @explore_groups
114
+
115
+ batch = make_request("/groups", params)
116
+ break if batch.empty?
117
+
118
+ groups.concat(batch)
119
+ page += 1
120
+ end
121
+
122
+ groups
123
+ end
124
+
125
+ def fetch_projects_for_group(group_id)
126
+ projects = []
127
+ page = 1
128
+
129
+ loop do
130
+ params = {
131
+ include_subgroups: true,
132
+ per_page: @per_page,
133
+ page: page,
134
+ order_by: "created_at"
135
+ }
136
+
137
+ batch = make_request("/groups/#{group_id}/projects", params)
138
+ break if batch.empty?
139
+
140
+ projects.concat(batch)
141
+ page += 1
142
+ end
143
+
144
+ projects
145
+ end
146
+
147
+ def fetch_all_projects
148
+ all_projects = []
149
+ groups = fetch_all_groups
150
+
151
+ groups.each_with_index do |group, idx|
152
+ progress.update("Fetching projects from #{group["full_path"]}", current: idx + 1, total: groups.size)
153
+ projects = fetch_projects_for_group(group["id"])
154
+ all_projects.concat(projects)
155
+ end
156
+
157
+ # Deduplicate by project ID
158
+ all_projects.uniq { |p| p["id"] }
159
+ end
160
+
161
+ def safe_dir_name(path)
162
+ path.gsub("/", ".")
163
+ end
164
+
165
+ def git_url_for(project)
166
+ project["ssh_url_to_repo"]
167
+ end
168
+
169
+ def generate_repository_imports(projects)
170
+ yaml_documents = projects.map do |project|
171
+ dir_name = safe_dir_name(project["path_with_namespace"])
172
+ repo_path = File.join(@target_dir, dir_name)
173
+ git_url = git_url_for(project)
174
+
175
+ # Build annotations for child import
176
+ child_annotations = {}
177
+ child_annotations["import/outputPath"] = @repo_output_path if @repo_output_path
178
+ child_annotations["import/cacheTime"] = @child_cache_time if @child_cache_time
179
+
180
+ import_yaml(
181
+ name: "Import:Repo:gitlab:#{dir_name}",
182
+ handler: "repository",
183
+ config: {
184
+ "path" => repo_path,
185
+ "gitUrl" => git_url,
186
+ "archived" => project["archived"].to_s,
187
+ "visibility" => project["visibility"] || "internal"
188
+ },
189
+ annotations: child_annotations
190
+ )
191
+ end
192
+
193
+ # Add self-marker with generated/at for caching
194
+ yaml_documents << self_marker
195
+
196
+ # Write all imports to a single file with --- separators
197
+ yaml_content = yaml_documents.map { |doc| YAML.dump(doc) }.join("\n")
198
+ write_yaml(yaml_content)
199
+ end
200
+ end
201
+
202
+ Archsight::Import::Registry.register("gitlab", Archsight::Import::Handlers::Gitlab)