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.
- checksums.yaml +4 -4
- data/README.md +26 -5
- data/lib/archsight/analysis/executor.rb +112 -0
- data/lib/archsight/analysis/result.rb +174 -0
- data/lib/archsight/analysis/sandbox.rb +319 -0
- data/lib/archsight/analysis.rb +11 -0
- data/lib/archsight/annotations/architecture_annotations.rb +2 -2
- data/lib/archsight/cli.rb +163 -0
- data/lib/archsight/database.rb +6 -2
- data/lib/archsight/helpers/analysis_renderer.rb +83 -0
- data/lib/archsight/helpers/formatting.rb +95 -0
- data/lib/archsight/helpers.rb +20 -4
- data/lib/archsight/import/concurrent_progress.rb +341 -0
- data/lib/archsight/import/executor.rb +466 -0
- data/lib/archsight/import/git_analytics.rb +626 -0
- data/lib/archsight/import/handler.rb +263 -0
- data/lib/archsight/import/handlers/github.rb +161 -0
- data/lib/archsight/import/handlers/gitlab.rb +202 -0
- data/lib/archsight/import/handlers/jira_base.rb +189 -0
- data/lib/archsight/import/handlers/jira_discover.rb +161 -0
- data/lib/archsight/import/handlers/jira_metrics.rb +179 -0
- data/lib/archsight/import/handlers/openapi_schema_parser.rb +279 -0
- data/lib/archsight/import/handlers/repository.rb +439 -0
- data/lib/archsight/import/handlers/rest_api.rb +293 -0
- data/lib/archsight/import/handlers/rest_api_index.rb +183 -0
- data/lib/archsight/import/progress.rb +91 -0
- data/lib/archsight/import/registry.rb +54 -0
- data/lib/archsight/import/shared_file_writer.rb +67 -0
- data/lib/archsight/import/team_matcher.rb +195 -0
- data/lib/archsight/import.rb +14 -0
- data/lib/archsight/resources/analysis.rb +91 -0
- data/lib/archsight/resources/application_component.rb +2 -2
- data/lib/archsight/resources/application_service.rb +12 -12
- data/lib/archsight/resources/business_product.rb +12 -12
- data/lib/archsight/resources/data_object.rb +1 -1
- data/lib/archsight/resources/import.rb +79 -0
- data/lib/archsight/resources/technology_artifact.rb +23 -2
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/docs.rb +17 -0
- data/lib/archsight/web/api/json_helpers.rb +164 -0
- data/lib/archsight/web/api/openapi/spec.yaml +500 -0
- data/lib/archsight/web/api/routes.rb +101 -0
- data/lib/archsight/web/application.rb +66 -43
- data/lib/archsight/web/doc/import.md +458 -0
- data/lib/archsight/web/doc/index.md.erb +1 -0
- data/lib/archsight/web/public/css/artifact.css +10 -0
- data/lib/archsight/web/public/css/graph.css +14 -0
- data/lib/archsight/web/public/css/instance.css +489 -0
- data/lib/archsight/web/views/api_docs.erb +19 -0
- data/lib/archsight/web/views/partials/artifact/_project_estimate.haml +14 -8
- data/lib/archsight/web/views/partials/instance/_analysis_detail.haml +74 -0
- data/lib/archsight/web/views/partials/instance/_analysis_result.haml +64 -0
- data/lib/archsight/web/views/partials/instance/_detail.haml +7 -3
- data/lib/archsight/web/views/partials/instance/_import_detail.haml +87 -0
- data/lib/archsight/web/views/partials/instance/_relations.haml +4 -4
- data/lib/archsight/web/views/partials/layout/_content.haml +4 -0
- data/lib/archsight/web/views/partials/layout/_navigation.haml +6 -5
- 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)
|