archsight 0.1.4 → 0.1.5
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.
- checksums.yaml +4 -4
- data/README.md +33 -0
- data/chart/archsight/Chart.yaml +6 -0
- data/chart/archsight/README.md +160 -0
- data/chart/archsight/templates/NOTES.txt +22 -0
- data/chart/archsight/templates/_helpers.tpl +62 -0
- data/chart/archsight/templates/deployment.yaml +114 -0
- data/chart/archsight/templates/ingress.yaml +56 -0
- data/chart/archsight/templates/resources-configmap.yaml +10 -0
- data/chart/archsight/templates/resources-pvc.yaml +23 -0
- data/chart/archsight/templates/service.yaml +15 -0
- data/chart/archsight/templates/serviceaccount.yaml +12 -0
- data/chart/archsight/values.yaml +162 -0
- data/lib/archsight/analysis/executor.rb +0 -10
- data/lib/archsight/annotations/annotation.rb +85 -36
- data/lib/archsight/annotations/architecture_annotations.rb +1 -34
- data/lib/archsight/annotations/computed.rb +1 -1
- data/lib/archsight/annotations/generated_annotations.rb +6 -3
- data/lib/archsight/annotations/git_annotations.rb +8 -4
- data/lib/archsight/annotations/interface_annotations.rb +35 -0
- data/lib/archsight/cli.rb +3 -1
- data/lib/archsight/editor/content_hasher.rb +37 -0
- data/lib/archsight/editor/file_writer.rb +79 -0
- data/lib/archsight/editor.rb +237 -0
- data/lib/archsight/import/handlers/github.rb +14 -6
- data/lib/archsight/import/handlers/gitlab.rb +14 -6
- data/lib/archsight/import/handlers/repository.rb +3 -1
- data/lib/archsight/import/team_matcher.rb +111 -61
- data/lib/archsight/mcp/execute_analysis_tool.rb +100 -0
- data/lib/archsight/mcp.rb +1 -0
- data/lib/archsight/resources/analysis.rb +1 -17
- data/lib/archsight/resources/application_interface.rb +1 -5
- data/lib/archsight/resources/base.rb +14 -14
- data/lib/archsight/resources/business_actor.rb +1 -1
- data/lib/archsight/resources/technology_interface.rb +1 -1
- data/lib/archsight/resources/technology_service.rb +5 -0
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/application.rb +8 -0
- data/lib/archsight/web/doc/import.md +10 -2
- data/lib/archsight/web/editor/form_builder.rb +100 -0
- data/lib/archsight/web/editor/routes.rb +293 -0
- data/lib/archsight/web/public/css/editor.css +863 -0
- data/lib/archsight/web/public/css/instance.css +6 -0
- data/lib/archsight/web/public/js/editor.js +421 -0
- data/lib/archsight/web/public/js/lexical-editor.js +308 -0
- data/lib/archsight/web/views/partials/editor/_field.haml +80 -0
- data/lib/archsight/web/views/partials/editor/_form.haml +131 -0
- data/lib/archsight/web/views/partials/editor/_relations.haml +39 -0
- data/lib/archsight/web/views/partials/editor/_yaml_output.haml +33 -0
- data/lib/archsight/web/views/partials/instance/_analysis_detail.haml +4 -11
- data/lib/archsight/web/views/partials/instance/_detail.haml +4 -0
- data/lib/archsight/web/views/partials/layout/_content.haml +8 -2
- data/lib/archsight/web/views/partials/layout/_head.haml +2 -0
- metadata +26 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "resources"
|
|
5
|
+
require_relative "editor/file_writer"
|
|
6
|
+
require_relative "editor/content_hasher"
|
|
7
|
+
|
|
8
|
+
module Archsight
|
|
9
|
+
# Editor handles building and validating resources for the web editor
|
|
10
|
+
module Editor
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Build a resource hash from form params
|
|
14
|
+
# @param kind [String] Resource kind (e.g., "TechnologyArtifact")
|
|
15
|
+
# @param name [String] Resource name
|
|
16
|
+
# @param annotations [Hash] Annotation key-value pairs
|
|
17
|
+
# @param relations [Array<Hash>] Array of {verb:, kind:, names:[]} hashes
|
|
18
|
+
# where kind is the target class name (e.g., "TechnologyArtifact")
|
|
19
|
+
# @return [Hash] Resource hash ready for YAML conversion
|
|
20
|
+
def build_resource(kind:, name:, annotations: {}, relations: [])
|
|
21
|
+
resource = {
|
|
22
|
+
"apiVersion" => "architecture/v1alpha1",
|
|
23
|
+
"kind" => kind,
|
|
24
|
+
"metadata" => {
|
|
25
|
+
"name" => name
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Add annotations if any non-empty values exist
|
|
30
|
+
filtered_annotations = filter_empty(annotations)
|
|
31
|
+
resource["metadata"]["annotations"] = filtered_annotations unless filtered_annotations.empty?
|
|
32
|
+
|
|
33
|
+
# Add spec with relations if any exist
|
|
34
|
+
spec = build_spec(kind, relations)
|
|
35
|
+
resource["spec"] = spec unless spec.empty?
|
|
36
|
+
|
|
37
|
+
resource
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Validate resource params using annotation definitions
|
|
41
|
+
# @param kind [String] Resource kind
|
|
42
|
+
# @param name [String] Resource name
|
|
43
|
+
# @param annotations [Hash] Annotation key-value pairs
|
|
44
|
+
# @return [Hash] { valid: Boolean, errors: { field => [messages] } }
|
|
45
|
+
def validate(kind, name:, annotations: {})
|
|
46
|
+
klass = Archsight::Resources[kind]
|
|
47
|
+
errors = {}
|
|
48
|
+
|
|
49
|
+
# Validate name
|
|
50
|
+
if name.nil? || name.strip.empty?
|
|
51
|
+
errors["name"] = ["Name is required"]
|
|
52
|
+
elsif name =~ /\s/
|
|
53
|
+
errors["name"] = ["Name cannot contain spaces"]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Validate annotations against their definitions
|
|
57
|
+
klass.annotations.reject(&:pattern?).each do |ann|
|
|
58
|
+
value = annotations[ann.key]
|
|
59
|
+
next if value.nil? || value.to_s.strip.empty?
|
|
60
|
+
|
|
61
|
+
ann_errors = ann.validate(value)
|
|
62
|
+
errors[ann.key] = ann_errors if ann_errors.any?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
{ valid: errors.empty?, errors: errors }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Generate YAML string from resource hash
|
|
69
|
+
# Uses custom YAML dump that formats multiline strings with literal block scalars
|
|
70
|
+
# @param resource_hash [Hash] Resource hash
|
|
71
|
+
# @return [String] YAML string
|
|
72
|
+
def to_yaml(resource_hash)
|
|
73
|
+
visitor = Psych::Visitors::YAMLTree.create
|
|
74
|
+
visitor << resource_hash
|
|
75
|
+
|
|
76
|
+
# Walk the AST and apply scalar style for multiline strings
|
|
77
|
+
ast = visitor.tree
|
|
78
|
+
apply_block_scalar_style(ast)
|
|
79
|
+
|
|
80
|
+
ast.yaml(nil, line_width: 80)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Recursively apply literal block style for multiline strings in YAML AST
|
|
84
|
+
# @param node [Psych::Nodes::Node] YAML AST node
|
|
85
|
+
def apply_block_scalar_style(node)
|
|
86
|
+
case node
|
|
87
|
+
when Psych::Nodes::Scalar
|
|
88
|
+
if node.value.is_a?(String)
|
|
89
|
+
# Normalize Windows/old Mac line endings to Unix style
|
|
90
|
+
node.value = node.value.gsub("\r\n", "\n").gsub("\r", "\n") if node.value.include?("\r")
|
|
91
|
+
# Use literal block style for multiline strings
|
|
92
|
+
node.style = Psych::Nodes::Scalar::LITERAL if node.value.include?("\n")
|
|
93
|
+
end
|
|
94
|
+
when Psych::Nodes::Sequence, Psych::Nodes::Mapping, Psych::Nodes::Document, Psych::Nodes::Stream
|
|
95
|
+
node.children.each { |child| apply_block_scalar_style(child) }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Get editable annotations for a resource kind
|
|
100
|
+
# Excludes pattern annotations, computed annotations, and annotations with editor: false
|
|
101
|
+
# @param kind [String] Resource kind
|
|
102
|
+
# @return [Array<Archsight::Annotations::Annotation>]
|
|
103
|
+
def editable_annotations(kind)
|
|
104
|
+
klass = Archsight::Resources[kind]
|
|
105
|
+
return [] unless klass
|
|
106
|
+
|
|
107
|
+
# Get all annotations except pattern annotations
|
|
108
|
+
annotations = klass.annotations.reject(&:pattern?)
|
|
109
|
+
|
|
110
|
+
# Filter out computed annotations
|
|
111
|
+
computed_keys = klass.computed_annotations.map(&:key)
|
|
112
|
+
annotations = annotations.reject { |a| computed_keys.include?(a.key) }
|
|
113
|
+
|
|
114
|
+
# Filter out non-editable annotations
|
|
115
|
+
annotations.reject { |a| a.editor == false }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get available relations for a resource kind
|
|
119
|
+
# @param kind [String] Resource kind
|
|
120
|
+
# @return [Array<Array>] Array of [verb, target_kind, target_class_name]
|
|
121
|
+
def available_relations(kind)
|
|
122
|
+
klass = Archsight::Resources[kind]
|
|
123
|
+
return [] unless klass
|
|
124
|
+
|
|
125
|
+
klass.relations
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Get unique verbs for a resource kind's relations
|
|
129
|
+
# @param kind [String] Resource kind
|
|
130
|
+
# @return [Array<String>]
|
|
131
|
+
def relation_verbs(kind)
|
|
132
|
+
available_relations(kind).map { |v, _, _| v.to_s }.uniq.sort
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Get valid target class names for a given verb (for UI display and instance lookup)
|
|
136
|
+
# @param kind [String] Resource kind
|
|
137
|
+
# @param verb [String] Relation verb
|
|
138
|
+
# @return [Array<String>] Target class names (e.g., "TechnologyArtifact")
|
|
139
|
+
def target_kinds_for_verb(kind, verb)
|
|
140
|
+
# Relations structure is [verb, relation_name, target_class_name]
|
|
141
|
+
available_relations(kind)
|
|
142
|
+
.select { |v, _, _| v.to_s == verb.to_s }
|
|
143
|
+
.map { |_, _, target_class| target_class.to_s }
|
|
144
|
+
.uniq
|
|
145
|
+
.sort
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Get relation name for a given verb and target class (for building spec)
|
|
149
|
+
# @param kind [String] Source resource kind
|
|
150
|
+
# @param verb [String] Relation verb
|
|
151
|
+
# @param target_class [String] Target class name
|
|
152
|
+
# @return [String, nil] Relation name (e.g., "technologyComponents")
|
|
153
|
+
def relation_name_for(kind, verb, target_class)
|
|
154
|
+
relation = available_relations(kind).find do |v, _, tc|
|
|
155
|
+
v.to_s == verb.to_s && tc.to_s == target_class.to_s
|
|
156
|
+
end
|
|
157
|
+
return nil unless relation
|
|
158
|
+
|
|
159
|
+
relation[1].to_s
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Get target class name for a given verb and relation name (reverse lookup)
|
|
163
|
+
# @param kind [String] Source resource kind
|
|
164
|
+
# @param verb [String] Relation verb
|
|
165
|
+
# @param relation_name [String] Relation name (e.g., "businessActors")
|
|
166
|
+
# @return [String, nil] Target class name (e.g., "BusinessActor")
|
|
167
|
+
def target_class_for_relation(kind, verb, relation_name)
|
|
168
|
+
relation = available_relations(kind).find do |v, rn, _|
|
|
169
|
+
v.to_s == verb.to_s && rn.to_s == relation_name.to_s
|
|
170
|
+
end
|
|
171
|
+
return nil unless relation
|
|
172
|
+
|
|
173
|
+
relation[2].to_s
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Filter out empty values from a hash and convert to plain Hash
|
|
177
|
+
# (avoids !ruby/hash:Sinatra::IndifferentHash in YAML output)
|
|
178
|
+
def filter_empty(hash)
|
|
179
|
+
return {} if hash.nil?
|
|
180
|
+
|
|
181
|
+
result = hash.reject { |_, v| v.nil? || v.to_s.strip.empty? }
|
|
182
|
+
# Convert to plain Hash to avoid Ruby-specific YAML tags
|
|
183
|
+
result.to_h
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Build spec hash from relations array
|
|
187
|
+
# @param source_kind [String] The source resource kind
|
|
188
|
+
# @param relations [Array<Hash>] Array of {verb:, kind:, names:[]} hashes
|
|
189
|
+
# where kind is the target class name (e.g., "TechnologyArtifact")
|
|
190
|
+
# @return [Hash] Spec hash with proper relation_name keys
|
|
191
|
+
def build_spec(source_kind, relations)
|
|
192
|
+
return {} if relations.nil? || relations.empty?
|
|
193
|
+
|
|
194
|
+
spec = {}
|
|
195
|
+
relations.each { |rel| add_relation_to_spec(spec, source_kind, rel) }
|
|
196
|
+
deduplicate_spec_values(spec)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def add_relation_to_spec(spec, source_kind, rel)
|
|
200
|
+
verb, target_class, names = extract_relation_parts(rel)
|
|
201
|
+
return if invalid_relation?(verb, target_class, names)
|
|
202
|
+
|
|
203
|
+
rel_name = relation_name_for(source_kind, verb, target_class)
|
|
204
|
+
return unless rel_name
|
|
205
|
+
|
|
206
|
+
spec[verb.to_s] ||= {}
|
|
207
|
+
spec[verb.to_s][rel_name] ||= []
|
|
208
|
+
spec[verb.to_s][rel_name].concat(names)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def extract_relation_parts(rel)
|
|
212
|
+
verb = rel[:verb] || rel["verb"]
|
|
213
|
+
target_class = rel[:kind] || rel["kind"]
|
|
214
|
+
names = normalize_names(rel[:names] || rel["names"] || [])
|
|
215
|
+
[verb, target_class, names]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def normalize_names(names)
|
|
219
|
+
names = [names] unless names.is_a?(Array)
|
|
220
|
+
names.map(&:to_s).reject(&:empty?)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def invalid_relation?(verb, target_class, names)
|
|
224
|
+
verb.nil? || verb.to_s.strip.empty? ||
|
|
225
|
+
target_class.nil? || target_class.to_s.strip.empty? ||
|
|
226
|
+
names.empty?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def deduplicate_spec_values(spec)
|
|
230
|
+
spec.transform_values { |kinds| kinds.transform_values(&:uniq) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private_class_method :filter_empty, :build_spec, :add_relation_to_spec,
|
|
234
|
+
:extract_relation_parts, :normalize_names, :invalid_relation?,
|
|
235
|
+
:deduplicate_spec_values
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -14,6 +14,9 @@ require_relative "../registry"
|
|
|
14
14
|
# import/config/org - GitHub organization name
|
|
15
15
|
# import/config/repoOutputPath - Output path for repository handler results (e.g., "generated/repositories.yaml")
|
|
16
16
|
# import/config/childCacheTime - Cache time for generated child imports (e.g., "1h", "30m")
|
|
17
|
+
# import/config/fallbackTeam - Default team when no contributor match found (propagated to child imports)
|
|
18
|
+
# import/config/botTeam - Team for bot-only repositories (propagated to child imports)
|
|
19
|
+
# import/config/corporateAffixes - Comma-separated corporate username affixes for team matching (propagated to child imports)
|
|
17
20
|
#
|
|
18
21
|
# Environment:
|
|
19
22
|
# GITHUB_TOKEN - GitHub Personal Access Token (required)
|
|
@@ -136,15 +139,20 @@ class Archsight::Import::Handlers::Github < Archsight::Import::Handler
|
|
|
136
139
|
child_annotations["import/outputPath"] = @repo_output_path if @repo_output_path
|
|
137
140
|
child_annotations["import/cacheTime"] = @child_cache_time if @child_cache_time
|
|
138
141
|
|
|
142
|
+
child_config = {
|
|
143
|
+
"path" => repo_path,
|
|
144
|
+
"gitUrl" => git_url,
|
|
145
|
+
"archived" => repo["isArchived"].to_s,
|
|
146
|
+
"visibility" => visibility == "public" ? "open-source" : "internal"
|
|
147
|
+
}
|
|
148
|
+
child_config["fallbackTeam"] = config("fallbackTeam") if config("fallbackTeam")
|
|
149
|
+
child_config["botTeam"] = config("botTeam") if config("botTeam")
|
|
150
|
+
child_config["corporateAffixes"] = config("corporateAffixes") if config("corporateAffixes")
|
|
151
|
+
|
|
139
152
|
import_yaml(
|
|
140
153
|
name: "Import:Repo:github:#{@org}:#{repo_name}",
|
|
141
154
|
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
|
-
},
|
|
155
|
+
config: child_config,
|
|
148
156
|
annotations: child_annotations
|
|
149
157
|
)
|
|
150
158
|
end
|
|
@@ -19,6 +19,9 @@ require_relative "../registry"
|
|
|
19
19
|
# import/config/sslFingerprint - SSL certificate fingerprint for pinning (SHA256, colon-separated hex)
|
|
20
20
|
# import/config/repoOutputPath - Output path for repository handler results (e.g., "generated/repositories.yaml")
|
|
21
21
|
# import/config/childCacheTime - Cache time for generated child imports (e.g., "1h", "30m")
|
|
22
|
+
# import/config/fallbackTeam - Default team when no contributor match found (propagated to child imports)
|
|
23
|
+
# import/config/botTeam - Team for bot-only repositories (propagated to child imports)
|
|
24
|
+
# import/config/corporateAffixes - Comma-separated corporate username affixes for team matching (propagated to child imports)
|
|
22
25
|
#
|
|
23
26
|
# Environment:
|
|
24
27
|
# GITLAB_TOKEN - GitLab personal access token (required)
|
|
@@ -177,15 +180,20 @@ class Archsight::Import::Handlers::Gitlab < Archsight::Import::Handler
|
|
|
177
180
|
child_annotations["import/outputPath"] = @repo_output_path if @repo_output_path
|
|
178
181
|
child_annotations["import/cacheTime"] = @child_cache_time if @child_cache_time
|
|
179
182
|
|
|
183
|
+
child_config = {
|
|
184
|
+
"path" => repo_path,
|
|
185
|
+
"gitUrl" => git_url,
|
|
186
|
+
"archived" => project["archived"].to_s,
|
|
187
|
+
"visibility" => project["visibility"] || "internal"
|
|
188
|
+
}
|
|
189
|
+
child_config["fallbackTeam"] = config("fallbackTeam") if config("fallbackTeam")
|
|
190
|
+
child_config["botTeam"] = config("botTeam") if config("botTeam")
|
|
191
|
+
child_config["corporateAffixes"] = config("corporateAffixes") if config("corporateAffixes")
|
|
192
|
+
|
|
180
193
|
import_yaml(
|
|
181
194
|
name: "Import:Repo:gitlab:#{dir_name}",
|
|
182
195
|
handler: "repository",
|
|
183
|
-
config:
|
|
184
|
-
"path" => repo_path,
|
|
185
|
-
"gitUrl" => git_url,
|
|
186
|
-
"archived" => project["archived"].to_s,
|
|
187
|
-
"visibility" => project["visibility"] || "internal"
|
|
188
|
-
},
|
|
196
|
+
config: child_config,
|
|
189
197
|
annotations: child_annotations
|
|
190
198
|
)
|
|
191
199
|
end
|
|
@@ -18,6 +18,7 @@ require_relative "../team_matcher"
|
|
|
18
18
|
# import/config/sccPath - Optional path to scc binary (default: scc)
|
|
19
19
|
# import/config/fallbackTeam - Optional team name when no contributor match found
|
|
20
20
|
# import/config/botTeam - Optional team name for bot-only repositories
|
|
21
|
+
# import/config/corporateAffixes - Optional comma-separated corporate username affixes for team matching (e.g., "ionos,1and1")
|
|
21
22
|
class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
|
|
22
23
|
def execute
|
|
23
24
|
@path = config("path")
|
|
@@ -256,7 +257,8 @@ class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
|
|
|
256
257
|
def match_teams(top_contributors, activity_status)
|
|
257
258
|
return nil unless database && top_contributors&.any?
|
|
258
259
|
|
|
259
|
-
|
|
260
|
+
affixes = config("corporateAffixes")&.split(",")&.map(&:strip) || []
|
|
261
|
+
matcher = Archsight::Import::TeamMatcher.new(database, corporate_affixes: affixes)
|
|
260
262
|
result = matcher.analyze(top_contributors)
|
|
261
263
|
|
|
262
264
|
# Apply fallbacks from config
|
|
@@ -15,11 +15,16 @@ class Archsight::Import::TeamMatcher
|
|
|
15
15
|
# Teams to ignore when matching (bots, unknown, etc.)
|
|
16
16
|
IGNORED_TEAMS = %w[Bot:Team No:Team Team:Unknown Team:Bot].freeze
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
# Team annotation keys that contain member/lead information
|
|
19
|
+
TEAM_ANNOTATION_KEYS = %w[team/members team/lead].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(database, corporate_affixes: [])
|
|
19
22
|
@database = database
|
|
23
|
+
@corporate_affixes = corporate_affixes
|
|
20
24
|
@teams = load_teams
|
|
21
25
|
@email_to_team = build_email_index
|
|
22
26
|
@name_to_team = build_name_index
|
|
27
|
+
@member_identities = build_member_identities
|
|
23
28
|
end
|
|
24
29
|
|
|
25
30
|
# Analyze top contributors and return team assignments
|
|
@@ -72,6 +77,12 @@ class Archsight::Import::TeamMatcher
|
|
|
72
77
|
return team if team
|
|
73
78
|
end
|
|
74
79
|
|
|
80
|
+
# Try corporate username pattern match (e.g. jsmith-ionos -> John Smith)
|
|
81
|
+
if name
|
|
82
|
+
team = pattern_match_username(name)
|
|
83
|
+
return team if team
|
|
84
|
+
end
|
|
85
|
+
|
|
75
86
|
nil
|
|
76
87
|
end
|
|
77
88
|
|
|
@@ -87,21 +98,22 @@ class Archsight::Import::TeamMatcher
|
|
|
87
98
|
end
|
|
88
99
|
end
|
|
89
100
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
# Yields (team_name, annotation_value) for each team/members and team/lead annotation
|
|
102
|
+
def each_team_annotation
|
|
93
103
|
@teams.each do |team|
|
|
94
104
|
team_name = team.name
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
parse_email_list(members).each do |email|
|
|
99
|
-
index[email.downcase] = team_name
|
|
105
|
+
TEAM_ANNOTATION_KEYS.each do |key|
|
|
106
|
+
value = team.annotations[key]
|
|
107
|
+
yield team_name, value if value && !value.empty?
|
|
100
108
|
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_email_index
|
|
113
|
+
index = {}
|
|
101
114
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
parse_email_list(lead).each do |email|
|
|
115
|
+
each_team_annotation do |team_name, value|
|
|
116
|
+
parse_email_list(value).each do |email|
|
|
105
117
|
index[email.downcase] = team_name
|
|
106
118
|
end
|
|
107
119
|
end
|
|
@@ -112,19 +124,8 @@ class Archsight::Import::TeamMatcher
|
|
|
112
124
|
def build_name_index
|
|
113
125
|
index = {}
|
|
114
126
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
# Extract names from team/members annotation
|
|
119
|
-
members = team.annotations["team/members"]
|
|
120
|
-
parse_name_list(members).each do |name|
|
|
121
|
-
normalized = normalize_name(name)
|
|
122
|
-
index[normalized] = team_name if normalized && !normalized.empty?
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# Extract name from team/lead annotation
|
|
126
|
-
lead = team.annotations["team/lead"]
|
|
127
|
-
parse_name_list(lead).each do |name|
|
|
127
|
+
each_team_annotation do |team_name, value|
|
|
128
|
+
parse_name_list(value).each do |name|
|
|
128
129
|
normalized = normalize_name(name)
|
|
129
130
|
index[normalized] = team_name if normalized && !normalized.empty?
|
|
130
131
|
end
|
|
@@ -136,60 +137,109 @@ class Archsight::Import::TeamMatcher
|
|
|
136
137
|
# Parse email addresses from team annotation
|
|
137
138
|
# Supports formats: "Name <email>", "email", or comma/newline separated lists
|
|
138
139
|
def parse_email_list(value)
|
|
139
|
-
|
|
140
|
+
parse_name_email_pairs(value).filter_map { |pair| pair[:email] }
|
|
141
|
+
end
|
|
140
142
|
|
|
141
|
-
|
|
143
|
+
# Parse names from team annotation
|
|
144
|
+
# Supports formats: "Name <email>", "First Last", or comma/newline separated lists
|
|
145
|
+
def parse_name_list(value)
|
|
146
|
+
parse_name_email_pairs(value).filter_map { |pair| pair[:name] }
|
|
147
|
+
end
|
|
142
148
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
# Normalize name for matching
|
|
150
|
+
# Converts to lowercase, removes extra spaces, handles common variations
|
|
151
|
+
def normalize_name(name)
|
|
152
|
+
return nil if name.nil?
|
|
147
153
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
name.to_s
|
|
155
|
+
.downcase
|
|
156
|
+
.gsub(/\s+/, " ")
|
|
157
|
+
.strip
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Match git author name as corporate username pattern {first_initial}{lastname}[-affix] or [affix-]{first_initial}{lastname}
|
|
161
|
+
# e.g. "jsmith-ionos" or "ionos-jsmith" -> initial "j", lastname "smith" -> matches "John Smith"
|
|
162
|
+
#
|
|
163
|
+
# Limitations: Multi-part names (e.g. "Hans von Braun") only match by the last
|
|
164
|
+
# name part ("braun"), so "hvonbraun" would not match. Hyphenated lastnames
|
|
165
|
+
# (e.g. "Meyer-Schmidt") only match the full hyphenated form or the email lastname.
|
|
166
|
+
def pattern_match_username(name)
|
|
167
|
+
username = name.downcase.strip
|
|
168
|
+
return nil unless username.match?(/\A[a-z0-9][-a-z0-9]*\z/)
|
|
169
|
+
return nil if @corporate_affixes.empty?
|
|
170
|
+
|
|
171
|
+
@corporate_affixes.each do |affix|
|
|
172
|
+
clean = affix.delete_prefix("-").delete_suffix("-")
|
|
173
|
+
username = username.delete_suffix("-#{clean}")
|
|
174
|
+
username = username.delete_prefix("#{clean}-")
|
|
175
|
+
end
|
|
176
|
+
return nil if username.length < 3
|
|
177
|
+
|
|
178
|
+
initial = username[0]
|
|
179
|
+
lastname = username[1..]
|
|
180
|
+
return nil if lastname.length < 3
|
|
181
|
+
|
|
182
|
+
candidates = @member_identities.select do |member|
|
|
183
|
+
lastname_match = member[:lastname] == lastname || member[:email_lastname] == lastname
|
|
184
|
+
initial_match = member[:firstname]&.start_with?(initial)
|
|
185
|
+
lastname_match && initial_match
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
teams = candidates.map { |c| c[:team] }.uniq
|
|
189
|
+
return teams.first if teams.size == 1
|
|
190
|
+
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Build identity records for corporate username pattern matching
|
|
195
|
+
def build_member_identities
|
|
196
|
+
identities = []
|
|
197
|
+
|
|
198
|
+
each_team_annotation do |team_name, value|
|
|
199
|
+
parse_name_email_pairs(value).each do |entry|
|
|
200
|
+
name_parts = entry[:name]&.downcase&.split(/\s+/)
|
|
201
|
+
next if name_parts.nil? || name_parts.size < 2
|
|
202
|
+
|
|
203
|
+
firstname = name_parts.first
|
|
204
|
+
lastname = name_parts.last
|
|
205
|
+
|
|
206
|
+
email_prefix = entry[:email]&.split("@")&.first
|
|
207
|
+
email_parts = email_prefix&.split(/[.-]/)
|
|
208
|
+
email_lastname = email_parts&.last&.downcase
|
|
209
|
+
|
|
210
|
+
identities << { team: team_name, firstname: firstname, lastname: lastname, email_lastname: email_lastname }
|
|
154
211
|
end
|
|
155
212
|
end
|
|
156
213
|
|
|
157
|
-
|
|
214
|
+
identities
|
|
158
215
|
end
|
|
159
216
|
|
|
160
|
-
# Parse
|
|
161
|
-
#
|
|
162
|
-
def
|
|
217
|
+
# Parse name and email pairs from team annotation
|
|
218
|
+
# Returns array of { name:, email: } hashes from "Name <email>" format
|
|
219
|
+
def parse_name_email_pairs(value)
|
|
163
220
|
return [] if value.nil? || value.empty?
|
|
164
221
|
|
|
165
|
-
|
|
222
|
+
pairs = []
|
|
166
223
|
|
|
167
|
-
# Split by comma or newline
|
|
168
224
|
value.split(/[,\n]/).each do |entry|
|
|
169
225
|
entry = entry.strip
|
|
170
226
|
next if entry.empty?
|
|
171
227
|
|
|
172
|
-
|
|
173
|
-
|
|
228
|
+
name = nil
|
|
229
|
+
email = nil
|
|
230
|
+
|
|
231
|
+
if (match = entry.match(/^([^<]+)<([^>]+)>/))
|
|
174
232
|
name = match[1].strip
|
|
175
|
-
|
|
176
|
-
elsif
|
|
177
|
-
|
|
178
|
-
|
|
233
|
+
email = match[2].strip
|
|
234
|
+
elsif entry.include?("@")
|
|
235
|
+
email = entry.strip
|
|
236
|
+
else
|
|
237
|
+
name = entry.strip
|
|
179
238
|
end
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
names
|
|
183
|
-
end
|
|
184
239
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def normalize_name(name)
|
|
188
|
-
return nil if name.nil?
|
|
240
|
+
pairs << { name: name, email: email } if name || email
|
|
241
|
+
end
|
|
189
242
|
|
|
190
|
-
|
|
191
|
-
.downcase
|
|
192
|
-
.gsub(/\s+/, " ")
|
|
193
|
-
.strip
|
|
243
|
+
pairs
|
|
194
244
|
end
|
|
195
245
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
class Archsight::MCP::ExecuteAnalysisTool < FastMcp::Tool
|
|
6
|
+
tool_name "execute_analysis"
|
|
7
|
+
|
|
8
|
+
description <<~DESC.gsub("\n", " ").strip
|
|
9
|
+
Discover and execute Analysis resources that validate architecture data and produce reports.
|
|
10
|
+
|
|
11
|
+
TWO MODES OF OPERATION:
|
|
12
|
+
|
|
13
|
+
1. LIST MODE (no name): Returns all available Analysis resources with name, description,
|
|
14
|
+
handler, and timeout. Use this to discover what analyses exist before running them.
|
|
15
|
+
|
|
16
|
+
2. EXECUTE MODE (name provided): Runs the specified Analysis script in a sandboxed environment
|
|
17
|
+
and returns the results as markdown. The output includes structured findings like tables,
|
|
18
|
+
lists, warnings, and errors generated by the script.
|
|
19
|
+
DESC
|
|
20
|
+
|
|
21
|
+
arguments do
|
|
22
|
+
optional(:name).filled(:string).description(
|
|
23
|
+
"Analysis name to execute (e.g., 'Analysis:Service:TeamOwnership'). " \
|
|
24
|
+
"Omit to list all available analyses."
|
|
25
|
+
)
|
|
26
|
+
optional(:verbose).filled(:bool).description(
|
|
27
|
+
"When true, tables and lists in output are not truncated. Default: false."
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(name: nil, verbose: false)
|
|
32
|
+
if name.nil?
|
|
33
|
+
list_analyses
|
|
34
|
+
else
|
|
35
|
+
execute_analysis(name, verbose: verbose)
|
|
36
|
+
end
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
error_response(e.message)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def list_analyses
|
|
44
|
+
db = Archsight::MCP.db
|
|
45
|
+
analyses = db.instances_by_kind("Analysis").values
|
|
46
|
+
|
|
47
|
+
resources = analyses.map do |analysis|
|
|
48
|
+
{
|
|
49
|
+
name: analysis.name,
|
|
50
|
+
description: analysis.annotations["analysis/description"] || analysis.annotations["architecture/description"],
|
|
51
|
+
handler: analysis.annotations["analysis/handler"] || "ruby",
|
|
52
|
+
timeout: analysis.annotations["analysis/timeout"] || "30s"
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
JSON.pretty_generate(
|
|
57
|
+
total: resources.size,
|
|
58
|
+
analyses: resources
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def execute_analysis(name, verbose: false)
|
|
63
|
+
require "archsight/analysis"
|
|
64
|
+
|
|
65
|
+
db = Archsight::MCP.db
|
|
66
|
+
analysis = db.instance_by_kind("Analysis", name)
|
|
67
|
+
return error_response("Analysis not found: #{name}") unless analysis
|
|
68
|
+
|
|
69
|
+
executor = Archsight::Analysis::Executor.new(db)
|
|
70
|
+
result = executor.execute(analysis)
|
|
71
|
+
|
|
72
|
+
if result.success?
|
|
73
|
+
JSON.pretty_generate(
|
|
74
|
+
name: result.name,
|
|
75
|
+
success: true,
|
|
76
|
+
duration: result.duration,
|
|
77
|
+
output: result.to_markdown(verbose: verbose),
|
|
78
|
+
has_findings: result.has_findings?,
|
|
79
|
+
warning_count: result.warning_count,
|
|
80
|
+
error_count: result.error_count
|
|
81
|
+
)
|
|
82
|
+
else
|
|
83
|
+
response = {
|
|
84
|
+
name: result.name,
|
|
85
|
+
success: false,
|
|
86
|
+
error: result.error
|
|
87
|
+
}
|
|
88
|
+
partial = result.to_markdown(verbose: verbose)
|
|
89
|
+
response[:partial_output] = partial unless partial.empty?
|
|
90
|
+
JSON.pretty_generate(response)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def error_response(message)
|
|
95
|
+
JSON.pretty_generate(
|
|
96
|
+
error: "Error",
|
|
97
|
+
message: message
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/archsight/mcp.rb
CHANGED