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,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "cgi"
7
+
8
+ require_relative "../handler"
9
+
10
+ # Shared module for Jira handlers
11
+ #
12
+ # Provides common functionality for Jira API interactions including:
13
+ # - HTTP client with Bearer token authentication
14
+ # - User lookup and caching (email -> Jira username)
15
+ # - Project info lookup and caching
16
+ # - Team loading and email extraction
17
+ # - Rate limiting
18
+ module Archsight::Import::Handlers::JiraBase
19
+ # Initialize Jira client
20
+ # @param host [String] Jira host (e.g., "hosting-jira.1and1.org")
21
+ # @param token [String] Jira Bearer token
22
+ # @param rate_limit_ms [Integer] Rate limit delay in milliseconds
23
+ def init_jira_client(host:, token:, rate_limit_ms: 100)
24
+ @jira_host = host
25
+ @jira_token = token
26
+ @rate_limit_ms = rate_limit_ms
27
+ @jira_uri = URI("https://#{host}")
28
+ @jira_http = Net::HTTP.new(@jira_uri.host, @jira_uri.port)
29
+ @jira_http.use_ssl = true
30
+ @jira_http.read_timeout = 120
31
+ @user_cache = {}
32
+ @project_cache = {}
33
+ end
34
+
35
+ # Make a GET request to the Jira API
36
+ # @param path [String] API path (e.g., "/rest/api/2/myself")
37
+ # @return [Hash, Array] Parsed JSON response
38
+ # @raise [RuntimeError] on HTTP errors
39
+ def jira_get(path)
40
+ request = Net::HTTP::Get.new(path)
41
+ request["Authorization"] = "Bearer #{@jira_token}"
42
+ request["Content-Type"] = "application/json"
43
+ response = @jira_http.request(request)
44
+
45
+ raise "Jira API error: #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
46
+
47
+ JSON.parse(response.body)
48
+ end
49
+
50
+ # Verify Jira credentials by calling /rest/api/2/myself
51
+ # @return [Hash] User info from Jira
52
+ # @raise [RuntimeError] on authentication failure
53
+ def verify_jira_credentials
54
+ progress.update("Verifying Jira credentials...")
55
+ user = jira_get("/rest/api/2/myself")
56
+ progress.update("Authenticated as #{user["displayName"] || user["name"]}")
57
+ user
58
+ rescue StandardError => e
59
+ raise "Jira authentication failed: #{e.message}"
60
+ end
61
+
62
+ # Find Jira usernames for a list of email addresses
63
+ # @param emails [Array<String>] Email addresses to look up
64
+ # @return [Array<String>] Jira usernames
65
+ def find_jira_users(emails)
66
+ users = []
67
+
68
+ emails.each do |email|
69
+ # Check cache first
70
+ if @user_cache.key?(email)
71
+ users << @user_cache[email] if @user_cache[email]
72
+ next
73
+ end
74
+
75
+ rate_limit
76
+ begin
77
+ result = jira_get("/rest/api/2/user/search?username=#{CGI.escape(email)}&maxResults=1")
78
+
79
+ if result&.any?
80
+ user = result.first
81
+ username = user["name"] || user["accountId"]
82
+ @user_cache[email] = username
83
+ users << username
84
+ else
85
+ @user_cache[email] = nil
86
+ end
87
+ rescue StandardError => e
88
+ progress.warn("Error searching for user #{email}: #{e.message}")
89
+ @user_cache[email] = nil
90
+ end
91
+ end
92
+
93
+ users.compact.uniq
94
+ end
95
+
96
+ # Get project info from Jira API
97
+ # @param project_key [String] Project key
98
+ # @return [Hash, nil] Project info or nil if not found
99
+ def get_project_info(project_key)
100
+ return @project_cache[project_key] if @project_cache.key?(project_key)
101
+
102
+ rate_limit
103
+ begin
104
+ project = jira_get("/rest/api/2/project/#{project_key}")
105
+ @project_cache[project_key] = project
106
+ project
107
+ rescue StandardError => e
108
+ progress.warn("Error fetching project #{project_key}: #{e.message}")
109
+ @project_cache[project_key] = nil
110
+ nil
111
+ end
112
+ end
113
+
114
+ # Extract email addresses from a team's annotations
115
+ # @param team [Archsight::Resources::Base] Team resource
116
+ # @return [Array<String>] Unique email addresses
117
+ def extract_team_emails(team)
118
+ emails = []
119
+
120
+ if (lead = team.annotations["team/lead"])
121
+ email = extract_email(lead)
122
+ emails << email if email
123
+ end
124
+
125
+ if (members = team.annotations["team/members"])
126
+ members.each_line do |line|
127
+ line = line.strip
128
+ next if line.empty?
129
+
130
+ email = extract_email(line)
131
+ emails << email if email
132
+ end
133
+ end
134
+
135
+ emails.compact.uniq
136
+ end
137
+
138
+ # Load teams from database with optional filter
139
+ # @param ignored_teams [Array<String>] Team names to ignore
140
+ # @param require_jira [Boolean] If true, only return teams WITH team/jira
141
+ # @param require_no_jira [Boolean] If true, only return teams WITHOUT team/jira
142
+ # @return [Array<Archsight::Resources::Base>] Filtered and sorted teams
143
+ def load_teams(ignored_teams: [], require_jira: false, require_no_jira: false)
144
+ teams = database.instances_by_kind("BusinessActor")
145
+
146
+ filtered = teams.values.reject do |team|
147
+ next true if ignored_teams.include?(team.name)
148
+ next true if extract_team_emails(team).empty?
149
+
150
+ jira_project = team.annotations["team/jira"]
151
+ has_jira = jira_project && !jira_project.empty?
152
+
153
+ next true if require_jira && !has_jira
154
+ next true if require_no_jira && has_jira
155
+
156
+ false
157
+ end
158
+
159
+ filtered.sort_by(&:name)
160
+ end
161
+
162
+ # Parse comma-separated config values
163
+ # @param value [String, nil] Comma-separated values
164
+ # @return [Array<String>] Parsed array
165
+ def parse_list_config(value)
166
+ return [] if value.nil? || value.empty?
167
+
168
+ value.split(",").map(&:strip)
169
+ end
170
+
171
+ # Apply rate limiting between API calls
172
+ def rate_limit
173
+ sleep(@rate_limit_ms / 1000.0)
174
+ end
175
+
176
+ private
177
+
178
+ # Extract email from a member string
179
+ # Handles formats like "Name <email@example.com>" or "email@example.com"
180
+ # @param member_str [String] Member string
181
+ # @return [String, nil] Extracted email or nil
182
+ def extract_email(member_str)
183
+ if member_str =~ /<([^>]+)>/
184
+ Regexp.last_match(1).strip.downcase
185
+ elsif member_str =~ /@/
186
+ member_str.strip.downcase
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../handler"
4
+ require_relative "../registry"
5
+ require_relative "jira_base"
6
+
7
+ # Jira Discover handler - discovers Jira projects for teams without team/jira set
8
+ #
9
+ # Configuration:
10
+ # import/config/host - Jira host (required, e.g., "hosting-jira.1and1.org")
11
+ # import/config/minActivityThreshold - Minimum issues for valid project (default: 5)
12
+ # import/config/excludedProjectCategories - Comma-separated category IDs to exclude
13
+ # import/config/excludedProjects - Comma-separated project keys to exclude
14
+ # import/config/ignoredTeams - Comma-separated team names to skip
15
+ # import/config/rateLimitMs - API rate limit delay (default: 100)
16
+ #
17
+ # Environment:
18
+ # JIRA_TOKEN - Jira Personal Access Token (required)
19
+ #
20
+ # Output:
21
+ # BusinessActor patches with team/jira annotation
22
+ class Archsight::Import::Handlers::JiraDiscover < Archsight::Import::Handler
23
+ include Archsight::Import::Handlers::JiraBase
24
+
25
+ def execute
26
+ load_configuration
27
+ verify_jira_credentials
28
+ discover_projects
29
+
30
+ write_generates_meta
31
+ end
32
+
33
+ private
34
+
35
+ def load_configuration
36
+ @host = config("host")
37
+ raise "Missing required config: host" unless @host
38
+
39
+ @token = ENV.fetch("JIRA_TOKEN", nil)
40
+ raise "Missing required environment variable: JIRA_TOKEN" unless @token
41
+
42
+ @min_activity_threshold = config("minActivityThreshold", default: "5").to_i
43
+ @excluded_project_categories = parse_list_config(config("excludedProjectCategories"))
44
+ @excluded_projects = parse_list_config(config("excludedProjects"))
45
+ @ignored_teams = parse_list_config(config("ignoredTeams"))
46
+ @rate_limit_ms = config("rateLimitMs", default: "100").to_i
47
+
48
+ init_jira_client(host: @host, token: @token, rate_limit_ms: @rate_limit_ms)
49
+ end
50
+
51
+ def discover_projects
52
+ teams = load_teams(ignored_teams: @ignored_teams, require_no_jira: true)
53
+
54
+ if teams.empty?
55
+ progress.warn("No teams found without Jira project configured")
56
+ # Still write self-marker for caching
57
+ write_yaml(YAML.dump(self_marker))
58
+ return
59
+ end
60
+
61
+ progress.update("Found #{teams.size} teams without Jira project configured")
62
+
63
+ documents = []
64
+ discovered_count = 0
65
+
66
+ teams.each_with_index do |team, idx|
67
+ emails = extract_team_emails(team)
68
+ progress.update("[#{idx + 1}/#{teams.size}] Processing #{team.name}...")
69
+
70
+ project_key = discover_team_project(team.name, emails)
71
+
72
+ next unless project_key
73
+
74
+ project_info = get_project_info(project_key)
75
+ project_name = project_info&.dig("name") || ""
76
+
77
+ documents << {
78
+ "apiVersion" => "architecture/v1alpha1",
79
+ "kind" => "BusinessActor",
80
+ "metadata" => {
81
+ "name" => team.name,
82
+ "annotations" => {
83
+ "team/jira" => project_key,
84
+ "generated/script" => import_resource.name,
85
+ "generated/at" => Time.now.utc.iso8601
86
+ }
87
+ }
88
+ }
89
+ discovered_count += 1
90
+ progress.update("[#{idx + 1}/#{teams.size}] #{team.name} -> #{project_key} (#{project_name})")
91
+ end
92
+
93
+ # Add self-marker for caching
94
+ documents << self_marker
95
+
96
+ if documents.size > 1
97
+ yaml_content = documents.map { |doc| YAML.dump(doc) }.join("\n")
98
+ write_yaml(yaml_content)
99
+ progress.complete("Discovered Jira projects for #{discovered_count}/#{teams.size} teams")
100
+ else
101
+ # Still write self-marker even if no projects discovered
102
+ write_yaml(YAML.dump(self_marker))
103
+ progress.complete("No Jira projects discovered for #{teams.size} teams")
104
+ end
105
+ end
106
+
107
+ def discover_team_project(team_name, emails)
108
+ jira_users = find_jira_users(emails)
109
+ return nil if jira_users.empty?
110
+
111
+ project_activity = Hash.new(0)
112
+
113
+ # Build JQL to find issues where team members are assignee or reporter
114
+ user_list = jira_users.map { |u| "\"#{u}\"" }.join(", ")
115
+ jql = "(assignee IN (#{user_list}) OR reporter IN (#{user_list})) AND updated >= -26w"
116
+
117
+ rate_limit
118
+ begin
119
+ # Get issues to count by project
120
+ result = jira_get("/rest/api/2/search?jql=#{CGI.escape(jql)}&maxResults=500&fields=project")
121
+ issues = result["issues"] || []
122
+ issues.each do |issue|
123
+ project_key = issue.dig("fields", "project", "key")
124
+ project_activity[project_key] += 1 if project_key
125
+ end
126
+ rescue StandardError => e
127
+ progress.warn("Error querying issues for #{team_name}: #{e.message}")
128
+ return nil
129
+ end
130
+
131
+ return nil if project_activity.empty?
132
+
133
+ # Filter out excluded project categories and find project with most activity
134
+ sorted_projects = project_activity.sort_by { |_, count| -count }
135
+
136
+ sorted_projects.each do |project_key, count|
137
+ next if excluded_project?(project_key)
138
+ next if count < @min_activity_threshold
139
+
140
+ return project_key
141
+ end
142
+
143
+ nil
144
+ end
145
+
146
+ def excluded_project?(project_key)
147
+ # Check if project key is explicitly excluded
148
+ return true if @excluded_projects.include?(project_key)
149
+
150
+ # Check if project category is excluded
151
+ return false if @excluded_project_categories.empty?
152
+
153
+ project_info = get_project_info(project_key)
154
+ return false unless project_info
155
+
156
+ category_id = project_info.dig("projectCategory", "id")&.to_s
157
+ @excluded_project_categories.include?(category_id)
158
+ end
159
+ end
160
+
161
+ Archsight::Import::Registry.register("jira-discover", Archsight::Import::Handlers::JiraDiscover)
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "../handler"
5
+ require_relative "../registry"
6
+ require_relative "jira_base"
7
+
8
+ # Jira Metrics handler - exports per-month issue metrics for teams with team/jira set
9
+ #
10
+ # Configuration:
11
+ # import/config/host - Jira host (required, e.g., "hosting-jira.1and1.org")
12
+ # import/config/monthsToAnalyze - Number of months for metrics (default: 6)
13
+ # import/config/ignoredTeams - Comma-separated team names to skip
14
+ # import/config/rateLimitMs - API rate limit delay (default: 100)
15
+ #
16
+ # Environment:
17
+ # JIRA_TOKEN - Jira Personal Access Token (required)
18
+ #
19
+ # Output:
20
+ # BusinessActor patches with jira/issues/created, jira/issues/resolved annotations
21
+ class Archsight::Import::Handlers::JiraMetrics < Archsight::Import::Handler
22
+ include Archsight::Import::Handlers::JiraBase
23
+
24
+ def execute
25
+ load_configuration
26
+ verify_jira_credentials
27
+ export_metrics
28
+
29
+ write_generates_meta
30
+ end
31
+
32
+ private
33
+
34
+ def load_configuration
35
+ @host = config("host")
36
+ raise "Missing required config: host" unless @host
37
+
38
+ @token = ENV.fetch("JIRA_TOKEN", nil)
39
+ raise "Missing required environment variable: JIRA_TOKEN" unless @token
40
+
41
+ @months_to_analyze = config("monthsToAnalyze", default: "6").to_i
42
+ @ignored_teams = parse_list_config(config("ignoredTeams"))
43
+ @rate_limit_ms = config("rateLimitMs", default: "100").to_i
44
+
45
+ init_jira_client(host: @host, token: @token, rate_limit_ms: @rate_limit_ms)
46
+ end
47
+
48
+ def export_metrics
49
+ teams = load_teams(ignored_teams: @ignored_teams, require_jira: true)
50
+
51
+ if teams.empty?
52
+ progress.warn("No teams found with Jira project configured")
53
+ write_yaml(YAML.dump(self_marker))
54
+ return
55
+ end
56
+
57
+ progress.update("Found #{teams.size} teams with Jira project configured")
58
+ progress.update("Collecting metrics for the last #{@months_to_analyze} months...")
59
+
60
+ documents = teams.each_with_index.filter_map do |team, idx|
61
+ process_team_metrics(team, idx, teams.size)
62
+ end
63
+
64
+ write_metrics_output(documents, teams.size)
65
+ end
66
+
67
+ def process_team_metrics(team, idx, total)
68
+ project_key = team.annotations["team/jira"]
69
+ primary_key = project_key.split(/[,\n]/).first&.strip
70
+ emails = extract_team_emails(team)
71
+
72
+ progress.update("[#{idx + 1}/#{total}] #{team.name} (#{primary_key})...")
73
+
74
+ metrics = collect_team_metrics(primary_key, emails)
75
+
76
+ if metrics[:created].all?(&:zero?) && metrics[:resolved].all?(&:zero?)
77
+ progress.update("[#{idx + 1}/#{total}] #{team.name} (#{primary_key}) - no activity")
78
+ return nil
79
+ end
80
+
81
+ log_metrics_summary(team, idx, total, metrics)
82
+ build_metrics_document(team, metrics)
83
+ end
84
+
85
+ def log_metrics_summary(team, idx, total, metrics)
86
+ created_total = metrics[:created].sum
87
+ resolved_total = metrics[:resolved].sum
88
+ progress.update("[#{idx + 1}/#{total}] #{team.name} - #{created_total} created, #{resolved_total} resolved")
89
+ end
90
+
91
+ def build_metrics_document(team, metrics)
92
+ {
93
+ "apiVersion" => "architecture/v1alpha1",
94
+ "kind" => "BusinessActor",
95
+ "metadata" => {
96
+ "name" => team.name,
97
+ "annotations" => {
98
+ "jira/issues/created" => metrics[:created].join(","),
99
+ "jira/issues/resolved" => metrics[:resolved].join(","),
100
+ "generated/script" => import_resource.name,
101
+ "generated/at" => Time.now.utc.iso8601
102
+ }
103
+ }
104
+ }
105
+ end
106
+
107
+ def write_metrics_output(documents, teams_size)
108
+ documents << self_marker
109
+
110
+ if documents.size > 1
111
+ yaml_content = documents.map { |doc| YAML.dump(doc) }.join("\n")
112
+ write_yaml(yaml_content)
113
+ progress.complete("Collected metrics for #{documents.size - 1} teams")
114
+ else
115
+ write_yaml(YAML.dump(self_marker))
116
+ progress.complete("No metrics collected for #{teams_size} teams")
117
+ end
118
+ end
119
+
120
+ def collect_team_metrics(project_key, emails)
121
+ jira_users = find_jira_users(emails)
122
+
123
+ created_counts = []
124
+ resolved_counts = []
125
+
126
+ # Generate month ranges for the last N months
127
+ months = generate_month_ranges(@months_to_analyze)
128
+
129
+ months.each do |month_start, month_end|
130
+ created = count_issues(project_key, jira_users, "created", month_start, month_end)
131
+ resolved = count_issues(project_key, jira_users, "resolved", month_start, month_end)
132
+
133
+ created_counts << created
134
+ resolved_counts << resolved
135
+ end
136
+
137
+ { created: created_counts, resolved: resolved_counts }
138
+ end
139
+
140
+ def generate_month_ranges(num_months)
141
+ ranges = []
142
+ today = Date.today
143
+
144
+ num_months.times do |i|
145
+ # Go back i+1 months (we don't include current incomplete month)
146
+ month_date = today << (num_months - i)
147
+ month_start = Date.new(month_date.year, month_date.month, 1)
148
+ month_end = (month_start >> 1) - 1 # Last day of month
149
+
150
+ ranges << [month_start, month_end]
151
+ end
152
+
153
+ ranges
154
+ end
155
+
156
+ def count_issues(project_key, jira_users, date_field, start_date, end_date)
157
+ return 0 if jira_users.empty?
158
+
159
+ user_list = jira_users.map { |u| "\"#{u}\"" }.join(", ")
160
+
161
+ # Build JQL for team members in the date range
162
+ jql = "project = #{project_key} AND " \
163
+ "(assignee IN (#{user_list}) OR reporter IN (#{user_list})) AND " \
164
+ "#{date_field} >= #{start_date.strftime("%Y-%m-%d")} AND " \
165
+ "#{date_field} <= #{end_date.strftime("%Y-%m-%d")}"
166
+
167
+ rate_limit
168
+ begin
169
+ # Use maxResults=0 to just get the count
170
+ result = jira_get("/rest/api/2/search?jql=#{CGI.escape(jql)}&maxResults=0")
171
+ result["total"] || 0
172
+ rescue StandardError => e
173
+ progress.warn("Error counting #{date_field} issues: #{e.message}")
174
+ 0
175
+ end
176
+ end
177
+ end
178
+
179
+ Archsight::Import::Registry.register("jira-metrics", Archsight::Import::Handlers::JiraMetrics)