predictability-engine 0.6.6
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 +7 -0
- data/bin/predictability-engine +19 -0
- data/bin/predictability-engine.bat +2 -0
- data/bin/setup +47 -0
- data/data/samples/sample_data.csv +19 -0
- data/data/samples/sample_data_large.csv +201 -0
- data/data/samples/wip_data.csv +5 -0
- data/lib/predictability_engine/agents/assistant.rb +29 -0
- data/lib/predictability_engine/agents/tools.rb +98 -0
- data/lib/predictability_engine/calculators/aging.rb +36 -0
- data/lib/predictability_engine/calculators/cfd.rb +80 -0
- data/lib/predictability_engine/calculators/cfd_forecaster.rb +112 -0
- data/lib/predictability_engine/calculators/cycle_time.rb +26 -0
- data/lib/predictability_engine/calculators/throughput.rb +42 -0
- data/lib/predictability_engine/cli.rb +414 -0
- data/lib/predictability_engine/config.rb +167 -0
- data/lib/predictability_engine/data_generator.rb +53 -0
- data/lib/predictability_engine/data_manager.rb +28 -0
- data/lib/predictability_engine/data_sources/base.rb +93 -0
- data/lib/predictability_engine/data_sources/csv.rb +62 -0
- data/lib/predictability_engine/data_sources/excel.rb +18 -0
- data/lib/predictability_engine/data_sources/factory.rb +20 -0
- data/lib/predictability_engine/data_sources/jira.rb +201 -0
- data/lib/predictability_engine/data_sources/jira_yaml.rb +103 -0
- data/lib/predictability_engine/duration.rb +16 -0
- data/lib/predictability_engine/excel_exporter.rb +48 -0
- data/lib/predictability_engine/html_style.rb +63 -0
- data/lib/predictability_engine/html_templates.rb +70 -0
- data/lib/predictability_engine/jira_auth/base.rb +26 -0
- data/lib/predictability_engine/jira_auth/basic.rb +15 -0
- data/lib/predictability_engine/jira_auth/bearer.rb +11 -0
- data/lib/predictability_engine/jira_auth/cookie.rb +15 -0
- data/lib/predictability_engine/jira_auth/mfa_api.rb +36 -0
- data/lib/predictability_engine/jira_auth/mfa_browser.rb +85 -0
- data/lib/predictability_engine/jira_auth.rb +22 -0
- data/lib/predictability_engine/jira_config_prompter.rb +48 -0
- data/lib/predictability_engine/jira_workflow.rb +137 -0
- data/lib/predictability_engine/logger.rb +45 -0
- data/lib/predictability_engine/mermaid_visualizer.rb +94 -0
- data/lib/predictability_engine/models/work_item.rb +43 -0
- data/lib/predictability_engine/pdf_visualizer/primitives.rb +83 -0
- data/lib/predictability_engine/pdf_visualizer.rb +64 -0
- data/lib/predictability_engine/raw_data_exporter.rb +46 -0
- data/lib/predictability_engine/report/constants.rb +70 -0
- data/lib/predictability_engine/report/image_generator.rb +36 -0
- data/lib/predictability_engine/report/text_renderer.rb +84 -0
- data/lib/predictability_engine/report.rb +328 -0
- data/lib/predictability_engine/report_generator.rb +170 -0
- data/lib/predictability_engine/setup_manager.rb +127 -0
- data/lib/predictability_engine/simulators/monte_carlo.rb +57 -0
- data/lib/predictability_engine/simulators/monte_carlo_validator.rb +85 -0
- data/lib/predictability_engine/summary_visualizer/helpers.rb +71 -0
- data/lib/predictability_engine/summary_visualizer/renderer.rb +88 -0
- data/lib/predictability_engine/summary_visualizer.rb +36 -0
- data/lib/predictability_engine/terminal_visualizer/cfd_renderer.rb +52 -0
- data/lib/predictability_engine/terminal_visualizer.rb +97 -0
- data/lib/predictability_engine/vega_visualizer/aging_wip_visualizer.rb +44 -0
- data/lib/predictability_engine/vega_visualizer/basic_charts.rb +121 -0
- data/lib/predictability_engine/vega_visualizer/cfd_charts.rb +82 -0
- data/lib/predictability_engine/vega_visualizer/cfd_layout.rb +132 -0
- data/lib/predictability_engine/vega_visualizer/tooltip_helpers.rb +34 -0
- data/lib/predictability_engine/vega_visualizer.rb +106 -0
- data/lib/predictability_engine/version.rb +5 -0
- data/lib/predictability_engine/visualizer.rb +114 -0
- data/lib/predictability_engine.rb +117 -0
- metadata +566 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module PredictabilityEngine
|
|
6
|
+
# Configuration management for the predictability engine.
|
|
7
|
+
class Config
|
|
8
|
+
CONFIG_FILE = '.predictability_engine.yml'
|
|
9
|
+
|
|
10
|
+
# Single source of truth: config key → JIRA_ env var suffix.
|
|
11
|
+
JIRA_FIELDS = {
|
|
12
|
+
site: 'SITE', email: 'EMAIL', token: 'API_TOKEN', project: 'PROJECT',
|
|
13
|
+
context_path: 'CONTEXT_PATH', auth_mode: 'AUTH_MODE',
|
|
14
|
+
bearer_token: 'BEARER_TOKEN', auth_cookie: 'AUTH_COOKIE',
|
|
15
|
+
password: 'PASSWORD', totp_secret: 'TOTP_SECRET',
|
|
16
|
+
mfa_login_url: 'MFA_LOGIN_URL', mfa_token_field: 'MFA_TOKEN_FIELD',
|
|
17
|
+
idp_login_url: 'IDP_LOGIN_URL', idp_callback_port: 'IDP_CALLBACK_PORT'
|
|
18
|
+
}.freeze
|
|
19
|
+
private_constant :JIRA_FIELDS
|
|
20
|
+
|
|
21
|
+
def self.jira(profile_name = nil)
|
|
22
|
+
load_jira_config(profile_name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.jira_client(profile = nil)
|
|
26
|
+
require 'jira-ruby'
|
|
27
|
+
instrument_jira_http! unless ::JIRA::HttpClient.ancestors.include?(JiraHttpLogger)
|
|
28
|
+
config = jira(profile)
|
|
29
|
+
validate_jira!(config)
|
|
30
|
+
origin, derived_path = split_jira_site(config[:site])
|
|
31
|
+
base = { site: origin, context_path: config[:context_path] || derived_path, default_headers: {} }
|
|
32
|
+
auth = JiraAuth.build(config)
|
|
33
|
+
options = auth.jira_options(base)
|
|
34
|
+
options[:http_debug] = true if jira_http_debug?
|
|
35
|
+
client = ::JIRA::Client.new(options)
|
|
36
|
+
auth.post_init(client)
|
|
37
|
+
client
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Splits a full Jira site URL (e.g. "https://host/jira") into the origin
|
|
41
|
+
# ("https://host") and context path ("/jira") that jira-ruby expects.
|
|
42
|
+
# This avoids 301/302 redirect errors when the server requires the path.
|
|
43
|
+
def self.split_jira_site(site_url)
|
|
44
|
+
uri = URI.parse(site_url.to_s.chomp('/'))
|
|
45
|
+
port_suffix = [80, 443].include?(uri.port) ? '' : ":#{uri.port}"
|
|
46
|
+
origin = "#{uri.scheme}://#{uri.host}#{port_suffix}"
|
|
47
|
+
[origin, uri.path]
|
|
48
|
+
end
|
|
49
|
+
private_class_method :split_jira_site
|
|
50
|
+
|
|
51
|
+
# Prepended to JIRA::HttpClient to trace every HTTP call at DEBUG level.
|
|
52
|
+
# SemanticLogger evaluates blocks only when debug is active → zero overhead at
|
|
53
|
+
# INFO or higher.
|
|
54
|
+
module JiraHttpLogger
|
|
55
|
+
def make_request(http_method, url, body = '', headers = {})
|
|
56
|
+
log = SemanticLogger['JIRA::HTTP']
|
|
57
|
+
log.debug { "→ #{http_method.upcase} #{url}" }
|
|
58
|
+
result = super
|
|
59
|
+
log.debug { "← #{result.code} #{result.message}" }
|
|
60
|
+
result
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
SemanticLogger['JIRA::HTTP'].debug { "✗ #{http_method.upcase} #{url} (#{e.class}: #{e.message})" }
|
|
63
|
+
raise
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
private_constant :JiraHttpLogger
|
|
67
|
+
|
|
68
|
+
def self.instrument_jira_http!
|
|
69
|
+
::JIRA::HttpClient.prepend(JiraHttpLogger)
|
|
70
|
+
end
|
|
71
|
+
private_class_method :instrument_jira_http!
|
|
72
|
+
|
|
73
|
+
def self.jira_http_debug?
|
|
74
|
+
ENV.fetch('JIRA_HTTP_DEBUG', nil).to_s.match?(/\A(true|1|yes)\z/i)
|
|
75
|
+
end
|
|
76
|
+
private_class_method :jira_http_debug?
|
|
77
|
+
|
|
78
|
+
def self.validate_jira!(config)
|
|
79
|
+
raise_missing(config, :site)
|
|
80
|
+
case config[:auth_mode].to_s
|
|
81
|
+
when 'bearer'
|
|
82
|
+
raise_missing(config, :bearer_token)
|
|
83
|
+
when 'cookie'
|
|
84
|
+
raise_missing(config, :auth_cookie)
|
|
85
|
+
when 'mfa_api'
|
|
86
|
+
%i[email password totp_secret mfa_login_url].each { |k| raise_missing(config, k) }
|
|
87
|
+
when 'mfa_browser'
|
|
88
|
+
raise_missing(config, :idp_login_url)
|
|
89
|
+
else
|
|
90
|
+
raise_missing(config, :email)
|
|
91
|
+
raise_missing(config, :token)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.raise_missing(config, key)
|
|
96
|
+
return if config[key]
|
|
97
|
+
|
|
98
|
+
env_suffix = JIRA_FIELDS[key]
|
|
99
|
+
env_var = env_suffix ? "JIRA_#{env_suffix}" : "JIRA_#{key.to_s.upcase}"
|
|
100
|
+
raise Error, "Jira #{key} not configured (use #{env_var} env var or credentials file)"
|
|
101
|
+
end
|
|
102
|
+
private_class_method :raise_missing
|
|
103
|
+
|
|
104
|
+
def self.load_jira_config(profile_name = nil)
|
|
105
|
+
profile_name ||= default_profile_name
|
|
106
|
+
global = load_global_jira_config
|
|
107
|
+
local = load_local_jira_config
|
|
108
|
+
|
|
109
|
+
config = load_profile(profile_name, global, local) if profile_name
|
|
110
|
+
config || fallback_config(global, local)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.default_profile_name
|
|
114
|
+
ENV.fetch('JIRA_PROFILE', nil) || ENV.fetch('JIRA_PROJECT', nil)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.fallback_config(global, local)
|
|
118
|
+
JIRA_FIELDS.transform_values { |env_key| jira_val(env_key, global, local) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.jira_val(name, global, local)
|
|
122
|
+
key = name.downcase.to_sym
|
|
123
|
+
ENV.fetch("JIRA_#{name}", nil) || local[key] || global[key]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.jira_credentials_file
|
|
127
|
+
File.expand_path('~/.config/jira/jira_credentials.yml')
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.load_global_jira_config
|
|
131
|
+
load_jira_file(jira_credentials_file, global: true)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.load_local_jira_config
|
|
135
|
+
load_jira_file(CONFIG_FILE, global: false)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.load_yaml_file(path)
|
|
139
|
+
YAML.load_file(path)
|
|
140
|
+
rescue Psych::SyntaxError => e
|
|
141
|
+
raise Error, "Invalid YAML in #{path}: #{e.message}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.load_jira_file(path, global: true)
|
|
145
|
+
raw = File.exist?(path) ? load_yaml_file(path) : {}
|
|
146
|
+
raw ||= {}
|
|
147
|
+
config = global ? (raw['jira'] || raw) : raw.fetch('jira', {})
|
|
148
|
+
extract_fields(config).merge(profiles: config.fetch('profiles', {}))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.load_profile(name, global, local)
|
|
152
|
+
profile = global[:profiles][name.to_s] || local[:profiles][name.to_s] || {}
|
|
153
|
+
return if profile.empty?
|
|
154
|
+
|
|
155
|
+
extract_fields(profile)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.extract_fields(hash)
|
|
159
|
+
JIRA_FIELDS.keys.to_h { |key| [key, hash[key.to_s]] }
|
|
160
|
+
end
|
|
161
|
+
private_class_method :extract_fields
|
|
162
|
+
|
|
163
|
+
private_class_method :load_jira_config, :load_global_jira_config,
|
|
164
|
+
:load_local_jira_config, :load_profile, :default_profile_name,
|
|
165
|
+
:fallback_config, :load_jira_file, :jira_val
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'csv'
|
|
4
|
+
|
|
5
|
+
module PredictabilityEngine
|
|
6
|
+
module DataGenerator
|
|
7
|
+
PRESETS = {
|
|
8
|
+
small: { completed: 10, wip: 4 },
|
|
9
|
+
medium: { completed: 40, wip: 10 },
|
|
10
|
+
large: { completed: 150, wip: 50 },
|
|
11
|
+
xl: { completed: 4000, wip: 400 }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.generate(output:, size: :medium, completed: nil, wip: nil)
|
|
15
|
+
File.binwrite(output, content(size: size, completed: completed, wip: wip))
|
|
16
|
+
output
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.content(size: :medium, completed: nil, wip: nil)
|
|
20
|
+
preset = PRESETS.fetch(size.to_sym) do
|
|
21
|
+
raise ArgumentError, "Unknown size: #{size} (available: #{PRESETS.keys.join(', ')})"
|
|
22
|
+
end
|
|
23
|
+
build_csv(completed || preset[:completed], wip || preset[:wip])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.build_csv(completed_count, wip_count)
|
|
27
|
+
today = PredictabilityEngine.today
|
|
28
|
+
CSV.generate do |csv|
|
|
29
|
+
csv << %w[id title start_date end_date]
|
|
30
|
+
write_completed(csv, completed_count, today)
|
|
31
|
+
write_wip(csv, completed_count, wip_count, today)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.write_completed(csv, count, today)
|
|
36
|
+
(1..count).each do |i|
|
|
37
|
+
start_date = today - rand(200..400)
|
|
38
|
+
end_date = start_date + rand(5..30)
|
|
39
|
+
csv << ["PROJ-#{i}", "Task #{i}",
|
|
40
|
+
PredictabilityEngine.format_date(start_date),
|
|
41
|
+
PredictabilityEngine.format_date(end_date)]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.write_wip(csv, offset, count, today)
|
|
46
|
+
((offset + 1)..(offset + count)).each do |i|
|
|
47
|
+
start_date = today - rand(1..100)
|
|
48
|
+
csv << ["PROJ-#{i}", "In Progress Task #{i}",
|
|
49
|
+
PredictabilityEngine.format_date(start_date), nil]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
class DataManager
|
|
5
|
+
attr_reader :work_items, :source
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@work_items = []
|
|
9
|
+
@source = nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def load(spec, **)
|
|
13
|
+
@source = spec
|
|
14
|
+
@work_items = DataSources::Factory.for(spec, **).load(spec)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Backward compatibility
|
|
18
|
+
alias load_csv load
|
|
19
|
+
|
|
20
|
+
def completed_items
|
|
21
|
+
@work_items.select(&:completed?)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def active_items
|
|
25
|
+
@work_items.reject(&:completed?)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module PredictabilityEngine
|
|
6
|
+
module DataSources
|
|
7
|
+
class Base
|
|
8
|
+
def configure(opts)
|
|
9
|
+
@url_prefix = opts[:url_prefix]
|
|
10
|
+
self
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def load(source_spec)
|
|
14
|
+
apply_project_config_defaults
|
|
15
|
+
perform_load(source_spec)
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
raise Error, "Failed to load from #{source_name}: #{e.message}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
protected
|
|
21
|
+
|
|
22
|
+
def source_name
|
|
23
|
+
self.class.name.split('::').last
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def perform_load(_source_spec)
|
|
27
|
+
raise NotImplementedError, "#{self.class} must implement perform_load"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build_work_items(data)
|
|
31
|
+
data.map do |row|
|
|
32
|
+
item_data = map_row(row)
|
|
33
|
+
item_id = item_data.delete(:id)
|
|
34
|
+
Models::WorkItem.new(item_id: item_id, **item_data)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse_date(val)
|
|
39
|
+
return val if val.is_a?(Date) || val.is_a?(Time)
|
|
40
|
+
return nil if val.nil? || val.to_s.strip.empty?
|
|
41
|
+
|
|
42
|
+
Date.parse(val.to_s)
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def load_data(iterator)
|
|
48
|
+
data = iterator.map do |row|
|
|
49
|
+
map_row(row)
|
|
50
|
+
end
|
|
51
|
+
build_work_items(data)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def map_row(row)
|
|
55
|
+
raw_id = row[:id] || row[:key] || row[:item_id]
|
|
56
|
+
{
|
|
57
|
+
id: raw_id,
|
|
58
|
+
title: row[:title] || row[:summary],
|
|
59
|
+
type: row[:type] || row[:issuetype],
|
|
60
|
+
priority: normalize_priority(row[:priority]),
|
|
61
|
+
start_date: parse_date(row[:start_date] || row[:created]),
|
|
62
|
+
end_date: parse_date(row[:end_date] || row[:resolutiondate] || row[:resolved]),
|
|
63
|
+
url: item_url(row[:url], raw_id)
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def item_url(url, item_id)
|
|
68
|
+
url.presence || (@url_prefix && "#{@url_prefix}#{item_id}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_priority(name)
|
|
72
|
+
return nil if name.nil?
|
|
73
|
+
|
|
74
|
+
(@priority_aliases || {})[name.to_s] || name
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def mock_data(env_key)
|
|
78
|
+
json = ENV.fetch(env_key, '[]')
|
|
79
|
+
JSON.parse(json, symbolize_names: true)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def apply_project_config_defaults
|
|
85
|
+
return if @url_prefix
|
|
86
|
+
return unless File.exist?(Config::CONFIG_FILE)
|
|
87
|
+
|
|
88
|
+
config = YAML.load_file(Config::CONFIG_FILE) || {}
|
|
89
|
+
@url_prefix = config['url_prefix']
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'csv'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module PredictabilityEngine
|
|
7
|
+
module DataSources
|
|
8
|
+
class Csv < Base
|
|
9
|
+
JIRA_HEADER_MAP = {
|
|
10
|
+
issue_key: :id,
|
|
11
|
+
issue_type: :type
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def perform_load(path)
|
|
15
|
+
config = load_csv_config(path)
|
|
16
|
+
@url_prefix ||= config['url_prefix']
|
|
17
|
+
@done_statuses = load_done_statuses(config)
|
|
18
|
+
@source_url = "file://#{File.expand_path(path)}"
|
|
19
|
+
CSV.open(path, headers: true, header_converters: :symbol, encoding: 'bom|UTF-8', row_sep: :auto)
|
|
20
|
+
.then { |csv| load_data(csv.map { |row| apply_jira_header_map(row.to_h) }) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def apply_jira_header_map(row_hash)
|
|
26
|
+
row_hash.transform_keys { |k| JIRA_HEADER_MAP.fetch(k, k) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def map_row(row)
|
|
30
|
+
super.tap do |mapped|
|
|
31
|
+
mapped[:url] ||= @source_url unless @url_prefix
|
|
32
|
+
next if mapped[:end_date]
|
|
33
|
+
next unless @done_statuses.include?(row[:status].to_s.downcase)
|
|
34
|
+
|
|
35
|
+
mapped[:end_date] = parse_date(row[:updated])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def load_done_statuses(config)
|
|
40
|
+
if (wf_path = config['workflow_config_path'])
|
|
41
|
+
JiraWorkflow.load(File.expand_path(wf_path))&.departure_names.to_a.map(&:downcase)
|
|
42
|
+
elsif config.key?('statuses')
|
|
43
|
+
JiraWorkflow.new(statuses: Array(config['statuses'])).departure_names.map(&:downcase)
|
|
44
|
+
else
|
|
45
|
+
Array(config['done_statuses']).map(&:downcase)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def load_csv_config(csv_path)
|
|
50
|
+
sidecar = csv_path.sub(/\.csv$/i, '.yml')
|
|
51
|
+
return YAML.load_file(sidecar) || {} if File.exist?(sidecar)
|
|
52
|
+
return project_jira_csv_config if File.exist?(Config::CONFIG_FILE)
|
|
53
|
+
|
|
54
|
+
{}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def project_jira_csv_config
|
|
58
|
+
(YAML.load_file(Config::CONFIG_FILE) || {}).fetch('jira_csv', {})
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'roo'
|
|
4
|
+
|
|
5
|
+
module PredictabilityEngine
|
|
6
|
+
module DataSources
|
|
7
|
+
class Excel < Base
|
|
8
|
+
def perform_load(path)
|
|
9
|
+
return build_work_items(mock_data('MOCK_EXCEL_DATA')) if ENV['MOCK_EXCEL_DATA']
|
|
10
|
+
|
|
11
|
+
xlsx = Roo::Spreadsheet.open(path)
|
|
12
|
+
iterator = xlsx.sheet(0).each(id: 'id', start_date: 'start_date', end_date: 'end_date')
|
|
13
|
+
.reject { |row| row[:id] == 'id' }
|
|
14
|
+
load_data(iterator)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module DataSources
|
|
5
|
+
class Factory
|
|
6
|
+
def self.for(spec, **opts)
|
|
7
|
+
source = case spec
|
|
8
|
+
when /^jira:/, /^jql:/, /\.yml$/, /\.yaml$/, 'jira', /^[A-Z][A-Z0-9]+$/
|
|
9
|
+
Jira.new
|
|
10
|
+
when /\.xlsx$/
|
|
11
|
+
Excel.new
|
|
12
|
+
else
|
|
13
|
+
Csv.new
|
|
14
|
+
end
|
|
15
|
+
source.configure(opts)
|
|
16
|
+
source
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jira-ruby'
|
|
4
|
+
require_relative 'jira_yaml'
|
|
5
|
+
|
|
6
|
+
module PredictabilityEngine
|
|
7
|
+
module DataSources
|
|
8
|
+
class Jira < Base
|
|
9
|
+
IN_PROGRESS_KEYWORDS = ['In Progress', 'Doing', 'Active', 'Development', 'Progress'].freeze
|
|
10
|
+
FIELDS = %w[summary issuetype created priority resolutiondate status].freeze
|
|
11
|
+
|
|
12
|
+
def perform_load(spec)
|
|
13
|
+
@priority_aliases = yaml_priority_aliases(spec)
|
|
14
|
+
|
|
15
|
+
if ENV['MOCK_JIRA'] == 'true'
|
|
16
|
+
data = mock_data('JIRA_MOCK_DATA')
|
|
17
|
+
# Even when mocked, we can validate the contract if requested
|
|
18
|
+
data.each { |row| validate_issue_contract!(row) } if ENV['JIRA_CONTRACT_CHECK'] == 'true'
|
|
19
|
+
return build_work_items(data)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
profile, query, @workflow = resolve_source(spec)
|
|
23
|
+
@jira_site = Config.jira(profile)[:site]
|
|
24
|
+
client = build_client(profile)
|
|
25
|
+
issues = fetch_issues(client, query)
|
|
26
|
+
|
|
27
|
+
# Contract Validation
|
|
28
|
+
issues.each { |issue| validate_issue_contract!(issue) } if ENV['JIRA_CONTRACT_CHECK'] == 'true'
|
|
29
|
+
|
|
30
|
+
data = issues.map { |issue| map_issue(issue) }
|
|
31
|
+
build_work_items(data)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate_issue_contract!(issue)
|
|
37
|
+
# Handle both JIRA::Resource::Issue and Hash (for mocks)
|
|
38
|
+
is_hash = issue.is_a?(Hash)
|
|
39
|
+
key = get_field(issue, :key, is_hash)
|
|
40
|
+
validate_basic_fields(issue, key, is_hash)
|
|
41
|
+
validate_issuetype(issue, key, is_hash)
|
|
42
|
+
validate_created(issue, key, is_hash)
|
|
43
|
+
validate_changelog(issue, key, is_hash)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_basic_fields(issue, key, is_hash)
|
|
47
|
+
summary = get_field(issue, :summary, is_hash)
|
|
48
|
+
raise Error, "Issue #{key} is missing 'key'" unless key
|
|
49
|
+
raise Error, "Issue #{key} is missing 'summary'" unless summary
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_issuetype(issue, key, is_hash)
|
|
53
|
+
issuetype = get_field(issue, :issuetype, is_hash)
|
|
54
|
+
raise Error, "Issue #{key} is missing 'issuetype'" unless issuetype
|
|
55
|
+
|
|
56
|
+
# Handle issuetype object or string
|
|
57
|
+
type_name = if issuetype.respond_to?(:name)
|
|
58
|
+
issuetype.name
|
|
59
|
+
elsif issuetype.is_a?(Hash)
|
|
60
|
+
issuetype[:name] || issuetype['name']
|
|
61
|
+
else
|
|
62
|
+
issuetype
|
|
63
|
+
end
|
|
64
|
+
raise Error, "Issue #{key} is missing 'issuetype.name'" unless type_name
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_created(issue, key, is_hash)
|
|
68
|
+
created = get_field(issue, :created, is_hash)
|
|
69
|
+
raise Error, "Issue #{key} is missing 'created'" unless created
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_changelog(issue, key, is_hash)
|
|
73
|
+
# Required for cycle time and aging
|
|
74
|
+
has_changelog = if is_hash
|
|
75
|
+
issue[:changelog] || issue['changelog']
|
|
76
|
+
else
|
|
77
|
+
issue.respond_to?(:changelog) && issue.changelog
|
|
78
|
+
end
|
|
79
|
+
return if has_changelog
|
|
80
|
+
|
|
81
|
+
PredictabilityEngine.logger.warn { "Issue #{key} is missing 'changelog' (expand=changelog failed?)" }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def get_field(issue, name, is_hash)
|
|
85
|
+
is_hash ? issue[name] || issue[name.to_s] : issue.send(name)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def yaml_priority_aliases(spec)
|
|
89
|
+
return {} unless spec.is_a?(String) && spec.match?(/\.ya?ml$/)
|
|
90
|
+
|
|
91
|
+
JiraYaml.new(spec).priority_aliases
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve_source(spec)
|
|
95
|
+
profile_name = ENV.fetch('JIRA_PROFILE', nil)
|
|
96
|
+
case spec
|
|
97
|
+
when /\.ya?ml$/
|
|
98
|
+
yaml = JiraYaml.new(spec)
|
|
99
|
+
[yaml.profile, yaml.query, load_workflow(yaml.workflow_config_path, yaml.profile)]
|
|
100
|
+
when 'jira'
|
|
101
|
+
[profile_name, query_from_config(profile_name), load_workflow(nil, profile_name)]
|
|
102
|
+
when /^[A-Z][A-Z0-9]+$/
|
|
103
|
+
[profile_name, "project = \"#{spec}\"", load_workflow(nil, profile_name)]
|
|
104
|
+
else
|
|
105
|
+
[nil, spec, nil]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def load_workflow(explicit_path, profile_name)
|
|
110
|
+
return JiraWorkflow.load(explicit_path) if explicit_path
|
|
111
|
+
|
|
112
|
+
profile_name && JiraWorkflow.load(JiraWorkflow.default_path(profile_name))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def query_from_config(profile_name)
|
|
116
|
+
config = Config.jira(profile_name)
|
|
117
|
+
project = config[:project]
|
|
118
|
+
query = ENV.fetch('JIRA_PROJECT_QUERY', nil) || (project ? "project = \"#{project}\"" : nil)
|
|
119
|
+
raise Error, 'No JIRA project specified (use JIRA_PROJECT env var or provide a query)' unless query
|
|
120
|
+
|
|
121
|
+
query
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_client(profile = nil)
|
|
125
|
+
Config.jira_client(profile)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def fetch_issues(client, query)
|
|
129
|
+
jql = query.start_with?('jira:') ? "filter = #{query.sub('jira:', '')}" : query.sub('jql:', '')
|
|
130
|
+
logger.debug { "JIRA JQL: #{jql}" }
|
|
131
|
+
client.Issue.jql(jql, expand: 'changelog', fields: FIELDS)
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
logger.debug { "JIRA fetch failed: #{e.class} — #{e.message}" }
|
|
134
|
+
raise Error, "Failed to fetch issues from Jira: #{e.message}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def map_issue(issue)
|
|
138
|
+
map_row({
|
|
139
|
+
id: issue.key,
|
|
140
|
+
url: "#{@jira_site.to_s.chomp('/')}/browse/#{issue.key}",
|
|
141
|
+
summary: issue.summary,
|
|
142
|
+
issuetype: issue.issuetype.name,
|
|
143
|
+
priority: jira_priority_name(issue),
|
|
144
|
+
created: issue.created,
|
|
145
|
+
start_date: first_in_progress_date(issue),
|
|
146
|
+
resolutiondate: last_departure_date(issue)
|
|
147
|
+
})
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def jira_priority_name(issue)
|
|
151
|
+
return nil unless issue.respond_to?(:priority) && issue.priority
|
|
152
|
+
|
|
153
|
+
issue.priority.respond_to?(:name) ? issue.priority.name : issue.priority.to_s
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def first_in_progress_date(issue)
|
|
157
|
+
names = @workflow&.arrival_names
|
|
158
|
+
return first_transition_matching(issue) { |t| names.any? { |n| t[:to].casecmp?(n) } } if names&.any?
|
|
159
|
+
|
|
160
|
+
warn_missing_workflow_once
|
|
161
|
+
first_transition_matching(issue) do |t|
|
|
162
|
+
IN_PROGRESS_KEYWORDS.any? { |kw| t[:to].downcase.include?(kw.downcase) }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def last_departure_date(issue)
|
|
167
|
+
names = @workflow&.departure_names
|
|
168
|
+
return issue.resolutiondate unless names&.any?
|
|
169
|
+
|
|
170
|
+
first_transition_matching(issue) { |t| names.any? { |n| t[:to].casecmp?(n) } } || issue.resolutiondate
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def first_transition_matching(issue, &)
|
|
174
|
+
return nil unless issue.respond_to?(:changelog) && issue.changelog
|
|
175
|
+
|
|
176
|
+
transitions = status_transitions(issue.changelog.fetch('histories', [])).sort_by { |t| t[:date] }
|
|
177
|
+
match = transitions.find(&)
|
|
178
|
+
match ? match[:date] : nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def warn_missing_workflow_once
|
|
182
|
+
return if @workflow_warning_emitted
|
|
183
|
+
|
|
184
|
+
@workflow_warning_emitted = true
|
|
185
|
+
PredictabilityEngine.logger.warn do
|
|
186
|
+
'No Jira workflow mapping found; falling back to in-progress keyword heuristic. ' \
|
|
187
|
+
'Run `./bin/predictability-engine jira_workflow <profile>` to generate an editable mapping.'
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def status_transitions(histories)
|
|
192
|
+
histories.flat_map do |history|
|
|
193
|
+
created_at = history['created']
|
|
194
|
+
history['items'].select { |item| item['field'] == 'status' }.map do |item|
|
|
195
|
+
{ date: created_at, to: item['toString'] }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|